diff --git a/TempLate/_footer b/TempLate/_footer
index a3291d9b..10b9d79f 100644
--- a/TempLate/_footer
+++ b/TempLate/_footer
@@ -145,15 +145,21 @@
-
+
- 微信:GVSADS
+ 微信:TermiNexus
-
+
+
+ QQ:3439961063
+
+
+
+
- Github: GVSADS
+ Github: GVSDS
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 现实分钟;活期随时可取,定期需满存期方可取款。
+
+
+
+
+
已选方案
+
+
+
+ 新增方案金额(GC)
+
+
+
添加到方案
+
提交充值
+
清空
+
+
合计扣除:0 GC
+
+
+
+
+
+
+
+
+
+
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 条
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 注销后将由系统审核,你不可再次操作这个企业,管理员审核后将会完全删除数据,如有需要请第一时间联系管理员
+
+
+ 企业名称:
+
+
+
+ 企业UUID:
+
+
+
+
+
+ 取消
+
+
+ 确认注销
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
枢机信用储备银行
+
银河锋锐软件开发工作室
+
+
+
+
+
© 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
企业详情
+
枢机信用储备银行附属红光证券
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
基本信息
+
+
+
企业UUID
+
+
+
+
上级企业
+
+
+
+
注册资本
+
+
+
+
资本币种
+
+
+
+
创建时间
+
+
+
+
营业期限
+
+
+
+
核准日期
+
+
+
+
+
+
+
+
联系信息
+
+
+
联系电话
+
+
+
+
电子邮箱
+
+
+
+
企业网站
+
+
+
+
企业地址
+
+
+
+
行政区划
+
+
+
+
许可证号
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
企业成员
+
+
+ 刷新列表
+
+
+ 添加成员
+
+
+
+
+
+
+
+
+
+
+
+
+ 玩家名
+ 用户名
+ 用户UUID
+ 玩家UUID
+ 用户类型
+ 操作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存
+
+
+ 取消
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 确认删除
+
+
+
+ 您确定要删除该成员吗?此操作无法撤销。
+
+
+ 用户UUID:
+
+
+
+
+
+
+
+ 确认删除
+
+
+ 取消
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
枢机协律商店
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ home 首页
+
+
+ receipt_long 订单
+
+
+ shopping_cart 购物车
+ 0
+
+
+ store 卖家
+
+
+
+
+
+
+
+
+
+
+
+
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 合纵阵线
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
晓 谕 诸 盟 书
+
A LETTER TO ALL ALLIANCES
+
+
+
+
+ 方今 K1350 王国一省大势已明,TL00、TL11、TL22 三盟结为一气,分头游说诸方,志在全境独尊。一旦其根基坐定,则区内要道、矿脉、城池皆由其裁断,诸盟进退维谷,再无置喙之地。
+
+ 若任其势力扩张,则疆土关隘、资源据点尽入其掌,诸盟虽欲偏安亦不可得。唇亡齿寒,此之谓也。
+
+
+
+
+
+ 「以众小攻一强,众小犹不可得, 况以三小攻一强乎?」
+
+
——《合纵论》引
+
+
+
+
+
+
+
+
+
+
+
战略形势一览
+
CURRENT STRATEGIC POSITION
+
+
+
+
+
+ 附图 · STRATEGIC MAP
+
+
+ K1350 · 第 1350 赛季
+
+
+
+
+ 图中红色方框 为 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,不再用独立表)