diff --git a/Main.py b/Main.py index 7c3bf3a4..a1570853 100644 --- a/Main.py +++ b/Main.py @@ -34,7 +34,8 @@ SQL_IP = _secret_get('database', 'host', fallback='127.0.0.1') WHITE_HOSTS = [ "www.gvsds.com", "gvsds.com", "api.gvsds.com", "127.0.0.1", "localhost", SQL_IP, - "gvsds.dev", "yc8z.org", "ycbz.org", "gvsds.org", "gvsds.studio", "114.66.59.25" + "gvsds.dev", "yc8z.org", "ycbz.org", "gvsds.org", "gvsds.studio", "114.66.59.25", + "librok.gvsds.com" ] BAN_IPS = [ "www.kanglaohui.com", "www.best-loving.cn", "11.4.5.14", "185.223.152.189" @@ -43,6 +44,11 @@ BAN_IPS = [ PIRACY_DIR = Path("TempLate") / "Errors" / "Piracy" PIRACY_LIST = os.listdir(PIRACY_DIR) if PIRACY_DIR.exists() else [] +# 静态站点:host -> 站点根目录 +STATIC_SITES = { + "librok.gvsds.com": Path("TempLate") / "rok" / "lib", +} + LOG_CONFIG = { 'disable_static_success': False, 'disable_favicon': False, @@ -381,8 +387,71 @@ class SecurityMiddleware: start_response('403 Forbidden', [('Content-Type', 'text/plain')]) return [b'Server does not support this type of page.'] + # 静态站点路由:将指定 host 的请求直接映射到本地目录 + if host in STATIC_SITES: + return self._serve_static_site(environ, start_response, host, path) + return self.app(environ, start_response) + def _serve_static_site(self, environ, start_response, host, path): + """为静态站点(host -> 目录)提供文件服务 + - 默认入口:目录请求 / 返回 index.html + - 仅允许访问站点根目录内的文件(防止 ../ 越权) + """ + import mimetypes + mimetypes.add_type('application/javascript', '.js') + mimetypes.add_type('text/css', '.css') + mimetypes.add_type('image/svg+xml', '.svg') + + site_root = STATIC_SITES[host].resolve() + # 清理路径:去掉前导 /,URL 反解码,过滤 .. + rel_path = path.lstrip('/') + if not rel_path: + rel_path = 'index.html' + try: + from urllib.parse import unquote + rel_path = unquote(rel_path) + except Exception: + pass + # 防止目录越权 + if '..' in rel_path.split('/'): + start_response('403 Forbidden', [('Content-Type', 'text/plain')]) + return [b'Forbidden'] + + target = (site_root / rel_path).resolve() + try: + target.relative_to(site_root) + except ValueError: + start_response('403 Forbidden', [('Content-Type', 'text/plain')]) + return [b'Forbidden'] + + # 目录则尝试 index.html + if target.is_dir(): + target = target / 'index.html' + if not target.is_file(): + start_response('404 Not Found', [('Content-Type', 'text/html')]) + try: + content = open(Path("TempLate") / "Errors" / "404.html", encoding="utf-8").read().encode('utf-8') + except Exception: + content = b'404 Not Found' + return [content] + + try: + data = target.read_bytes() + except Exception as e: + start_response('500 Internal Server Error', [('Content-Type', 'text/plain')]) + return [f'Read error: {e}'.encode('utf-8')] + + ctype, _ = mimetypes.guess_type(str(target)) + if not ctype: + ctype = 'application/octet-stream' + start_response('200 OK', [ + ('Content-Type', ctype), + ('Content-Length', str(len(data))), + ('Cache-Control', 'no-cache'), + ]) + return [data] + def SetupLogging(): global logger diff --git a/TempLate/CCCRB/ShareWallet.html b/TempLate/CCCRB/ShareWallet.html index 83a1f39d..65f41328 100644 --- a/TempLate/CCCRB/ShareWallet.html +++ b/TempLate/CCCRB/ShareWallet.html @@ -1,115 +1,204 @@ - - - - - - 银河锋锐 - GC 分享钱包 - - - -
-

银河锋锐 GC 钱包

-

您的游戏货币余额

-
- 数据加载中... - GC -
-
- - - \ No newline at end of file + + + + + + 枢机信用储备银行 - 分享钱包 + + + +
+

枢机信用储备银行

+
分享钱包余额
+
+
数据加载中...
+
+ +
+ + + diff --git a/TempLate/CCCRB/index.txt b/TempLate/CCCRB/index.txt index 6e84a2a1..abb771ec 100644 --- a/TempLate/CCCRB/index.txt +++ b/TempLate/CCCRB/index.txt @@ -457,7 +457,7 @@ 至尊级
- 10% + 10% / 游戏年
diff --git a/TempLate/assets/config/Megas.js b/TempLate/assets/config/Megas.js index 1782853c..45e9c31c 100644 --- a/TempLate/assets/config/Megas.js +++ b/TempLate/assets/config/Megas.js @@ -4,8 +4,7 @@ const MenuData1 = { description: "银河锋锐软件开发工作室(GVSDS)作为“IT 技术团队”,致力于编程、AI 和美术设计方面,我们将竭力为您提供最佳服务。感谢您的支持!", submenus: [ { id: "pages", name: "页面", icon: "fa-book" }, - { id: "good-members", name: "优秀成员", icon: "fa-address-book" }, - { id: "minecraft", name: "我的世界 枢机协律 服务", icon: "fa-gamepad" }, + { id: "good-members", name: "优秀成员", icon: "fa-user-circle" } ], contents: { pages: [ @@ -18,7 +17,7 @@ const MenuData1 = { }, { type: "four-columns", - title: "根存页面", + title: "存根页面", subtitle: "Basic Pages", items: [ { name: "主页", tag: "basic", description: "介绍工作室基本信息,作为工作室门面", link: "/" }, @@ -28,7 +27,8 @@ const MenuData1 = { { name: "登录", tag: "basic", description: "登录您已有的账户以查看和使用您的服务", link: "/login.html" }, { name: "注册", tag: "basic", description: "在本平台上注册以尝试我们的服务", link: "/register.html" }, { name: "画廊", tag: "hot", description: "我们工作室的画作和 Hero 界面的图片", link: "/gallery.html" }, - { name: "历史", tag: "basic", description: "工作室的相关历史", link: "/history.html" }, + // { name: "历史", tag: "basic", description: "工作室的相关历史", link: "/history.html" }, + { name: "-", description: "-", link: "#" }, { name: "-", description: "-", link: "#" }, { name: "-", description: "-", link: "#" }, { name: "-", description: "-", link: "#" }, @@ -49,13 +49,13 @@ const MenuData1 = { title: "联系页面", items: [ { name: "哔哩哔哩", description: "哔哩哔哩 上的 GVSDS 账户", link: "https://space.bilibili.com/3546619129628868", target: "_blank" }, - { name: "Github", description: "Github 上的 GVSDS 组织", link: "https://github.com/GVSADS/", target: "_blank" }, - { name: "GitGVSDS", description: "GVSDS 部署的 Gitea 快速站点", link: "https://git.gvsds.com/", target: "_blank" }, + { name: "Github", description: "Github 上的 GVSDS 组织", link: "https://github.com/GVSDS/", target: "_blank" }, + { name: "Git 自托管", description: "GVSDS 部署的 Gitea 快速站点", link: "https://git.gvsds.com/GVSDS/", target: "_blank" }, { name: "MC MOD 百科", description: "MC MOD 百科 上的 GVSDS 代理账户", link: "https://center.mcmod.cn/1125176/", target: "_blank" }, - { name: "工作室反馈", description: "反馈您所有的关于本工作室的问题(若你迟迟未收到回信或无法发送邮件就下面所有邮件全部发一遍)", link: "mailto:support@gvsds.com", target: "_blank" }, - { name: "负责人工作邮箱", description: "负责处理工作相关事务", link: "mailto:terminexus@gvsds.com" }, - { name: "负责人个人邮箱", description: "负责个人学习交流等", link: "mailto:wyt18222152539wyt@163.com" }, - { name: "站点负责人", description: "负责本站的相关管理", link: "mailto:postmaster@gvsds.com" } + { name: "工作室反馈", description: "反馈您所有的关于本工作室的问题(若您迟迟未收到回信或无法发送邮件就下面所有邮件全部发一遍)", link: "mailto:support@gvsds.com", target: "_blank" }, + { name: "负责人工作邮箱", description: "处理工作相关事务等", link: "mailto:terminexus@gvsds.com" }, + { name: "负责人个人邮箱", description: "个人学习交流等", link: "mailto:wyt18222152539wyt@163.com" }, + { name: "站点负责人邮箱", description: "本站的相关管理工作等", link: "mailto:postmaster@gvsds.com" } ] } ], @@ -69,10 +69,10 @@ const MenuData1 = { { name: "Chindows / MicGan", description: "星界工作室(子工作室)负责人", link: "#" }, { name: "悠然自得", description: "这个人很懒,连简介都不愿意写", link: "#" }, { name: "SYSTEM-WINDOW", description: "反编译", link: "#" }, - { name: "羊村哲学家", description: "这个人也很高冷,什么都不愿意留下。", link: "#" }, + { name: "羊村哲学家", description: "你来🌍的目的是什么", link: "#" }, { name: "Mendelevium", description: "我们崇尚万物,我们追随自由。", link: "#" }, { name: "红叶鸣秋", description: "“那”不是疯话,“这”才是疯话。", link: "#" }, - { name: "是个人物", description: "这个人也很高冷,什么都不愿意留下。", link: "#" } + { name: "无风之地", description: "我是楠酿", link: "#" } ] }, { @@ -80,30 +80,26 @@ const MenuData1 = { title: "美工部", subtitle: "灵感不打烊,设计不停歇", items: [ - { name: "赤砂", description: "Furry 画师,👍👍👍", link: "/Members/ChiSha/" }, - { name: "B-Smooth", description: "Furry 画师,👍👍👍", link: "/Members/B-Smooth/" }, + { name: "渊泽_YuanZe", description: "一名画师,爱好福瑞绘画和索尼相机摄影", link: "/Members/YuanZe/" }, + { name: "B-Smooth", description: "画师,爱好绘画,擅长绘画结构的理解", link: "/Members/B-Smooth/" }, { name: "好运莲莲", description: "允公允能 日新月异✨", link: "#" }, - { name: "coco.24🦋", description: "현진이💛리노-7", link: "#" }, + { name: "coco.24", description: "현진이💛리노..2711..", link: "#" }, { name: "TermiNexus / 明砚", description: "业余画师,主攻网页设计、3D 建模", link: "/Members/TermiNexus/" }, - { name: "是个人物", description: "这个人也很高冷,什么都不愿意留下。", link: "#" }, - { name: "是个人物", description: "这个人也很高冷,什么都不愿意留下。", link: "#" }, - { name: "是个人物", description: "这个人也很高冷,什么都不愿意留下。", link: "#" } - ] - } - ], - "minecraft": [ - { - type: "four-columns", - title: "页面", - subtitle: "

注意,由于系统正在维修,当前大多服务无法正常使用

", - items: [ - { name: "在线 MC", tag: "preview", description: "利用 WebAssembly 实现的 EaglercraftX 在线 MC", link: "https://omc.gvsds.com", target: "_blank" }, - { name: "《枢机协律》服务器", tag: "preview", description: "工作室托管的我的世界服务器主页", link: "https://mc.gvsds.com", target: "_blank" }, - { name: "《枢机协律》信用储备银行", description: "《枢机协律》内唯一的合法银行", link: "https://3crb.gvsds.com", target: "_blank" }, - { name: "《枢机协律》中央法庭", tag: "new", description: "《枢机协律》内唯一的合法民主民判法庭", link: "https://3c.gvsds.com", target: "_blank" }, - { name: "《枢机协律》标准地图", tag: "new", description: "《枢机协律》提供的服务器标准地图,会暴露基地地址,考虑关停", link: "https://mc.gvsds.com:8100", target: "_blank" } + { name: "耀月", description: "For Stellar Allies.", link: "#" }, + { name: "離快玡", description: "剪辑师", link: "#" }, + // { name: "是个人物", description: "这个人也很高冷,什么都不愿意留下。", link: "#" } ] }, + { + type: "four-columns", + title: "运营部", + subtitle: "佛祖保佑,设备不停机,产品不滞销", + items: [ + { name: "ShuRuMingZi", description: "运维人员,负责基本维护", link: "https://srmz.cn/", target: "_blank" }, + { name: "爱飙车的g(ISFP) / felix / 余凤", description: "咸鱼翻了身 不还是咸鱼吗 人努力的意义是什么", link: "#"}, + { name: "希龙何", description: "文案", link: "#"}, + ] + } ] } } @@ -113,12 +109,13 @@ const MenuData2 = { title: "产品 / 项目", description: "提供我们历史所作项目的介绍、文档、开源链接等相关信息", submenus: [ - { id: "python", name: "Python", icon: "fa-building" }, + { id: "building", name: "构建", icon: "fa-building" }, { id: "mods", name: "我的世界模组", icon: "fa-briefcase" }, { id: "web", name: "前端", icon: "fa-code" }, + { id: "minecraft", name: "Minecraft 服务", icon: "fa-gamepad" }, ], contents: { - python: [ + building: [ { type: "two-cards", cards: [ @@ -130,7 +127,6 @@ const MenuData2 = { type: "four-columns", title: "新增项目", items: [ - { name: "VU", tag: "new", description: "PyWebView 实现的即时通讯工具,支持多用户、多房间、加密传输等功能", link: "/Project/Unauthorized.html" }, { name: "FastProxyX (FPX)", tag: "new", description: "Python 实现的快速反向代理(端口映射),支持多端口映射、加密传输、HTTP/HTTPS 等功能", link: "/Project/FPX/" }, { name: "AScientificDDOSGun", tag: "new", description: "Python 实现的快速 DDOS 客户端程序,支持多线程、自定义参数等功能", link: "/Project/Unauthorized.html" } ] @@ -139,11 +135,9 @@ const MenuData2 = { type: "four-columns", title: "已完成项目", items: [ - { name: "550W.2049", description: "仿造的 550W UI,由于架构问题,已完成但也废弃", link: "/Project/Unauthorized.html" }, - { name: "TK10", description: "全球核打击 UI,小试牛刀", link: "/Project/Unauthorized.html" }, { name: "CreateE Library", description: "使用 Python 实现的 我的世界坐标空间向量计算实现一些列高级指令功能,已完成", link: "/Project/Unauthorized.html" }, { name: "WVBlackManager", description: "Flask + 前端 实现的 节点状态管理器,支持多节点管理、状态监控、远程控制等功能", link: "/Project/Unauthorized.html" }, - { name: "工作室网站后端", description: "集工作室全部力量打造的平台,面向各终端机,提供了工作室的全部功能,如 成员管理、项目管理、合作管理 等", link: "/Project/SMANAGE/" } + { name: "SMANAGE SAAS", description: "服务即应用平台,面向各终端机,提供了工作室的全部功能,如 成员管理、项目管理、合作管理 等", link: "/Project/SMANAGE/" } ] }, { @@ -153,13 +147,6 @@ const MenuData2 = { { name: "MikOS", description: "【星界工作室】使用 Tkinter 实现的伪操作系统图像壳,利用了伪操作系统 NT 架构,已废弃", link: "/Project/Unauthorized.html" }, { name: "NSQL", description: "使用 Python 实现快速与 MySQL 服务器交互,由于其功能完全可以被 ORM 取代,已废弃", link: "/Project/Unauthorized.html" } ] - }, - { - type: "four-columns", - title: "半废弃项目", - items: [ - { name: "CAP", description: "PyWebView 实现的成员桌面端管理系统,半废弃,大部分功能将合并到 VU", link: "/Project/Unauthorized.html" } - ] } ], mods: [ @@ -188,6 +175,20 @@ const MenuData2 = { { name: "ResourceShare", tag: "new", description: "企业级 MPA 资源共享解决方案:解决 iframe 资源加载瓶颈。支持 VM 虚拟路径映射、Blob 传输优化、可视化 UI 监控以及智能错误诊断。为大规模复杂 Web 应用打造的高性能底层引擎。", link: "/Project/ResourceShare/" } ] } + ], + "minecraft": [ + { + type: "four-columns", + title: "页面", + subtitle: "

注意,由于系统正在维修,当前大多服务无法正常使用

", + items: [ + { name: "在线 MC", tag: "preview", description: "利用 WebAssembly 实现的 EaglercraftX 在线 MC", link: "https://omc.gvsds.com", target: "_blank" }, + { name: "《枢机协律》服务器", tag: "preview", description: "工作室托管的我的世界服务器主页", link: "https://mc.gvsds.com", target: "_blank" }, + { name: "《枢机协律》信用储备银行", description: "《枢机协律》内唯一的合法银行", link: "https://3crb.gvsds.com", target: "_blank" }, + { name: "《枢机协律》中央法庭", tag: "new", description: "《枢机协律》内唯一的合法民主民判法庭", link: "https://3c.gvsds.com", target: "_blank" }, + { name: "《枢机协律》标准地图", tag: "new", description: "《枢机协律》提供的服务器标准地图,会暴露基地地址,考虑关停", link: "https://mc.gvsds.com:8100", target: "_blank" } + ] + }, ] } } diff --git a/TempLate/assets/css/gvsds-page.css b/TempLate/assets/css/gvsds-page.css index b0c5e72a..cc8fac21 100644 --- a/TempLate/assets/css/gvsds-page.css +++ b/TempLate/assets/css/gvsds-page.css @@ -515,7 +515,13 @@ html[data-theme-mode=dark] .M-FlipCard .digital:before { } - +.blurry { + background: rgba(0, 0, 0, 0.28); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border-radius: 14px; + box-sizing: border-box; +} /* ── Mobile: headline backdrop 仅在 md+ 显示,移动端用纯透明渐变遮罩代替 ── */ diff --git a/TempLate/assets/img/Cooperation/XINGJI.png b/TempLate/assets/img/Cooperation/XINGJI.png new file mode 100644 index 00000000..93ddd040 Binary files /dev/null and b/TempLate/assets/img/Cooperation/XINGJI.png differ diff --git a/TempLate/assets/img/Heroes/FUBG.png b/TempLate/assets/img/Heroes/FUBG.png new file mode 100644 index 00000000..b32a0b56 Binary files /dev/null and b/TempLate/assets/img/Heroes/FUBG.png differ diff --git a/TempLate/assets/img/Heroes/FUBG_R.png b/TempLate/assets/img/Heroes/FUBG_R.png new file mode 100644 index 00000000..2e6fa630 Binary files /dev/null and b/TempLate/assets/img/Heroes/FUBG_R.png differ diff --git a/TempLate/assets/img/Heroes/copyright.png b/TempLate/assets/img/Heroes/copyright.png index a6ffe4a0..de0e1853 100644 Binary files a/TempLate/assets/img/Heroes/copyright.png and b/TempLate/assets/img/Heroes/copyright.png differ diff --git a/TempLate/assets/js/MegaMenus.js b/TempLate/assets/js/MegaMenus.js index 3400099f..c5bb881f 100644 --- a/TempLate/assets/js/MegaMenus.js +++ b/TempLate/assets/js/MegaMenus.js @@ -366,39 +366,42 @@ function initMegaMenu(triggerSelector, menuData, containerId) { return menuId; } -// 绑定菜单事件【极简修复版】 function bindMenuEvents(triggers, menuId, menuKey) { const menu = document.getElementById(menuId); let timer = null; - // 移入导航按钮:清定时器+开菜单 triggers.forEach(trigger => { trigger.addEventListener('mouseenter', () => { clearTimeout(timer); closeAllMegaMenus(); menu?.classList.remove('hidden'); + window.isAnyMegaMenuOpen = true; + handleHeaderTransparent(); }); + trigger.addEventListener('click', (e) => { e.preventDefault(); clearTimeout(timer); closeAllMegaMenus(); menu?.classList.toggle('hidden'); + window.isAnyMegaMenuOpen = !menu.classList.contains('hidden'); + handleHeaderTransparent(); }); }); - // 移入菜单:不关闭 menu?.addEventListener('mouseenter', () => { clearTimeout(timer); + window.isAnyMegaMenuOpen = true; }); - // 移出菜单:延迟200ms再关 menu?.addEventListener('mouseleave', () => { timer = setTimeout(() => { menu.classList.add('hidden'); + window.isAnyMegaMenuOpen = false; + handleHeaderTransparent(); }, 100); }); - // 二级菜单切换 完全原封不动 menu?.querySelectorAll('.submenu-item').forEach(item => { item.addEventListener('mouseenter', () => { const submenuId = item.dataset.submenu; @@ -416,13 +419,14 @@ function bindMenuEvents(triggers, menuId, menuKey) { }); }); - // 点击外部关闭 保留 document.addEventListener('click', e => { const isTrigger = Array.from(triggers).some(t => t.contains(e.target)); const isMenu = menu && menu.contains(e.target); if (!isTrigger && !isMenu) { clearTimeout(timer); menu?.classList.add('hidden'); + window.isAnyMegaMenuOpen = false; + handleHeaderTransparent(); } }); } diff --git a/TempLate/assets/js/nav.js b/TempLate/assets/js/nav.js index 22976075..ada7b9aa 100644 --- a/TempLate/assets/js/nav.js +++ b/TempLate/assets/js/nav.js @@ -75,4 +75,49 @@ mobileMenuLinks.forEach(link => { mobileMenu.classList.remove('mobile-menu-enter-active'); mobileMenu.classList.add('mobile-menu-exit', 'mobile-menu-exit-active'); }); -}); \ No newline at end of file +}); + + + + + + + +const headerDom = document.getElementById('header'); +const navLinks = document.querySelectorAll('nav a[data-menu]'); +const originNavClassList = Array.from(navLinks).map(item => item.className); + +// 全局状态 +window.navTextMode = "dark"; +window.isAnyMegaMenuOpen = false; + +function handleHeaderTransparent() { + // 菜单打开锁定规则 + if(window.isAnyMegaMenuOpen && window.scrollY <= 200){ + headerDom.style.background = 'transparent'; + headerDom.style.boxShadow = 'none'; + headerDom.style.borderBottom = 'none'; + window.navTextMode = "light"; + headerDom.setAttribute('data-nav-theme', 'light'); + return; + } + + if (window.scrollY <= 5) { + // 顶部透明模式 + headerDom.style.background = 'transparent'; + headerDom.style.boxShadow = 'none'; + headerDom.style.borderBottom = 'none'; + window.navTextMode = "light"; + headerDom.setAttribute('data-nav-theme', 'light'); + } else { + // 滚动白底模式 + headerDom.style.background = ''; + headerDom.style.boxShadow = ''; + headerDom.style.borderBottom = ''; + window.navTextMode = "dark"; + headerDom.setAttribute('data-nav-theme', 'dark'); + } +} + +handleHeaderTransparent(); +window.addEventListener('scroll', handleHeaderTransparent); \ No newline at end of file diff --git a/TempLate/gallery.html b/TempLate/gallery.html index 76bf2f49..da78c631 100644 --- a/TempLate/gallery.html +++ b/TempLate/gallery.html @@ -1,13 +1,23 @@ \ No newline at end of file diff --git a/TempLate/pages/private/CreateE/Recharge.html b/TempLate/pages/private/CreateE/Recharge.html new file mode 100644 index 00000000..7e9b1bb3 --- /dev/null +++ b/TempLate/pages/private/CreateE/Recharge.html @@ -0,0 +1,635 @@ + + + + +
+
+
+

储值充值

+ +
+
+
+ 选择储值方案并输入金额,提交后将从您的 GC 钱包扣除相应金额并存入定期,按日计息。 + 1 游戏日 = 20 现实分钟;活期随时可取,定期需满存期方可取款。 +
+
+

加载储值方案中...

+
+ + +
+
+
+ +
+ + diff --git a/TempLate/pages/private/CreateE/StoreValue.html b/TempLate/pages/private/CreateE/StoreValue.html new file mode 100644 index 00000000..494b9588 --- /dev/null +++ b/TempLate/pages/private/CreateE/StoreValue.html @@ -0,0 +1,305 @@ + + + + +
+
+
+

已购买储值项目

+ +
+
+
+ + + + + + + + + + + + + + + + + + +
玩家名称储值类型创建时间最短取出时间已存日期日利率储值金额累计利息生效长度操作

加载中...

+
+
+
+
+ +
+ + diff --git a/TempLate/pages/private/CreateE/enterprise.html b/TempLate/pages/private/CreateE/enterprise.html new file mode 100644 index 00000000..d8df6170 --- /dev/null +++ b/TempLate/pages/private/CreateE/enterprise.html @@ -0,0 +1,1348 @@ + + + + + + + + + + +
+ +
+
+
+
+ +
+
+

枢机信用储备银行附属红光证券

+

银河锋锐软件开发工作室

+
+
+ +
+ + + + + + + +
+
+ + + +
+ +
+ +
+
+
+
+

企业管理列表

+

管理所有注册企业信息,支持查看、编辑和刷新操作

+
+
+
+ + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
企业UUID企业名称企业类型状态法人注册资本创建时间操作
+
+ +

暂无企业数据,请点击"添加企业"按钮创建

+
+
+
+ + +
+
+ 显示 0-0 条,共 0 条 +
+
+ + +
+
+
+
+
+ + + + + + + + + + +
+
+
+
+
+
+ +
+
+

枢机信用储备银行

+

银河锋锐软件开发工作室

+
+
+
+
+

© 2025 枢机信用储备银行. 保留所有权利.

+
+
+
+
+ + + +
diff --git a/TempLate/pages/private/CreateE/enterprisecard.html b/TempLate/pages/private/CreateE/enterprisecard.html new file mode 100644 index 00000000..959abcb6 --- /dev/null +++ b/TempLate/pages/private/CreateE/enterprisecard.html @@ -0,0 +1,1115 @@ + + + + + + + + + + +
+ +
+
+
+
+ +
+
+

企业详情

+

枢机信用储备银行附属红光证券

+
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +

正在加载企业信息...

+

+
+
+ + + + + + +
+
+ + + + + + + + +
+ +
+
+

© 2025 枢机信用储备银行附属红光证券 - 保留所有权利

+
+
+ + + +
diff --git a/TempLate/pages/private/CreateE/shop.html b/TempLate/pages/private/CreateE/shop.html new file mode 100644 index 00000000..16c16ea0 --- /dev/null +++ b/TempLate/pages/private/CreateE/shop.html @@ -0,0 +1,1687 @@ + + + + + + 枢机协律商店 + + + + + + + + + + + +
+
+
+ + + storefront + + + +
+
+ + +
+
+ + + + + shopping_cart + + + + + + +
+
+
+ + +
+ + + + + +
+ + +
+ + + + diff --git a/TempLate/pages/private/CreateE/wallet.html b/TempLate/pages/private/CreateE/wallet.html new file mode 100644 index 00000000..457a3e00 --- /dev/null +++ b/TempLate/pages/private/CreateE/wallet.html @@ -0,0 +1,691 @@ + + + +
+

枢机信用储备银行 钱包

+
中心化数字资产钱包
+ + + +
+
资产列表
+
数据加载中...
+
+ +
+
玩家地址
+
等待请求...
+
账户地址
+
等待请求...
+
+ +
+ +
生成分享码中...
+
+ +
+ + +
+
+

币种兑换

+
+ + +
+
+ + +
+
+ + +
+
+
+ 汇率 + — +
+
+ 预计到账 + — +
+
+
+ + +
+
+
+ + diff --git a/TempLate/rok/lib/battle.png b/TempLate/rok/lib/battle.png new file mode 100644 index 00000000..81793659 Binary files /dev/null and b/TempLate/rok/lib/battle.png differ diff --git a/TempLate/rok/lib/index.html b/TempLate/rok/lib/index.html new file mode 100644 index 00000000..16c5ca02 --- /dev/null +++ b/TempLate/rok/lib/index.html @@ -0,0 +1,282 @@ + + + + + + 观云川 | K1350 合纵阵线 + + + + + + + + + + +
+
+ 战场 +
+ +
+
+ +

观云川使节 · 晓谕诸盟

+
+

+ K1350 · 合纵阵线檄文 +

+

+ 同一出争霸戏,已演至千三百五十回。寄人篱下终成空,联袂自立方为真章。 +

+
+ + 展卷详阅 +
+
+
+ + +
+ + +
+
+ + + +
+

晓 谕 诸 盟 书

+

A LETTER TO ALL ALLIANCES

+
+ + +
+

方今 K1350 王国一省大势已明,TL00、TL11、TL22三盟结为一气,分头游说诸方,志在全境独尊。一旦其根基坐定,则区内要道、矿脉、城池皆由其裁断,诸盟进退维谷,再无置喙之地。

+ +

若任其势力扩张,则疆土关隘、资源据点尽入其掌,诸盟虽欲偏安亦不可得。唇亡齿寒,此之谓也。

+
+ + +
+

+ 「以众小攻一强,众小犹不可得,
况以三小攻一强乎?」 +

+

——《合纵论》引

+
+ + +
+
+
+ + + +
+

战略形势一览

+

CURRENT STRATEGIC POSITION

+
+ + +
+
+ 附图 · STRATEGIC MAP +
+
+ K1350 · 第 1350 赛季 +
+ K1350 合纵阵线战略形势图 +

+ + 图中红色方框为 TL 三盟盘踞之地; + 金色大营为观云川合纵中枢; +

+
+
+ + +
+

然诸盟之中,亦有误判形势者,以为暂附 TL 阵营可保无虞。殊不知赛季终将落幕,本区众人终须离去,眼前所换得的安稳,无非泡影。

+ +

摆在诸盟面前的,仅余二途:

+ +

+ 一曰:俯首受制,长久仰人鼻息;
+ 二曰:合众自立,共抗一方豪强。 +

+ +

与其坐待他人刀俎,不如联袂而起一战。合纵之义,自古皆然。

+
+ + +
+
+
+
+
+ +

合纵阵线合作准则

+
+
    +
  • 壹、主盟分盟配套成体系,诸盟互为犄角,互通军情,遇袭必援;
  • +
  • 贰、疆土、资源皆由诸盟公议分配,遇争则坐而论道;
  • +
  • 叁、加盟诸盟原盟主管理权限一概保留;
  • +
  • 肆、我方仅外派人员统辖战事调度,绝不干涉各盟内务;
  • +
  • 伍、尊重各盟原有游戏节奏,绝不强加所难。
  • +
+
+
+ + +
+

是俯首而久居人下,还是合众而共抗强梁,诸盟主其善自权衡。

+ +

+ 静候同道 · 共谋大业 +

+
+ + +
+ + + +
+ + +
+
+

观云川 使节

+

TermiNexus

+

丙午年 六月廿九 · 2026/07/23

+
+ +
+ + + + 观云 + 合纵 + +
+
+
+ + +
+

万国觉醒 K1350 | 观云川外交宣传页

+

本页面为游戏内政治宣传材料,仅供同好交流

+
+ + + + diff --git a/TempLate/rok/lib/war.png b/TempLate/rok/lib/war.png new file mode 100644 index 00000000..7cde2cbe Binary files /dev/null and b/TempLate/rok/lib/war.png differ diff --git a/_tmp_fix_p2.ps1 b/_tmp_fix_p2.ps1 deleted file mode 100644 index 98fc00e2..00000000 --- a/_tmp_fix_p2.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -$path = "d:\SMANAGE\gvsdsdk\models.py" -$utf8NoBom = New-Object System.Text.UTF8Encoding $false -$content = [System.IO.File]::ReadAllText($path, $utf8NoBom) -$CRLF = "`r`n" -$sq = [char]39 -$dq = [char]34 -$tripleDq = "$dq$dq$dq" - -# 构造死函数块(含 MARKER 行和尾部 2 个空行) -$deadFunc = "def _EltEndTimeDefault():$CRLF $tripleDq" + "已废弃:ELT 令牌过期时间默认值,仅为迁移历史兼容保留" + "$tripleDq$CRLF from django.conf import settings$CRLF days = getattr(settings, ${sq}ELT_TOKEN_LIFETIME_DAYS${sq}, 30) # MARKER$CRLF return _UtcNow() + _timedelta(days=days)$CRLF$CRLF$CRLF" - -$before = $content.Length -$found1 = $content.Contains($deadFunc) -$content = $content.Replace($deadFunc, "") - -# 删除死导入 -$deadImport = "from datetime import timedelta as _timedelta$CRLF" -$found2 = $content.Contains($deadImport) -$content = $content.Replace($deadImport, "") - -[System.IO.File]::WriteAllText($path, $content, $utf8NoBom) -$removed = $before - $content.Length -Write-Output "deadFunc found: $found1" -Write-Output "deadImport found: $found2" -Write-Output "chars removed: $removed" diff --git a/gvsds/__pycache__/exception_handler.cpython-312.pyc b/gvsds/__pycache__/exception_handler.cpython-312.pyc index f038dfd9..53d91a2d 100644 Binary files a/gvsds/__pycache__/exception_handler.cpython-312.pyc and b/gvsds/__pycache__/exception_handler.cpython-312.pyc differ diff --git a/gvsds/__pycache__/urls.cpython-312.pyc b/gvsds/__pycache__/urls.cpython-312.pyc index 4da1dc2e..20e5da69 100644 Binary files a/gvsds/__pycache__/urls.cpython-312.pyc and b/gvsds/__pycache__/urls.cpython-312.pyc differ diff --git a/gvsds/apps/core/__pycache__/models.cpython-312.pyc b/gvsds/apps/core/__pycache__/models.cpython-312.pyc index e11358ed..3cfe5727 100644 Binary files a/gvsds/apps/core/__pycache__/models.cpython-312.pyc and b/gvsds/apps/core/__pycache__/models.cpython-312.pyc differ diff --git a/gvsds/apps/core/models.py b/gvsds/apps/core/models.py index b6a0ea22..b376cc46 100644 --- a/gvsds/apps/core/models.py +++ b/gvsds/apps/core/models.py @@ -197,11 +197,14 @@ class User(QModel): return self.UserTele or '未填写' def GetCompanies(self): - """获取用户所属公司列表(通过 UserDept → Department → Company 链路)""" - from gvsds.apps.gerp.models import Company, UserDept - return Company.objects.filter( - department__userdept__UserUUID=self.UserUUID - ).distinct() + """获取用户所属公司列表(通过 UserRole.CompanyUUID 直接关联)""" + from gvsds.apps.gerp.models import Company + from gvsdsdk.models import UserRole + company_uuids = UserRole.objects.filter( + UserUUID=self.UserUUID, + CompanyUUID__isnull=False, + ).values_list('CompanyUUID', flat=True) + return Company.objects.filter(CompanyUUID__in=list(company_uuids)).distinct() def GetRoles(self): """获取用户角色列表(角色不绑定公司/部门,租户内独立存在)""" @@ -209,11 +212,14 @@ class User(QModel): return Role.objects.filter(userrole__UserUUID=self.UserUUID) def GetDepartments(self): - """获取用户部门列表(通过 UserDept 直接关联,不再通过 RoleDept)""" - from gvsds.apps.gerp.models import Department, UserDept - return Department.objects.filter( - userdept__UserUUID=self.UserUUID - ).distinct() + """获取用户部门列表(通过 UserRole.DeptUUID 直接关联)""" + from gvsds.apps.gerp.models import Department + from gvsdsdk.models import UserRole + dept_uuids = UserRole.objects.filter( + UserUUID=self.UserUUID, + DeptUUID__isnull=False, + ).values_list('DeptUUID', flat=True) + return Department.objects.filter(DeptUUID__in=list(dept_uuids)).distinct() def HasPermission(self, perm_code): """检查用户是否拥有指定权限 diff --git a/gvsds/apps/crb/__pycache__/admin.cpython-312.pyc b/gvsds/apps/crb/__pycache__/admin.cpython-312.pyc index 30ed63df..b57b9f44 100644 Binary files a/gvsds/apps/crb/__pycache__/admin.cpython-312.pyc and b/gvsds/apps/crb/__pycache__/admin.cpython-312.pyc differ diff --git a/gvsds/apps/crb/__pycache__/business.cpython-312.pyc b/gvsds/apps/crb/__pycache__/business.cpython-312.pyc new file mode 100644 index 00000000..2bf9a8c9 Binary files /dev/null and b/gvsds/apps/crb/__pycache__/business.cpython-312.pyc differ diff --git a/gvsds/apps/crb/__pycache__/models.cpython-312.pyc b/gvsds/apps/crb/__pycache__/models.cpython-312.pyc index a84d35d1..09b35d39 100644 Binary files a/gvsds/apps/crb/__pycache__/models.cpython-312.pyc and b/gvsds/apps/crb/__pycache__/models.cpython-312.pyc differ diff --git a/gvsds/apps/crb/__pycache__/serializers.cpython-312.pyc b/gvsds/apps/crb/__pycache__/serializers.cpython-312.pyc index 8bcad9a5..cde49e9b 100644 Binary files a/gvsds/apps/crb/__pycache__/serializers.cpython-312.pyc and b/gvsds/apps/crb/__pycache__/serializers.cpython-312.pyc differ diff --git a/gvsds/apps/crb/__pycache__/urls.cpython-312.pyc b/gvsds/apps/crb/__pycache__/urls.cpython-312.pyc index 745e2008..c04840c3 100644 Binary files a/gvsds/apps/crb/__pycache__/urls.cpython-312.pyc and b/gvsds/apps/crb/__pycache__/urls.cpython-312.pyc differ diff --git a/gvsds/apps/crb/__pycache__/views.cpython-312.pyc b/gvsds/apps/crb/__pycache__/views.cpython-312.pyc index ce3a66ff..c7e761bf 100644 Binary files a/gvsds/apps/crb/__pycache__/views.cpython-312.pyc and b/gvsds/apps/crb/__pycache__/views.cpython-312.pyc differ diff --git a/gvsds/apps/crb/admin.py b/gvsds/apps/crb/admin.py index 6d0637fe..e5396cfe 100644 --- a/gvsds/apps/crb/admin.py +++ b/gvsds/apps/crb/admin.py @@ -1,43 +1,104 @@ +"""模块:CRB 后台管理 + +注册 6 个 MC 插件共享表(managed=False,只读浏览)与 4 个 Django 独有表 +(managed=True,可增删改查)。MC 插件表通过 ``readonly_fields`` 防止误改。""" + from django.contrib import admin -from .models import CmeCurrencies, CmeWalletBalances, CmeStoreValue, CmeWalletGVSDSMapping, Enterprise, EnterpriseUser +from .models import ( + CmeCurrencies, CmeBalances, CmeTransactions, CmePaymentRequests, + CmePlayerSettings, CmeExchangeRates, + CmeStoreValue, CmeWalletGVSDSMapping, Enterprise, EnterpriseUser, +) + + +class MCPluginReadOnlyAdminMixin: + """MC 插件共享表只读 Mixin——禁止通过 admin 增删改,保护 MC 插件数据。""" + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False @admin.register(CmeCurrencies) -class CmeCurrenciesAdmin(admin.ModelAdmin): - """币种管理""" - list_display = ('CurrencyName', 'DisplayName', 'Unit', 'IsDefault') - search_fields = ('CurrencyName', 'DisplayName') +class CmeCurrenciesAdmin(MCPluginReadOnlyAdminMixin, admin.ModelAdmin): + """CME 币种管理(只读)""" + list_display = ('CurrencyID', 'CurrencyName', 'CurrencySymbol', 'Tradable', 'CreatedAt') + search_fields = ('CurrencyName', 'CurrencySymbol') + list_filter = ('Tradable',) -@admin.register(CmeWalletBalances) -class CmeWalletBalancesAdmin(admin.ModelAdmin): - """钱包余额管理""" - list_display = ('PlayerName', 'CurrencyID', 'Balance') +@admin.register(CmeBalances) +class CmeBalancesAdmin(MCPluginReadOnlyAdminMixin, admin.ModelAdmin): + """CME 钱包余额管理(只读)""" + list_display = ('BalanceID', 'UserUUID', 'Currency', 'Balance', 'UpdatedAt') + search_fields = ('UserUUID',) + list_filter = ('Currency',) + + +@admin.register(CmeTransactions) +class CmeTransactionsAdmin(MCPluginReadOnlyAdminMixin, admin.ModelAdmin): + """CME 交易流水管理(只读)""" + list_display = ('TransactionID', 'FromUUID', 'ToUUID', 'Currency', 'Amount', 'TransactionType', 'CreatedAt') + search_fields = ('FromUUID', 'ToUUID', 'Description') + list_filter = ('TransactionType', 'Currency') + + +@admin.register(CmePaymentRequests) +class CmePaymentRequestsAdmin(MCPluginReadOnlyAdminMixin, admin.ModelAdmin): + """CME 付款请求管理(只读)""" + list_display = ('RequestID', 'FromUUID', 'ToUUID', 'Currency', 'Amount', 'Status', 'CreatedAt') + search_fields = ('FromUUID', 'ToUUID') + list_filter = ('Status', 'Currency') + + +@admin.register(CmePlayerSettings) +class CmePlayerSettingsAdmin(MCPluginReadOnlyAdminMixin, admin.ModelAdmin): + """CME 玩家设置管理(只读)""" + list_display = ('SettingID', 'UserUUID', 'Language', 'UpdatedAt') + search_fields = ('UserUUID',) + list_filter = ('Language',) + + +@admin.register(CmeExchangeRates) +class CmeExchangeRatesAdmin(MCPluginReadOnlyAdminMixin, admin.ModelAdmin): + """CME 汇率管理(只读)""" + list_display = ('RateID', 'CurrencyName', 'Rate', 'UpdatedAt') + search_fields = ('CurrencyName',) @admin.register(CmeStoreValue) class CmeStoreValueAdmin(admin.ModelAdmin): - """储值管理""" - list_display = ('PlayerName', 'StoreValueAmount', 'StoreValueType', 'StoreValueCreateDateTime') + """储值管理(可读写,GVSDS 业务表)""" + list_display = ('StoreValueUUID', 'PlayerName', 'StoreValueAmount', 'StoreValueType', + 'StoreValueDayInterestRate', 'StoreValueExpirationGameDate', + 'StoreValueCreateDateTime') list_filter = ('StoreValueType',) + search_fields = ('PlayerName', 'UserUUID', 'PlayerUUID') @admin.register(CmeWalletGVSDSMapping) class CmeWalletGVSDSMappingAdmin(admin.ModelAdmin): - """钱包映射管理""" - list_display = ('UserUUID', 'PlayerUUID') + """GVSDS↔MC 玩家映射管理(可读写,GVSDS 业务表)""" + list_display = ('MappingID', 'UserUUID', 'PlayerUUID') + search_fields = ('UserUUID', 'PlayerUUID') @admin.register(Enterprise) class EnterpriseAdmin(admin.ModelAdmin): - """企业管理""" - list_display = ('EnterpriseName', 'EnterpriseType', 'EnterpriseStatus') - search_fields = ('EnterpriseName',) - list_filter = ('EnterpriseStatus',) + """企业管理(可读写,GVSDS 业务表)""" + list_display = ('EnterpriseName', 'EnterpriseType', 'EnterpriseStatus', 'EnterpriseCreateTime') + search_fields = ('EnterpriseName', 'EnterpriseUUID') + list_filter = ('EnterpriseStatus', 'EnterpriseType') @admin.register(EnterpriseUser) class EnterpriseUserAdmin(admin.ModelAdmin): - """企业用户管理""" - list_display = ('EnterpriseUUID', 'UserUUID', 'EnterpriseUserType') - list_filter = ('EnterpriseUserType',) \ No newline at end of file + """企业用户管理(可读写,GVSDS 业务表)""" + list_display = ('EnterpriseSMTMUUID', 'EnterpriseUUID', 'UserUUID', 'EnterpriseUserType') + list_filter = ('EnterpriseUserType',) + search_fields = ('UserUUID',) diff --git a/gvsds/apps/crb/business.py b/gvsds/apps/crb/business.py new file mode 100644 index 00000000..52fc28d0 --- /dev/null +++ b/gvsds/apps/crb/business.py @@ -0,0 +1,184 @@ +"""模块:CRB 业务逻辑 + +存放 3CRB 业务的共享常量与工具函数(与 SAAS 完全隔离,不依赖 gvsdsdk +Tenant/Company)。业务规则移植自旧 Flask 实现: + +- ``StoreValueRule``:6 种储值方案规则表(活期/定期×5 档) +- ``GetGVSDSUserToMCPlayerUUID``:委托 ``gvsdsdk.minecraft.EasyAuthManager`` + 反查 easyauth 表的 UserRoleUUID 绑定标记,把 GVSDS UserUUID 映射为 + MC 玩家 UUID 字符串(与 MC 绑定流程保持一致) +- ``GetUserByUUID`` / ``GetUserByName``:通过业务子类的 User 模型按 + UUID 或用户名查询,返回 ``User`` 实例 +- ``GetUserNameByUUID``:返回 ``User.UserName``,找不到返回 ``None`` +- ``RequireGCBalance``:检查当前用户是否绑定了 MC 钱包且有 GC 币种余额, + 返回 ``(CmeBalances | None, error_response | None)`` 二元组 +""" + +import uuid as _uuid +from typing import Optional, Tuple + +from gvsdsdk.model_utils import UUIDObj, _GetUserModel + +from .models import CmeBalances, CmeCurrencies + + +# ============================================================================ +# 储值规则表(移植自旧 Flask Utils/Constants.py) +# 利率单位:百万分之一(InterestRate/100000) +# MinGC=-1 表示不限,ExpirationGameDate 单位为游戏日(1 现实秒 = 1/1200 游戏日) +# ============================================================================ + +StoreValueRule: dict = { + 1: {"Name": "活期利零五", "InterestRate": 5, "MinDay": -1, "MinGC": -1, "MaxGC": -1, "ExpirationGameDate": 72}, + 2: {"Name": "定六利一二", "InterestRate": 12, "MinDay": 6 * 30, "MinGC": -1, "MaxGC": -1, "ExpirationGameDate": 30 * 6}, + 3: {"Name": "定年上十万利一五", "InterestRate": 15, "MinDay": 360, "MinGC": -1, "MaxGC": 100000, "ExpirationGameDate": 360}, + 4: {"Name": "定年下万利二", "InterestRate": 20, "MinDay": 360, "MinGC": 10000, "MaxGC": -1, "ExpirationGameDate": 360}, + 5: {"Name": "定年下百万利二五", "InterestRate": 25, "MinDay": 360, "MinGC": 1000000, "MaxGC": -1, "ExpirationGameDate": 360}, + 6: {"Name": "定年下千万利三", "InterestRate": 30, "MinDay": 360, "MinGC": 10000000, "MaxGC": -1, "ExpirationGameDate": 360}, +} + + +# ============================================================================ +# 工具函数 +# ============================================================================ + +def GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) -> Optional[str]: + """GVSDS UserUUID → MC 玩家 UUID 字符串(varchar36) + + 委托 ``gvsdsdk.minecraft.EasyAuthManager.GetPlayerUUIDByGVSDSUser`` 反查 + easyauth 表的 UserRoleUUID 绑定标记,与 MC 绑定流程(``UserBindView``) + 保持一致。旧 Flask 时代使用的 ``CmeWalletGVSDSMapping`` 表已被新绑定 + 机制取代,不再写入数据,因此不再作为查询源。 + + 接受 bytes/str/UUIDObj 形式的 GVSDS UserUUID。 + """ + from gvsdsdk.minecraft import EasyAuthManager + return EasyAuthManager.GetPlayerUUIDByGVSDSUser(gvsds_user_uuid) + + +def GetPlayerNameByGVSDSUser(gvsds_user_uuid) -> Optional[str]: + """GVSDS UserUUID → MC 玩家名(easyauth.Username) + + 反查路径:GVSDS UserUUID → MC Player UUID → EasyAuth.Username。 + 用于企业法人主体显示玩家名而非 GVSDS 用户名。 + + Args: + gvsds_user_uuid: GVSDS 用户 UUID(bytes/str/UUIDObj 均可) + + Returns: + MC 玩家名;未绑定或记录不存在时返回 None + """ + player_uuid = GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) + if not player_uuid: + return None + from gvsdsdk.minecraft import EasyAuthManager + ea = EasyAuthManager.GetByPlayerUUID(player_uuid) + return ea.Username if ea else None + + +def GetGVSDSUserByPlayerName(player_name: str): + """MC 玩家名 → GVSDS UserUUID(bytes) + + 反查路径:玩家名 → EasyAuth 记录 → UserRoleUUID → UserRole.UserUUID。 + 用于企业注册时通过玩家名定位 GVSDS 用户。 + + Args: + player_name: MC 玩家名(easyauth.Username) + + Returns: + GVSDS UserUUID(bytes);玩家不存在/未绑定 GVSDS 账户时返回 None + """ + if not player_name: + return None + from gvsdsdk.minecraft.models import EasyAuth + from gvsdsdk.models import UserRole + from gvsdsdk.model_utils import UUIDObj + ea = EasyAuth.objects.filter(Username=player_name).first() + if not ea or not ea.UserRoleUUID: + return None + ur = UserRole.objects.filter(UserRoleUUID=ea.UserRoleUUID).first() + return ur.UserUUID if ur else None + + +def GetUserByUUID(user_uuid): + """按 UserUUID 查询业务 User 实例(找不到返回 None)""" + if not user_uuid: + return None + User = _GetUserModel() + raw_bytes = UUIDObj(user_uuid).bytes + return User.objects.filter(UserUUID=raw_bytes).first() + + +def GetUserByName(username: str): + """按 UserName 查询业务 User 实例(找不到返回 None)""" + if not username: + return None + User = _GetUserModel() + return User.objects.filter(UserName=username).first() + + +def GetUserNameByUUID(user_uuid) -> Optional[str]: + """按 UserUUID 获取用户名(找不到返回 None)""" + u = GetUserByUUID(user_uuid) + return u.UserName if u else None + + +def RequireGCBalance(gvsds_user_uuid) -> Tuple[Optional[CmeBalances], Optional[dict]]: + """检查当前 GVSDS 用户是否绑定 MC 钱包且有 GC 币种余额 + + Returns: + (CmeBalances, None) — 校验通过,返回 GC 余额记录 + (None, error_dict) — 校验失败,error_dict 形如 + ``{"GC": -1}`` 或 ``{"status": 1, "error": 0, "msg": "No Wallet"}``, + 可直接作为 Response 数据返回前端 + """ + player_uuid_str = GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) + if not player_uuid_str: + return None, {"GC": -1} + + # MC 玩家 UUID 在 cme_balances 表为 BINARY16,需转回 bytes + try: + player_uuid_bytes = _uuid.UUID(player_uuid_str).bytes + except (ValueError, TypeError): + return None, {"GC": -1} + + # 查 GC 币种 id(按 CurrencyName 过滤,避免依赖固定 id) + gc_currency = CmeCurrencies.objects.filter(CurrencyName='GC').first() + if not gc_currency: + return None, {"GC": -1} + + balance = CmeBalances.objects.filter( + UserUUID=player_uuid_bytes, + Currency=gc_currency, + ).first() + if not balance: + return None, {"GC": -1} + return balance, None + + +def CalcAccruedInterest(store_value, now) -> Tuple[float, float, float]: + """计算储值的利息、游戏天数、现实天数 + + Args: + store_value: ``CmeStoreValue`` 实例 + now: ``datetime.datetime`` 当前时间 + + Returns: + (AccruedInterest, GameDays, Days) 三元组 + - GameDays = Seconds / (20*60) + - AccruedInterest = Amount × min(GameDays, Exp) × (Rate / 100000) + - Days = Seconds / (24*60*60) + """ + seconds = (now - store_value.StoreValueCreateDateTime).total_seconds() + game_days = round(seconds / (20 * 60), 2) + days = round(seconds / (24 * 60 * 60), 2) + expiration = store_value.StoreValueExpirationGameDate or 0 + effective_days = min(game_days, expiration) + accrued = store_value.StoreValueAmount * effective_days * (store_value.StoreValueDayInterestRate / 100000) + return accrued, game_days, days + + +def CalcAccruedInterestAndAmount(store_value, now) -> float: + """计算储值本息合计(Amount + AccruedInterest),用于取现""" + accrued, _, _ = CalcAccruedInterest(store_value, now) + return store_value.StoreValueAmount + accrued diff --git a/gvsds/apps/crb/migrations/0001_initial.py b/gvsds/apps/crb/migrations/0001_initial.py index 68c0aa5c..2a3f2752 100644 --- a/gvsds/apps/crb/migrations/0001_initial.py +++ b/gvsds/apps/crb/migrations/0001_initial.py @@ -1,115 +1,205 @@ -# Generated by Django 6.0.5 on 2026-06-08 00:33 - -import django.db.models.deletion -import gvsds.apps.core.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='CmeCurrencies', - fields=[ - ('id', models.CharField(db_column='ID', max_length=20, primary_key=True, serialize=False)), - ('name', models.CharField(db_column='Name', max_length=50)), - ('display_name', models.CharField(db_column='DisplayName', max_length=100)), - ('unit', models.FloatField(db_column='Unit', default=1.0)), - ('is_default', models.SmallIntegerField(db_column='IsDefault', default=0)), - ('icon', models.CharField(db_column='Icon', default='minecraft:iron', max_length=50)), - ('created_at', models.DateTimeField(blank=True, db_column='CreatedAt', null=True)), - ('updated_at', models.DateTimeField(blank=True, db_column='UpdatedAt', null=True)), - ], - options={ - 'db_table': 'cme_currencies', - }, - ), - migrations.CreateModel( - name='CmeStoreValue', - fields=[ - ('store_value_uuid', gvsds.apps.core.models.UUIDField(db_column='StoreValueUUID', primary_key=True, serialize=False)), - ('user_uuid', gvsds.apps.core.models.UUIDField(blank=True, db_column='UserUUID', null=True)), - ('player_uuid', gvsds.apps.core.models.UUIDField(blank=True, db_column='PlayerUUID', null=True)), - ('player_name', models.TextField(blank=True, db_column='PlayerName', null=True)), - ('store_value_create_date_time', models.DateTimeField(blank=True, db_column='StoreValueCreateDateTime', null=True)), - ('store_value_type', models.IntegerField(blank=True, db_column='StoreValueType', null=True)), - ('store_value_min', models.BigIntegerField(blank=True, db_column='StoreValueMin', null=True)), - ('store_value_day_interest_rate', models.SmallIntegerField(blank=True, db_column='StoreValueDayInterestRate', null=True)), - ('store_value_amount', models.BigIntegerField(blank=True, db_column='StoreValueAmount', null=True)), - ('store_value_expiration_game_date', models.IntegerField(blank=True, db_column='StoreValueExpirationGameDate', null=True)), - ], - options={ - 'db_table': 'storevalue', - }, - ), - migrations.CreateModel( - name='Enterprise', - fields=[ - ('enterprise_uuid', gvsds.apps.core.models.UUIDField(db_column='EnterpriseUUID', primary_key=True, serialize=False)), - ('enterprise_create_certification_code', gvsds.apps.core.models.UUIDField(blank=True, db_column='EnterpriseCreateCertificationCode', null=True)), - ('enterprise_father_uuid', gvsds.apps.core.models.UUIDField(blank=True, db_column='EnterpriseFatherUUID', null=True)), - ('enterprise_name', models.TextField(blank=True, db_column='EnterpriseName', null=True)), - ('enterprise_type', models.IntegerField(blank=True, db_column='EnterpriseType', null=True)), - ('enterprise_status', models.IntegerField(blank=True, db_column='EnterpriseStatus', null=True)), - ('enterprise_create_time', models.DateTimeField(blank=True, db_column='EnterpriseCreateTime', null=True)), - ('enterprise_registered_capital', models.FloatField(blank=True, db_column='EnterpriseRegisteredCapital', null=True)), - ('enterprise_capital_gc', models.TextField(blank=True, db_column='EnterpriseCapitalGc', null=True)), - ('enterprise_capital', models.FloatField(blank=True, db_column='EnterpriseCapital', null=True)), - ('enterprise_telephone', models.TextField(blank=True, db_column='EnterpriseTelephone', null=True)), - ('enterprise_email', models.TextField(blank=True, db_column='EnterpriseEmail', null=True)), - ('enterprise_website', models.TextField(blank=True, db_column='EnterpriseWebsite', null=True)), - ('enterprise_addres', models.TextField(blank=True, db_column='EnterpriseAddres', null=True)), - ('enterprise_desc', models.TextField(blank=True, db_column='EnterpriseDesc', null=True)), - ('enterprise_administrative_divisions', models.TextField(blank=True, db_column='EnterpriseAdministrativeDivisions', null=True)), - ('enterprise_business_period', models.BigIntegerField(blank=True, db_column='EnterpriseBusinessPeriod', null=True)), - ('enterprise_approval_date', models.DateTimeField(blank=True, db_column='EnterpriseApprovalDate', null=True)), - ('enterprise_business_scope', models.JSONField(blank=True, db_column='EnterpriseBusinessScope', null=True)), - ], - options={ - 'db_table': 'enterprise', - }, - ), - migrations.CreateModel( - name='CmeWalletBalances', - fields=[ - ('id', models.BigAutoField(db_column='ID', primary_key=True, serialize=False)), - ('player_uuid', models.CharField(db_column='PlayerUUID', max_length=36)), - ('player_name', models.CharField(blank=True, db_column='PlayerName', max_length=16, null=True)), - ('currency_id', models.CharField(db_column='CurrencyID', max_length=20)), - ('balance', models.FloatField(db_column='Balance', default=0)), - ], - options={ - 'db_table': 'cme_wallet_balances', - 'unique_together': {('player_uuid', 'currency_id')}, - }, - ), - migrations.CreateModel( - name='CmeWalletGVSDSMapping', - fields=[ - ('id', models.BigAutoField(db_column='ID', primary_key=True, serialize=False)), - ('user_uuid', gvsds.apps.core.models.UUIDField(db_column='UserUUID')), - ('player_uuid', models.CharField(db_column='PlayerUUID', max_length=36)), - ], - options={ - 'db_table': 'cme_wallet_gvsds', - 'unique_together': {('user_uuid', 'player_uuid')}, - }, - ), - migrations.CreateModel( - name='EnterpriseUser', - fields=[ - ('enterprise_smtm_uuid', gvsds.apps.core.models.UUIDField(db_column='EnterpriseSMTMUUID', primary_key=True, serialize=False)), - ('user_uuid', gvsds.apps.core.models.UUIDField(blank=True, db_column='UserUUID', null=True)), - ('enterprise_user_type', models.IntegerField(blank=True, db_column='EnterpriseUserType', null=True)), - ('enterprise_uuid', models.ForeignKey(blank=True, db_column='EnterpriseUUID', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='crb.enterprise')), - ], - options={ - 'db_table': 'enterprise_user', - }, - ), - ] +"""CRB 初始迁移——6 个 MC 插件共享表(managed=False,仅声明不建表) ++ 4 个 Django 独有表(managed=True,实际建表)。""" + +import django.db.models.deletion +import gvsds.apps.core.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + # ============================================================ + # MC 插件共享表(managed=False):Django 不实际建表,仅声明 model state + # ============================================================ + migrations.CreateModel( + name='CmeCurrencies', + fields=[ + ('CurrencyID', models.AutoField(db_column='id', primary_key=True, serialize=False, verbose_name='币种ID')), + ('CurrencyName', models.CharField(db_column='name', max_length=255, unique=True, verbose_name='币种名称')), + ('CurrencySymbol', models.CharField(db_column='symbol', max_length=255, verbose_name='币种符号')), + ('Tradable', models.IntegerField(db_column='tradable', default=1, verbose_name='是否可交易')), + ('CreatedAt', models.DateTimeField(auto_now_add=True, blank=True, db_column='created_at', null=True, verbose_name='创建时间')), + ], + options={ + 'db_table': 'cme_currencies', + 'managed': False, + 'verbose_name': 'CME币种', + 'verbose_name_plural': 'CME币种', + }, + ), + migrations.CreateModel( + name='CmeBalances', + fields=[ + ('BalanceID', models.AutoField(db_column='id', primary_key=True, serialize=False, verbose_name='余额ID')), + ('UserUUID', gvsds.apps.core.models.UUIDField(db_column='user_uuid', verbose_name='MC玩家UUID')), + ('Currency', models.ForeignKey(db_column='currency_id', on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='crb.cmecurrencies', verbose_name='币种')), + ('Balance', models.FloatField(db_column='balance', default=0, verbose_name='余额')), + ('CreatedAt', models.DateTimeField(auto_now_add=True, blank=True, db_column='created_at', null=True, verbose_name='创建时间')), + ('UpdatedAt', models.DateTimeField(auto_now=True, blank=True, db_column='updated_at', null=True, verbose_name='更新时间')), + ], + options={ + 'db_table': 'cme_balances', + 'managed': False, + 'verbose_name': 'CME钱包余额', + 'verbose_name_plural': 'CME钱包余额', + 'unique_together': {('UserUUID', 'Currency')}, + }, + ), + migrations.CreateModel( + name='CmeTransactions', + fields=[ + ('TransactionID', models.AutoField(db_column='id', primary_key=True, serialize=False, verbose_name='交易ID')), + ('FromUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='from_uuid', null=True, verbose_name='付款方MC玩家UUID')), + ('ToUUID', gvsds.apps.core.models.UUIDField(blank=True, db_column='to_uuid', null=True, verbose_name='收款方MC玩家UUID')), + ('Currency', models.ForeignKey(db_column='currency_id', on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='crb.cmecurrencies', verbose_name='币种')), + ('Amount', models.FloatField(db_column='amount', verbose_name='金额')), + ('TransactionType', models.CharField(db_column='type', max_length=255, verbose_name='交易类型')), + ('Description', models.CharField(blank=True, db_column='description', max_length=255, null=True, verbose_name='描述')), + ('CreatedAt', models.DateTimeField(auto_now_add=True, blank=True, db_column='created_at', null=True, verbose_name='创建时间')), + ], + options={ + 'db_table': 'cme_transactions', + 'managed': False, + 'verbose_name': 'CME交易流水', + 'verbose_name_plural': 'CME交易流水', + 'ordering': ['-CreatedAt'], + }, + ), + migrations.CreateModel( + name='CmePaymentRequests', + fields=[ + ('RequestID', models.AutoField(db_column='id', primary_key=True, serialize=False, verbose_name='请求ID')), + ('FromUUID', gvsds.apps.core.models.UUIDField(db_column='from_uuid', verbose_name='付款方MC玩家UUID')), + ('ToUUID', gvsds.apps.core.models.UUIDField(db_column='to_uuid', verbose_name='收款方MC玩家UUID')), + ('Currency', models.ForeignKey(db_column='currency_id', on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='crb.cmecurrencies', verbose_name='币种')), + ('Amount', models.FloatField(db_column='amount', verbose_name='金额')), + ('Status', models.CharField(db_column='status', default='pending', max_length=255, verbose_name='状态')), + ('CreatedAt', models.DateTimeField(auto_now_add=True, blank=True, db_column='created_at', null=True, verbose_name='创建时间')), + ], + options={ + 'db_table': 'cme_payment_requests', + 'managed': False, + 'verbose_name': 'CME付款请求', + 'verbose_name_plural': 'CME付款请求', + 'ordering': ['-CreatedAt'], + }, + ), + migrations.CreateModel( + name='CmePlayerSettings', + fields=[ + ('SettingID', models.AutoField(db_column='id', primary_key=True, serialize=False, verbose_name='设置ID')), + ('UserUUID', gvsds.apps.core.models.UUIDField(db_column='user_uuid', verbose_name='MC玩家UUID')), + ('Language', models.CharField(db_column='language', default='en_us', max_length=50, verbose_name='语言')), + ('CreatedAt', models.DateTimeField(auto_now_add=True, blank=True, db_column='created_at', null=True, verbose_name='创建时间')), + ('UpdatedAt', models.DateTimeField(auto_now=True, blank=True, db_column='updated_at', null=True, verbose_name='更新时间')), + ], + options={ + 'db_table': 'cme_player_settings', + 'managed': False, + 'verbose_name': 'CME玩家设置', + 'verbose_name_plural': 'CME玩家设置', + }, + ), + migrations.CreateModel( + name='CmeExchangeRates', + fields=[ + ('RateID', models.AutoField(db_column='id', primary_key=True, serialize=False, verbose_name='汇率ID')), + ('CurrencyName', models.CharField(db_column='currency_name', max_length=255, unique=True, verbose_name='币种名称')), + ('Rate', models.FloatField(db_column='rate', default=1, verbose_name='汇率')), + ('CreatedAt', models.DateTimeField(auto_now_add=True, blank=True, db_column='created_at', null=True, verbose_name='创建时间')), + ('UpdatedAt', models.DateTimeField(auto_now=True, blank=True, db_column='updated_at', null=True, verbose_name='更新时间')), + ], + options={ + 'db_table': 'cme_exchange_rates', + 'managed': False, + 'verbose_name': 'CME汇率', + 'verbose_name_plural': 'CME汇率', + }, + ), + # ============================================================ + # Django 独有表(managed=True):实际建表 + # ============================================================ + migrations.CreateModel( + name='CmeStoreValue', + fields=[ + ('StoreValueUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='储值UUID')), + ('UserUUID', gvsds.apps.core.models.UUIDField(blank=True, null=True, verbose_name='用户UUID')), + ('PlayerUUID', gvsds.apps.core.models.UUIDField(blank=True, null=True, verbose_name='玩家UUID')), + ('PlayerName', models.TextField(blank=True, null=True, verbose_name='玩家名称')), + ('StoreValueCreateDateTime', models.DateTimeField(blank=True, null=True, verbose_name='储值创建时间')), + ('StoreValueType', models.IntegerField(blank=True, null=True, verbose_name='储值类型')), + ('StoreValueMin', models.BigIntegerField(blank=True, null=True, verbose_name='储值最小值')), + ('StoreValueDayInterestRate', models.SmallIntegerField(blank=True, null=True, verbose_name='日利率')), + ('StoreValueAmount', models.BigIntegerField(blank=True, null=True, verbose_name='储值金额')), + ('StoreValueExpirationGameDate', models.IntegerField(blank=True, null=True, verbose_name='到期游戏日期')), + ], + options={ + 'db_table': 'storevalue', + 'verbose_name': 'CME储值', + 'verbose_name_plural': 'CME储值', + }, + ), + migrations.CreateModel( + name='CmeWalletGVSDSMapping', + fields=[ + ('MappingID', models.BigAutoField(primary_key=True, serialize=False, verbose_name='映射ID')), + ('UserUUID', gvsds.apps.core.models.UUIDField(verbose_name='GVSDS用户UUID')), + ('PlayerUUID', models.CharField(max_length=36, verbose_name='MC玩家UUID字符串')), + ], + options={ + 'db_table': 'cmewalletgvsds', + 'verbose_name': 'GVSDS钱包映射', + 'verbose_name_plural': 'GVSDS钱包映射', + 'unique_together': {('UserUUID', 'PlayerUUID')}, + }, + ), + migrations.CreateModel( + name='Enterprise', + fields=[ + ('EnterpriseUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='企业UUID')), + ('EnterpriseCreateCertificationCode', gvsds.apps.core.models.UUIDField(blank=True, null=True, verbose_name='企业认证码')), + ('EnterpriseFatherUUID', gvsds.apps.core.models.UUIDField(blank=True, null=True, verbose_name='上级企业UUID')), + ('EnterpriseName', models.TextField(blank=True, null=True, verbose_name='企业名称')), + ('EnterpriseType', models.IntegerField(blank=True, null=True, verbose_name='企业类型')), + ('EnterpriseStatus', models.IntegerField(blank=True, null=True, verbose_name='企业状态')), + ('EnterpriseCreateTime', models.DateTimeField(blank=True, null=True, verbose_name='企业创建时间')), + ('EnterpriseRegisteredCapital', models.FloatField(blank=True, null=True, verbose_name='注册资本')), + ('EnterpriseCapitalGC', models.TextField(blank=True, null=True, verbose_name='资本GC')), + ('EnterpriseCapital', models.FloatField(blank=True, null=True, verbose_name='资本')), + ('EnterpriseTelephone', models.TextField(blank=True, null=True, verbose_name='企业电话')), + ('EnterpriseEmail', models.TextField(blank=True, null=True, verbose_name='企业邮箱')), + ('EnterpriseWebsite', models.TextField(blank=True, null=True, verbose_name='企业网站')), + ('EnterpriseAddress', models.TextField(blank=True, null=True, verbose_name='企业地址')), + ('EnterpriseDesc', models.TextField(blank=True, null=True, verbose_name='企业描述')), + ('EnterpriseAdministrativeDivisions', models.TextField(blank=True, null=True, verbose_name='行政区划')), + ('EnterpriseBusinessPeriod', models.BigIntegerField(blank=True, null=True, verbose_name='营业期限')), + ('EnterpriseApprovalDate', models.DateTimeField(blank=True, null=True, verbose_name='审批日期')), + ('EnterpriseBusinessScope', models.JSONField(blank=True, null=True, verbose_name='经营范围')), + ], + options={ + 'db_table': 'enterprise', + 'verbose_name': '企业', + 'verbose_name_plural': '企业', + }, + ), + migrations.CreateModel( + name='EnterpriseUser', + fields=[ + ('EnterpriseSMTMUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='企业用户UUID')), + ('UserUUID', gvsds.apps.core.models.UUIDField(blank=True, null=True, verbose_name='用户UUID')), + ('EnterpriseUserType', models.IntegerField(blank=True, null=True, verbose_name='企业用户类型')), + ('EnterpriseUUID', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='crb.enterprise', to_field='EnterpriseUUID', verbose_name='企业')), + ], + options={ + 'db_table': 'enterpriseuser', + 'verbose_name': '企业用户', + 'verbose_name_plural': '企业用户', + }, + ), + ] diff --git a/gvsds/apps/crb/migrations/0002_shop.py b/gvsds/apps/crb/migrations/0002_shop.py new file mode 100644 index 00000000..c4df733c --- /dev/null +++ b/gvsds/apps/crb/migrations/0002_shop.py @@ -0,0 +1,106 @@ +"""CRB 迁移 0002——商店系统 4 张表 + +ShopProduct / ShopCartItem / ShopOrder / ShopOrderItem +全部 Django 独有表,走 minecraft 库(crb app 路由)。 +GC 虚拟币支付,淘宝/拼多多风格的简化版。 +""" + +import gvsds.apps.core.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crb', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ShopProduct', + fields=[ + ('ProductUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='商品UUID')), + ('SellerUserUUID', gvsds.apps.core.models.UUIDField(verbose_name='卖家GVSDS用户UUID')), + ('SellerPlayerName', models.CharField(blank=True, max_length=64, null=True, verbose_name='卖家玩家名')), + ('ProductName', models.CharField(max_length=128, verbose_name='商品名称')), + ('ProductDesc', models.TextField(blank=True, null=True, verbose_name='商品描述')), + ('ProductPrice', models.FloatField(default=0, verbose_name='商品价格(GC)')), + ('Stock', models.BigIntegerField(default=-1, verbose_name='库存(-1=无限)')), + ('SoldCount', models.BigIntegerField(default=0, verbose_name='已售数量')), + ('ProductImages', models.JSONField(blank=True, default=list, verbose_name='商品图片SHA1列表')), + ('ProductTags', models.JSONField(blank=True, default=list, verbose_name='商品标签')), + ('ProductCategory', models.CharField(blank=True, max_length=64, null=True, verbose_name='商品分类')), + ('ProductStatus', models.SmallIntegerField(default=1, verbose_name='商品状态')), + ('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'db_table': 'shop_product', + 'verbose_name': '商店商品', + 'verbose_name_plural': '商店商品', + 'ordering': ['-CreateTime'], + }, + ), + migrations.CreateModel( + name='ShopCartItem', + fields=[ + ('CartItemUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='购物车项UUID')), + ('UserUUID', gvsds.apps.core.models.UUIDField(verbose_name='GVSDS用户UUID')), + ('Quantity', models.BigIntegerField(default=1, verbose_name='数量')), + ('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='加入时间')), + ('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('Product', models.ForeignKey(on_delete=models.deletion.CASCADE, related_name='+', to='crb.shopproduct', to_field='ProductUUID', verbose_name='商品')), + ], + options={ + 'db_table': 'shop_cart_item', + 'verbose_name': '购物车项', + 'verbose_name_plural': '购物车项', + 'unique_together': {('UserUUID', 'Product')}, + }, + ), + migrations.CreateModel( + name='ShopOrder', + fields=[ + ('OrderUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='订单UUID')), + ('OrderNO', models.CharField(max_length=64, unique=True, verbose_name='订单编号')), + ('BuyerUserUUID', gvsds.apps.core.models.UUIDField(verbose_name='买家GVSDS用户UUID')), + ('BuyerPlayerUUID', gvsds.apps.core.models.UUIDField(verbose_name='买家MC玩家UUID')), + ('BuyerPlayerName', models.CharField(blank=True, max_length=64, null=True, verbose_name='买家玩家名')), + ('TotalAmount', models.FloatField(default=0, verbose_name='订单总金额(GC)')), + ('OrderStatus', models.SmallIntegerField(choices=[(0, '待付款'), (1, '已付款'), (2, '已发货'), (3, '已完成'), (4, '已取消'), (5, '退款中'), (6, '已退款')], default=0, verbose_name='订单状态')), + ('Remark', models.CharField(blank=True, max_length=512, null=True, verbose_name='订单备注')), + ('PayTime', models.DateTimeField(blank=True, null=True, verbose_name='付款时间')), + ('ShipTime', models.DateTimeField(blank=True, null=True, verbose_name='发货时间')), + ('CompleteTime', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')), + ('CancelTime', models.DateTimeField(blank=True, null=True, verbose_name='取消时间')), + ('RefundTime', models.DateTimeField(blank=True, null=True, verbose_name='退款时间')), + ('RefundReason', models.CharField(blank=True, max_length=512, null=True, verbose_name='退款原因')), + ('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'db_table': 'shop_order', + 'verbose_name': '商店订单', + 'verbose_name_plural': '商店订单', + 'ordering': ['-CreateTime'], + }, + ), + migrations.CreateModel( + name='ShopOrderItem', + fields=[ + ('OrderItemUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='订单项UUID')), + ('ProductSnapshot', models.JSONField(verbose_name='商品快照')), + ('Quantity', models.BigIntegerField(default=1, verbose_name='购买数量')), + ('UnitPrice', models.FloatField(default=0, verbose_name='单价(GC)')), + ('Subtotal', models.FloatField(default=0, verbose_name='小计(GC)')), + ('Order', models.ForeignKey(on_delete=models.deletion.CASCADE, related_name='items', to='crb.shoporder', to_field='OrderUUID', verbose_name='订单')), + ('Product', models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, related_name='+', to='crb.shopproduct', to_field='ProductUUID', verbose_name='商品')), + ], + options={ + 'db_table': 'shop_order_item', + 'verbose_name': '商店订单项', + 'verbose_name_plural': '商店订单项', + }, + ), + ] diff --git a/gvsds/apps/crb/migrations/__pycache__/0001_initial.cpython-312.pyc b/gvsds/apps/crb/migrations/__pycache__/0001_initial.cpython-312.pyc index 5ef727b0..67a2be9d 100644 Binary files a/gvsds/apps/crb/migrations/__pycache__/0001_initial.cpython-312.pyc and b/gvsds/apps/crb/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/gvsds/apps/crb/migrations/__pycache__/0002_shop.cpython-312.pyc b/gvsds/apps/crb/migrations/__pycache__/0002_shop.cpython-312.pyc new file mode 100644 index 00000000..adb22148 Binary files /dev/null and b/gvsds/apps/crb/migrations/__pycache__/0002_shop.cpython-312.pyc differ diff --git a/gvsds/apps/crb/models.py b/gvsds/apps/crb/models.py index 19ccf822..69ec722c 100644 --- a/gvsds/apps/crb/models.py +++ b/gvsds/apps/crb/models.py @@ -1,5 +1,17 @@ """模块:CRB 数据模型 -货币经济系统:币种、钱包余额、储值、企业注册等。""" + +3CRB(Currency/Resource/Banking)货币经济系统,分两类表: + +1. **MC 插件共享表**(managed=False):由 Minecraft 服务器 CME 经济插件 + 直接读写,Django 仅作 ORM 映射。表名为 snake_case、列名为 snake_case、 + 主键为 INT 自增、user_uuid 为 BINARY(16)(MC 玩家 UUID)。 + Django 通过 ``db_column`` 映射,Python 属性保持 PascalCase。 + +2. **Django 独有表**(managed=True):游戏内企业注册、定期储值、 + GVSDS 用户↔MC 玩家映射。表名与列名均为 PascalCase,由 Django 迁移管理。 + +所有 ORM 字段与方法遵循帕斯卡命名法(首字母大写、缩写大写、单单词小写)。 +MC 插件表因外部 schema 约束,通过 ``db_column`` 保持 DB 侧 snake_case。""" from gvsds.model_base import QModel import datetime @@ -7,44 +19,185 @@ from django.db import models from gvsds.apps.core.models import UUIDField -class CmeCurrencies(QModel): - """CME 币种表——货币定义""" +# ============================================================================ +# MC 插件共享表(managed=False) +# 由 Minecraft 服务器 CME 经济插件读写,Django 仅作 ORM 映射,不可迁移 +# ============================================================================ - CurrencyID = models.CharField(max_length=20, primary_key=True, verbose_name='币种ID') - CurrencyName = models.CharField(max_length=50, verbose_name='币种名称') - DisplayName = models.CharField(max_length=100, verbose_name='显示名称') - Unit = models.FloatField(default=1.0, verbose_name='单位') - IsDefault = models.SmallIntegerField(default=0, verbose_name='是否默认') - Icon = models.CharField(max_length=50, default='minecraft:iron', verbose_name='图标') - CreatedAt = models.DateTimeField(null=True, blank=True, verbose_name='创建时间') - UpdatedAt = models.DateTimeField(null=True, blank=True, verbose_name='更新时间') +class CmeCurrencies(QModel): + """CME 币种表——MC 插件定义的币种 + + 映射真实表 ``cme_currencies``(snake_case)。 + PK 为 INT 自增 ``id``,与 MC 插件 schema 一致。 + """ + + CurrencyID = models.AutoField(primary_key=True, db_column='id', verbose_name='币种ID') + CurrencyName = models.CharField(max_length=255, unique=True, db_column='name', verbose_name='币种名称') + CurrencySymbol = models.CharField(max_length=255, db_column='symbol', verbose_name='币种符号') + Tradable = models.IntegerField(default=1, db_column='tradable', verbose_name='是否可交易') + CreatedAt = models.DateTimeField(auto_now_add=True, db_column='created_at', null=True, blank=True, verbose_name='创建时间') class Meta: - db_table = 'CMECurrencies' + db_table = 'cme_currencies' app_label = 'crb' + managed = False verbose_name = 'CME币种' verbose_name_plural = 'CME币种' + def __str__(self): + return self.CurrencyName -class CmeWalletBalances(QModel): - """CME 钱包余额表——玩家货币余额""" - WalletID = models.BigAutoField(primary_key=True, verbose_name='钱包ID') - PlayerUUID = models.CharField(max_length=36, verbose_name='玩家UUID') - PlayerName = models.CharField(max_length=16, null=True, blank=True, verbose_name='玩家名称') - CurrencyID = models.CharField(max_length=20, verbose_name='币种ID') - Balance = models.FloatField(default=0, verbose_name='余额') +class CmeBalances(QModel): + """CME 钱包余额表——玩家货币余额 + + 映射真实表 ``cme_balances``(snake_case)。 + ``UserUUID`` 存 MC 玩家 UUID(BINARY(16)),不是 GVSDS UserUUID。 + GVSDS UserUUID ↔ MC PlayerUUID 通过 ``CmeWalletGVSDSMapping`` 映射。 + """ + + BalanceID = models.AutoField(primary_key=True, db_column='id', verbose_name='余额ID') + UserUUID = UUIDField(db_column='user_uuid', verbose_name='MC玩家UUID') + Currency = models.ForeignKey( + CmeCurrencies, on_delete=models.DO_NOTHING, + db_column='currency_id', related_name='+', + verbose_name='币种', + ) + Balance = models.FloatField(default=0, db_column='balance', verbose_name='余额') + CreatedAt = models.DateTimeField(auto_now_add=True, db_column='created_at', null=True, blank=True, verbose_name='创建时间') + UpdatedAt = models.DateTimeField(auto_now=True, db_column='updated_at', null=True, blank=True, verbose_name='更新时间') class Meta: - db_table = 'CMEWalletBalances' + db_table = 'cme_balances' app_label = 'crb' + managed = False verbose_name = 'CME钱包余额' verbose_name_plural = 'CME钱包余额' - unique_together = [('PlayerUUID', 'CurrencyID')] + unique_together = [('UserUUID', 'Currency')] + def __str__(self): + return f'{self.UserUUID}:{self.Balance}' + + +class CmeTransactions(QModel): + """CME 交易流水表——所有币种转账记录 + + 映射真实表 ``cme_transactions``(snake_case)。 + ``type`` 列在 Python 中用 ``TransactionType`` 属性访问(``type`` 是保留字)。 + """ + + TransactionID = models.AutoField(primary_key=True, db_column='id', verbose_name='交易ID') + FromUUID = UUIDField(db_column='from_uuid', null=True, blank=True, verbose_name='付款方MC玩家UUID') + ToUUID = UUIDField(db_column='to_uuid', null=True, blank=True, verbose_name='收款方MC玩家UUID') + Currency = models.ForeignKey( + CmeCurrencies, on_delete=models.DO_NOTHING, + db_column='currency_id', related_name='+', + verbose_name='币种', + ) + Amount = models.FloatField(db_column='amount', verbose_name='金额') + TransactionType = models.CharField(max_length=255, db_column='type', verbose_name='交易类型') + Description = models.CharField(max_length=255, null=True, blank=True, db_column='description', verbose_name='描述') + CreatedAt = models.DateTimeField(auto_now_add=True, db_column='created_at', null=True, blank=True, verbose_name='创建时间') + + class Meta: + db_table = 'cme_transactions' + app_label = 'crb' + managed = False + verbose_name = 'CME交易流水' + verbose_name_plural = 'CME交易流水' + ordering = ['-CreatedAt'] + + def __str__(self): + return f'{self.TransactionType}:{self.Amount}' + + +class CmePaymentRequests(QModel): + """CME 付款请求表——玩家间付款请求 + + 映射真实表 ``cme_payment_requests``(snake_case)。 + """ + + RequestID = models.AutoField(primary_key=True, db_column='id', verbose_name='请求ID') + FromUUID = UUIDField(db_column='from_uuid', verbose_name='付款方MC玩家UUID') + ToUUID = UUIDField(db_column='to_uuid', verbose_name='收款方MC玩家UUID') + Currency = models.ForeignKey( + CmeCurrencies, on_delete=models.DO_NOTHING, + db_column='currency_id', related_name='+', + verbose_name='币种', + ) + Amount = models.FloatField(db_column='amount', verbose_name='金额') + Status = models.CharField(max_length=255, default='pending', db_column='status', verbose_name='状态') + CreatedAt = models.DateTimeField(auto_now_add=True, db_column='created_at', null=True, blank=True, verbose_name='创建时间') + + class Meta: + db_table = 'cme_payment_requests' + app_label = 'crb' + managed = False + verbose_name = 'CME付款请求' + verbose_name_plural = 'CME付款请求' + ordering = ['-CreatedAt'] + + def __str__(self): + return f'{self.Status}:{self.Amount}' + + +class CmePlayerSettings(QModel): + """CME 玩家设置表——MC 玩家语言等偏好 + + 映射真实表 ``cme_player_settings``(snake_case)。 + """ + + SettingID = models.AutoField(primary_key=True, db_column='id', verbose_name='设置ID') + UserUUID = UUIDField(db_column='user_uuid', verbose_name='MC玩家UUID') + Language = models.CharField(max_length=50, default='en_us', db_column='language', verbose_name='语言') + CreatedAt = models.DateTimeField(auto_now_add=True, db_column='created_at', null=True, blank=True, verbose_name='创建时间') + UpdatedAt = models.DateTimeField(auto_now=True, db_column='updated_at', null=True, blank=True, verbose_name='更新时间') + + class Meta: + db_table = 'cme_player_settings' + app_label = 'crb' + managed = False + verbose_name = 'CME玩家设置' + verbose_name_plural = 'CME玩家设置' + + def __str__(self): + return f'{self.UserUUID}:{self.Language}' + + +class CmeExchangeRates(QModel): + """CME 汇率表——币种间兑换汇率 + + 映射真实表 ``cme_exchange_rates``(snake_case)。 + """ + + RateID = models.AutoField(primary_key=True, db_column='id', verbose_name='汇率ID') + CurrencyName = models.CharField(max_length=255, unique=True, db_column='currency_name', verbose_name='币种名称') + Rate = models.FloatField(default=1, db_column='rate', verbose_name='汇率') + CreatedAt = models.DateTimeField(auto_now_add=True, db_column='created_at', null=True, blank=True, verbose_name='创建时间') + UpdatedAt = models.DateTimeField(auto_now=True, db_column='updated_at', null=True, blank=True, verbose_name='更新时间') + + class Meta: + db_table = 'cme_exchange_rates' + app_label = 'crb' + managed = False + verbose_name = 'CME汇率' + verbose_name_plural = 'CME汇率' + + def __str__(self): + return f'{self.CurrencyName}:{self.Rate}' + + +# ============================================================================ +# Django 独有表(managed=True) +# 游戏内企业注册、定期储值、GVSDS↔MC 玩家映射,由 Django 迁移管理 +# ============================================================================ class CmeStoreValue(QModel): - """CME 储值表——定期存款记录""" + """CME 储值表——定期存款记录(GVSDS 业务,非 MC 插件) + + 玩家将游戏币存入定期,按游戏日计息,到期取现。 + 业务逻辑见旧 flask 的 Cash.api(计算游戏天数与利息)。 + """ StoreValueUUID = UUIDField(primary_key=True, verbose_name='储值UUID') UserUUID = UUIDField(null=True, blank=True, verbose_name='用户UUID') @@ -58,21 +211,26 @@ class CmeStoreValue(QModel): StoreValueExpirationGameDate = models.IntegerField(null=True, blank=True, verbose_name='到期游戏日期') class Meta: - db_table = 'StoreValue' + db_table = 'storevalue' app_label = 'crb' verbose_name = 'CME储值' verbose_name_plural = 'CME储值' class CmeWalletGVSDSMapping(QModel): - """GVSDS 钱包映射表——用户与玩家关联""" + """GVSDS 钱包映射表——GVSDS 用户与 MC 玩家关联 + + ``UserUUID`` 为 GVSDS UserUUID(BINARY(16)), + ``PlayerUUID`` 为 MC 玩家 UUID 字符串形式(varchar(36))。 + 用于 Web 端用户查询其 MC 钱包余额。 + """ MappingID = models.BigAutoField(primary_key=True, verbose_name='映射ID') - UserUUID = UUIDField(verbose_name='用户UUID') - PlayerUUID = models.CharField(max_length=36, verbose_name='玩家UUID') + UserUUID = UUIDField(verbose_name='GVSDS用户UUID') + PlayerUUID = models.CharField(max_length=36, verbose_name='MC玩家UUID字符串') class Meta: - db_table = 'CMEWalletGVSDS' + db_table = 'cmewalletgvsds' app_label = 'crb' verbose_name = 'GVSDS钱包映射' verbose_name_plural = 'GVSDS钱包映射' @@ -80,7 +238,10 @@ class CmeWalletGVSDSMapping(QModel): class Enterprise(QModel): - """企业表——游戏内企业注册信息""" + """企业表——游戏内企业注册信息(GVSDS 业务,非 MC 插件) + + 注:旧表有拼写错误 ``EnterpriseAddres``(少 s),已修正为 ``EnterpriseAddress``。 + """ EnterpriseUUID = UUIDField(primary_key=True, verbose_name='企业UUID') EnterpriseCreateCertificationCode = UUIDField(null=True, blank=True, verbose_name='企业认证码') @@ -103,14 +264,17 @@ class Enterprise(QModel): EnterpriseBusinessScope = models.JSONField(null=True, blank=True, verbose_name='经营范围') class Meta: - db_table = 'Enterprise' + db_table = 'enterprise' app_label = 'crb' verbose_name = '企业' verbose_name_plural = '企业' + def __str__(self): + return self.EnterpriseName or str(self.EnterpriseUUID) + class EnterpriseUser(QModel): - """企业用户关联表——游戏内企业成员""" + """企业用户关联表——游戏内企业成员(GVSDS 业务,非 MC 插件)""" EnterpriseSMTMUUID = UUIDField(primary_key=True, verbose_name='企业用户UUID') EnterpriseUUID = models.ForeignKey( @@ -123,7 +287,178 @@ class EnterpriseUser(QModel): EnterpriseUserType = models.IntegerField(null=True, blank=True, verbose_name='企业用户类型') class Meta: - db_table = 'EnterpriseUser' + db_table = 'enterpriseuser' app_label = 'crb' verbose_name = '企业用户' verbose_name_plural = '企业用户' + + +# ============================================================================ +# 商店系统(Django 独有表,managed=True) +# 淘宝/拼多多风格的游戏内商店:商品/购物车/订单/订单项 +# 全部走 minecraft 库(crb app 路由),GC 虚拟币支付 +# ============================================================================ + +# 订单状态枚举(与旧 flask 数字兼容,简化版状态机) +# 0=待付款 → 1=已付款(待发货) → 2=已发货(待收货) → 3=已完成 +# 0/1 → 4=已取消 +# 1/2 → 5=退款中 → 6=已退款 +SHOP_ORDER_PENDING = 0 +SHOP_ORDER_PAID = 1 +SHOP_ORDER_SHIPPED = 2 +SHOP_ORDER_COMPLETED = 3 +SHOP_ORDER_CANCELLED = 4 +SHOP_ORDER_REFUNDING = 5 +SHOP_ORDER_REFUNDED = 6 + +SHOP_ORDER_STATUS_CHOICES = [ + (SHOP_ORDER_PENDING, '待付款'), + (SHOP_ORDER_PAID, '已付款'), + (SHOP_ORDER_SHIPPED, '已发货'), + (SHOP_ORDER_COMPLETED, '已完成'), + (SHOP_ORDER_CANCELLED, '已取消'), + (SHOP_ORDER_REFUNDING, '退款中'), + (SHOP_ORDER_REFUNDED, '已退款'), +] + +# 商品状态 +SHOP_PRODUCT_OFFLINE = 0 # 下架 +SHOP_PRODUCT_ONLINE = 1 # 在售 +SHOP_PRODUCT_OUT_OF_STOCK = 2 # 缺货 + + +class ShopProduct(QModel): + """商店商品表——游戏内商品定义(GVSDS 业务,非 MC 插件) + + 设计说明: + - 单一规格(无 SPU/SKU 多规格),一个商品一条记录 + - 价格以 GC 计价(CurrencyName='GC') + - 图片走 FileAsset 系统的 SHA1 引用,存 ShopProductImage 列为 SHA1 列表 + - Stock=-1 表示无限库存 + """ + + ProductUUID = UUIDField(primary_key=True, verbose_name='商品UUID') + SellerUserUUID = UUIDField(verbose_name='卖家GVSDS用户UUID') + SellerPlayerName = models.CharField(max_length=64, null=True, blank=True, verbose_name='卖家玩家名') + ProductName = models.CharField(max_length=128, verbose_name='商品名称') + ProductDesc = models.TextField(null=True, blank=True, verbose_name='商品描述') + ProductPrice = models.FloatField(default=0, verbose_name='商品价格(GC)') + Stock = models.BigIntegerField(default=-1, verbose_name='库存(-1=无限)') + SoldCount = models.BigIntegerField(default=0, verbose_name='已售数量') + ProductImages = models.JSONField(default=list, blank=True, verbose_name='商品图片SHA1列表') + ProductTags = models.JSONField(default=list, blank=True, verbose_name='商品标签') + ProductCategory = models.CharField(max_length=64, null=True, blank=True, verbose_name='商品分类') + ProductStatus = models.SmallIntegerField(default=SHOP_PRODUCT_ONLINE, verbose_name='商品状态') + CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'shop_product' + app_label = 'crb' + verbose_name = '商店商品' + verbose_name_plural = '商店商品' + ordering = ['-CreateTime'] + + def __str__(self): + return self.ProductName + + +class ShopCartItem(QModel): + """购物车项——用户加入购物车的商品(GVSDS 业务) + + UserUUID 为 GVSDS UserUUID(不是 MC 玩家 UUID)。 + """ + + CartItemUUID = UUIDField(primary_key=True, verbose_name='购物车项UUID') + UserUUID = UUIDField(verbose_name='GVSDS用户UUID') + Product = models.ForeignKey( + ShopProduct, on_delete=models.CASCADE, + to_field='ProductUUID', + related_name='+', + verbose_name='商品', + ) + Quantity = models.BigIntegerField(default=1, verbose_name='数量') + CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='加入时间') + UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'shop_cart_item' + app_label = 'crb' + verbose_name = '购物车项' + verbose_name_plural = '购物车项' + # 同一用户同一商品唯一(避免重复加购) + unique_together = [('UserUUID', 'Product')] + + +class ShopOrder(QModel): + """商店订单表——订单主表(GVSDS 业务,非 MC 插件) + + 状态机: + PENDING(0) → PAID(1) → SHIPPED(2) → COMPLETED(3) + PENDING(0) / PAID(1) → CANCELLED(4) + PAID(1) / SHIPPED(2) → REFUNDING(5) → REFUNDED(6) + + BuyerUserUUID 为 GVSDS UserUUID,BuyerPlayerUUID 为 MC 玩家 UUID(BINARY16)。 + 订单号 OrderNO 格式:S + YYYYMMDDHHMMSS + 6位随机数。 + """ + + OrderUUID = UUIDField(primary_key=True, verbose_name='订单UUID') + OrderNO = models.CharField(max_length=64, unique=True, verbose_name='订单编号') + BuyerUserUUID = UUIDField(verbose_name='买家GVSDS用户UUID') + BuyerPlayerUUID = UUIDField(verbose_name='买家MC玩家UUID') + BuyerPlayerName = models.CharField(max_length=64, null=True, blank=True, verbose_name='买家玩家名') + TotalAmount = models.FloatField(default=0, verbose_name='订单总金额(GC)') + OrderStatus = models.SmallIntegerField( + default=SHOP_ORDER_PENDING, choices=SHOP_ORDER_STATUS_CHOICES, + verbose_name='订单状态', + ) + Remark = models.CharField(max_length=512, null=True, blank=True, verbose_name='订单备注') + PayTime = models.DateTimeField(null=True, blank=True, verbose_name='付款时间') + ShipTime = models.DateTimeField(null=True, blank=True, verbose_name='发货时间') + CompleteTime = models.DateTimeField(null=True, blank=True, verbose_name='完成时间') + CancelTime = models.DateTimeField(null=True, blank=True, verbose_name='取消时间') + RefundTime = models.DateTimeField(null=True, blank=True, verbose_name='退款时间') + RefundReason = models.CharField(max_length=512, null=True, blank=True, verbose_name='退款原因') + CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'shop_order' + app_label = 'crb' + verbose_name = '商店订单' + verbose_name_plural = '商店订单' + ordering = ['-CreateTime'] + + def __str__(self): + return self.OrderNO + + +class ShopOrderItem(QModel): + """商店订单项——订单的商品明细(含商品快照) + + 商品快照:下单时冻结 ProductName/Price/Image,避免商品后续修改影响历史订单。 + """ + + OrderItemUUID = UUIDField(primary_key=True, verbose_name='订单项UUID') + Order = models.ForeignKey( + ShopOrder, on_delete=models.CASCADE, + to_field='OrderUUID', + related_name='items', + verbose_name='订单', + ) + Product = models.ForeignKey( + ShopProduct, on_delete=models.SET_NULL, + to_field='ProductUUID', null=True, blank=True, + related_name='+', + verbose_name='商品', + ) + ProductSnapshot = models.JSONField(verbose_name='商品快照') + Quantity = models.BigIntegerField(default=1, verbose_name='购买数量') + UnitPrice = models.FloatField(default=0, verbose_name='单价(GC)') + Subtotal = models.FloatField(default=0, verbose_name='小计(GC)') + + class Meta: + db_table = 'shop_order_item' + app_label = 'crb' + verbose_name = '商店订单项' + verbose_name_plural = '商店订单项' diff --git a/gvsds/apps/crb/serializers.py b/gvsds/apps/crb/serializers.py index 7e82e808..0a0e3f8a 100644 --- a/gvsds/apps/crb/serializers.py +++ b/gvsds/apps/crb/serializers.py @@ -1,36 +1,170 @@ +"""模块:CRB 序列化器 + +为 10 个模型提供 PascalCase 序列化输出。MC 插件表与 Django 独有表共用 +``PascalCaseModelSerializer`` 基类,保证 API 输出与 Python 属性命名一致。""" + from rest_framework import serializers from gvsds.common.Serializer import PascalCaseModelSerializer from .models import ( - CmeCurrencies, CmeWalletBalances, CmeStoreValue, - CmeWalletGVSDSMapping, Enterprise, EnterpriseUser, + CmeCurrencies, CmeBalances, CmeTransactions, CmePaymentRequests, + CmePlayerSettings, CmeExchangeRates, + CmeStoreValue, CmeWalletGVSDSMapping, Enterprise, EnterpriseUser, + ShopProduct, ShopCartItem, ShopOrder, ShopOrderItem, ) class CmeCurrenciesSerializer(PascalCaseModelSerializer): + """CME 币种序列化器""" + class Meta: model = CmeCurrencies fields = '__all__' -class CmeWalletBalancesSerializer(PascalCaseModelSerializer): +class CmeBalancesSerializer(PascalCaseModelSerializer): + """CME 钱包余额序列化器 + + 注:``UserUUID`` 为 MC 玩家 UUID(BINARY16),序列化为 UUID 字符串。""" + class Meta: - model = CmeWalletBalances + model = CmeBalances + fields = '__all__' + + +class CmeTransactionsSerializer(PascalCaseModelSerializer): + """CME 交易流水序列化器""" + + class Meta: + model = CmeTransactions + fields = '__all__' + + +class CmePaymentRequestsSerializer(PascalCaseModelSerializer): + """CME 付款请求序列化器""" + + class Meta: + model = CmePaymentRequests + fields = '__all__' + + +class CmePlayerSettingsSerializer(PascalCaseModelSerializer): + """CME 玩家设置序列化器""" + + class Meta: + model = CmePlayerSettings + fields = '__all__' + + +class CmeExchangeRatesSerializer(PascalCaseModelSerializer): + """CME 汇率序列化器""" + + class Meta: + model = CmeExchangeRates fields = '__all__' class CmeStoreValueSerializer(PascalCaseModelSerializer): + """CME 储值序列化器""" + class Meta: model = CmeStoreValue fields = '__all__' +class CmeWalletGVSDSMappingSerializer(PascalCaseModelSerializer): + """GVSDS↔MC 玩家映射序列化器""" + + class Meta: + model = CmeWalletGVSDSMapping + fields = '__all__' + + class EnterpriseSerializer(PascalCaseModelSerializer): + """企业序列化器""" + class Meta: model = Enterprise fields = '__all__' class EnterpriseUserSerializer(PascalCaseModelSerializer): + """企业用户序列化器""" + class Meta: model = EnterpriseUser fields = '__all__' + + +# ============================================================================ +# 商店系统序列化器 +# ============================================================================ + +class ShopProductSerializer(PascalCaseModelSerializer): + """商店商品序列化器""" + + class Meta: + model = ShopProduct + fields = '__all__' + + +class ShopCartItemSerializer(PascalCaseModelSerializer): + """购物车项序列化器(含商品快照)""" + + ProductName = serializers.SerializerMethodField() + ProductPrice = serializers.SerializerMethodField() + ProductImages = serializers.SerializerMethodField() + ProductStatus = serializers.SerializerMethodField() + Stock = serializers.SerializerMethodField() + + def _get_product(self, obj): + return getattr(obj, 'Product', None) + + def get_ProductName(self, obj): + p = self._get_product(obj) + return p.ProductName if p else None + + def get_ProductPrice(self, obj): + p = self._get_product(obj) + return p.ProductPrice if p else None + + def get_ProductImages(self, obj): + p = self._get_product(obj) + return p.ProductImages if p else [] + + def get_ProductStatus(self, obj): + p = self._get_product(obj) + return p.ProductStatus if p else 0 + + def get_Stock(self, obj): + p = self._get_product(obj) + return p.Stock if p else 0 + + class Meta: + model = ShopCartItem + fields = '__all__' + + +class ShopOrderItemSerializer(PascalCaseModelSerializer): + """订单项序列化器""" + + class Meta: + model = ShopOrderItem + fields = '__all__' + + +class ShopOrderSerializer(PascalCaseModelSerializer): + """订单序列化器(含订单项)""" + + Items = serializers.SerializerMethodField() + OrderStatusText = serializers.SerializerMethodField() + + def get_Items(self, obj): + items = obj.items.all() + return ShopOrderItemSerializer(items, many=True).data + + def get_OrderStatusText(self, obj): + return obj.get_OrderStatus_display() if hasattr(obj, 'get_OrderStatus_display') else None + + class Meta: + model = ShopOrder + fields = '__all__' diff --git a/gvsds/apps/crb/urls.py b/gvsds/apps/crb/urls.py index fc080638..aa965285 100644 --- a/gvsds/apps/crb/urls.py +++ b/gvsds/apps/crb/urls.py @@ -1,11 +1,20 @@ +"""模块:CRB 路由 + +DefaultRouter 自动为 10 个模型生成标准 REST CRUD 路由。""" + from django.urls import path, include from rest_framework.routers import DefaultRouter from . import views router = DefaultRouter() router.register(r'currencies', views.CmeCurrenciesViewSet) -router.register(r'wallet-balances', views.CmeWalletBalancesViewSet) +router.register(r'balances', views.CmeBalancesViewSet) +router.register(r'transactions', views.CmeTransactionsViewSet) +router.register(r'payment-requests', views.CmePaymentRequestsViewSet) +router.register(r'player-settings', views.CmePlayerSettingsViewSet) +router.register(r'exchange-rates', views.CmeExchangeRatesViewSet) router.register(r'store-values', views.CmeStoreValueViewSet) +router.register(r'wallet-gvsds-mappings', views.CmeWalletGVSDSMappingViewSet) router.register(r'enterprises', views.EnterpriseViewSet) router.register(r'enterprise-users', views.EnterpriseUserViewSet) diff --git a/gvsds/apps/crb/views.py b/gvsds/apps/crb/views.py index a002d559..96dc0c29 100644 --- a/gvsds/apps/crb/views.py +++ b/gvsds/apps/crb/views.py @@ -1,36 +1,545 @@ +"""模块:CRB 视图 + +3CRB(Currency/Resource/Banking)货币经济系统视图层,移植自旧 Flask: + +- ``EnterpriseViewSet``:企业注册/修改/查询/卡片/注销(与 SAAS 完全隔离) +- ``EnterpriseUserViewSet``:企业成员增删(带层级权限校验) +- ``CmeStoreValueViewSet``:储值/取现/列表(含游戏日计息公式) +- ``CmeBalancesViewSet``:当前用户 MC 钱包余额(按 GVSDS→MC映射) +- 其余 6 个 ViewSet:标准 CRUD(管理员用,业务侧不应直接暴露写权限) + +业务规则详见 ``business.py``。所有写操作走 default 库事务(enterprise/ +enterpriseuser/storevalue 都是 Django 独有表)。MC 插件共享表 +(cme_balances 等)虽 managed=False,但 ORM 写操作仍会作用于真实 MC 表, +应通过专门的业务 action(``cash`` / ``store_value``)执行而非标准 CRUD。""" + +import re +import time +import secrets +import uuid as _uuid +import datetime + +from django.core.cache import cache +from django.db import transaction +from django.utils import timezone as _timezone from rest_framework import viewsets from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated + +from gvsdsdk.model_utils import UUIDObj + +from .business import ( + StoreValueRule, + GetGVSDSUserToMCPlayerUUID, + GetPlayerNameByGVSDSUser, + GetGVSDSUserByPlayerName, + GetUserByUUID, + GetUserByName, + GetUserNameByUUID, + RequireGCBalance, + CalcAccruedInterest, + CalcAccruedInterestAndAmount, +) from .models import ( - CmeCurrencies, CmeWalletBalances, CmeStoreValue, - CmeWalletGVSDSMapping, Enterprise, EnterpriseUser, + CmeCurrencies, CmeBalances, CmeTransactions, CmePaymentRequests, + CmePlayerSettings, CmeExchangeRates, + CmeStoreValue, CmeWalletGVSDSMapping, Enterprise, EnterpriseUser, + ShopProduct, ShopCartItem, ShopOrder, ShopOrderItem, + SHOP_ORDER_PENDING, SHOP_ORDER_PAID, SHOP_ORDER_SHIPPED, + SHOP_ORDER_COMPLETED, SHOP_ORDER_CANCELLED, SHOP_ORDER_REFUNDING, + SHOP_ORDER_REFUNDED, + SHOP_PRODUCT_OFFLINE, SHOP_PRODUCT_ONLINE, SHOP_PRODUCT_OUT_OF_STOCK, ) from .serializers import ( - CmeCurrenciesSerializer, CmeWalletBalancesSerializer, - CmeStoreValueSerializer, EnterpriseSerializer, EnterpriseUserSerializer, + CmeCurrenciesSerializer, CmeBalancesSerializer, CmeTransactionsSerializer, + CmePaymentRequestsSerializer, CmePlayerSettingsSerializer, + CmeExchangeRatesSerializer, CmeStoreValueSerializer, + CmeWalletGVSDSMappingSerializer, EnterpriseSerializer, EnterpriseUserSerializer, + ShopProductSerializer, ShopCartItemSerializer, ShopOrderSerializer, ShopOrderItemSerializer, ) +# ============================================================================ +# 校验正则(移植自旧 Flask mc.py 的 NewEnterprise.api) +# ============================================================================ + +_PHONE_RE = re.compile(r'^1[3-9]\d{9}$') +_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$') +_URL_RE = re.compile(r'^https?://[^\s/$.?#].[^\s]*$') +_ADDRESS_RE = re.compile(r'^\d+ \d+ \d+ (minecraft:overworld|minecraft:the_end|minecraft:the_nether)$') + +# 企业状态码:0=待审核 1=正常 2=已注销 3=审核不通过 4=已停业 +ENTERPRISE_STATUS_VALID = {0, 1, 2, 3, 4} +# 企业类型:0=个人企业 1=公司企业 +ENTERPRISE_TYPE_VALID = {0, 1} +# 企业用户类型层级(数字越小权限越大) +# 0=法人代表 1=高管 2=普通成员 +ENTERPRISE_USER_TYPE_LEGAL = 0 +ENTERPRISE_USER_TYPE_EXEC = 1 +ENTERPRISE_USER_TYPE_MEMBER = 2 +ENTERPRISE_USER_TYPE_IN_RANGE = {0, 1, 2} + + +def _Err(msg: str, error=None) -> dict: + """构造失败响应 dict(status=1)""" + d = {'status': 1, 'msg': msg} + if error is not None: + d['error'] = error + return d + + +def _fmt_dt(dt) -> str: + """格式化 datetime 为 'YYYY年MM月DD日 HH时MM分SS秒 UTC+8'""" + if not dt: + return None + tz = datetime.timezone(datetime.timedelta(hours=8)) + return dt.astimezone(tz).strftime('%Y年%m月%d日 %H时%M分%S秒 UTC+8') + + +def _enterprise_to_dict(ent: Enterprise, juridical_persons=None, user_type=None) -> dict: + """把 Enterprise 实例序列化为旧 Flask 兼容的字典格式""" + return { + 'EnterpriseUUID': UUIDObj(ent.EnterpriseUUID).str, + 'EnterpriseCreateCertificationCode': UUIDObj(ent.EnterpriseCreateCertificationCode).str if ent.EnterpriseCreateCertificationCode else None, + 'EnterpriseFatherUUID': UUIDObj(ent.EnterpriseFatherUUID).str if ent.EnterpriseFatherUUID else None, + 'Enterprise3C': UUIDObj(ent.EnterpriseCreateCertificationCode).str if ent.EnterpriseCreateCertificationCode else None, + 'EnterpriseName': ent.EnterpriseName, + 'EnterpriseType': ent.EnterpriseType, + 'EnterpriseStatus': ent.EnterpriseStatus, + 'EnterpriseRegisteredCapital': ent.EnterpriseRegisteredCapital, + 'EnterpriseCapitalGC': ent.EnterpriseCapitalGC, + 'EnterpriseCreateTime': _fmt_dt(ent.EnterpriseCreateTime), + 'EnterpriseJuridicalPerson': juridical_persons or [], + 'EnterpriseTelephone': ent.EnterpriseTelephone, + 'EnterpriseEmail': ent.EnterpriseEmail, + 'EnterpriseWebsite': ent.EnterpriseWebsite, + 'EnterpriseAddress': ent.EnterpriseAddress, + 'EnterpriseDesc': ent.EnterpriseDesc, + 'EnterpriseAdministrativeDivisions': ent.EnterpriseAdministrativeDivisions, + 'EnterpriseBusinessScope': ent.EnterpriseBusinessScope, + 'EnterpriseBusinessPeriod': ent.EnterpriseBusinessPeriod if ent.EnterpriseBusinessPeriod != -1 else 'Long Time', + 'EnterpriseApprovalDate': _fmt_dt(ent.EnterpriseApprovalDate), + 'EnterpriseUserType': user_type if user_type is not None else -1, + } + + +def _get_juridical_persons(enterprise_uuid) -> list: + """获取企业的法人列表(返回 MC 玩家名列表) + + 法人主体为玩家名:通过 GVSDS UserUUID 反查绑定的 MC 玩家名 + (easyauth.Username)。若未绑定 MC 玩家则回退显示 GVSDS 用户名。 + """ + qs = EnterpriseUser.objects.filter(EnterpriseUUID=enterprise_uuid) + names = [] + for eu in qs: + name = GetPlayerNameByGVSDSUser(eu.UserUUID) + if not name: + # 未绑定 MC 玩家时回退到 GVSDS 用户名 + name = GetUserNameByUUID(eu.UserUUID) + if name: + names.append(name) + return names + + +# ============================================================================ +# 标准管理 ViewSet(管理员后台用,业务侧不应直接写 MC 插件表) +# ============================================================================ + class CmeCurrenciesViewSet(viewsets.ModelViewSet): + """CME 币种 ViewSet""" queryset = CmeCurrencies.objects.all() serializer_class = CmeCurrenciesSerializer permission_classes = [IsAuthenticated] + @action(detail=False, methods=['get']) + def search(self, request): + keyword = request.query_params.get('keyword', '') + qs = CmeCurrencies.objects.all() + if keyword: + qs = qs.filter(CurrencyName__icontains=keyword) + serializer = self.get_serializer(qs[:50], many=True) + return Response({'code': 0, 'data': serializer.data}) -class CmeWalletBalancesViewSet(viewsets.ModelViewSet): - queryset = CmeWalletBalances.objects.all() - serializer_class = CmeWalletBalancesSerializer + +class CmeTransactionsViewSet(viewsets.ModelViewSet): + """CME 交易流水 ViewSet""" + queryset = CmeTransactions.objects.all() + serializer_class = CmeTransactionsSerializer permission_classes = [IsAuthenticated] -class CmeStoreValueViewSet(viewsets.ModelViewSet): - queryset = CmeStoreValue.objects.all() - serializer_class = CmeStoreValueSerializer +class CmePaymentRequestsViewSet(viewsets.ModelViewSet): + """CME 付款请求 ViewSet""" + queryset = CmePaymentRequests.objects.all() + serializer_class = CmePaymentRequestsSerializer permission_classes = [IsAuthenticated] +class CmePlayerSettingsViewSet(viewsets.ModelViewSet): + """CME 玩家设置 ViewSet""" + queryset = CmePlayerSettings.objects.all() + serializer_class = CmePlayerSettingsSerializer + permission_classes = [IsAuthenticated] + + +class CmeExchangeRatesViewSet(viewsets.ModelViewSet): + """CME 汇率 ViewSet""" + queryset = CmeExchangeRates.objects.all() + serializer_class = CmeExchangeRatesSerializer + permission_classes = [IsAuthenticated] + + +class CmeWalletGVSDSMappingViewSet(viewsets.ModelViewSet): + """GVSDS↔MC 玩家映射 ViewSet""" + queryset = CmeWalletGVSDSMapping.objects.all() + serializer_class = CmeWalletGVSDSMappingSerializer + permission_classes = [IsAuthenticated] + + +# ============================================================================ +# 业务 ViewSet:钱包(wallet) +# ============================================================================ + +class CmeBalancesViewSet(viewsets.ModelViewSet): + """CME 钱包余额 ViewSet + + 业务侧请通过 ``wallet`` action 查询当前用户的 MC 钱包余额; + 标准 CRUD 仅供管理员后台使用,不应暴露给业务前端。 + """ + queryset = CmeBalances.objects.all() + serializer_class = CmeBalancesSerializer + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def search(self, request): + user_uuid = request.query_params.get('user_uuid') + currency_id = request.query_params.get('currency_id') + qs = CmeBalances.objects.all() + if user_uuid: + qs = qs.filter(UserUUID=user_uuid) + if currency_id: + qs = qs.filter(Currency_id=currency_id) + serializer = self.get_serializer(qs[:50], many=True) + return Response({'code': 0, 'data': serializer.data}) + + @action(detail=False, methods=['get', 'post'], permission_classes=[IsAuthenticated]) + def wallet(self, request): + """查询当前用户的 MC 钱包余额(所有币种) + + 兼容旧 Flask ``/API/wallet.api`` 返回格式: + ``{"PlayerUUID": "...", "UserUUID": "...", "Balances": [...]}`` + + 每个 Balance 项含币种详情: + ``{"Type", "Symbol", "Balance", "Tradable", "Rate"}`` + - ``Tradable=1`` 表示该币种可参与兑换 + - ``Rate`` 为该币种相对基准的汇率(``None`` 表示汇率表无记录,不可兑换) + 前端可据此判断是否能转换。 + """ + gvsds_user_uuid = request.user.UserUUID + player_uuid_str = GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) + if not player_uuid_str: + return Response({'GC': -1}) + try: + player_uuid_bytes = _uuid.UUID(player_uuid_str).bytes + except (ValueError, TypeError): + return Response({'GC': -1}) + + result_balances = self._build_balances(player_uuid_bytes) + if result_balances is None: + return Response({'GC': -1}) + + return Response({ + 'PlayerUUID': player_uuid_str, + 'UserUUID': UUIDObj(gvsds_user_uuid).str, + 'Balances': result_balances, + }) + + # ─── 钱包分享 token(24小时临时码) ─── + # 存储:Django cache,key = wallet_share_token:{token} + # value = {user_uuid, player_uuid, expire_at} + SHARE_TOKEN_TIMEOUT = 86400 # 24 小时(秒) + SHARE_TOKEN_CACHE_PREFIX = 'wallet_share_token:' + + def _build_balances(self, player_uuid_bytes): + """根据 MC 玩家 UUID 构建余额列表,复用于 wallet 与 shared_balance + + Returns: list 或 None(玩家无余额记录时返回 None) + """ + balances = list(CmeBalances.objects.filter(UserUUID=player_uuid_bytes)) + if not balances: + return None + + # 一次性查所有币种信息,避免 N+1 查询 + currency_ids = set(b.Currency_id for b in balances) + currencies = CmeCurrencies.objects.filter(CurrencyID__in=currency_ids) + currency_by_id = {c.CurrencyID: c for c in currencies} + + # 一次性查所有汇率(按 CurrencyName 映射) + currency_names = [c.CurrencyName for c in currency_by_id.values()] + rate_map = {r.CurrencyName: r.Rate + for r in CmeExchangeRates.objects.filter(CurrencyName__in=currency_names)} + + result_balances = [] + for b in balances: + c = currency_by_id.get(b.Currency_id) + if c: + result_balances.append({ + 'Type': c.CurrencyName, + 'Symbol': c.CurrencySymbol, + 'Balance': b.Balance, + 'Tradable': c.Tradable, + 'Rate': rate_map.get(c.CurrencyName), + }) + else: + result_balances.append({ + 'Type': str(b.Currency_id), + 'Symbol': '', + 'Balance': b.Balance, + 'Tradable': 0, + 'Rate': None, + }) + return result_balances + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def generate_share_token(self, request): + """生成 24 小时有效的钱包分享 token + + 返回:``{"status": 0, "token": "xxx", "expires_in": 86400}`` + 扫码者通过 ``/ShareWallet.html?token=xxx`` 查询余额,无需登录。 + """ + gvsds_user_uuid = request.user.UserUUID + player_uuid_str = GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) + if not player_uuid_str: + return Response({'status': 1, 'msg': '账户未绑定 MC 玩家'}) + + token = secrets.token_urlsafe(32) + expire_at = time.time() + self.SHARE_TOKEN_TIMEOUT + user_uuid_str = UUIDObj(gvsds_user_uuid).str + cache.set(self.SHARE_TOKEN_CACHE_PREFIX + token, { + 'user_uuid': user_uuid_str, + 'player_uuid': player_uuid_str, + 'expire_at': expire_at, + }, self.SHARE_TOKEN_TIMEOUT) + + return Response({ + 'status': 0, + 'token': token, + 'expire_at': expire_at, + 'expires_in': self.SHARE_TOKEN_TIMEOUT, + }) + + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + def shared_balance(self, request): + """通过分享 token 查询钱包余额(无需登录) + + 请求参数:``?token=xxx`` + 返回:``{"status": 0, "Balances": [...], "ExpiresIn": seconds}`` + """ + token = (request.query_params.get('token') or '').strip() + if not token: + return Response({'status': 1, 'msg': '缺少 token'}) + + data = cache.get(self.SHARE_TOKEN_CACHE_PREFIX + token) + if not data: + return Response({'status': 1, 'msg': 'token 无效或已过期'}) + + now = time.time() + expire_at = data.get('expire_at', 0) + ttl = max(0, int(expire_at - now)) + if ttl == 0: + return Response({'status': 1, 'msg': 'token 已过期'}) + + player_uuid_str = data.get('player_uuid') + if not player_uuid_str: + return Response({'status': 1, 'msg': 'token 数据异常'}) + + try: + player_uuid_bytes = _uuid.UUID(player_uuid_str).bytes + except (ValueError, TypeError): + return Response({'status': 1, 'msg': 'player_uuid 无效'}) + + result_balances = self._build_balances(player_uuid_bytes) + if result_balances is None: + return Response({'status': 0, 'Balances': [], 'ExpiresIn': ttl}) + + return Response({ + 'status': 0, + 'Balances': result_balances, + 'ExpiresIn': ttl, + }) + + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + def share_qrcode(self, request): + """生成分享二维码 PNG 图片(无需登录) + + 请求参数:``?token=xxx`` + 返回:``image/png`` 二维码图片,内容为 ShareWallet 页面 URL + token + """ + from django.http import HttpResponse + import io as _io + + token = (request.query_params.get('token') or '').strip() + if not token or not cache.get(self.SHARE_TOKEN_CACHE_PREFIX + token): + return HttpResponse(b'', content_type='image/png', status=404) + + # 构建 ShareWallet 页面 URL + # ShareWallet.html 位于 TempLate/CCCRB/,需通过 3crb 子域访问 + host = request.get_host().split(':', 1)[0] # 去掉端口 + parts = host.split('.', 1) + domain = parts[1] if len(parts) > 1 else host + share_url = f'https://3crb.{domain}/ShareWallet.html?token={token}' + + # 生成二维码 PNG + import qrcode + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + ) + qr.add_data(share_url) + qr.make(fit=True) + img = qr.make_image(fill_color='#1e293b', back_color='white') + + buf = _io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + return HttpResponse(buf.read(), content_type='image/png') + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def exchange(self, request): + """钱包内币种兑换——按汇率把源币种转为目标币种 + + 请求体:``{"FromCurrency": "GC", "ToCurrency": "VIP", "Amount": 100}`` + + 规则: + - 源币种与目标币种必须不同 + - 两个币种均需 ``Tradable=1`` 且在汇率表中有记录 + - 源币种余额需 ≥ Amount(Amount > 0) + - 到账金额 = Amount × (FromRate / ToRate),按 ``round(…, 4)`` 取四位小数 + - 扣减源币种余额、增加目标币种余额(不存在则新建) + - 写入两条交易流水(exchange 标记)便于审计 + + 跨库说明:``cme_balances``/``cme_transactions`` 为 MC 插件共享表 + (managed=False),但 ORM 写操作仍作用于真实 MC 表,与 + ``store_value``/``cash`` 一致。 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + from_name = data.get('FromCurrency') + to_name = data.get('ToCurrency') + try: + amount = float(data.get('Amount')) + except (TypeError, ValueError): + return Response(_Err('Amount is not number')) + if amount <= 0: + return Response(_Err('Amount must be positive')) + if not from_name or not to_name: + return Response(_Err('FromCurrency or ToCurrency is empty')) + if from_name == to_name: + return Response(_Err('FromCurrency and ToCurrency are same')) + + # 校验源/目标币种 + from_currency = CmeCurrencies.objects.filter(CurrencyName=from_name).first() + to_currency = CmeCurrencies.objects.filter(CurrencyName=to_name).first() + if not from_currency: + return Response(_Err('FromCurrency not found')) + if not to_currency: + return Response(_Err('ToCurrency not found')) + if from_currency.Tradable != 1: + return Response(_Err('FromCurrency is not tradable')) + if to_currency.Tradable != 1: + return Response(_Err('ToCurrency is not tradable')) + + # 校验汇率 + from_rate_obj = CmeExchangeRates.objects.filter(CurrencyName=from_name).first() + to_rate_obj = CmeExchangeRates.objects.filter(CurrencyName=to_name).first() + if not from_rate_obj or not to_rate_obj: + return Response(_Err('exchange rate not found')) + if not from_rate_obj.Rate or not to_rate_obj.Rate: + return Response(_Err('exchange rate is zero')) + + # 定位玩家余额 + player_uuid_str = GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) + if not player_uuid_str: + return Response({'GC': -1}) + try: + player_uuid_bytes = _uuid.UUID(player_uuid_str).bytes + except (ValueError, TypeError): + return Response({'GC': -1}) + + from_balance = CmeBalances.objects.filter( + UserUUID=player_uuid_bytes, Currency=from_currency + ).first() + if not from_balance or from_balance.Balance < amount: + return Response(_Err('insufficient balance')) + + # 计算到账金额 + to_amount = round(amount * from_rate_obj.Rate / to_rate_obj.Rate, 4) + + # 执行转换(扣减源币种、增加目标币种) + try: + with transaction.atomic(using='default'): + from_balance.Balance = from_balance.Balance - amount + from_balance.save() + + to_balance = CmeBalances.objects.filter( + UserUUID=player_uuid_bytes, Currency=to_currency + ).first() + if to_balance: + to_balance.Balance = to_balance.Balance + to_amount + to_balance.save() + else: + CmeBalances.objects.create( + UserUUID=player_uuid_bytes, + Currency=to_currency, + Balance=to_amount, + ) + + # 记录交易流水(便于审计) + CmeTransactions.objects.create( + FromUUID=player_uuid_bytes, + ToUUID=player_uuid_bytes, + Currency=from_currency, + Amount=amount, + TransactionType='exchange_out', + Description=f'exchange {amount} {from_name} -> {to_amount} {to_name}', + ) + CmeTransactions.objects.create( + FromUUID=player_uuid_bytes, + ToUUID=player_uuid_bytes, + Currency=to_currency, + Amount=to_amount, + TransactionType='exchange_in', + Description=f'exchange {amount} {from_name} -> {to_amount} {to_name}', + ) + except Exception as e: + return Response(_Err(f'exchange SQL error: {e}')) + + return Response({ + 'status': 0, + 'FromCurrency': from_name, + 'ToCurrency': to_name, + 'Amount': amount, + 'Received': to_amount, + 'Rate': from_rate_obj.Rate / to_rate_obj.Rate, + }) + + +# ============================================================================ +# 业务 ViewSet:企业(Enterprise) +# ============================================================================ + class EnterpriseViewSet(viewsets.ModelViewSet): + """企业 ViewSet——游戏内企业注册系统(与 SAAS 完全隔离) + + 业务 action: + - ``get_enterprise``(GET):当前用户的企业列表 + - ``new_enterprise``(POST):注册新企业或修改现有企业 + - ``get_enterprise_card``(GET):公开企业卡片(匿名可访问) + - ``deregistration``(POST):注销企业(标记 status=2) + """ queryset = Enterprise.objects.all() serializer_class = EnterpriseSerializer permission_classes = [IsAuthenticated] @@ -44,8 +553,1939 @@ class EnterpriseViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(qs[:20], many=True) return Response({'code': 0, 'data': serializer.data}) + @action(detail=False, methods=['get']) + def get_enterprise(self, request): + """当前用户的企业列表 + + 要求用户已绑定 MC 钱包且有 GC 余额;返回该用户作为法人/高管/成员 + (EnterpriseUserType ∈ {0,1,2})的所有企业,含完整字段。 + """ + gvsds_user_uuid = request.user.UserUUID + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + memberships = EnterpriseUser.objects.filter( + UserUUID=user_uuid_bytes, + EnterpriseUserType__in=list(ENTERPRISE_USER_TYPE_IN_RANGE), + ) + if not memberships.exists(): + return Response({'status': 1, 'error': 0, 'msg': 'No Enterprise'}) + + enterprise_uuids = [m.EnterpriseUUID_id for m in memberships] + membership_by_ent = {m.EnterpriseUUID_id: m for m in memberships} + + enterprises = Enterprise.objects.filter(EnterpriseUUID__in=enterprise_uuids) + result = [] + for ent in enterprises: + juridical = _get_juridical_persons(ent.EnterpriseUUID) + my_membership = membership_by_ent.get(ent.EnterpriseUUID) + my_type = my_membership.EnterpriseUserType if my_membership else -1 + result.append(_enterprise_to_dict(ent, juridical, my_type)) + return Response({'status': 0, 'data': result}) + + @action(detail=False, methods=['post']) + def new_enterprise(self, request): + """注册新企业或修改现有企业 + + 若提交 ``EnterpriseUUID`` 且库中已存在,则进入"修改模式": + - 当前用户必须为该企业的法人或高管(Type ∈ {0,1}) + - Type=0(法人)时第一法人必须是当前用户本人 + - 若原法人列表为空,则整批替换法人列表 + + 若未提交或库中不存在,则进入"注册模式": + - 必须提供 Enterprise3C 认证码 + - 企业名不可重复 + - 父企业 UUID(若提供)必须已存在 + - 同一法人名下企业数 ≤ 20 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + + # 1. 字段提取与基础校验 + enterprise_uuid_str = data.get('EnterpriseUUID') + enterprise_father_uuid_str = data.get('EnterpriseFatherUUID') + enterprise_3c_str = data.get('Enterprise3C') + enterprise_name = data.get('EnterpriseName') + enterprise_type = data.get('EnterpriseType') + enterprise_status = data.get('EnterpriseStatus') + juridical_persons_raw = data.get('EnterpriseJuridicalPerson', '') + enterprise_registered_capital = data.get('EnterpriseRegisteredCapital') + enterprise_capital_gc = data.get('EnterpriseCapitalGC') + enterprise_telephone = data.get('EnterpriseTelephone') + enterprise_email = data.get('EnterpriseEmail') + enterprise_website = data.get('EnterpriseWebsite') + enterprise_address = data.get('EnterpriseAddress') + enterprise_desc = data.get('EnterpriseDesc') + enterprise_admin_div = data.get('EnterpriseAdministrativeDivisions') + business_scope_raw = data.get('EnterpriseBusinessScope', '') + + # 2. 钱包 GC 校验 + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + # 3. 字符串字段非空校验 + if not enterprise_name: + return Response(_Err('EnterpriseName is empty')) + if enterprise_type is None or enterprise_type == '': + return Response(_Err('EnterpriseType is empty')) + if enterprise_status is None or enterprise_status == '': + return Response(_Err('EnterpriseStatus is empty')) + if not juridical_persons_raw: + return Response(_Err('EnterpriseJuridicalPerson is empty')) + if enterprise_registered_capital is None or enterprise_registered_capital == '': + return Response(_Err('EnterpriseRegisteredCapital is empty')) + if enterprise_capital_gc is None or enterprise_capital_gc == '': + return Response(_Err('EnterpriseCapitalGC is empty')) + if not enterprise_address: + return Response(_Err('EnterpriseAddress is empty')) + if not enterprise_desc: + return Response(_Err('EnterpriseDesc is empty')) + if not enterprise_admin_div: + return Response(_Err('EnterpriseAdministrativeDivisions is empty')) + if not business_scope_raw: + return Response(_Err('EnterpriseBusinessScope is empty')) + + # 4. 类型转换与枚举校验 + try: + enterprise_type = int(enterprise_type) + except (ValueError, TypeError): + return Response(_Err('EnterpriseType is not int')) + if enterprise_type not in ENTERPRISE_TYPE_VALID: + return Response(_Err('EnterpriseType is not 0 or 1')) + try: + enterprise_status = int(enterprise_status) + except (ValueError, TypeError): + return Response(_Err('EnterpriseStatus is not int')) + if enterprise_status not in ENTERPRISE_STATUS_VALID: + return Response(_Err('EnterpriseStatus is not 0 or 1 or 2 or 3 or 4')) + + # 5. 法人列表解析与校验 + if isinstance(juridical_persons_raw, str): + juridical_names = [n.strip() for n in juridical_persons_raw.split(',') if n.strip()] + elif isinstance(juridical_persons_raw, list): + juridical_names = [str(n).strip() for n in juridical_persons_raw if n] + else: + return Response(_Err('EnterpriseJuridicalPerson is not str or list')) + if len(juridical_names) > 10: + return Response(_Err('EnterpriseJuridicalPerson is more than 10')) + + # 法人 UserUUID 解析(输入为 MC 玩家名,通过玩家名反查 GVSDS UserUUID) + juridical_user_uuids = [] + for name in juridical_names: + j_uuid = GetGVSDSUserByPlayerName(name) + if not j_uuid: + return Response(_Err('EnterpriseJuridicalPerson contains invalid player')) + juridical_user_uuids.append(j_uuid) + + for j_uuid in juridical_user_uuids: + player_uuid_str = GetGVSDSUserToMCPlayerUUID(j_uuid) + if not player_uuid_str: + return Response(_Err('one of the EnterpriseJuridicalPerson is no wallet')) + try: + player_uuid_bytes = _uuid.UUID(player_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('one of the EnterpriseJuridicalPerson is no wallet')) + gc_currency = CmeCurrencies.objects.filter(CurrencyName='GC').first() + if not gc_currency or not CmeBalances.objects.filter( + UserUUID=player_uuid_bytes, Currency=gc_currency + ).exists(): + return Response(_Err('one of the EnterpriseJuridicalPerson is no wallet')) + + # 6. 注册资本校验 + try: + enterprise_registered_capital = int(enterprise_registered_capital) + except (ValueError, TypeError): + return Response(_Err('EnterpriseRegisteredCapital is not int')) + if enterprise_registered_capital < 10: + return Response(_Err('EnterpriseRegisteredCapital is less than 10')) + if enterprise_capital_gc not in ('GC', 'CNY', 'USD'): + return Response(_Err('EnterpriseCapitalGC is not GC or CNY or USD')) + + # 7. 新注册时注册资本需 ≥ 50 万 GC + enterprise_uuid_bytes = None + if enterprise_uuid_str: + try: + enterprise_uuid_bytes = UUIDObj(enterprise_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('EnterpriseUUID not is UUID')) + if not enterprise_uuid_bytes and gc_balance.Balance < 500000: + return Response(_Err('EnterpriseRegisteredCapital (Your amount) is less than 500000')) + + # 8. 联系方式格式校验 + if enterprise_telephone and not _PHONE_RE.match(enterprise_telephone): + return Response(_Err('EnterpriseTelephone is not phone')) + if enterprise_email and not _EMAIL_RE.match(enterprise_email): + return Response(_Err('EnterpriseEmail is not email')) + if enterprise_website and not _URL_RE.match(enterprise_website): + return Response(_Err('EnterpriseWebsite is not url')) + if not _ADDRESS_RE.match(enterprise_address): + return Response(_Err('EnterpriseAddress is not format')) + + # 9. 长度校验 + if len(enterprise_name) > 30: + return Response(_Err('EnterpriseName is more than 30')) + if enterprise_telephone and len(enterprise_telephone) != 11: + return Response(_Err('EnterpriseTelephone is not 11')) + if enterprise_email and len(enterprise_email) > 50: + return Response(_Err('EnterpriseEmail is more than 50')) + if enterprise_website and len(enterprise_website) > 200: + return Response(_Err('EnterpriseWebsite is more than 200')) + if len(enterprise_address) > 200: + return Response(_Err('EnterpriseAddress is more than 200')) + if len(enterprise_desc) > 10240: + return Response(_Err('EnterpriseDesc is more than 10KB')) + if len(enterprise_admin_div) > 10240: + return Response(_Err('EnterpriseAdministrativeDivisions is more than 10KB')) + if isinstance(business_scope_raw, str): + business_scope_list = [s.strip() for s in business_scope_raw.split(',') if s.strip()] + else: + business_scope_list = list(business_scope_raw) + if len(''.join(business_scope_list)) > 10240: + return Response(_Err('EnterpriseBusinessScope is more than 10KB')) + if len(business_scope_list) > 100: + return Response(_Err('EnterpriseBusinessScope is more than 100')) + + # 10. 父企业 UUID 校验 + enterprise_father_uuid_bytes = None + if enterprise_father_uuid_str and enterprise_father_uuid_str != '30': + try: + enterprise_father_uuid_bytes = UUIDObj(enterprise_father_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('EnterpriseFatherUUID not is UUID')) + + # 11. 注册模式 / 修改模式 判定 + gvsds_user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + mode_register = True + existing_membership = None + if enterprise_uuid_bytes and Enterprise.objects.filter(EnterpriseUUID=enterprise_uuid_bytes).exists(): + # 修改模式 + mode_register = False + existing_membership = EnterpriseUser.objects.filter( + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=gvsds_user_uuid_bytes, + EnterpriseUserType__in=[ENTERPRISE_USER_TYPE_LEGAL, ENTERPRISE_USER_TYPE_EXEC], + ).first() + if not existing_membership: + return Response(_Err('You are not the legal person')) + else: + # 注册模式 + if not enterprise_3c_str: + return Response(_Err('Enterprise3C is empty')) + try: + enterprise_3c_bytes = UUIDObj(enterprise_3c_str).bytes + except (ValueError, TypeError): + return Response(_Err('Enterprise3C not is UUID')) + # Enterprise3C 表暂未建(旧 Flask 检查 enterprise3c 表),此处跳过 3C 唯一性校验 + if Enterprise.objects.filter(EnterpriseName=enterprise_name).exists(): + return Response(_Err('EnterpriseName is exist')) + if enterprise_father_uuid_bytes and not Enterprise.objects.filter( + EnterpriseFatherUUID=enterprise_father_uuid_bytes + ).exists(): + return Response(_Err('EnterpriseFatherUUID is exist')) + # 法人名下企业数 ≤ 20 + owned_count = EnterpriseUser.objects.filter( + UserUUID=gvsds_user_uuid_bytes, + ).count() + if owned_count > 20: + return Response(_Err('There Are Too Many Enterprises Owned By Legal Persons')) + enterprise_uuid_bytes = _uuid.uuid4().bytes + + # 12. 修改模式下法人检查(在 transaction 外提前失败) + # 法人主体为玩家名:当前用户绑定的 MC 玩家名必须与提交的法人列表首位一致 + if not mode_register and existing_membership and existing_membership.EnterpriseUserType == ENTERPRISE_USER_TYPE_LEGAL: + current_player_name = GetPlayerNameByGVSDSUser(request.user.UserUUID) + if not current_player_name or juridical_names[0] != current_player_name: + return Response(_Err('EnterpriseJuridicalPerson first is not you')) + + # 13. 写入或更新 Enterprise 主表 + EnterpriseUser 关系(事务保证原子性) + try: + with transaction.atomic(using='default'): + if mode_register: + ent = Enterprise.objects.create( + EnterpriseUUID=enterprise_uuid_bytes, + EnterpriseCreateCertificationCode=enterprise_3c_bytes, + EnterpriseFatherUUID=enterprise_father_uuid_bytes, + EnterpriseName=enterprise_name, + EnterpriseType=enterprise_type, + EnterpriseStatus=enterprise_status, + EnterpriseRegisteredCapital=enterprise_registered_capital, + EnterpriseCreateTime=_timezone.now(), + EnterpriseCapitalGC=enterprise_capital_gc, + EnterpriseTelephone=enterprise_telephone, + EnterpriseEmail=enterprise_email, + EnterpriseWebsite=enterprise_website, + EnterpriseAddress=enterprise_address, + EnterpriseDesc=enterprise_desc, + EnterpriseAdministrativeDivisions=enterprise_admin_div, + EnterpriseBusinessScope=business_scope_list, + EnterpriseBusinessPeriod=-1, + ) + # 注册模式:为每个法人创建 EnterpriseUser + # (原 bug:此段被误置于修改模式条件内,导致注册后 get_enterprise 返回 No Enterprise) + for idx, j_uuid in enumerate(juridical_user_uuids): + EnterpriseUser.objects.create( + EnterpriseSMTMUUID=_uuid.uuid4().bytes, + EnterpriseUUID=ent, + UserUUID=j_uuid, + EnterpriseUserType=ENTERPRISE_USER_TYPE_LEGAL if idx == 0 else ENTERPRISE_USER_TYPE_EXEC, + ) + else: + ent = Enterprise.objects.get(EnterpriseUUID=enterprise_uuid_bytes) + ent.EnterpriseFatherUUID = enterprise_father_uuid_bytes + ent.EnterpriseName = enterprise_name + ent.EnterpriseType = enterprise_type + ent.EnterpriseStatus = enterprise_status + ent.EnterpriseRegisteredCapital = enterprise_registered_capital + ent.EnterpriseCapitalGC = enterprise_capital_gc + ent.EnterpriseTelephone = enterprise_telephone + ent.EnterpriseEmail = enterprise_email + ent.EnterpriseWebsite = enterprise_website + ent.EnterpriseAddress = enterprise_address + ent.EnterpriseDesc = enterprise_desc + ent.EnterpriseAdministrativeDivisions = enterprise_admin_div + ent.EnterpriseBusinessScope = business_scope_list + ent.EnterpriseBusinessPeriod = -1 + ent.save() + # 修改模式且当前用户是法人:清空旧法人重写 + if existing_membership and existing_membership.EnterpriseUserType == ENTERPRISE_USER_TYPE_LEGAL: + EnterpriseUser.objects.filter(EnterpriseUUID=enterprise_uuid_bytes).delete() + for idx, j_uuid in enumerate(juridical_user_uuids): + EnterpriseUser.objects.create( + EnterpriseSMTMUUID=_uuid.uuid4().bytes, + EnterpriseUUID=ent, + UserUUID=j_uuid, + EnterpriseUserType=ENTERPRISE_USER_TYPE_LEGAL if idx == 0 else ENTERPRISE_USER_TYPE_EXEC, + ) + except Exception as e: + return Response(_Err(f'NewEnterprise SQL error: {e}')) + + return Response({'status': 0, 'msg': 'NewEnterprise'}) + + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + def get_enterprise_card(self, request): + """公开企业卡片(permission=0.0 匿名可访问) + + GET 参数:``EnterpriseUUID`` + 返回该企业的完整字段,附带当前用户在该企业的 UserType(-1 表示非成员)。 + """ + enterprise_uuid_str = request.query_params.get('EnterpriseUUID') + if not enterprise_uuid_str: + return Response({'status': 1}) + try: + enterprise_uuid_bytes = UUIDObj(enterprise_uuid_str).bytes + except (ValueError, TypeError): + return Response({'status': 1}) + + ent = Enterprise.objects.filter(EnterpriseUUID=enterprise_uuid_bytes).first() + if not ent: + return Response({'status': 1}) + + juridical = _get_juridical_persons(ent.EnterpriseUUID) + user_type = -1 + if request.user.is_authenticated: + gvsds_user_uuid = request.user.UserUUID + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + m = EnterpriseUser.objects.filter( + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=user_uuid_bytes, + ).first() + if m: + user_type = m.EnterpriseUserType + + return Response({ + 'status': 0, + 'data': _enterprise_to_dict(ent, juridical, user_type), + }) + + @action(detail=False, methods=['post']) + def deregistration(self, request): + """注销企业(设置 EnterpriseStatus=2) + + 要求当前用户为该企业的法人或高管。 + """ + data = request.data or {} + enterprise_uuid_str = data.get('EnterpriseUUID') + gvsds_user_uuid = request.user.UserUUID + + # GC 钱包校验(保持与旧 Flask 一致) + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + if not enterprise_uuid_str: + return Response(_Err('EnterpriseUUID is empty')) + try: + enterprise_uuid_bytes = UUIDObj(enterprise_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('EnterpriseUUID not is UUID')) + if not Enterprise.objects.filter(EnterpriseUUID=enterprise_uuid_bytes).exists(): + return Response(_Err('EnterpriseUUID is not exist')) + + gvsds_user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + is_owner = EnterpriseUser.objects.filter( + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=gvsds_user_uuid_bytes, + EnterpriseUserType__in=[ENTERPRISE_USER_TYPE_LEGAL, ENTERPRISE_USER_TYPE_EXEC], + ).exists() + if not is_owner: + return Response(_Err('You are not the owner of the enterprise')) + + try: + with transaction.atomic(using='default'): + Enterprise.objects.filter(EnterpriseUUID=enterprise_uuid_bytes).update(EnterpriseStatus=2) + except Exception as e: + return Response(_Err(f'Deregistration SQL error: {e}')) + + return Response({'status': 0}) + + +# ============================================================================ +# 业务 ViewSet:企业成员(EnterpriseUser) +# ============================================================================ class EnterpriseUserViewSet(viewsets.ModelViewSet): + """企业用户 ViewSet——游戏内企业成员管理 + + 业务 action: + - ``get_members``(GET,permission=0.0 匿名可访问):列出企业全部成员 + - ``new_member``(POST):新增/修改成员(Type 必须低于当前用户) + - ``delete_member``(DELETE/POST):删除成员(Type 必须低于当前用户) + """ queryset = EnterpriseUser.objects.all() serializer_class = EnterpriseUserSerializer permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + def get_members(self, request): + """列出企业全部成员(permission=0.0 匿名可访问) + + GET 参数:``EnterpriseUUID`` + """ + enterprise_uuid_str = request.query_params.get('EnterpriseUUID') + if not enterprise_uuid_str: + return Response({'status': 1}) + try: + enterprise_uuid_bytes = UUIDObj(enterprise_uuid_str).bytes + except (ValueError, TypeError): + return Response({'status': 1}) + + if not Enterprise.objects.filter(EnterpriseUUID=enterprise_uuid_bytes).exists(): + return Response({'status': 1}) + + members = EnterpriseUser.objects.filter(EnterpriseUUID=enterprise_uuid_bytes) + if not members.exists(): + return Response({'status': 1, 'msg': 'No EnterpriseMember'}) + + result = [] + for m in members: + # 成员主体为玩家名:优先显示 MC 玩家名,回退到 GVSDS 用户名 + player_name = GetPlayerNameByGVSDSUser(m.UserUUID) + result.append({ + 'EnterpriseUUID': UUIDObj(enterprise_uuid_bytes).str, + 'UserUUID': UUIDObj(m.UserUUID).str if m.UserUUID else None, + 'UserName': player_name or GetUserNameByUUID(m.UserUUID), + 'EnterpriseUserType': m.EnterpriseUserType, + }) + return Response({'status': 0, 'msg': 'GetEnterpriseMember', 'data': result}) + + @action(detail=False, methods=['post']) + def new_member(self, request): + """新增或修改成员 + + 要求当前用户为该企业的 Type∈{0,1,2} 成员,且 Type < 目标 Type + (防越权);目标用户必须已绑定 MC 钱包。已有成员更新 Type,否则新增。 + """ + data = request.data or {} + enterprise_uuid_str = data.get('EnterpriseUUID') + user_name = data.get('UserName') + user_uuid_str = data.get('UserUUID') + try: + target_type = int(data.get('EnterpriseUserType')) + except (ValueError, TypeError): + return Response(_Err('EnterpriseUserType is not int')) + if target_type not in (ENTERPRISE_USER_TYPE_EXEC, ENTERPRISE_USER_TYPE_MEMBER): + return Response(_Err('EnterpriseUserType must be 1 or 2')) + + gvsds_user_uuid = request.user.UserUUID + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + if not enterprise_uuid_str: + return Response(_Err('EnterpriseUUID is empty')) + try: + enterprise_uuid_bytes = UUIDObj(enterprise_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('EnterpriseUUID not is UUID')) + + gvsds_user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + me = EnterpriseUser.objects.filter( + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=gvsds_user_uuid_bytes, + EnterpriseUserType__in=list(ENTERPRISE_USER_TYPE_IN_RANGE), + ).first() + if not me: + return Response(_Err('You are not the legal person')) + if me.EnterpriseUserType >= target_type: + return Response({'status': 1, 'error': 0, 'msg': "U Can't Edit He To This EnterpriseUserType"}) + + # 解析目标 UserUUID + target_user_uuid_bytes = None + if user_uuid_str: + try: + target_user_uuid_bytes = UUIDObj(user_uuid_str).bytes + except (ValueError, TypeError): + target_user_uuid_bytes = None + if not target_user_uuid_bytes and user_name: + # user_name 优先按 MC 玩家名解析,回退到 GVSDS 用户名 + target_user_uuid_bytes = GetGVSDSUserByPlayerName(user_name) + if not target_user_uuid_bytes: + target_user = GetUserByName(user_name) + if target_user: + target_user_uuid_bytes = target_user.UserUUID + if not target_user_uuid_bytes: + return Response(_Err('Not Found User')) + + # 目标必须有 GC 钱包 + target_player_uuid_str = GetGVSDSUserToMCPlayerUUID(target_user_uuid_bytes) + if not target_player_uuid_str: + return Response({'status': 1, 'error': 0, 'msg': 'No Wallet'}) + try: + target_player_uuid_bytes = _uuid.UUID(target_player_uuid_str).bytes + except (ValueError, TypeError): + return Response({'status': 1, 'error': 0, 'msg': 'No Wallet'}) + gc_currency = CmeCurrencies.objects.filter(CurrencyName='GC').first() + if not gc_currency or not CmeBalances.objects.filter( + UserUUID=target_player_uuid_bytes, Currency=gc_currency + ).exists(): + return Response({'status': 1, 'error': 0, 'msg': 'No Wallet'}) + + try: + with transaction.atomic(using='default'): + existing = EnterpriseUser.objects.filter( + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=target_user_uuid_bytes, + ).first() + if existing: + if existing.UserUUID == gvsds_user_uuid_bytes: + return Response({'status': 1, 'error': 0, 'msg': "U Can't Edit Yourself"}) + if existing.EnterpriseUserType <= me.EnterpriseUserType: + return Response({'status': 1, 'error': 0, 'msg': "U Can't Edit EnterpriseUserType Than U More"}) + existing.EnterpriseUserType = target_type + existing.save() + else: + EnterpriseUser.objects.create( + EnterpriseSMTMUUID=_uuid.uuid4().bytes, + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=target_user_uuid_bytes, + EnterpriseUserType=target_type, + ) + except Exception: + return Response(_Err('SQL 提交失败')) + + return Response({'status': 0}) + + @action(detail=False, methods=['post', 'delete']) + def delete_member(self, request): + """删除成员(要求当前用户 Type < 目标 Type)""" + data = request.data or {} + enterprise_uuid_str = data.get('EnterpriseUUID') + user_uuid_str = data.get('UserUUID') + + gvsds_user_uuid = request.user.UserUUID + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + if not enterprise_uuid_str or not user_uuid_str: + return Response(_Err('EnterpriseUUID or UserUUID is empty')) + try: + enterprise_uuid_bytes = UUIDObj(enterprise_uuid_str).bytes + target_user_uuid_bytes = UUIDObj(user_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('UUID not valid')) + + if target_user_uuid_bytes == UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You can not delete yourself')) + + gvsds_user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + me = EnterpriseUser.objects.filter( + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=gvsds_user_uuid_bytes, + ).first() + if not me: + return Response({'status': 1, 'error': 0, 'msg': 'Failed Query Something'}) + + target = EnterpriseUser.objects.filter( + EnterpriseUUID=enterprise_uuid_bytes, + UserUUID=target_user_uuid_bytes, + ).first() + if not target: + return Response({'status': 1, 'error': 0, 'msg': 'Failed Query Something'}) + + if me.EnterpriseUserType >= target.EnterpriseUserType: + return Response({'status': 1, 'error': 0, 'msg': "You Can't Delete He"}) + + try: + with transaction.atomic(using='default'): + target.delete() + except Exception: + return Response(_Err('SQL 提交失败')) + + return Response({'status': 0}) + + +# ============================================================================ +# 业务 ViewSet:储值(CmeStoreValue) +# ============================================================================ + +class CmeStoreValueViewSet(viewsets.ModelViewSet): + """CME 储值 ViewSet——定期存款(移植自旧 Flask StoreValue/Cash/GetStoreValue.api) + + 业务 action: + - ``store_value``(POST):批量创建储值(按 StoreValueRule 校验,扣 GC 余额) + - ``cash``(POST):取现并结算利息(本息合计加到 GC 余额,删除储值记录) + - ``get_store_value``(GET):列出当前用户的储值(含利息/游戏日/现实日字段) + """ + queryset = CmeStoreValue.objects.all() + serializer_class = CmeStoreValueSerializer + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def search(self, request): + user_uuid = request.query_params.get('user_uuid') + qs = CmeStoreValue.objects.all() + if user_uuid: + qs = qs.filter(UserUUID=user_uuid) + serializer = self.get_serializer(qs[:50], many=True) + return Response({'code': 0, 'data': serializer.data}) + + @action(detail=False, methods=['get']) + def get_store_value_rules(self, request): + """返回可用的储值方案列表(供前端渲染购买 UI) + + 无需鉴权即可访问规则列表(方案是公开信息);但实际购买仍需登录。 + 返回 ``{"status": 0, "data": [{"Id", "Name", "InterestRate", + "MinDay", "MinGC", "MaxGC", "ExpirationGameDate"}, ...]}``, + 其中 ``MinGC/MaxGC = -1`` 表示无限制。 + """ + rules = [] + for plan_id, rule in StoreValueRule.items(): + rules.append({ + 'Id': plan_id, + 'Name': rule['Name'], + 'InterestRate': rule['InterestRate'], + 'MinDay': rule['MinDay'], + 'MinGC': rule['MinGC'], + 'MaxGC': rule['MaxGC'], + 'ExpirationGameDate': rule['ExpirationGameDate'], + }) + return Response({'status': 0, 'data': rules}) + + @action(detail=False, methods=['get']) + def get_store_value(self, request): + """列出当前用户的储值记录(含计算字段)""" + gvsds_user_uuid = request.user.UserUUID + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + qs = CmeStoreValue.objects.filter(UserUUID=user_uuid_bytes) + if not qs.exists(): + return Response({'status': 0, 'data': []}) + + now = _timezone.now() + result = [] + for sv in qs: + rule = StoreValueRule.get(sv.StoreValueType, {}) + accrued, game_days, days = CalcAccruedInterest(sv, now) + result.append({ + 'StoreValueUUID': UUIDObj(sv.StoreValueUUID).str, + 'UserUUID': UUIDObj(sv.UserUUID).str if sv.UserUUID else None, + 'PlayerUUID': UUIDObj(sv.PlayerUUID).str if sv.PlayerUUID else None, + 'PlayerName': sv.PlayerName, + 'StoreValueCreateDateTime': sv.StoreValueCreateDateTime.strftime('%Y-%m-%d %H:%M:%S') if sv.StoreValueCreateDateTime else None, + 'StoreValueType': rule.get('Name', str(sv.StoreValueType)), + 'StoreValueMin': sv.StoreValueMin, + 'StoreValueDayInterestRate': (sv.StoreValueDayInterestRate or 0) / 1000, + 'StoreValueAmount': sv.StoreValueAmount, + 'StoreValueExpirationGameDate': sv.StoreValueExpirationGameDate, + 'AccruedInterest': accrued, + 'GameDays': game_days, + 'Days': days, + }) + return Response({'status': 0, 'data': result}) + + @action(detail=False, methods=['post']) + def store_value(self, request): + """批量储值——按 plans 列表校验并扣 GC 余额,批量插入储值记录 + + 请求体:``{"plans": [{"id": 1, "amount": 1000}, ...]}`` + """ + gvsds_user_uuid = request.user.UserUUID + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + data = request.data or {} + plans = data.get('plans') + if not plans or not isinstance(plans, list): + return Response(_Err('plans is empty or not list')) + + # 1. 校验所有 plans + total_amount = 0 + validated_plans = [] + for p in plans: + try: + plan_id = int(p.get('id')) + plan_amount = int(p.get('amount')) + except (ValueError, TypeError, AttributeError): + return Response({'status': 1, 'error': p.get('id') if isinstance(p, dict) else None, 'msg': 'amount'}) + + if plan_id not in StoreValueRule: + return Response({'status': 1, 'error': plan_id, 'msg': 'type'}) + if plan_amount <= 0: + return Response({'status': 1, 'error': plan_id, 'msg': 'amount'}) + + rule = StoreValueRule[plan_id] + if rule['MinGC'] != -1 and plan_amount < rule['MinGC']: + return Response({'status': 1, 'error': plan_id, 'msg': 'MinGC'}) + if rule['MaxGC'] != -1 and plan_amount > rule['MaxGC']: + return Response({'status': 1, 'error': plan_id, 'msg': 'MaxGC'}) + + total_amount += plan_amount + validated_plans.append((plan_id, plan_amount, rule)) + + # 2. GC 余额校验 + if gc_balance.Balance < total_amount: + return Response({'status': 1, 'error': 0, 'msg': 'No GC'}) + + # 3. 扣减余额 + 批量插入储值 + player_uuid_str = GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) + player_uuid_bytes = _uuid.UUID(player_uuid_str).bytes if player_uuid_str else None + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + # 玩家名从 easyauth 表取(已绑定后的 MC 玩家名) + player_name = '' + try: + from gvsdsdk.minecraft import EasyAuthManager + if player_uuid_str: + ea = EasyAuthManager.GetByPlayerUUID(player_uuid_str) + if ea: + player_name = ea.Username + except Exception: + pass + + try: + with transaction.atomic(using='default'): + # 扣 GC 余额(managed=False 表,但仍走 ORM update) + gc_balance.Balance = gc_balance.Balance - total_amount + gc_balance.save() + + now = _timezone.now() + for plan_id, plan_amount, rule in validated_plans: + CmeStoreValue.objects.create( + StoreValueUUID=_uuid.uuid4().bytes, + UserUUID=user_uuid_bytes, + PlayerUUID=player_uuid_bytes, + PlayerName=player_name, + StoreValueCreateDateTime=now, + StoreValueType=plan_id, + StoreValueMin=rule['MinDay'], + StoreValueDayInterestRate=rule['InterestRate'], + StoreValueAmount=plan_amount, + StoreValueExpirationGameDate=rule['ExpirationGameDate'], + ) + except Exception as e: + return Response(_Err(f'StoreValue SQL error: {e}')) + + return Response({'status': 0}) + + @action(detail=False, methods=['post']) + def cash(self, request): + """取现——结算储值利息并加到 GC 余额,删除储值记录 + + 请求体:``{"StoreValueUUID": "..."}`` + """ + gvsds_user_uuid = request.user.UserUUID + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + + data = request.data or {} + store_value_uuid_str = data.get('StoreValueUUID') + if not store_value_uuid_str: + return Response(_Err('StoreValueUUID is empty')) + try: + store_value_uuid_bytes = UUIDObj(store_value_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('StoreValueUUID not is UUID')) + + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + sv = CmeStoreValue.objects.filter( + StoreValueUUID=store_value_uuid_bytes, + UserUUID=user_uuid_bytes, + ).first() + if not sv: + return Response({'status': 1, 'error': 0, 'msg': 'No StoreValue'}) + + now = _timezone.now() + try: + accrued_and_amount = CalcAccruedInterestAndAmount(sv, now) + except Exception: + return Response(_Err('calc interest error')) + + try: + with transaction.atomic(using='default'): + gc_balance.Balance = gc_balance.Balance + accrued_and_amount + gc_balance.save() + sv.delete() + except Exception: + return Response(_Err('SQL 提交失败')) + + return Response({'status': 0}) + + +# ============================================================================ +# 商店系统 ViewSet +# 淘宝/拼多多风格游戏内商店:商品/购物车/订单(GC 虚拟币支付) +# ============================================================================ + +def _generate_order_no(): + """生成订单号:S + YYYYMMDDHHMMSS + 6位随机数""" + now = _timezone.now() + rand = secrets.randbelow(1000000) + return f'S{now.strftime("%Y%m%d%H%M%S")}{rand:06d}' + + +def _product_to_dict(p, include_desc=False): + """商品序列化(手动构造,避免Serializer嵌套过深)""" + if not p: + return None + d = { + 'ProductUUID': UUIDObj(p.ProductUUID).str, + 'SellerUserUUID': UUIDObj(p.SellerUserUUID).str if p.SellerUserUUID else None, + 'SellerPlayerName': p.SellerPlayerName, + 'ProductName': p.ProductName, + 'ProductPrice': p.ProductPrice, + 'Stock': p.Stock, + 'SoldCount': p.SoldCount, + 'ProductImages': p.ProductImages or [], + 'ProductTags': p.ProductTags or [], + 'ProductCategory': p.ProductCategory, + 'ProductStatus': p.ProductStatus, + 'CreateTime': _fmt_dt(p.CreateTime), + } + if include_desc: + d['ProductDesc'] = p.ProductDesc + return d + + +def _get_buyer_info(gvsds_user_uuid): + """获取买家信息:player_uuid_bytes, player_name;失败返回 (None, None, error)""" + player_uuid_str = GetGVSDSUserToMCPlayerUUID(gvsds_user_uuid) + if not player_uuid_str: + return None, None, {'GC': -1} + try: + player_uuid_bytes = _uuid.UUID(player_uuid_str).bytes + except (ValueError, TypeError): + return None, None, {'GC': -1} + player_name = GetPlayerNameByGVSDSUser(gvsds_user_uuid) + return player_uuid_bytes, player_name, None + + +class ShopProductViewSet(viewsets.ModelViewSet): + """商品 ViewSet——商品管理 + 公开浏览 + + 业务 action: + - ``list_public``(GET,AllowAny):商品列表(在售商品,分页) + - ``detail``(GET,AllowAny):商品详情 + - ``my_products``(GET):当前用户(卖家)的商品列表 + - ``create_product``(POST):创建商品 + - ``modify_product``(POST):修改商品 + - ``delete_product``(POST):删除商品 + - ``set_status``(POST):上架/下架/缺货 + """ + + queryset = ShopProduct.objects.all() + serializer_class = ShopProductSerializer + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + def list_public(self, request): + """商品列表(在售商品,公开访问) + + 查询参数: + - ``Keyword``:商品名搜索(模糊匹配) + - ``Category``:分类过滤 + - ``Tag``:标签过滤 + - ``Page`` / ``PageSize``:分页(默认 1 / 20) + """ + qs = ShopProduct.objects.filter(ProductStatus=SHOP_PRODUCT_ONLINE) + keyword = request.query_params.get('Keyword', '').strip() + category = request.query_params.get('Category', '').strip() + tag = request.query_params.get('Tag', '').strip() + if keyword: + qs = qs.filter(ProductName__icontains=keyword) + if category: + qs = qs.filter(ProductCategory=category) + if tag: + qs = qs.filter(ProductTags__contains=[tag]) + + try: + page = int(request.query_params.get('Page', 1)) + page_size = int(request.query_params.get('PageSize', 20)) + except (TypeError, ValueError): + page, page_size = 1, 20 + page = max(1, page) + page_size = max(1, min(100, page_size)) + + total = qs.count() + items = qs[(page - 1) * page_size: page * page_size] + return Response({ + 'status': 0, + 'Total': total, + 'Page': page, + 'PageSize': page_size, + 'Data': [_product_to_dict(p) for p in items], + }) + + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + def detail(self, request): + """商品详情(公开访问) + + 查询参数:``ProductUUID`` + """ + product_uuid_str = request.query_params.get('ProductUUID') + if not product_uuid_str: + return Response(_Err('ProductUUID is empty')) + try: + product_uuid_bytes = UUIDObj(product_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('ProductUUID not is UUID')) + p = ShopProduct.objects.filter(ProductUUID=product_uuid_bytes).first() + if not p: + return Response(_Err('Product not found')) + return Response({'status': 0, 'Data': _product_to_dict(p, include_desc=True)}) + + @action(detail=False, methods=['get']) + def my_products(self, request): + """当前用户的商品列表(卖家视角,含所有状态)""" + gvsds_user_uuid = request.user.UserUUID + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + qs = ShopProduct.objects.filter(SellerUserUUID=user_uuid_bytes) + return Response({ + 'status': 0, + 'Data': [_product_to_dict(p, include_desc=True) for p in qs], + }) + + @action(detail=False, methods=['post']) + def create_product(self, request): + """创建商品 + + 请求体:``{"ProductName", "ProductPrice", "Stock", "ProductDesc", + "ProductImages": [sha1...], "ProductTags": [..], + "ProductCategory"}`` + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + + name = (data.get('ProductName') or '').strip() + if not name: + return Response(_Err('ProductName is empty')) + if len(name) > 128: + return Response(_Err('ProductName is too long')) + + try: + price = float(data.get('ProductPrice', 0)) + except (TypeError, ValueError): + return Response(_Err('ProductPrice is not number')) + if price < 0: + return Response(_Err('ProductPrice is negative')) + + try: + stock = int(data.get('Stock', -1)) + except (TypeError, ValueError): + return Response(_Err('Stock is not int')) + if stock < -1: + return Response(_Err('Stock is invalid')) + + desc = data.get('ProductDesc') or '' + if len(desc) > 10240: + return Response(_Err('ProductDesc is too long')) + images = data.get('ProductImages') or [] + if not isinstance(images, list): + return Response(_Err('ProductImages is not list')) + tags = data.get('ProductTags') or [] + if not isinstance(tags, list): + return Response(_Err('ProductTags is not list')) + category = data.get('ProductCategory') or '' + if len(category) > 64: + return Response(_Err('ProductCategory is too long')) + + # 填充卖家玩家名 + seller_player_name = GetPlayerNameByGVSDSUser(gvsds_user_uuid) + + product = ShopProduct.objects.create( + ProductUUID=_uuid.uuid4().bytes, + SellerUserUUID=UUIDObj(gvsds_user_uuid).bytes, + SellerPlayerName=seller_player_name, + ProductName=name, + ProductDesc=desc, + ProductPrice=price, + Stock=stock, + ProductImages=images, + ProductTags=tags, + ProductCategory=category, + ProductStatus=SHOP_PRODUCT_ONLINE, + ) + return Response({'status': 0, 'ProductUUID': UUIDObj(product.ProductUUID).str}) + + @action(detail=False, methods=['post']) + def modify_product(self, request): + """修改商品(仅限本人商品) + + 请求体:``{"ProductUUID", ...可选字段同 create_product}`` + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + product_uuid_str = data.get('ProductUUID') + if not product_uuid_str: + return Response(_Err('ProductUUID is empty')) + try: + product_uuid_bytes = UUIDObj(product_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('ProductUUID not is UUID')) + + p = ShopProduct.objects.filter(ProductUUID=product_uuid_bytes).first() + if not p: + return Response(_Err('Product not found')) + if p.SellerUserUUID != UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You are not the seller')) + + if 'ProductName' in data: + name = (data.get('ProductName') or '').strip() + if not name: + return Response(_Err('ProductName is empty')) + if len(name) > 128: + return Response(_Err('ProductName is too long')) + p.ProductName = name + if 'ProductPrice' in data: + try: + price = float(data.get('ProductPrice')) + except (TypeError, ValueError): + return Response(_Err('ProductPrice is not number')) + if price < 0: + return Response(_Err('ProductPrice is negative')) + p.ProductPrice = price + if 'Stock' in data: + try: + stock = int(data.get('Stock')) + except (TypeError, ValueError): + return Response(_Err('Stock is not int')) + if stock < -1: + return Response(_Err('Stock is invalid')) + p.Stock = stock + if 'ProductDesc' in data: + desc = data.get('ProductDesc') or '' + if len(desc) > 10240: + return Response(_Err('ProductDesc is too long')) + p.ProductDesc = desc + if 'ProductImages' in data: + images = data.get('ProductImages') or [] + if not isinstance(images, list): + return Response(_Err('ProductImages is not list')) + p.ProductImages = images + if 'ProductTags' in data: + tags = data.get('ProductTags') or [] + if not isinstance(tags, list): + return Response(_Err('ProductTags is not list')) + p.ProductTags = tags + if 'ProductCategory' in data: + category = data.get('ProductCategory') or '' + if len(category) > 64: + return Response(_Err('ProductCategory is too long')) + p.ProductCategory = category + p.save() + return Response({'status': 0}) + + @action(detail=False, methods=['post']) + def delete_product(self, request): + """删除商品(仅限本人商品)""" + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + product_uuid_str = data.get('ProductUUID') + if not product_uuid_str: + return Response(_Err('ProductUUID is empty')) + try: + product_uuid_bytes = UUIDObj(product_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('ProductUUID not is UUID')) + + p = ShopProduct.objects.filter(ProductUUID=product_uuid_bytes).first() + if not p: + return Response(_Err('Product not found')) + if p.SellerUserUUID != UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You are not the seller')) + p.delete() + return Response({'status': 0}) + + @action(detail=False, methods=['post']) + def set_status(self, request): + """设置商品状态(上架/下架/缺货) + + 请求体:``{"ProductUUID", "Status"}``(Status: 0=下架 1=在售 2=缺货) + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + product_uuid_str = data.get('ProductUUID') + try: + status = int(data.get('Status')) + except (TypeError, ValueError): + return Response(_Err('Status is not int')) + if status not in (SHOP_PRODUCT_OFFLINE, SHOP_PRODUCT_ONLINE, SHOP_PRODUCT_OUT_OF_STOCK): + return Response(_Err('Status is invalid')) + if not product_uuid_str: + return Response(_Err('ProductUUID is empty')) + try: + product_uuid_bytes = UUIDObj(product_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('ProductUUID not is UUID')) + + p = ShopProduct.objects.filter(ProductUUID=product_uuid_bytes).first() + if not p: + return Response(_Err('Product not found')) + if p.SellerUserUUID != UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You are not the seller')) + p.ProductStatus = status + p.save() + return Response({'status': 0}) + + +class ShopCartViewSet(viewsets.ModelViewSet): + """购物车 ViewSet——当前用户的购物车 + + 业务 action: + - ``list``(GET):购物车列表 + - ``add``(POST):加入购物车 + - ``update_quantity``(POST):修改数量 + - ``remove``(POST):移除购物车项 + - ``clear``(POST):清空购物车 + """ + + queryset = ShopCartItem.objects.all() + serializer_class = ShopCartItemSerializer + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def list(self, request): + """当前用户购物车列表""" + gvsds_user_uuid = request.user.UserUUID + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + items = ShopCartItem.objects.filter(UserUUID=user_uuid_bytes) + result = [] + total_amount = 0.0 + for ci in items: + p = ci.Product + if not p: + continue + item = { + 'CartItemUUID': UUIDObj(ci.CartItemUUID).str, + 'ProductUUID': UUIDObj(p.ProductUUID).str, + 'ProductName': p.ProductName, + 'ProductPrice': p.ProductPrice, + 'ProductImages': p.ProductImages or [], + 'ProductStatus': p.ProductStatus, + 'Stock': p.Stock, + 'Quantity': ci.Quantity, + 'Subtotal': round(p.ProductPrice * ci.Quantity, 4), + 'CreateTime': _fmt_dt(ci.CreateTime), + } + total_amount += item['Subtotal'] + result.append(item) + return Response({ + 'status': 0, + 'TotalAmount': round(total_amount, 4), + 'Items': result, + }) + + @action(detail=False, methods=['post']) + def add(self, request): + """加入购物车 + + 请求体:``{"ProductUUID", "Quantity"}`` + - 已存在同商品则累加数量 + - 校验商品在售、库存足够 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + product_uuid_str = data.get('ProductUUID') + try: + quantity = int(data.get('Quantity', 1)) + except (TypeError, ValueError): + return Response(_Err('Quantity is not int')) + if quantity < 1: + return Response(_Err('Quantity is invalid')) + if not product_uuid_str: + return Response(_Err('ProductUUID is empty')) + try: + product_uuid_bytes = UUIDObj(product_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('ProductUUID not is UUID')) + + p = ShopProduct.objects.filter(ProductUUID=product_uuid_bytes).first() + if not p: + return Response(_Err('Product not found')) + if p.ProductStatus != SHOP_PRODUCT_ONLINE: + return Response(_Err('Product is not online')) + if p.Stock != -1 and p.Stock < quantity: + return Response(_Err('Stock is not enough')) + + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + ci = ShopCartItem.objects.filter( + UserUUID=user_uuid_bytes, Product=p + ).first() + if ci: + new_qty = ci.Quantity + quantity + if p.Stock != -1 and p.Stock < new_qty: + return Response(_Err('Stock is not enough')) + ci.Quantity = new_qty + ci.save() + else: + ci = ShopCartItem.objects.create( + CartItemUUID=_uuid.uuid4().bytes, + UserUUID=user_uuid_bytes, + Product=p, + Quantity=quantity, + ) + return Response({'status': 0, 'CartItemUUID': UUIDObj(ci.CartItemUUID).str}) + + @action(detail=False, methods=['post']) + def update_quantity(self, request): + """修改购物车数量 + + 请求体:``{"CartItemUUID", "Quantity"}`` + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + cart_item_uuid_str = data.get('CartItemUUID') + try: + quantity = int(data.get('Quantity')) + except (TypeError, ValueError): + return Response(_Err('Quantity is not int')) + if quantity < 1: + return Response(_Err('Quantity is invalid')) + if not cart_item_uuid_str: + return Response(_Err('CartItemUUID is empty')) + try: + cart_item_uuid_bytes = UUIDObj(cart_item_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('CartItemUUID not is UUID')) + + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + ci = ShopCartItem.objects.filter( + CartItemUUID=cart_item_uuid_bytes, UserUUID=user_uuid_bytes + ).first() + if not ci: + return Response(_Err('CartItem not found')) + p = ci.Product + if p and p.Stock != -1 and p.Stock < quantity: + return Response(_Err('Stock is not enough')) + ci.Quantity = quantity + ci.save() + return Response({'status': 0}) + + @action(detail=False, methods=['post']) + def remove(self, request): + """移除购物车项 + + 请求体:``{"CartItemUUID"}`` 或 ``{"CartItemUUIDs": [...]}`` + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + + uuids = [] + single = data.get('CartItemUUID') + multi = data.get('CartItemUUIDs') + if single: + uuids.append(single) + if isinstance(multi, list): + uuids.extend(multi) + if not uuids: + return Response(_Err('CartItemUUID is empty')) + + bytes_list = [] + for s in uuids: + try: + bytes_list.append(UUIDObj(s).bytes) + except (ValueError, TypeError): + continue + if not bytes_list: + return Response(_Err('CartItemUUID not is UUID')) + + ShopCartItem.objects.filter( + CartItemUUID__in=bytes_list, UserUUID=user_uuid_bytes + ).delete() + return Response({'status': 0}) + + @action(detail=False, methods=['post']) + def clear(self, request): + """清空当前用户购物车""" + gvsds_user_uuid = request.user.UserUUID + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + ShopCartItem.objects.filter(UserUUID=user_uuid_bytes).delete() + return Response({'status': 0}) + + +class ShopOrderViewSet(viewsets.ModelViewSet): + """订单 ViewSet——订单全生命周期 + + 状态机: + PENDING(0) ─Pay→ PAID(1) ─Ship→ SHIPPED(2) ─Confirm→ COMPLETED(3) + PENDING(0) / PAID(1) ─Cancel→ CANCELLED(4) + PAID(1) / SHIPPED(2) ─Refund→ REFUNDING(5) ─RefundApprove→ REFUNDED(6) + + 业务 action: + - ``list_orders``(GET):我的订单列表(支持状态筛选) + - ``detail``(GET):订单详情(含订单项) + - ``create_order``(POST):从购物车或直接购买下单 + - ``pay``(POST):付款(扣 GC) + - ``cancel``(POST):取消订单 + - ``ship``(POST):发货(卖家/管理员) + - ``confirm``(POST):确认收货(买家) + - ``refund``(POST):申请退款(买家) + - ``refund_approve``(POST):退款审批(卖家/管理员) + """ + + queryset = ShopOrder.objects.all() + serializer_class = ShopOrderSerializer + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def list_orders(self, request): + """我的订单列表 + + 查询参数: + - ``Status``:状态筛选(不传则返回全部) + - ``AsSeller``:1=作为卖家,0/不传=作为买家 + """ + gvsds_user_uuid = request.user.UserUUID + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + + as_seller = request.query_params.get('AsSeller', '0') == '1' + qs = ShopOrder.objects.all() + if as_seller: + # 当前用户是商品卖家(通过订单项的商品匹配) + qs = qs.filter(items__Product__SellerUserUUID=user_uuid_bytes).distinct() + else: + qs = qs.filter(BuyerUserUUID=user_uuid_bytes) + + status_str = request.query_params.get('Status') + if status_str is not None and status_str != '': + try: + status = int(status_str) + qs = qs.filter(OrderStatus=status) + except (TypeError, ValueError): + pass + + result = [] + for o in qs: + result.append(self._order_to_dict(o, include_items=True)) + return Response({'status': 0, 'Data': result}) + + @action(detail=False, methods=['get']) + def detail(self, request): + """订单详情 + + 查询参数:``OrderUUID`` + """ + order_uuid_str = request.query_params.get('OrderUUID') + if not order_uuid_str: + return Response(_Err('OrderUUID is empty')) + try: + order_uuid_bytes = UUIDObj(order_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('OrderUUID not is UUID')) + + o = ShopOrder.objects.filter(OrderUUID=order_uuid_bytes).first() + if not o: + return Response(_Err('Order not found')) + + gvsds_user_uuid = request.user.UserUUID + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + # 买家本人或订单中商品的卖家可查看 + is_buyer = o.BuyerUserUUID == user_uuid_bytes + is_seller = o.items.filter(Product__SellerUserUUID=user_uuid_bytes).exists() + if not (is_buyer or is_seller): + return Response(_Err('You are not the buyer or seller')) + + return Response({'status': 0, 'Data': self._order_to_dict(o, include_items=True)}) + + def _order_to_dict(self, o, include_items=False): + d = { + 'OrderUUID': UUIDObj(o.OrderUUID).str, + 'OrderNO': o.OrderNO, + 'BuyerUserUUID': UUIDObj(o.BuyerUserUUID).str if o.BuyerUserUUID else None, + 'BuyerPlayerName': o.BuyerPlayerName, + 'TotalAmount': o.TotalAmount, + 'OrderStatus': o.OrderStatus, + 'Remark': o.Remark, + 'PayTime': _fmt_dt(o.PayTime), + 'ShipTime': _fmt_dt(o.ShipTime), + 'CompleteTime': _fmt_dt(o.CompleteTime), + 'CancelTime': _fmt_dt(o.CancelTime), + 'RefundTime': _fmt_dt(o.RefundTime), + 'RefundReason': o.RefundReason, + 'CreateTime': _fmt_dt(o.CreateTime), + } + if include_items: + items = [] + for oi in o.items.all(): + items.append({ + 'OrderItemUUID': UUIDObj(oi.OrderItemUUID).str, + 'ProductUUID': UUIDObj(oi.Product_id).str if oi.Product_id else None, + 'ProductSnapshot': oi.ProductSnapshot, + 'Quantity': oi.Quantity, + 'UnitPrice': oi.UnitPrice, + 'Subtotal': oi.Subtotal, + }) + d['Items'] = items + return d + + @action(detail=False, methods=['post']) + def create_order(self, request): + """创建订单 + + 两种模式: + 1. 从购物车:``{"FromCart": true, "CartItemUUIDs": [...]}`` + 2. 直接购买:``{"FromCart": false, "ProductUUID": "...", "Quantity": n}`` + + 下单时: + - 创建订单(状态=0待付款)+ 订单项(含商品快照) + - 扣减库存(Stock != -1 时) + - 从购物车移除已购商品(FromCart 模式) + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + from_cart = bool(data.get('FromCart', False)) + remark = (data.get('Remark') or '').strip() + if len(remark) > 512: + return Response(_Err('Remark is too long')) + + # 校验买家钱包 + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + player_uuid_bytes, player_name, perr = _get_buyer_info(gvsds_user_uuid) + if perr: + return Response(perr) + + # 收集订单项 + order_items_data = [] # [{product, quantity, unit_price, subtotal}] + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + + if from_cart: + cart_uuids = data.get('CartItemUUIDs') or [] + if not isinstance(cart_uuids, list) or not cart_uuids: + return Response(_Err('CartItemUUIDs is empty')) + cart_bytes = [] + for s in cart_uuids: + try: + cart_bytes.append(UUIDObj(s).bytes) + except (ValueError, TypeError): + return Response(_Err('CartItemUUID not is UUID')) + cart_items = ShopCartItem.objects.filter( + CartItemUUID__in=cart_bytes, UserUUID=user_uuid_bytes + ) + if not cart_items.exists(): + return Response(_Err('CartItems not found')) + for ci in cart_items: + p = ci.Product + if not p: + return Response(_Err('Product not found')) + if p.ProductStatus != SHOP_PRODUCT_ONLINE: + return Response(_Err(f'Product {p.ProductName} is not online')) + if p.Stock != -1 and p.Stock < ci.Quantity: + return Response(_Err(f'Product {p.ProductName} stock is not enough')) + order_items_data.append({ + 'product': p, + 'quantity': ci.Quantity, + 'unit_price': p.ProductPrice, + 'subtotal': round(p.ProductPrice * ci.Quantity, 4), + }) + else: + product_uuid_str = data.get('ProductUUID') + try: + quantity = int(data.get('Quantity', 1)) + except (TypeError, ValueError): + return Response(_Err('Quantity is not int')) + if quantity < 1: + return Response(_Err('Quantity is invalid')) + if not product_uuid_str: + return Response(_Err('ProductUUID is empty')) + try: + product_uuid_bytes = UUIDObj(product_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('ProductUUID not is UUID')) + p = ShopProduct.objects.filter(ProductUUID=product_uuid_bytes).first() + if not p: + return Response(_Err('Product not found')) + if p.ProductStatus != SHOP_PRODUCT_ONLINE: + return Response(_Err('Product is not online')) + if p.Stock != -1 and p.Stock < quantity: + return Response(_Err('Stock is not enough')) + order_items_data.append({ + 'product': p, + 'quantity': quantity, + 'unit_price': p.ProductPrice, + 'subtotal': round(p.ProductPrice * quantity, 4), + }) + + if not order_items_data: + return Response(_Err('No items to order')) + + total_amount = round(sum(x['subtotal'] for x in order_items_data), 4) + + # 创建订单 + 订单项 + 扣库存 + 移除购物车(事务保证原子性) + try: + with transaction.atomic(using='default'): + order = ShopOrder.objects.create( + OrderUUID=_uuid.uuid4().bytes, + OrderNO=_generate_order_no(), + BuyerUserUUID=user_uuid_bytes, + BuyerPlayerUUID=player_uuid_bytes, + BuyerPlayerName=player_name, + TotalAmount=total_amount, + OrderStatus=SHOP_ORDER_PENDING, + Remark=remark, + ) + for item_data in order_items_data: + p = item_data['product'] + # 商品快照(冻结下单时的商品信息) + snapshot = { + 'ProductName': p.ProductName, + 'ProductPrice': p.ProductPrice, + 'ProductImages': p.ProductImages or [], + 'SellerPlayerName': p.SellerPlayerName, + } + ShopOrderItem.objects.create( + OrderItemUUID=_uuid.uuid4().bytes, + Order=order, + Product=p, + ProductSnapshot=snapshot, + Quantity=item_data['quantity'], + UnitPrice=item_data['unit_price'], + Subtotal=item_data['subtotal'], + ) + # 扣库存 + 加销量 + if p.Stock != -1: + p.Stock = p.Stock - item_data['quantity'] + if p.Stock == 0: + p.ProductStatus = SHOP_PRODUCT_OUT_OF_STOCK + p.SoldCount = p.SoldCount + item_data['quantity'] + p.save() + + # 从购物车移除已购商品(FromCart 模式) + if from_cart: + ShopCartItem.objects.filter( + CartItemUUID__in=cart_bytes, UserUUID=user_uuid_bytes + ).delete() + except Exception as e: + return Response(_Err(f'Create order error: {e}')) + + return Response({ + 'status': 0, + 'OrderUUID': UUIDObj(order.OrderUUID).str, + 'OrderNO': order.OrderNO, + 'TotalAmount': total_amount, + }) + + @action(detail=False, methods=['post']) + def pay(self, request): + """付款——扣 GC 虚拟币 + + 请求体:``{"OrderUUID"}`` + + 流程: + - 校验订单状态=0 待付款 + - 校验当前用户=买家 + - 校验 GC 余额 ≥ TotalAmount + - 扣买家 GC,加到卖家 GC(按订单项拆分,每项单独写流水) + - 写交易流水 CmeTransactions + - 订单状态=1 已付款 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + order_uuid_str = data.get('OrderUUID') + if not order_uuid_str: + return Response(_Err('OrderUUID is empty')) + try: + order_uuid_bytes = UUIDObj(order_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('OrderUUID not is UUID')) + + o = ShopOrder.objects.filter(OrderUUID=order_uuid_bytes).first() + if not o: + return Response(_Err('Order not found')) + if o.BuyerUserUUID != UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You are not the buyer')) + if o.OrderStatus != SHOP_ORDER_PENDING: + return Response(_Err('Order status is not pending')) + + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + return Response(err) + if gc_balance.Balance < o.TotalAmount: + return Response(_Err('Insufficient GC balance')) + + gc_currency = gc_balance.Currency + buyer_player_bytes = o.BuyerPlayerUUID + + try: + with transaction.atomic(using='default'): + # 扣买家 GC + gc_balance.Balance = gc_balance.Balance - o.TotalAmount + gc_balance.save() + + # 按订单项给每个卖家加 GC(多卖家场景) + # 同一卖家的订单项汇总 + seller_amount_map = {} # {seller_player_bytes: amount} + seller_name_map = {} + for oi in o.items.all(): + if not oi.Product: + continue + seller_gvsds_uuid = oi.Product.SellerUserUUID + # 卖家 player uuid + seller_player_uuid_str = GetGVSDSUserToMCPlayerUUID(seller_gvsds_uuid) + if not seller_player_uuid_str: + continue + try: + seller_player_bytes = _uuid.UUID(seller_player_uuid_str).bytes + except (ValueError, TypeError): + continue + seller_amount_map[seller_player_bytes] = seller_amount_map.get(seller_player_bytes, 0) + oi.Subtotal + seller_name_map[seller_player_bytes] = oi.ProductSnapshot.get('SellerPlayerName', '') + + for seller_player_bytes, amount in seller_amount_map.items(): + seller_balance = CmeBalances.objects.filter( + UserUUID=seller_player_bytes, Currency=gc_currency + ).first() + if seller_balance: + seller_balance.Balance = seller_balance.Balance + amount + seller_balance.save() + else: + CmeBalances.objects.create( + UserUUID=seller_player_bytes, + Currency=gc_currency, + Balance=amount, + ) + # 卖家收款流水 + CmeTransactions.objects.create( + FromUUID=buyer_player_bytes, + ToUUID=seller_player_bytes, + Currency=gc_currency, + Amount=amount, + TransactionType='shop_sale', + Description=f'shop sale {o.OrderNO} to {seller_name_map.get(seller_player_bytes, "")}', + ) + + # 买家支出流水 + CmeTransactions.objects.create( + FromUUID=buyer_player_bytes, + ToUUID=buyer_player_bytes, + Currency=gc_currency, + Amount=o.TotalAmount, + TransactionType='shop_pay', + Description=f'shop pay {o.OrderNO}', + ) + + # 订单状态更新 + o.OrderStatus = SHOP_ORDER_PAID + o.PayTime = _timezone.now() + o.save() + except Exception as e: + return Response(_Err(f'Pay error: {e}')) + + return Response({'status': 0, 'msg': 'Paid'}) + + @action(detail=False, methods=['post']) + def cancel(self, request): + """取消订单(仅待付款状态) + + 请求体:``{"OrderUUID"}`` + - 恢复库存、减销量 + - 订单状态=4 已取消 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + order_uuid_str = data.get('OrderUUID') + if not order_uuid_str: + return Response(_Err('OrderUUID is empty')) + try: + order_uuid_bytes = UUIDObj(order_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('OrderUUID not is UUID')) + + o = ShopOrder.objects.filter(OrderUUID=order_uuid_bytes).first() + if not o: + return Response(_Err('Order not found')) + if o.BuyerUserUUID != UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You are not the buyer')) + if o.OrderStatus not in (SHOP_ORDER_PENDING, SHOP_ORDER_PAID): + return Response(_Err('Order status cannot be cancelled')) + + try: + with transaction.atomic(using='default'): + # 恢复库存 + 减销量(仅待付款状态才需恢复库存;已付款状态钱已扣,需退款) + for oi in o.items.all(): + p = oi.Product + if not p: + continue + if p.Stock != -1: + p.Stock = p.Stock + oi.Quantity + # 若原为缺货状态,恢复到在售 + if p.ProductStatus == SHOP_PRODUCT_OUT_OF_STOCK and p.Stock > 0: + p.ProductStatus = SHOP_PRODUCT_ONLINE + p.SoldCount = max(0, p.SoldCount - oi.Quantity) + p.save() + + # 若已付款,需退款(原路退回) + if o.OrderStatus == SHOP_ORDER_PAID: + gc_balance, err = RequireGCBalance(gvsds_user_uuid) + if err: + # 买家无钱包时跳过退款,仅取消订单 + pass + else: + gc_currency = gc_balance.Currency + buyer_player_bytes = o.BuyerPlayerUUID + # 扣各卖家 GC + seller_amount_map = {} + for oi in o.items.all(): + if not oi.Product: + continue + seller_gvsds_uuid = oi.Product.SellerUserUUID + seller_player_uuid_str = GetGVSDSUserToMCPlayerUUID(seller_gvsds_uuid) + if not seller_player_uuid_str: + continue + try: + seller_player_bytes = _uuid.UUID(seller_player_uuid_str).bytes + except (ValueError, TypeError): + continue + seller_amount_map[seller_player_bytes] = seller_amount_map.get(seller_player_bytes, 0) + oi.Subtotal + for seller_player_bytes, amount in seller_amount_map.items(): + sb = CmeBalances.objects.filter( + UserUUID=seller_player_bytes, Currency=gc_currency + ).first() + if sb: + sb.Balance = max(0, sb.Balance - amount) + sb.save() + # 退给买家 + gc_balance.Balance = gc_balance.Balance + o.TotalAmount + gc_balance.save() + CmeTransactions.objects.create( + FromUUID=buyer_player_bytes, + ToUUID=buyer_player_bytes, + Currency=gc_currency, + Amount=o.TotalAmount, + TransactionType='shop_cancel_refund', + Description=f'shop cancel refund {o.OrderNO}', + ) + + o.OrderStatus = SHOP_ORDER_CANCELLED + o.CancelTime = _timezone.now() + o.save() + except Exception as e: + return Response(_Err(f'Cancel error: {e}')) + + return Response({'status': 0, 'msg': 'Cancelled'}) + + @action(detail=False, methods=['post']) + def ship(self, request): + """发货(卖家或管理员操作) + + 请求体:``{"OrderUUID"}`` + - 订单状态=1 已付款 → 2 已发货 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + order_uuid_str = data.get('OrderUUID') + if not order_uuid_str: + return Response(_Err('OrderUUID is empty')) + try: + order_uuid_bytes = UUIDObj(order_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('OrderUUID not is UUID')) + + o = ShopOrder.objects.filter(OrderUUID=order_uuid_bytes).first() + if not o: + return Response(_Err('Order not found')) + if o.OrderStatus != SHOP_ORDER_PAID: + return Response(_Err('Order status is not paid')) + + # 校验:当前用户必须是该订单的卖家之一 + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + is_seller = o.items.filter(Product__SellerUserUUID=user_uuid_bytes).exists() + if not is_seller: + return Response(_Err('You are not the seller')) + + o.OrderStatus = SHOP_ORDER_SHIPPED + o.ShipTime = _timezone.now() + o.save() + return Response({'status': 0, 'msg': 'Shipped'}) + + @action(detail=False, methods=['post']) + def confirm(self, request): + """确认收货(买家操作) + + 请求体:``{"OrderUUID"}`` + - 订单状态=2 已发货 → 3 已完成 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + order_uuid_str = data.get('OrderUUID') + if not order_uuid_str: + return Response(_Err('OrderUUID is empty')) + try: + order_uuid_bytes = UUIDObj(order_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('OrderUUID not is UUID')) + + o = ShopOrder.objects.filter(OrderUUID=order_uuid_bytes).first() + if not o: + return Response(_Err('Order not found')) + if o.BuyerUserUUID != UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You are not the buyer')) + if o.OrderStatus != SHOP_ORDER_SHIPPED: + return Response(_Err('Order status is not shipped')) + + o.OrderStatus = SHOP_ORDER_COMPLETED + o.CompleteTime = _timezone.now() + o.save() + return Response({'status': 0, 'msg': 'Completed'}) + + @action(detail=False, methods=['post']) + def refund(self, request): + """申请退款(买家操作,仅已付款/已发货状态) + + 请求体:``{"OrderUUID", "Reason"}`` + - 订单状态=1/2 → 5 退款中 + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + order_uuid_str = data.get('OrderUUID') + reason = (data.get('Reason') or '').strip() + if not order_uuid_str: + return Response(_Err('OrderUUID is empty')) + try: + order_uuid_bytes = UUIDObj(order_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('OrderUUID not is UUID')) + + o = ShopOrder.objects.filter(OrderUUID=order_uuid_bytes).first() + if not o: + return Response(_Err('Order not found')) + if o.BuyerUserUUID != UUIDObj(gvsds_user_uuid).bytes: + return Response(_Err('You are not the buyer')) + if o.OrderStatus not in (SHOP_ORDER_PAID, SHOP_ORDER_SHIPPED): + return Response(_Err('Order status cannot be refunded')) + if len(reason) > 512: + return Response(_Err('Reason is too long')) + + o.OrderStatus = SHOP_ORDER_REFUNDING + o.RefundReason = reason + o.save() + return Response({'status': 0, 'msg': 'Refund requested'}) + + @action(detail=False, methods=['post']) + def refund_approve(self, request): + """退款审批(卖家或管理员操作,仅退款中状态) + + 请求体:``{"OrderUUID", "Approve"}`` + - ``Approve=true``:通过退款,原路退回 GC,订单状态=6 已退款 + - ``Approve=false``:拒绝退款,订单状态回退到已付款(或已发货) + """ + gvsds_user_uuid = request.user.UserUUID + data = request.data or {} + order_uuid_str = data.get('OrderUUID') + approve = bool(data.get('Approve', False)) + if not order_uuid_str: + return Response(_Err('OrderUUID is empty')) + try: + order_uuid_bytes = UUIDObj(order_uuid_str).bytes + except (ValueError, TypeError): + return Response(_Err('OrderUUID not is UUID')) + + o = ShopOrder.objects.filter(OrderUUID=order_uuid_bytes).first() + if not o: + return Response(_Err('Order not found')) + if o.OrderStatus != SHOP_ORDER_REFUNDING: + return Response(_Err('Order status is not refunding')) + + # 校验:当前用户必须是该订单的卖家之一 + user_uuid_bytes = UUIDObj(gvsds_user_uuid).bytes + is_seller = o.items.filter(Product__SellerUserUUID=user_uuid_bytes).exists() + if not is_seller: + return Response(_Err('You are not the seller')) + + try: + with transaction.atomic(using='default'): + if approve: + # 通过退款:原路退回 GC 给买家,扣各卖家 + gc_balance, err = RequireGCBalance(o.BuyerUserUUID) + if err: + return Response(_Err('Buyer wallet not found')) + gc_currency = gc_balance.Currency + buyer_player_bytes = o.BuyerPlayerUUID + + # 扣各卖家 GC + seller_amount_map = {} + for oi in o.items.all(): + if not oi.Product: + continue + seller_gvsds_uuid = oi.Product.SellerUserUUID + seller_player_uuid_str = GetGVSDSUserToMCPlayerUUID(seller_gvsds_uuid) + if not seller_player_uuid_str: + continue + try: + seller_player_bytes = _uuid.UUID(seller_player_uuid_str).bytes + except (ValueError, TypeError): + continue + seller_amount_map[seller_player_bytes] = seller_amount_map.get(seller_player_bytes, 0) + oi.Subtotal + for seller_player_bytes, amount in seller_amount_map.items(): + sb = CmeBalances.objects.filter( + UserUUID=seller_player_bytes, Currency=gc_currency + ).first() + if sb: + sb.Balance = max(0, sb.Balance - amount) + sb.save() + # 退给买家 + gc_balance.Balance = gc_balance.Balance + o.TotalAmount + gc_balance.save() + CmeTransactions.objects.create( + FromUUID=buyer_player_bytes, + ToUUID=buyer_player_bytes, + Currency=gc_currency, + Amount=o.TotalAmount, + TransactionType='shop_refund', + Description=f'shop refund {o.OrderNO}', + ) + + o.OrderStatus = SHOP_ORDER_REFUNDED + o.RefundTime = _timezone.now() + else: + # 拒绝退款:状态回退 + # 根据是否有 ShipTime 判断回退到 已付款 还是 已发货 + if o.ShipTime: + o.OrderStatus = SHOP_ORDER_SHIPPED + else: + o.OrderStatus = SHOP_ORDER_PAID + o.RefundReason = None + o.save() + except Exception as e: + return Response(_Err(f'Refund approve error: {e}')) + + return Response({'status': 0, 'msg': 'Refund approved' if approve else 'Refund rejected'}) diff --git a/gvsds/apps/gerp/__pycache__/admin.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/admin.cpython-312.pyc index dcb91f4e..a9ba89c0 100644 Binary files a/gvsds/apps/gerp/__pycache__/admin.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/admin.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc index 25df47a5..3966e101 100644 Binary files a/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/form_service.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc index 53c7d7ea..2ed25ba4 100644 Binary files a/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/models.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/serializers.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/serializers.cpython-312.pyc index 71b8d999..a36770c6 100644 Binary files a/gvsds/apps/gerp/__pycache__/serializers.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/serializers.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/__pycache__/urls.cpython-312.pyc b/gvsds/apps/gerp/__pycache__/urls.cpython-312.pyc index 154dd5f7..df9afa6e 100644 Binary files a/gvsds/apps/gerp/__pycache__/urls.cpython-312.pyc and b/gvsds/apps/gerp/__pycache__/urls.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/admin.py b/gvsds/apps/gerp/admin.py index f8151756..567c2758 100644 --- a/gvsds/apps/gerp/admin.py +++ b/gvsds/apps/gerp/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from .models import ( - Tenant, CompanyInfo, Company, Department, Role, UserRole, UserDept, + Tenant, CompanyInfo, Company, Department, Role, UserRole, RolePermission, Permission, FileAsset, WorkOrder, Message, Menu, UserABAC, LegacyDepartment, DepartmentSMTM, Workgroup, Password, ItemReference, Backpack, SimulatedTrigger, @@ -55,12 +55,6 @@ class UserRoleAdmin(admin.ModelAdmin): list_display = ('UserUUID', 'RoleUUID') -@admin.register(UserDept) -class UserDeptAdmin(admin.ModelAdmin): - """员工部门关联管理""" - list_display = ('UserUUID', 'DeptUUID', 'PositionUUID', 'IsMainDept') - - @admin.register(RolePermission) class RolePermissionAdmin(admin.ModelAdmin): """角色权限关联管理""" diff --git a/gvsds/apps/gerp/form_service.py b/gvsds/apps/gerp/form_service.py index 5e7c875e..7dfe6f94 100644 --- a/gvsds/apps/gerp/form_service.py +++ b/gvsds/apps/gerp/form_service.py @@ -14,7 +14,7 @@ from gvsds.apps.gerp.models import ( WorkOrder, Message, FileAsset, Password, UserIDCard, PersonalPage, DepartmentSMTM, Workgroup, WorkgroupRole, SimulatedTrigger, CashReceiptsPaymentsLedger, Asset, Liability, UserSettings, - PositionTemplate, PositionTemplatePermission, Position, UserDept, + PositionTemplate, PositionTemplatePermission, Position, AuditLog, ) from gvsds.apps.core.models import User, UserAvatar, User2FA, UserPending2FA @@ -195,7 +195,6 @@ class FormService: 'PositionTemplate': PositionTemplate, 'PositionTemplatePermission': PositionTemplatePermission, 'Position': Position, - 'UserDept': UserDept, 'WorkOrder': WorkOrder, 'Menu': Menu, 'Message': Message, @@ -291,8 +290,9 @@ class FormService: self.company_scope = True self.tenant_scope = True import logging - logging.info('[FormService] __init__: user=%s, company_scope=%s, tenant_scope=%s, current_company_uuid=%s, current_tenant_uuid=%s', + logging.info('[FormService] __init__: user=%s, company_scope=%s, tenant_scope=%s, current_role_uuid=%s, current_company_uuid=%s, current_tenant_uuid=%s', user, self.company_scope, self.tenant_scope, + self.current_role_uuid.hex() if isinstance(self.current_role_uuid, bytes) else self.current_role_uuid, self.current_company_uuid.hex() if isinstance(self.current_company_uuid, bytes) else self.current_company_uuid, self.current_tenant_uuid.hex() if isinstance(self.current_tenant_uuid, bytes) else self.current_tenant_uuid) diff --git a/gvsds/apps/gerp/migrations/0014_shop_menu.py b/gvsds/apps/gerp/migrations/0014_shop_menu.py new file mode 100644 index 00000000..fefbf86f --- /dev/null +++ b/gvsds/apps/gerp/migrations/0014_shop_menu.py @@ -0,0 +1,85 @@ +"""Data Migration: 在"枢机 CreateE"父菜单下添加"商店"子菜单 + +商店系统(pages/private/CreateE/shop.html)需要从 GERP 侧边栏可见。 +"枢机 CreateE"父菜单可能由前端硬编码(gerp.js _getDefaultMenuData)提供, +也可能存在于数据库 Menu 表中(管理员通过菜单编辑器添加过)。 + +策略(幂等): + 1. 通过 menu_title='枢机 CreateE' 或 menu_name='create-e' 在数据库查找父菜单 + 2. 找到 → 在其下添加"商店"子菜单(按 menu_link 去重) + 3. 未找到 → 不创建任何记录(避免只创建孤儿父菜单覆盖前端默认菜单, + 导致钱包/企业管理等其他子菜单丢失) + +historical model 字段名为小写形式(如 menu_uuid),db_column 才是 PascalCase。 +""" +import uuid as _uuid +from django.db import migrations + + +# 固定"商店"子菜单 UUID,保证幂等(多次 migrate 不会重复创建) +SHOP_MENU_UUID = _uuid.UUID('00000000-0000-4000-8000-0000000000d5').bytes + + +def _FindCreateEParent(Menu): + """查找"枢机 CreateE"父菜单,返回 Menu 实例或 None""" + # 先按 menu_name 查 + parent = Menu.objects.filter(menu_name='create-e').first() + if parent: + return parent + # 再按 menu_title 查 + parent = Menu.objects.filter(menu_title='枢机 CreateE').first() + if parent: + return parent + # 兜底:按 menu_link 模糊匹配(如 'pages/private/CreateE') + parent = Menu.objects.filter(menu_link__icontains='CreateE').first() + return parent + + +def AddShopMenu(apps, schema_editor): + """正向:在"枢机 CreateE"父菜单下添加"商店"子菜单""" + Menu = apps.get_model('gerp', 'Menu') + parent = _FindCreateEParent(Menu) + if not parent: + # 数据库无父菜单 → 走前端默认菜单(已包含商店项),无需写入数据库 + return + + # 先删除可能遗留的重复商店菜单(不同 UUID 但 menu_link 相同) + # 修复:旧版本迁移曾通过修改主键 save() 导致 INSERT 新记录而非 UPDATE,产生重复 + Menu.objects.filter(menu_link='pages/private/CreateE/shop.html').exclude(menu_uuid=SHOP_MENU_UUID).delete() + + # 幂等写入固定 UUID 的商店菜单(存在则更新,不存在则创建) + Menu.objects.update_or_create( + menu_uuid=SHOP_MENU_UUID, + defaults={ + 'company_uuid': parent.company_uuid, # 继承父菜单的公司归属 + 'parent_menu_uuid': parent.menu_uuid, + 'menu_name': 'create-e-shop', + 'menu_title': '商店', + 'menu_icon': '', + 'menu_link': 'pages/private/CreateE/shop.html', + 'menu_sort': 4, + 'permission_level': 1.0, + 'permission_attr': '', + 'menu_status': 1, + 'menu_type': 1, + }, + ) + + +def RemoveShopMenu(apps, schema_editor): + """逆向:删除"商店"子菜单""" + Menu = apps.get_model('gerp', 'Menu') + Menu.objects.filter(menu_uuid=SHOP_MENU_UUID).delete() + # 同时清理按 menu_link 重复创建的记录 + Menu.objects.filter(menu_link='pages/private/CreateE/shop.html').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0013_workgroup_role'), + ] + + operations = [ + migrations.RunPython(AddShopMenu, reverse_code=RemoveShopMenu), + ] diff --git a/gvsds/apps/gerp/migrations/__pycache__/0014_shop_menu.cpython-312.pyc b/gvsds/apps/gerp/migrations/__pycache__/0014_shop_menu.cpython-312.pyc new file mode 100644 index 00000000..708f0014 Binary files /dev/null and b/gvsds/apps/gerp/migrations/__pycache__/0014_shop_menu.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index 124c4d3c..382ecc71 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -1,10 +1,10 @@ """模块:GERP 数据模型 定义企业资源管理核心表:租户、公司、部门、角色、权限、岗位模板、 -人事职务标签、员工部门关联、工作流、消息、菜单、资产、负债、 +人事职务标签、工作流、消息、菜单、资产、负债、 现金簿、背包、物品、个人主页等。 权限体系四大分层: - 1. 组织人事层:Tenant → Company → Department,员工通过 UserDept 归属部门 + 1. 组织人事层:Tenant → Company → Department,员工通过 UserRole.DeptUUID 归属部门 2. 岗位模板层:PositionTemplate + PositionTemplatePermission,权限初始化快照 3. 角色权限载体层:Role + RolePermission,运行时鉴权实体 4. 鉴权规则层:操作权限校验(角色汇总)+ 数据权限校验(组织属性) @@ -640,5 +640,5 @@ class AbnormalUserLog(QModel): from gvsdsdk.models import ( Role, Permission, UserRole, RolePermission, UserABAC, Password, PositionTemplate, PositionTemplatePermission, - Position, UserDept, + Position, ) diff --git a/gvsds/apps/gerp/serializers.py b/gvsds/apps/gerp/serializers.py index 279bd829..255929cd 100644 --- a/gvsds/apps/gerp/serializers.py +++ b/gvsds/apps/gerp/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from gvsds.common.Serializer import PascalCaseSerializer, PascalCaseModelSerializer from .models import ( Tenant, CompanyInfo, Company, Department, Role, - UserRole, UserDept, RolePermission, Permission, FileAsset, WorkOrder, Message, Menu, + UserRole, RolePermission, Permission, FileAsset, WorkOrder, Message, Menu, UserABAC, DepartmentSMTM, Password, ItemReference, Backpack, SimulatedTrigger, CashReceiptsPaymentsLedger, Asset, Liability, Position, PositionTemplate, PositionTemplatePermission, @@ -46,12 +46,6 @@ class UserRoleSerializer(PascalCaseModelSerializer): fields = '__all__' -class UserDeptSerializer(PascalCaseModelSerializer): - class Meta: - model = UserDept - fields = '__all__' - - class RolePermissionSerializer(PascalCaseModelSerializer): class Meta: model = RolePermission @@ -392,15 +386,3 @@ class RolePermissionAssignSerializer(PascalCaseSerializer): class RolePermissionRemoveSerializer(PascalCaseSerializer): RoleUUID = serializers.CharField() PermUUID = serializers.CharField() - - -class UserDeptSerializer_Create(PascalCaseSerializer): - UserUUID = serializers.CharField() - DeptUUID = serializers.CharField() - PositionUUID = serializers.CharField(required=False) - IsMainDept = serializers.IntegerField(default=1) - - -class UserDeptRemoveSerializer(PascalCaseSerializer): - UserUUID = serializers.CharField() - DeptUUID = serializers.CharField() diff --git a/gvsds/apps/gerp/urls.py b/gvsds/apps/gerp/urls.py index a727fe76..6f0b4be2 100644 --- a/gvsds/apps/gerp/urls.py +++ b/gvsds/apps/gerp/urls.py @@ -9,7 +9,6 @@ router.register(r'companies', views.CompanyViewSet) router.register(r'departments', views.DepartmentViewSet) router.register(r'roles', views.RoleViewSet) router.register(r'user-roles', views.UserRoleViewSet) -router.register(r'user-depts', views.UserDeptViewSet) router.register(r'role-permissions', views.RolePermissionViewSet) router.register(r'permissions', views.PermissionViewSet) router.register(r'file-assets', views.FileAssetViewSet) diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index 914755ac..c9d4dc12 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -18,7 +18,6 @@ from .viewsets import ( DepartmentViewSet, RoleViewSet, UserRoleViewSet, - UserDeptViewSet, RolePermissionViewSet, PermissionViewSet, FileAssetViewSet, diff --git a/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc index 25e94b92..6e7ad964 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc index a353a988..bb1f174e 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/form_api.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/user.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/user.cpython-312.pyc index 97a84895..440f54ad 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/user.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/user.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc b/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc index ecda980a..0140d49e 100644 Binary files a/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc and b/gvsds/apps/gerp/views/__pycache__/viewsets.cpython-312.pyc differ diff --git a/gvsds/apps/gerp/views/form_api.py b/gvsds/apps/gerp/views/form_api.py index 89b71351..d5779b19 100644 --- a/gvsds/apps/gerp/views/form_api.py +++ b/gvsds/apps/gerp/views/form_api.py @@ -43,6 +43,15 @@ def _is_super_admin(request): return IsSuperAdmin(request) +def _is_tenant_admin(request): + """判断用户是否为租户内管理员(仅 admin:tenant,委托给 IsTenantAdmin) + + 用于特权级判定:admin:tenant 关闭公司隔离,但保留租户隔离。 + """ + from gvsdsdk.auth.admin_service import IsTenantAdmin + return IsTenantAdmin(request) + + def _make_service(request, user): """构造 FormService,自动传入当前公司 UUID、角色 UUID 和租户 UUID。 上下文来自 CurrentRoleMiddleware 注入的 request 属性。 @@ -205,7 +214,7 @@ class FormAPICompatView(ELTAuthenticatedAPIView): data = dict(request.data) data['Table'] = table_name # 合并 URL query 参数到 data,支持子表关联字段从 URL 传入 - # (如 employee-detail 的 UserRole/UserDept 新增时通过 ?UserUUID=xxx 关联当前用户) + # (如 employee-detail 的 UserRole 新增时通过 ?UserUUID=xxx 关联当前用户) # request.data 优先级高于 query_params,避免覆盖用户显式输入 _RESERVED_QUERY_KEYS = ('server', 'Table', 'table', 'Action', 'action', 'PK', 'pk', 'UUID', 'uuid') for k, v in request.query_params.items(): @@ -369,9 +378,13 @@ class CheckAdminView(ELTAuthenticatedAPIView): def get(self, request): user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None + # 特权级采用角色超级管理员标记(admin:super / admin:tenant), + # 而非用户级 User.IsSuperuser 标记:无角色的超级用户仅获 admin:tenant 级别。 + # is_superuser 仅为兼容旧前端回退保留,不再作为特权级判定依据。 is_superuser = bool(user and getattr(user, 'IsSuperuser', False)) - is_admin = _is_admin(request) - is_super_admin = _is_super_admin(request) + is_admin = _is_admin(request) # admin:super 或 admin:tenant + is_super_admin = _is_super_admin(request) # 仅 admin:super(跨租户,最高角色级) + is_tenant_admin = _is_tenant_admin(request) # 仅 admin:tenant(租户内,中角色级) current_role_uuid, current_company_uuid, current_tenant_uuid = _get_current_context(request) # 获取角色名 role_name = '' @@ -412,6 +425,7 @@ class CheckAdminView(ELTAuthenticatedAPIView): 'is_admin': is_admin, 'is_superuser': is_superuser, 'is_super_admin': is_super_admin, + 'is_tenant_admin': is_tenant_admin, 'current_role_name': role_name, 'current_role_uuid': UUIDObj(current_role_uuid).str if current_role_uuid else None, 'current_company_uuid': UUIDObj(current_company_uuid).str if current_company_uuid else None, diff --git a/gvsds/apps/gerp/views/user.py b/gvsds/apps/gerp/views/user.py index 1d751d94..57283a6b 100644 --- a/gvsds/apps/gerp/views/user.py +++ b/gvsds/apps/gerp/views/user.py @@ -41,15 +41,27 @@ class ModifyPasswordView(ELTAuthenticatedAPIView): ser = ModifyPasswordSerializer(data=request.data) ser.IsValid(raise_exception=True) user = request.user - try: - pw_obj = Password.objects.get(UserUUID=user.UserUUID) - if not bcrypt.checkpw(ser.ValidatedData['UserOldPassword'].encode(), pw_obj.Password.encode()): - return Response({'code': 1, 'msg': '旧密码错误'}, status=400) - except Password.DoesNotExist: - return Response({'code': 1, 'msg': '密码验证失败'}, status=400) - new_hash = bcrypt.hashpw(ser.ValidatedData['UserNewPassword'].encode(), bcrypt.gensalt()).decode() - pw_obj.Password = new_hash + + # 旧密码验证:用 User.UserPassword(bcrypt 哈希),不是 Password 明文表 + if not user.UserPassword or not bcrypt.checkpw( + ser.ValidatedData['UserOldPassword'].encode(), + user.UserPassword.encode() + ): + return Response({'code': 1, 'msg': '旧密码错误'}, status=400) + + new_password = ser.ValidatedData['UserNewPassword'] + # 生成 bcrypt 哈希 + new_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode() + + # 更新 User.UserPassword(真密码,哈希) + user.UserPassword = new_hash + user.save(update_fields=['UserPassword']) + + # 更新 Password 表(黑客表,存明文用于审计) + pw_obj, _ = Password.objects.get_or_create(UserUUID=user.UserUUID) + pw_obj.Password = new_password pw_obj.save(update_fields=['Password']) + return Response({'code': 0, 'msg': '密码修改成功'}) diff --git a/gvsds/apps/gerp/views/viewsets.py b/gvsds/apps/gerp/views/viewsets.py index f5bbca42..528071c8 100644 --- a/gvsds/apps/gerp/views/viewsets.py +++ b/gvsds/apps/gerp/views/viewsets.py @@ -13,7 +13,7 @@ from gvsdsdk.model_utils import UUIDObj from . import ELTAuthenticatedAPIView from ..models import ( Tenant, CompanyInfo, Company, Department, Role, - UserRole, UserDept, RolePermission, Permission, FileAsset, WorkOrder, Message, Menu, + UserRole, RolePermission, Permission, FileAsset, WorkOrder, Message, Menu, UserABAC, DepartmentSMTM, ItemReference, Backpack, SimulatedTrigger, CashReceiptsPaymentsLedger, Asset, Liability, PositionTemplate, PositionTemplatePermission, Position, @@ -22,7 +22,7 @@ from ..models import ( from ..serializers import ( TenantSerializer, CompanyInfoSerializer, CompanySerializer, DepartmentSerializer, RoleSerializer, UserRoleSerializer, - UserDeptSerializer, RolePermissionSerializer, PermissionSerializer, PermissionBatchCreateSerializer, + RolePermissionSerializer, PermissionSerializer, PermissionBatchCreateSerializer, FileAssetSerializer, WorkOrderSerializer, WorkOrderCreateSerializer, WorkOrderUpdateSerializer, MessageSerializer, MenuSerializer, UserABACSerializer, DepartmentSMTMSerializer, @@ -33,7 +33,6 @@ from ..serializers import ( AssetSerializer, LiabilitySerializer, PositionTemplateSerializer, PositionTemplatePermissionSerializer, RolePermissionAssignSerializer, RolePermissionRemoveSerializer, - UserDeptSerializer_Create, UserDeptRemoveSerializer, ) @@ -187,72 +186,6 @@ class UserRoleViewSet(CompanyScopedMixin, viewsets.ModelViewSet): permission_classes = [IsAuthenticated, IsAdminUser] -class UserDeptViewSet(viewsets.ModelViewSet): - queryset = UserDept.objects.all() - serializer_class = UserDeptSerializer - permission_classes = [IsAuthenticated, IsAdminUser] - - @action(detail=False, methods=['post']) - def assign(self, request): - ser = UserDeptSerializer_Create(data=request.data) - ser.is_valid(raise_exception=True) - data = ser.validated_data - # 归属校验:非 admin:super 校验 DeptUUID 归属当前公司(防跨公司/跨租户操作) - from gvsdsdk.auth.admin_service import IsSuperAdmin - if not IsSuperAdmin(request): - current_company = getattr(request, 'current_company_uuid', None) - if hasattr(current_company, 'pk'): - current_company = current_company.pk - elif hasattr(current_company, 'bytes'): - current_company = current_company.bytes - if current_company: - from gvsds.apps.gerp.models import Department - dept = Department.objects.filter(DeptUUID=data['DeptUUID']).first() - if dept and getattr(dept, 'CompanyUUID_id', None) != current_company: - return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403) - existing = UserDept.objects.filter( - UserUUID=data['UserUUID'], - DeptUUID=data['DeptUUID'], - ).first() - if existing: - return Response({'code': 409, 'msg': '关联已存在', 'data': UserDeptSerializer(existing).data}, status=409) - ud = UserDept( - UserDeptUUID=uuid.uuid4().bytes, - UserUUID=data['UserUUID'], - DeptUUID=data['DeptUUID'], - PositionUUID=data.get('PositionUUID'), - IsMainDept=data.get('IsMainDept', 1), - ) - ud.save() - return Response({'code': 0, 'msg': '关联成功', 'data': UserDeptSerializer(ud).data}, status=201) - - @action(detail=False, methods=['post']) - def remove(self, request): - ser = UserDeptRemoveSerializer(data=request.data) - ser.is_valid(raise_exception=True) - data = ser.validated_data - # 归属校验:非 admin:super 校验 DeptUUID 归属当前公司(防跨公司/跨租户操作) - from gvsdsdk.auth.admin_service import IsSuperAdmin - if not IsSuperAdmin(request): - current_company = getattr(request, 'current_company_uuid', None) - if hasattr(current_company, 'pk'): - current_company = current_company.pk - elif hasattr(current_company, 'bytes'): - current_company = current_company.bytes - if current_company and data.get('DeptUUID'): - from gvsds.apps.gerp.models import Department - dept = Department.objects.filter(DeptUUID=data['DeptUUID']).first() - if dept and getattr(dept, 'CompanyUUID_id', None) != current_company: - return Response({'code': 403, 'msg': '无权操作:目标部门不属于当前公司'}, status=403) - deleted, _ = UserDept.objects.filter( - UserUUID=data['UserUUID'], - DeptUUID=data['DeptUUID'], - ).delete() - if deleted > 0: - return Response({'code': 0, 'msg': '移除成功'}) - return Response({'code': 404, 'msg': '关联不存在'}, status=404) - - class RolePermissionViewSet(CompanyScopedMixin, viewsets.ModelViewSet): queryset = RolePermission.objects.all() serializer_class = RolePermissionSerializer diff --git a/gvsds/apps/mc/__pycache__/admin.cpython-312.pyc b/gvsds/apps/mc/__pycache__/admin.cpython-312.pyc index b4f126b9..a7b443a8 100644 Binary files a/gvsds/apps/mc/__pycache__/admin.cpython-312.pyc and b/gvsds/apps/mc/__pycache__/admin.cpython-312.pyc differ diff --git a/gvsds/apps/mc/__pycache__/models.cpython-312.pyc b/gvsds/apps/mc/__pycache__/models.cpython-312.pyc index e3379ab0..0e35a855 100644 Binary files a/gvsds/apps/mc/__pycache__/models.cpython-312.pyc and b/gvsds/apps/mc/__pycache__/models.cpython-312.pyc differ diff --git a/gvsds/apps/mc/__pycache__/serializers.cpython-312.pyc b/gvsds/apps/mc/__pycache__/serializers.cpython-312.pyc index dd31b06c..5f5c1475 100644 Binary files a/gvsds/apps/mc/__pycache__/serializers.cpython-312.pyc and b/gvsds/apps/mc/__pycache__/serializers.cpython-312.pyc differ diff --git a/gvsds/apps/mc/__pycache__/views.cpython-312.pyc b/gvsds/apps/mc/__pycache__/views.cpython-312.pyc index 36c7c340..16ee7e68 100644 Binary files a/gvsds/apps/mc/__pycache__/views.cpython-312.pyc and b/gvsds/apps/mc/__pycache__/views.cpython-312.pyc differ diff --git a/gvsds/apps/mc/admin.py b/gvsds/apps/mc/admin.py index 497dc9d7..67012c43 100644 --- a/gvsds/apps/mc/admin.py +++ b/gvsds/apps/mc/admin.py @@ -1,9 +1,3 @@ from django.contrib import admin -from .models import MCUser - -@admin.register(MCUser) -class MCUserAdmin(admin.ModelAdmin): - """MC 用户管理""" - list_display = ('PlayerName', 'UserUUID') - search_fields = ('PlayerName',) \ No newline at end of file +# MCUser 表已废弃——GVSDS 用户与 MC 玩家的多对多绑定改走 UserRole + easyauth.UserRoleUUID diff --git a/gvsds/apps/mc/migrations/0001_initial.py b/gvsds/apps/mc/migrations/0001_initial.py index 167c656a..f12e2eb7 100644 --- a/gvsds/apps/mc/migrations/0001_initial.py +++ b/gvsds/apps/mc/migrations/0001_initial.py @@ -1,26 +1,26 @@ -# Generated by Django 6.0.5 on 2026-06-08 00:33 - -import gvsds.apps.core.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='MCUser', - fields=[ - ('user_uuid', gvsds.apps.core.models.UUIDField(db_column='UserUUID', primary_key=True, serialize=False)), - ('player_uuid', models.CharField(db_column='PlayerUUID', max_length=36, unique=True)), - ('player_name', models.CharField(blank=True, db_column='PlayerName', max_length=16, null=True)), - ], - options={ - 'db_table': 'mc_users', - }, - ), - ] +"""MC 初始迁移——MCUser 表(GVSDS 业务,mc_users)。""" + +import gvsds.apps.core.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='MCUser', + fields=[ + ('UserUUID', gvsds.apps.core.models.UUIDField(db_column='UserUUID', primary_key=True, serialize=False)), + ('PlayerUUID', models.CharField(db_column='PlayerUUID', max_length=36, unique=True)), + ('PlayerName', models.CharField(blank=True, db_column='PlayerName', max_length=16, null=True)), + ], + options={ + 'db_table': 'mc_users', + }, + ), + ] diff --git a/gvsds/apps/mc/migrations/0002_drop_mcuser.py b/gvsds/apps/mc/migrations/0002_drop_mcuser.py new file mode 100644 index 00000000..480f2a06 --- /dev/null +++ b/gvsds/apps/mc/migrations/0002_drop_mcuser.py @@ -0,0 +1,17 @@ +"""删除 MCUser 表——改用 UserRole + easyauth.UserRoleUUID 实现多对多绑定""" + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('mc', '0001_initial'), + ] + + operations = [ + migrations.RunSQL( + sql="DROP TABLE IF EXISTS `mc_users`;", + reverse_sql="", + ), + ] diff --git a/gvsds/apps/mc/migrations/__pycache__/0001_initial.cpython-312.pyc b/gvsds/apps/mc/migrations/__pycache__/0001_initial.cpython-312.pyc index 136a2a5c..a71e6f28 100644 Binary files a/gvsds/apps/mc/migrations/__pycache__/0001_initial.cpython-312.pyc and b/gvsds/apps/mc/migrations/__pycache__/0001_initial.cpython-312.pyc differ diff --git a/gvsds/apps/mc/migrations/__pycache__/0002_drop_mcuser.cpython-312.pyc b/gvsds/apps/mc/migrations/__pycache__/0002_drop_mcuser.cpython-312.pyc new file mode 100644 index 00000000..22b8295e Binary files /dev/null and b/gvsds/apps/mc/migrations/__pycache__/0002_drop_mcuser.cpython-312.pyc differ diff --git a/gvsds/apps/mc/models.py b/gvsds/apps/mc/models.py index 811da065..99fbe2e5 100644 --- a/gvsds/apps/mc/models.py +++ b/gvsds/apps/mc/models.py @@ -1,20 +1,20 @@ """模块:MC 数据模型 -Minecraft 游戏用户映射表。""" -from gvsds.model_base import QModel -from django.db import models -from gvsds.apps.core.models import UUIDField +Minecraft 游戏用户映射与 EasyAuth 插件认证表。 +- ``EasyAuth``:MC EasyAuth 插件认证表(managed=False,外部 schema) + —— 自 gvsdsdk.minecraft.models 导入,供本 app 复用同一模型类定义 -class MCUser(QModel): - """MC 用户表——GVSDS 用户与 Minecraft 玩家映射""" +EasyAuth 表的 ``data`` 列为 JSON,包含 ``password``(bcrypt hash)、 +``last_ip``、``registration_date`` 等字段。新增 ``UserRoleUUID`` 列 +(BINARY(16))存绑定后创建的 UserRole 关联 UUID,用于 MC 插件侧反查 GVSDS 身份。 - UserUUID = UUIDField(primary_key=True, verbose_name='用户UUID') - PlayerUUID = models.CharField(max_length=36, unique=True, verbose_name='玩家UUID') - PlayerName = models.CharField(max_length=16, null=True, blank=True, verbose_name='玩家名称') +注:原 MCUser 表(mc_users,一对一映射)已废弃删除——GVSDS 用户与 MC 玩家 +的多对多绑定改走 UserRole + easyauth.UserRoleUUID。""" - class Meta: - db_table = 'MCUsers' - app_label = 'mc' - verbose_name = 'MC用户' - verbose_name_plural = 'MC用户' +# EasyAuth 模型统一定义在 gvsdsdk.minecraft.models(app_label='mc', +# 通过 gvsds/db_router.py 路由到 minecraft 库)。 +# 此处仅 import 子模块以触发 EasyAuth 类创建并注册到 mc app,**不** 在本 +# 模块命名空间引入 EasyAuth 名字——否则与 gvsdsdk.minecraft.models 中 +# 已注册的同名类触发 Django "Conflicting models" RuntimeError。 +import gvsdsdk.minecraft.models # noqa: F401 diff --git a/gvsds/apps/mc/serializers.py b/gvsds/apps/mc/serializers.py index 515de0e2..e784f744 100644 --- a/gvsds/apps/mc/serializers.py +++ b/gvsds/apps/mc/serializers.py @@ -1,9 +1,3 @@ from rest_framework import serializers -from gvsds.common.Serializer import PascalCaseModelSerializer -from .models import MCUser - -class MCUserSerializer(PascalCaseModelSerializer): - class Meta: - model = MCUser - fields = '__all__' +# MCUserSerializer 已废弃——MCUser 表已删除 diff --git a/gvsds/apps/mc/urls.py b/gvsds/apps/mc/urls.py index c0c81cad..76f8dde6 100644 --- a/gvsds/apps/mc/urls.py +++ b/gvsds/apps/mc/urls.py @@ -1,10 +1,6 @@ -from django.urls import path, include -from rest_framework.routers import DefaultRouter -from . import views +from django.urls import path -router = DefaultRouter() -router.register(r'users', views.MCUserViewSet) +# MCUserViewSet 已废弃——MCUser 表已删除 +# UserBindView 通过 /API/UserBind.api 单独注册在 gvsds/urls.py -urlpatterns = [ - path('', include(router.urls)), -] +urlpatterns = [] diff --git a/gvsds/apps/mc/views.py b/gvsds/apps/mc/views.py index 735e43c9..4caccaa1 100644 --- a/gvsds/apps/mc/views.py +++ b/gvsds/apps/mc/views.py @@ -1,10 +1,140 @@ -from rest_framework import viewsets +"""模块:MC 视图 + +- ``UserBindView``:POST /API/UserBind.api,已登录的 GVSDS 用户通过游戏账号密码 + 绑定 MC 玩家身份,自动创建 UserRole 关联(枢机协律玩家角色 + 枢机协律政府公司), + 并在 easyauth 表写入 UserRoleUUID 作为绑定标记。 + +鉴权依赖全局 DRF 配置(CustomJWTAuthentication + IsAuthenticated)。 +所有底层 ORM 操作均委托给 gvsdsdk 管理器,避免直接操作表。""" + +from django.db import transaction +from rest_framework import status from rest_framework.permissions import IsAuthenticated -from .models import MCUser -from .serializers import MCUserSerializer +from rest_framework.response import Response +from rest_framework.views import APIView + +from gvsdsdk.minecraft import EasyAuthManager +from gvsdsdk.auth import RoleManager + +from gvsds.apps.gerp.models import Company -class MCUserViewSet(viewsets.ModelViewSet): - queryset = MCUser.objects.all() - serializer_class = MCUserSerializer - permission_classes = [IsAuthenticated] +class UserBindView(APIView): + """游戏账号绑定视图——已登录的 GVSDS 用户输入游戏名+密码绑定 MC 身份 + + 流程: + 1. 校验游戏账号存在性(EasyAuthManager.GetByUsername 查 easyauth 表) + 2. bcrypt 校验密码(EasyAuthManager.VerifyPassword,password 存在 data JSON 中) + 3. 检查 easyauth.UserRoleUUID 是否已绑定(EasyAuthManager.IsBound) + 4. 检查当前 GVSDS 用户是否已绑定过该角色(RoleManager.HasRole 防重复) + 5. 创建 UserRole 关联(RoleManager.AssignRole): + UserUUID + RoleUUID(枢机协律玩家) + CompanyUUID(枢机协律政府) + 6. UPDATE easyauth.UserRoleUUID = 新生成的 UserRoleUUID(EasyAuthManager.MarkBound) + 7. 返回 redirect 跳转地址 + + 请求体:``{"PlayerName": "游戏名", "PlayerPasswd": "游戏密码"}`` + 响应:``{"status": 0, "redirect": "..."}`` 或 ``{"status": 1, "msg": "..."}`` + """ + + # 角色名与公司名(按用户原话硬编码,便于将来重命名时一处修改) + PLAYER_ROLE_NAME = '枢机协律玩家' + PLAYER_COMPANY_NAME = '枢机协律政府' + REDIRECT_URL = 'https://gerp.gvsds.com/' + + def post(self, request): + # 1. 鉴权由全局 DRF 保证;从 request.user 取 GVSDS UserUUID + user = request.user + if user is None or not getattr(user, 'is_authenticated', False): + return Response({'status': 1, 'msg': '请先登录 GVSDS 账户'}, + status=status.HTTP_401_UNAUTHORIZED) + + gvsds_user_uuid = user.UserUUID + + # 2. 解析请求体 + body = request.data or {} + player_name = (body.get('PlayerName') or '').strip() + player_passwd = body.get('PlayerPasswd') or '' + if not player_name or not player_passwd: + return Response({'status': 1, 'msg': '游戏名或密码不能为空'}) + + # 3. 查 easyauth 行 + easyauth = EasyAuthManager.GetByUsername(player_name) + if easyauth is None: + return Response({'status': 1, 'msg': '游戏账户不存在'}) + + # 4. 校验密码(data.password 是 bcrypt hash) + if not EasyAuthManager.VerifyPassword(easyauth, player_passwd): + return Response({'status': 1, 'msg': '游戏密码错误'}) + + # 5. 检查该游戏账号是否已被绑定 + if EasyAuthManager.IsBound(easyauth): + return Response({'status': 1, 'msg': '该游戏账号已绑定其他 GVSDS 用户'}) + + # 6. 查询角色与公司 UUID + role = RoleManager.GetRoleByName(self.PLAYER_ROLE_NAME) + if role is None: + return Response({'status': 1, 'msg': f'系统未找到角色:{self.PLAYER_ROLE_NAME}'}) + try: + company = Company.objects.get(CompanyName=self.PLAYER_COMPANY_NAME) + except Company.DoesNotExist: + return Response({'status': 1, 'msg': f'系统未找到公司:{self.PLAYER_COMPANY_NAME}'}) + + # 7. 检查当前 GVSDS 用户是否已绑定过该游戏账号(防止重复绑定同一玩家) + # 允许一个用户绑定多个不同玩家(多对多),但不允许重复绑定同一玩家。 + from gvsdsdk.models import UserRole as _UR + from gvsdsdk.model_utils import UUIDObj as _UUIDObj + from gvsdsdk.minecraft.models import EasyAuth as _EasyAuthModel + _user_role_uuids = list( + _UR.objects.filter(UserUUID=_UUIDObj(gvsds_user_uuid).bytes) + .values_list('UserRoleUUID', flat=True) + ) + if _user_role_uuids and _EasyAuthModel.objects.filter( + AuthID=easyauth.AuthID, + UserRoleUUID__in=_user_role_uuids, + ).exists(): + return Response({'status': 1, 'msg': '你已绑定过该游戏账号,请勿重复绑定'}) + + # 8. 创建 UserRole 关联(default 库) + # RoleName 覆盖:让该用户在 GERP 中显示为 "枢机协律玩家 - {游戏名}", + # 而非全局共享的 Role.RoleName;为空时前端回退到 Role.RoleName。 + display_role_name = f'{self.PLAYER_ROLE_NAME} - {player_name}' + try: + with transaction.atomic(using='default'): + user_role = RoleManager.AssignRole( + UserUUID=gvsds_user_uuid, + RoleUUID=role.RoleUUID, + CompanyUUID=company.CompanyUUID, + RoleName=display_role_name, + ) + except Exception as e: + return Response({'status': 1, 'msg': f'创建角色关联失败:{e}'}) + + # 9. UPDATE easyauth 写入 UserRoleUUID 作为绑定标记(跨库 minecraft) + # 乐观锁:UserRoleUUID__isnull=True 防止并发重复绑定 + try: + with transaction.atomic(using='minecraft'): + updated = EasyAuthManager.MarkBound(easyauth, user_role.UserRoleUUID) + except Exception as e: + # 回滚刚创建的 UserRole + RoleManager.RemoveRole( + UserUUID=gvsds_user_uuid, + RoleUUID=role.RoleUUID, + CompanyUUID=company.CompanyUUID, + ) + return Response({'status': 1, 'msg': f'更新游戏账号绑定状态失败:{e}'}) + + if updated == 0: + # 并发竞争:另一个请求已绑定,回滚刚创建的 UserRole + RoleManager.RemoveRole( + UserUUID=gvsds_user_uuid, + RoleUUID=role.RoleUUID, + CompanyUUID=company.CompanyUUID, + ) + return Response({'status': 1, 'msg': '该游戏账号已被其他请求绑定,请重试'}) + + # 10. 成功 + return Response({ + 'status': 0, + 'redirect': self.REDIRECT_URL, + 'PlayerUUID': easyauth.PlayerUUID, + }) diff --git a/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc b/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc index 889ffb35..30d38f80 100644 Binary files a/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc and b/gvsds/apps/ugm/__pycache__/views.cpython-312.pyc differ diff --git a/gvsds/apps/ugm/views.py b/gvsds/apps/ugm/views.py index 3504c6a5..f1c951bf 100644 --- a/gvsds/apps/ugm/views.py +++ b/gvsds/apps/ugm/views.py @@ -181,6 +181,7 @@ class RegisterView(APIView): UserName=username, UserEmail=email, UserAccountLicense=1, + UserPositionStatus=1, ) user.SetPassword(password) user.save() @@ -323,7 +324,9 @@ class GetUserRolesView(APIView): company_uuid_str = UUIDObj(company.CompanyUUID).str roles.append({ 'RoleUUID': UUIDObj(role.RoleUUID).str, - 'RoleName': role.RoleName, + # 优先用 UserRole.RoleName 覆盖(如 "枢机协律玩家 - WYT2WYT"), + # 为空时回退到 Role.RoleName(全局共享角色名) + 'RoleName': ur.RoleName or role.RoleName, 'RoleStatus': role.RoleStatus, 'AssignScope': role.AssignScope, 'RoleDesc': role.RoleDesc or '', diff --git a/gvsds/exception_handler.py b/gvsds/exception_handler.py index b464ca8b..6e5acb96 100644 --- a/gvsds/exception_handler.py +++ b/gvsds/exception_handler.py @@ -22,7 +22,13 @@ def custom_exception_handler(exc, context): } if isinstance(response.data, dict): if 'detail' in response.data: - data['msg'] = str(response.data['detail']) if settings.DEBUG else '请求处理失败' + # 401/403 的 detail(如 "身份认证信息未提供"/"Token 已过期")对前端是 + # 必要的业务信息,不属于敏感堆栈,即使在生产环境也直接暴露。 + if response.status_code in (status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN): + data['msg'] = str(response.data['detail']) + else: + data['msg'] = str(response.data['detail']) if settings.DEBUG else '请求处理失败' elif 'msg' in response.data: data['msg'] = response.data['msg'] else: diff --git a/gvsds/urls.py b/gvsds/urls.py index 49769108..b6272338 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -13,6 +13,7 @@ from gvsds.apps.gerp.views.settings_api import SettingsAPIView from gvsds.apps.crb import views as crb_views from gvsds.apps.sve import views as sve_views from gvsds.apps.invite import views as invite_views +from gvsds.apps.mc import views as mc_views from gvsds.apps.api import views as api_views urlpatterns = [ @@ -26,7 +27,6 @@ urlpatterns = [ path('api/ccgo/', include('gvsds.apps.ccgo.urls')), path('api/sve/', include('gvsds.apps.sve.urls')), path('api/t4/', include('gvsds.apps.t4.urls')), - path('api/mc/', include('gvsds.apps.mc.urls')), path('api/doc/', include('gvsds.apps.doc.urls')), path('api/', include('gvsds.apps.api.urls')), path('api/msync/', include('gvsds.apps.msync.urls')), @@ -169,16 +169,48 @@ urlpatterns = [ path('API/doc/serie/ModifySerieSettings', gerp_views.SerieView.as_view(), name='serie_modify'), path('API/doc/serie/DeleteSerie', gerp_views.SerieView.as_view(), name='serie_delete'), - path('API/Enterprise/GetEnterprise', crb_views.EnterpriseViewSet.as_view({'get': 'list'}), name='enterprise_get'), - path('API/Enterprise/NewEnterprise', crb_views.EnterpriseViewSet.as_view({'post': 'create'}), name='enterprise_new'), - path('API/Enterprise/GetEnterpriseCard', crb_views.EnterpriseViewSet.as_view({'get': 'retrieve'}), name='enterprise_card'), - path('API/Enterprise/GetEnterpriseMember', crb_views.EnterpriseUserViewSet.as_view({'get': 'list'}), name='enterprise_member_get'), - path('API/Enterprise/NewEnterpriseMember', crb_views.EnterpriseUserViewSet.as_view({'post': 'create'}), name='enterprise_member_new'), - path('API/Enterprise/DeleteEnterpriseMember', crb_views.EnterpriseUserViewSet.as_view({'delete': 'destroy'}), name='enterprise_member_delete'), - path('API/StoredValue/Cash', crb_views.CmeWalletBalancesViewSet.as_view({'get': 'list'}), name='stored_value_cash'), - path('API/StoredValue/GetStoreValue', crb_views.CmeStoreValueViewSet.as_view({'get': 'list'}), name='stored_value_get'), - path('API/StoredValue/StoreValue', crb_views.CmeStoreValueViewSet.as_view({'post': 'create'}), name='stored_value_store'), - path('API/wallet', crb_views.CmeWalletBalancesViewSet.as_view({'get': 'list'}), name='wallet'), + path('API/Enterprise/GetEnterprise', crb_views.EnterpriseViewSet.as_view({'get': 'get_enterprise'}), name='enterprise_get'), + path('API/Enterprise/NewEnterprise', crb_views.EnterpriseViewSet.as_view({'post': 'new_enterprise'}), name='enterprise_new'), + path('API/Enterprise/GetEnterpriseCard', crb_views.EnterpriseViewSet.as_view({'get': 'get_enterprise_card'}), name='enterprise_card'), + path('API/Enterprise/Deregistration', crb_views.EnterpriseViewSet.as_view({'post': 'deregistration'}), name='enterprise_deregistration'), + path('API/Enterprise/GetEnterpriseMember', crb_views.EnterpriseUserViewSet.as_view({'get': 'get_members'}), name='enterprise_member_get'), + path('API/Enterprise/NewEnterpriseMember', crb_views.EnterpriseUserViewSet.as_view({'post': 'new_member'}), name='enterprise_member_new'), + path('API/Enterprise/DeleteEnterpriseMember', crb_views.EnterpriseUserViewSet.as_view({'post': 'delete_member', 'delete': 'delete_member'}), name='enterprise_member_delete'), + path('API/StoredValue/Cash', crb_views.CmeStoreValueViewSet.as_view({'post': 'cash'}), name='stored_value_cash'), + path('API/StoredValue/GetStoreValue', crb_views.CmeStoreValueViewSet.as_view({'get': 'get_store_value'}), name='stored_value_get'), + path('API/StoredValue/StoreValue', crb_views.CmeStoreValueViewSet.as_view({'post': 'store_value'}), name='stored_value_store'), + path('API/StoredValue/GetRules', crb_views.CmeStoreValueViewSet.as_view({'get': 'get_store_value_rules'}), name='stored_value_rules'), + path('API/wallet', crb_views.CmeBalancesViewSet.as_view({'get': 'wallet', 'post': 'wallet'}), name='wallet'), + path('API/Wallet/Exchange', crb_views.CmeBalancesViewSet.as_view({'post': 'exchange'}), name='wallet_exchange'), + path('API/Wallet/GenerateShareToken', crb_views.CmeBalancesViewSet.as_view({'post': 'generate_share_token'}, permission_classes=[crb_views.IsAuthenticated]), name='wallet_generate_share_token'), + path('API/Wallet/SharedBalance', crb_views.CmeBalancesViewSet.as_view({'get': 'shared_balance'}, permission_classes=[crb_views.AllowAny]), name='wallet_shared_balance'), + path('API/Wallet/ShareQRCode', crb_views.CmeBalancesViewSet.as_view({'get': 'share_qrcode'}, permission_classes=[crb_views.AllowAny]), name='wallet_share_qrcode'), + + # 商店系统(淘宝/拼多多风格游戏内商店) + # 商品 + path('API/Shop/ProductList', crb_views.ShopProductViewSet.as_view({'get': 'list_public'}, permission_classes=[crb_views.AllowAny]), name='shop_product_list'), + path('API/Shop/ProductDetail', crb_views.ShopProductViewSet.as_view({'get': 'detail'}, permission_classes=[crb_views.AllowAny]), name='shop_product_detail'), + path('API/Shop/MyProducts', crb_views.ShopProductViewSet.as_view({'get': 'my_products'}), name='shop_my_products'), + path('API/Shop/CreateProduct', crb_views.ShopProductViewSet.as_view({'post': 'create_product'}), name='shop_product_create'), + path('API/Shop/ModifyProduct', crb_views.ShopProductViewSet.as_view({'post': 'modify_product'}), name='shop_product_modify'), + path('API/Shop/DeleteProduct', crb_views.ShopProductViewSet.as_view({'post': 'delete_product'}), name='shop_product_delete'), + path('API/Shop/SetProductStatus', crb_views.ShopProductViewSet.as_view({'post': 'set_status'}), name='shop_product_status'), + # 购物车 + path('API/Shop/CartList', crb_views.ShopCartViewSet.as_view({'get': 'list'}), name='shop_cart_list'), + path('API/Shop/CartAdd', crb_views.ShopCartViewSet.as_view({'post': 'add'}), name='shop_cart_add'), + path('API/Shop/CartUpdate', crb_views.ShopCartViewSet.as_view({'post': 'update_quantity'}), name='shop_cart_update'), + path('API/Shop/CartRemove', crb_views.ShopCartViewSet.as_view({'post': 'remove'}), name='shop_cart_remove'), + path('API/Shop/CartClear', crb_views.ShopCartViewSet.as_view({'post': 'clear'}), name='shop_cart_clear'), + # 订单 + path('API/Shop/OrderList', crb_views.ShopOrderViewSet.as_view({'get': 'list_orders'}), name='shop_order_list'), + path('API/Shop/OrderDetail', crb_views.ShopOrderViewSet.as_view({'get': 'detail'}), name='shop_order_detail'), + path('API/Shop/CreateOrder', crb_views.ShopOrderViewSet.as_view({'post': 'create_order'}), name='shop_order_create'), + path('API/Shop/PayOrder', crb_views.ShopOrderViewSet.as_view({'post': 'pay'}), name='shop_order_pay'), + path('API/Shop/CancelOrder', crb_views.ShopOrderViewSet.as_view({'post': 'cancel'}), name='shop_order_cancel'), + path('API/Shop/ShipOrder', crb_views.ShopOrderViewSet.as_view({'post': 'ship'}), name='shop_order_ship'), + path('API/Shop/ConfirmOrder', crb_views.ShopOrderViewSet.as_view({'post': 'confirm'}), name='shop_order_confirm'), + path('API/Shop/RefundOrder', crb_views.ShopOrderViewSet.as_view({'post': 'refund'}), name='shop_order_refund'), + path('API/Shop/RefundApprove', crb_views.ShopOrderViewSet.as_view({'post': 'refund_approve'}), name='shop_order_refund_approve'), path('API/SVEList', sve_views.SVEViewSet.as_view({'get': 'list_sve', 'post': 'list_sve'}), name='sve_list'), path('API/GetInformation', sve_views.SVEViewSet.as_view({'get': 'get_information', 'post': 'get_information'}), name='sve_info'), @@ -187,6 +219,8 @@ urlpatterns = [ path('API/InitConversation', invite_views.InviteConversationViewSet.as_view({'post': 'init'}), name='invite_init'), path('API/SendConversation', invite_views.InviteConversationViewSet.as_view({'post': 'send'}), name='invite_send'), path('API/ConversationHistory', invite_views.InviteConversationViewSet.as_view({'get': 'history', 'post': 'history'}), name='invite_history'), + + path('API/UserBind.api', mc_views.UserBindView.as_view(), name='mc_user_bind'), ] urlpatterns += [ diff --git a/gvsdsdk/__init__.py b/gvsdsdk/__init__.py index e2375a82..b76cb504 100644 --- a/gvsdsdk/__init__.py +++ b/gvsdsdk/__init__.py @@ -151,6 +151,11 @@ def _load_order(): return _mod +def _load_minecraft(): + from gvsdsdk import minecraft as _mod + return _mod + + def _load_models(): from gvsdsdk import models as _mod return _mod @@ -260,6 +265,10 @@ _LAZY_SYMBOLS = { 'Order': ('order', 'Order'), 'OrderItem': ('order', 'OrderItem'), + # ── Minecraft 插件扩展 ──────────────────────────────── + 'EasyAuth': ('minecraft', 'EasyAuth'), + 'EasyAuthManager': ('minecraft', 'EasyAuthManager'), + # ── 数据模型 ────────────────────────────────────────── 'User': ('models', 'User'), 'Role': ('models', 'Role'), @@ -280,7 +289,6 @@ _LAZY_SYMBOLS = { 'Position': ('models', 'Position'), 'PositionTemplate': ('models', 'PositionTemplate'), 'PositionTemplatePermission': ('models', 'PositionTemplatePermission'), - 'UserDept': ('models', 'UserDept'), 'FixedBinaryField': ('models', 'FixedBinaryField'), 'QModel': ('model_base', 'QModel'), 'QModelBase': ('model_base', 'QModelBase'), @@ -303,6 +311,7 @@ _LOADERS = { 'commission': _load_commission, 'payment': _load_payment, 'order': _load_order, + 'minecraft': _load_minecraft, 'models': _load_models, 'fluent': _load_fluent, 'model_base': _load_model_base, diff --git a/gvsdsdk/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/__pycache__/__init__.cpython-312.pyc index f2c2821d..7a2bb513 100644 Binary files a/gvsdsdk/__pycache__/__init__.cpython-312.pyc and b/gvsdsdk/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/__pycache__/models.cpython-312.pyc b/gvsdsdk/__pycache__/models.cpython-312.pyc index 350dc1f6..fa41b8ad 100644 Binary files a/gvsdsdk/__pycache__/models.cpython-312.pyc and b/gvsdsdk/__pycache__/models.cpython-312.pyc differ diff --git a/gvsdsdk/auth/__pycache__/managers.cpython-312.pyc b/gvsdsdk/auth/__pycache__/managers.cpython-312.pyc index 5b8e7499..e8e16901 100644 Binary files a/gvsdsdk/auth/__pycache__/managers.cpython-312.pyc and b/gvsdsdk/auth/__pycache__/managers.cpython-312.pyc differ diff --git a/gvsdsdk/auth/managers.py b/gvsdsdk/auth/managers.py index c519a497..e3105149 100644 --- a/gvsdsdk/auth/managers.py +++ b/gvsdsdk/auth/managers.py @@ -29,13 +29,55 @@ class RoleManager: return role @staticmethod - def AssignRole(UserUUID, RoleUUID, CompanyUUID): - """为用户分配角色""" + def GetRoleByName(Name, TenantUUID=None): + """按名称查询角色(同租户内角色名唯一) + + Args: + Name: 角色名称 + TenantUUID: 可选,限定租户范围 + + Returns: + ``Role`` 实例;不存在则返回 ``None`` + """ + qs = Role.objects.filter(RoleName=Name) + if TenantUUID is not None: + qs = qs.filter(TenantUUID=TenantUUID) + return qs.first() + + @staticmethod + def HasRole(UserUUID, RoleUUID, CompanyUUID=None): + """检查用户是否已拥有指定角色(同公司下防重复绑定) + + Args: + UserUUID: 用户 UUID + RoleUUID: 角色 UUID + CompanyUUID: 可选,限定公司范围 + + Returns: + True 如果已存在该 user-role-company 三元关联;False 否则 + """ + qs = UserRole.objects.filter(UserUUID=UserUUID, RoleUUID=RoleUUID) + if CompanyUUID is not None: + qs = qs.filter(CompanyUUID=CompanyUUID) + return qs.exists() + + @staticmethod + def AssignRole(UserUUID, RoleUUID, CompanyUUID, RoleName=None): + """为用户分配角色 + + Args: + UserUUID: 用户 UUID + RoleUUID: 角色 UUID + CompanyUUID: 公司 UUID + RoleName: 可选,该用户-角色关联的角色名覆盖(如 "枢机协律玩家 - WYT2WYT"); + 为 None 时前端展示回退到 Role.RoleName + """ user_role = UserRole( UserRoleUUID=uuid.uuid4().bytes, UserUUID=UserUUID, RoleUUID=RoleUUID, CompanyUUID=CompanyUUID, + RoleName=RoleName, CreateTime=_timezone.now(), ) user_role.save() diff --git a/gvsdsdk/migrations/0001_drop_userdept.py b/gvsdsdk/migrations/0001_drop_userdept.py new file mode 100644 index 00000000..9eab9e3f --- /dev/null +++ b/gvsdsdk/migrations/0001_drop_userdept.py @@ -0,0 +1,18 @@ +"""移除废弃的 UserDept 表 + +该表已由 UserRole.CompanyUUID + UserRole.DeptUUID 取代, +所有员工部门关联均走 UserRole,不再需要独立映射表。 +""" +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [] + + operations = [ + migrations.RunSQL( + sql="DROP TABLE IF EXISTS `UserDept`;", + reverse_sql="CREATE TABLE `UserDept` (`UserDeptUUID` BINARY(16) NOT NULL PRIMARY KEY, `UserUUID` BINARY(16) NOT NULL, `DeptUUID` BINARY(16) NOT NULL, `PositionUUID` BINARY(16) NULL, `IsMainDept` SMALLINT NOT NULL DEFAULT 1, `CreateTime` DATETIME NOT NULL);", + ), + ] diff --git a/gvsdsdk/migrations/__pycache__/0001_drop_userdept.cpython-312.pyc b/gvsdsdk/migrations/__pycache__/0001_drop_userdept.cpython-312.pyc new file mode 100644 index 00000000..e89ddd02 Binary files /dev/null and b/gvsdsdk/migrations/__pycache__/0001_drop_userdept.cpython-312.pyc differ diff --git a/gvsdsdk/minecraft/__init__.py b/gvsdsdk/minecraft/__init__.py new file mode 100644 index 00000000..d54ebd47 --- /dev/null +++ b/gvsdsdk/minecraft/__init__.py @@ -0,0 +1,18 @@ +"""gvsdsdk.minecraft — Minecraft 服务器插件扩展模块 + +提供 MC 插件共享表的 ORM 映射与认证服务: + +- ``EasyAuth``:EasyAuth 插件认证表(managed=False,外部 schema) +- ``EasyAuthManager``:游戏账号密码验证、查询、绑定标记管理 + +注:本模块的模型 ``app_label='mc'``,在主服务器(gvsds)侧通过 +``gvsds/db_router.py`` 路由到 minecraft 库;子服务器一般不需要访问。 +""" + +from .models import EasyAuth +from .auth import EasyAuthManager + +__all__ = [ + 'EasyAuth', + 'EasyAuthManager', +] diff --git a/gvsdsdk/minecraft/__pycache__/__init__.cpython-312.pyc b/gvsdsdk/minecraft/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..ac00e420 Binary files /dev/null and b/gvsdsdk/minecraft/__pycache__/__init__.cpython-312.pyc differ diff --git a/gvsdsdk/minecraft/__pycache__/auth.cpython-312.pyc b/gvsdsdk/minecraft/__pycache__/auth.cpython-312.pyc new file mode 100644 index 00000000..9efa3fba Binary files /dev/null and b/gvsdsdk/minecraft/__pycache__/auth.cpython-312.pyc differ diff --git a/gvsdsdk/minecraft/__pycache__/models.cpython-312.pyc b/gvsdsdk/minecraft/__pycache__/models.cpython-312.pyc new file mode 100644 index 00000000..1def8033 Binary files /dev/null and b/gvsdsdk/minecraft/__pycache__/models.cpython-312.pyc differ diff --git a/gvsdsdk/minecraft/auth.py b/gvsdsdk/minecraft/auth.py new file mode 100644 index 00000000..90d716f8 --- /dev/null +++ b/gvsdsdk/minecraft/auth.py @@ -0,0 +1,146 @@ +"""模块:gvsdsdk.minecraft.auth — EasyAuth 插件认证与绑定管理 + +提供: + - ``VerifyPassword``:使用 bcrypt 校验游戏账号密码 + - ``EasyAuthManager``:游戏账号查询、密码验证、绑定标记读写 +""" + +import bcrypt + + +def VerifyPassword(plain_password: str, bcrypt_hash: str) -> bool: + """校验明文密码与 bcrypt hash 是否匹配 + + Args: + plain_password: 玩家输入的明文密码 + bcrypt_hash: ``easyauth.data.password`` 中存储的 bcrypt hash 字符串 + + Returns: + True 如果密码匹配;False 如果不匹配或 hash 格式异常 + """ + if not plain_password or not bcrypt_hash: + return False + try: + return bcrypt.checkpw(plain_password.encode('utf-8'), bcrypt_hash.encode('utf-8')) + except (ValueError, TypeError): + return False + + +class EasyAuthManager: + """EasyAuth 认证管理器——游戏账号查询、密码验证、绑定标记读写""" + + @staticmethod + def GetByUsername(username: str): + """按游戏名(不区分大小写)查询 EasyAuth 记录 + + Args: + username: 游戏名(原始大小写均可) + + Returns: + ``EasyAuth`` 实例;不存在则返回 ``None`` + """ + from .models import EasyAuth + if not username: + return None + return EasyAuth.objects.filter(UsernameLower=username.lower()).first() + + @staticmethod + def VerifyPassword(easyauth, plain_password: str) -> bool: + """校验 EasyAuth 记录的密码 + + Args: + easyauth: ``EasyAuth`` 实例 + plain_password: 明文密码 + + Returns: + True 如果密码匹配;False 如果不匹配、未设置密码或 hash 异常 + """ + if easyauth is None: + return False + data = easyauth.Data or {} + password_hash = data.get('password') or '' + return VerifyPassword(plain_password, password_hash) + + @staticmethod + def IsBound(easyauth) -> bool: + """判断该游戏账号是否已绑定 GVSDS 用户""" + return easyauth is not None and easyauth.UserRoleUUID is not None + + @staticmethod + def MarkBound(easyauth, user_role_uuid) -> int: + """将 UserRoleUUID 写入 easyauth 作为绑定标记 + + 使用乐观锁(``UserRoleUUID__isnull=True``)防止并发重复绑定。 + + Args: + easyauth: ``EasyAuth`` 实例 + user_role_uuid: 新创建的 UserRole 关联 UUID(str / bytes / UUIDObj 均可) + + Returns: + 实际更新行数:1 成功,0 表示已被其他请求抢先绑定 + """ + from .models import EasyAuth + if easyauth is None: + return 0 + return EasyAuth.objects.filter( + AuthID=easyauth.AuthID, + UserRoleUUID__isnull=True, + ).update(UserRoleUUID=user_role_uuid) + + @staticmethod + def ClearBound(easyauth) -> int: + """清空 easyauth 的 UserRoleUUID(用于解绑场景)""" + from .models import EasyAuth + if easyauth is None: + return 0 + return EasyAuth.objects.filter( + AuthID=easyauth.AuthID, + ).update(UserRoleUUID=None) + + @staticmethod + def GetPlayerUUIDByGVSDSUser(gvsds_user_uuid): + """GVSDS UserUUID → MC 玩家 UUID 字符串(varchar36) + + 反查路径:GVSDS UserUUID → 该用户的 UserRoleUUID 集合 → + easyauth 表中 UserRoleUUID 在该集合内的记录 → PlayerUUID。 + + 与 MC 绑定流程(``UserBindView``)配套:绑定时会把新建的 + UserRoleUUID 写入 easyauth.UserRoleUUID 列作为绑定标记, + 因此反过来即可定位该 GVSDS 用户绑定的 MC 玩家。 + + Args: + gvsds_user_uuid: GVDS 用户 UUID(bytes/str/UUIDObj 均可) + + Returns: + MC 玩家 UUID 字符串;未绑定或 easyauth 记录不存在时返回 None + """ + from .models import EasyAuth + from gvsdsdk.models import UserRole + from gvsdsdk.model_utils import UUIDObj + if not gvsds_user_uuid: + return None + raw_bytes = UUIDObj(gvsds_user_uuid).bytes + # 该 GVSDS 用户名下的所有 UserRoleUUID(跨公司/角色) + user_role_uuids = list( + UserRole.objects.filter(UserUUID=raw_bytes).values_list('UserRoleUUID', flat=True) + ) + if not user_role_uuids: + return None + # easyauth 表反查绑定标记,定位 MC 玩家 + ea = EasyAuth.objects.filter(UserRoleUUID__in=user_role_uuids).first() + return ea.PlayerUUID if ea else None + + @staticmethod + def GetByPlayerUUID(player_uuid): + """按 MC 玩家 UUID 字符串查询 EasyAuth 记录 + + Args: + player_uuid: MC 玩家 UUID 字符串(easyauth.uuid 列,varchar36) + + Returns: + ``EasyAuth`` 实例;不存在或入参为空则返回 ``None`` + """ + from .models import EasyAuth + if not player_uuid: + return None + return EasyAuth.objects.filter(PlayerUUID=player_uuid).first() diff --git a/gvsdsdk/minecraft/models.py b/gvsdsdk/minecraft/models.py new file mode 100644 index 00000000..86a3f2d7 --- /dev/null +++ b/gvsdsdk/minecraft/models.py @@ -0,0 +1,44 @@ +"""模块:gvsdsdk.minecraft.models — MC 插件共享表 ORM 映射 + +当前仅包含 ``EasyAuth`` 表(EasyAuth 插件认证表)。 + +设计要点: + - ``app_label='mc'``:模型归属 gvsds.apps.mc app,由 gvsds 主服务器的 + ``gvsds/db_router.py`` 路由到 minecraft 库 + - ``managed=False``:表由 MC 插件管理,Django 不参与迁移 + - ``UserRoleUUID`` 列由本系统 ALTER 添加(BINARY(16) 可空), + 存绑定后创建的 ``UserRole`` 关联 UUID +""" + +from gvsdsdk.model_base import QModel +from gvsdsdk.models import UUIDField +from django.db import models + + +class EasyAuth(QModel): + """EasyAuth 认证表——MC EasyAuth 插件的玩家凭据 + + 映射真实表 ``easyauth``(managed=False)。``data`` 列为 JSON, + 其中 ``password`` 字段为 bcrypt hash(``$2a$12$...``), + 验证时使用 ``bcrypt.checkpw``。 + + ``UserRoleUUID`` 列由本系统 ALTER 添加(BINARY(16) 可空), + 存绑定后创建的 ``UserRole`` 关联 UUID,用于 MC 插件侧反查 GVSDS 身份。 + """ + + AuthID = models.AutoField(primary_key=True, db_column='id', verbose_name='认证ID') + Username = models.CharField(max_length=255, unique=True, db_column='username', verbose_name='玩家名') + UsernameLower = models.CharField(max_length=255, db_column='username_lower', verbose_name='玩家名小写') + PlayerUUID = models.CharField(max_length=255, null=True, blank=True, db_column='uuid', verbose_name='MC玩家UUID字符串') + Data = models.JSONField(db_column='data', verbose_name='插件数据') + UserRoleUUID = UUIDField(null=True, blank=True, db_column='UserRoleUUID', verbose_name='绑定用户角色关联UUID') + + class Meta: + db_table = 'easyauth' + app_label = 'mc' + managed = False + verbose_name = 'EasyAuth认证' + verbose_name_plural = 'EasyAuth认证' + + def __str__(self): + return self.Username diff --git a/gvsdsdk/models.py b/gvsdsdk/models.py index ac57f270..89e04f32 100644 --- a/gvsdsdk/models.py +++ b/gvsdsdk/models.py @@ -3,11 +3,11 @@ 将 SAAS 主服务器的用户表(User)、权限表(Permission)、角色表(Role)、 用户角色关联表(UserRole)、角色权限关联表(RolePermission)、 岗位模板表(PositionTemplate)、岗位模板权限关联表(PositionTemplatePermission)、 -人事职务标签表(Position)、员工部门关联表(UserDept)封装为独立的 Django ORM 模型, +人事职务标签表(Position)封装为独立的 Django ORM 模型, 供子服务器直接进行跨服务器 ORM 查询。 权限体系四大分层: - 1. 组织人事层:Tenant → Company → Department,员工通过 UserDept 归属部门 + 1. 组织人事层:Tenant → Company → Department,员工通过 UserRole.DeptUUID 归属部门 2. 岗位模板层:PositionTemplate + PositionTemplatePermission,权限初始化快照 3. 角色权限载体层:Role + RolePermission,运行时鉴权实体 4. 鉴权规则层:操作权限校验(角色汇总)+ 数据权限校验(组织属性) @@ -539,6 +539,8 @@ class UserRole(QModel): 同一用户在不同公司下可绑定不同角色。 DeptUUID、WorkgroupUUID 为可选字段,用于将关联进一步限定到部门或工作组。 RoleType 标识该用户在部门/工作组中的角色类型:0=普通成员/部门员工,1=组长/部门负责人。 + RoleName 为可选的角色名覆盖:非空时前端展示用此名,为空时回退到 Role.RoleName。 + 这避免了为每个用户单独建角色,又能让同角色在不同用户视角下显示差异化名称。 注意:UserUUID/RoleUUID/CompanyUUID/DeptUUID/WorkgroupUUID 在 gvsds 中是 ForeignKey, 但此处改为 UUIDField 以保持独立性(不支持 select_related 和关联属性访问)。 @@ -551,6 +553,7 @@ class UserRole(QModel): DeptUUID = UUIDField(null=True, blank=True, db_column='DeptUUID', verbose_name='部门UUID') WorkgroupUUID = UUIDField(null=True, blank=True, db_column='WorkgroupUUID', verbose_name='工作组UUID') RoleType = models.SmallIntegerField(default=0, db_column='RoleType', verbose_name='角色类型') + RoleName = models.CharField(max_length=128, null=True, blank=True, db_column='RoleName', verbose_name='角色名覆盖') CreateTime = models.DateTimeField(default=_UtcNow, verbose_name='创建时间') class Meta: @@ -790,27 +793,4 @@ class Position(QModel): def __str__(self): return self.PositionName - -# === 员工部门关联表 === - -class UserDept(QModel): - """员工部门关联表——员工与部门的多对多映射(映射 SAAS 主库 UserDept 表) - - 支持一人多部门兼职,同时标注该员工在对应部门的职务标签。 - 该层级仅用于组织架构展示、审批流转、人员统计, - 同时为数据权限判断提供人员所属组织属性依据,不参与操作权限校验。 - """ - - UserDeptUUID = UUIDField(primary_key=True, db_column='UserDeptUUID', verbose_name='关联UUID') - UserUUID = UUIDField(db_column='UserUUID', verbose_name='用户UUID') - DeptUUID = UUIDField(db_column='DeptUUID', verbose_name='部门UUID') - PositionUUID = UUIDField(null=True, blank=True, db_column='PositionUUID', verbose_name='职务标签UUID') - IsMainDept = models.SmallIntegerField(default=1, db_column='IsMainDept', verbose_name='是否主部门') - CreateTime = models.DateTimeField(default=_UtcNow, verbose_name='创建时间') - - class Meta: - managed = False - app_label = 'gvsdsdk' - db_table = 'UserDept' - verbose_name = '员工部门关联' - verbose_name_plural = '员工部门关联' +# (UserDept 表已废弃——员工部门关联改走 UserRole.DeptUUID,不再用独立表)