Files
TermiNexus 7ad151bf74 refactor(orm): 手写 ORM 下沉 gvsdsdk Manager,通用工具改为 SDK 转发
P1 通用查询:viewsets / app / menu / mail / gitea / mc / stats / sync_apps 等视图与命令的裸查询改委托 SDK Manager(Role/Company/Department/Position/Menu/Tenant/App)。

P2 App 体系:app_manager.py 与 app_manifests.py 实现零裸 ORM,职务多值/虚拟职务/公司下拉全部走 SDK。

P3 工具下沉:money/json_encoder/celery_utils/exception_handler/redis_lock 改为纯转发展开;ip_filter/storage 转发并注入 app.ini 配置源;common 下 DateTime/String/Naming/UUID 转发 SDK。

删除死代码 commission/strategies/engine.py(SDK 已有同物且零引用)与 payment/models.py 的 FromTenantAndPlatform;单测 patch 目标同步为 gvsdsdk.*,test_attack_detect fixture 补齐限频去重状态。
2026-09-13 21:58:06 +08:00

2638 lines
113 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模块: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 gvsdsdk.model_utils import UUIDObj
from gvsds.apps.gerp.app_access import AppAccessPermission
# 各业务应用编码(未安装的企业租户无法使用对应业务功能)
_APP_ACCESS_WALLET = AppAccessPermission('wallet')
_APP_ACCESS_STORE_VALUE = AppAccessPermission('store-value')
_APP_ACCESS_SHOP = AppAccessPermission('shop')
_APP_ACCESS_ENTERPRISE = AppAccessPermission('enterprise')
from .business import (
StoreValueRule,
GetGVSDSUserToMCPlayerUUID,
GetPlayerNameByGVSDSUser,
GetGVSDSUserByPlayerName,
GetUserByUUID,
GetUserByName,
GetUserNameByUUID,
RequireGCBalance,
CalcAccruedInterest,
CalcAccruedInterestAndAmount,
)
from .models import (
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, 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
# ============================================================================
# 公司私有业务校验(按 menu 表私有性通用拦截)
# ============================================================================
# 原理:菜单表 Menu.MenuLink 是页面路径(如 pages/private/CreateE/wallet.html),
# 私有菜单(CompanyUUID 非空)属于特定公司。iframe 内旧页面直调后端 API 时,
# Referer 携带页面路径,据此查出菜单所属公司,校验当前角色公司是否匹配。
# 公开菜单(CompanyUUID 为空)不拦截。这样"所有公司特有业务"自动按 menu
# 表私有性生效,新增私有菜单无需改代码。
_MENU_COMPANY_CACHE = {}
def _MenuLinkCompanyUUID(page_path: str):
"""根据页面路径查菜单所属公司 UUID bytes(缓存,避免 N+1)
返回 (company_uuid_bytes, is_private);is_private=False 表示公开菜单不拦截。
"""
from gvsdsdk.auth.managers import MenuManager
if not page_path:
return None, False
cached = _MENU_COMPANY_CACHE.get(page_path)
if cached is not None:
return cached
menu = MenuManager.GetByLink(page_path)
if menu is None or menu.CompanyUUID is None:
_MENU_COMPANY_CACHE[page_path] = (None, False)
return None, False
# Menu.CompanyUUID 是 UUIDField(UUIDObj 带 .bytes),直接取 bytes
company_uuid = menu.CompanyUUID
company_bytes = company_uuid.bytes if hasattr(company_uuid, 'bytes') else company_uuid
_MENU_COMPANY_CACHE[page_path] = (company_bytes, True)
return company_bytes, True
def RequireCompanyPrivateAccess(request, page_path: str = None, explicit_company_uuid: str = None) -> dict | None:
"""校验当前角色有权访问页面对应的公司私有业务
规则(对齐 menu.py 菜单过滤):
- 显式指定 explicit_company_uuid:直接按该公司校验(不查菜单表)
- 页面无对应菜单 / 菜单为公有 → 放行
- 菜单私有:当前角色公司必须等于菜单所属公司,否则拒绝
- 管理员(admin:super / admin:tenant)跳过校验(与菜单管理员特权一致)
Args:
request: DRF request(依赖 CurrentRoleMiddleware 注入 current_company_uuid)
page_path: 请求对应的菜单页面路径(如 pages/private/CreateE/wallet.html)。
缺省时从 Referer 头自动提取。
explicit_company_uuid: 显式指定归属公司 UUID 字符串(可选,用于菜单公有
但业务归属明确的场景,如商店交易)。
Returns:
None 表示放行;dict 表示拒绝响应(直接返回给前端)。
"""
if explicit_company_uuid:
try:
company_uuid = UUIDObj(explicit_company_uuid).bytes
except (ValueError, TypeError):
return _Err('业务公司配置异常,无权访问')
is_private = True
else:
if page_path is None:
referer = request.META.get('HTTP_REFERER', '') or ''
# Referer 形如 https://gerp.gvsds.com/pages/private/CreateE/wallet.html
page_path = referer.split('?')[0].rstrip('/')
if page_path.startswith('http'):
from urllib.parse import urlparse
page_path = urlparse(page_path).path.lstrip('/')
company_uuid, is_private = _MenuLinkCompanyUUID(page_path)
if not is_private:
return None # 公有菜单或未配置菜单:放行
# 管理员特权:与菜单过滤一致,管理员可访问所有公司私有业务
try:
from gvsdsdk.auth.admin_service import IsAdmin
if IsAdmin(request):
return None
except Exception:
pass
current_company = getattr(request, 'current_company_uuid', None)
if current_company is None:
return _Err('当前角色未绑定公司,无权访问该业务')
try:
if UUIDObj(current_company).bytes != company_uuid:
return _Err('当前角色不属于该公司,无权访问该业务')
except (ValueError, TypeError):
return _Err('当前角色公司信息异常,无权访问该业务')
return None
class CompanyPrivateAccessMixin:
"""按 menu 表私有性校验公司访问权限(通用拦截公司特有业务)
配置(子类覆盖):
- company_private_page: 本 ViewSet 对应的菜单页面路径
(如 pages/private/CreateE/wallet.html;缺省时按 Referer 自动提取)
- company_private_company_uuid: 显式指定归属公司 UUID 字符串(可选)。
当菜单为公有(CompanyUUID 为空)但业务实际归属某公司时使用,
指定后直接按该公司校验,不查菜单表。示例:商店菜单为公有
(允许公开浏览商品),但购物车/下单等交易仅限枢机协律公司。
- company_private_exempt: 免校验的 action 名集合(如公开浏览接口)
校验不通过时抛 PermissionDenied(HTTP 403),iframe 内旧页面
收到 403 后展示加载失败,前端路由守卫同时拦截 URL 直达——双层防线。
"""
company_private_page = None
company_private_company_uuid = None
company_private_exempt = set()
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
action = getattr(self, 'action', None)
if action in self.company_private_exempt:
return
err = RequireCompanyPrivateAccess(
request,
self.company_private_page,
explicit_company_uuid=self.company_private_company_uuid,
)
if err:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(err.get('msg', '无权访问该公司业务'))
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 CmeTransactionsViewSet(viewsets.ModelViewSet):
"""CME 交易流水 ViewSet"""
queryset = CmeTransactions.objects.all()
serializer_class = CmeTransactionsSerializer
permission_classes = [IsAuthenticated]
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(CompanyPrivateAccessMixin, viewsets.ModelViewSet):
"""CME 钱包余额 ViewSet
业务侧请通过 ``wallet`` action 查询当前用户的 MC 钱包余额;
标准 CRUD 仅供管理员后台使用,不应暴露给业务前端。
"""
queryset = CmeBalances.objects.all()
serializer_class = CmeBalancesSerializer
permission_classes = [IsAuthenticated, _APP_ACCESS_WALLET]
company_private_page = 'pages/private/CreateE/wallet.html'
@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(CompanyPrivateAccessMixin, 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, _APP_ACCESS_ENTERPRISE]
company_private_page = 'pages/private/CreateE/enterprise.html'
company_private_exempt = {'get_enterprise_card'}
@action(detail=False, methods=['get'])
def search(self, request):
keyword = request.query_params.get('keyword', '')
qs = Enterprise.objects.all()
if keyword:
qs = qs.filter(EnterpriseName__icontains=keyword)
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(CompanyPrivateAccessMixin, 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, _APP_ACCESS_ENTERPRISE]
company_private_page = 'pages/private/CreateE/enterprise.html'
company_private_exempt = {'get_members'}
@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(CompanyPrivateAccessMixin, 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, _APP_ACCESS_STORE_VALUE]
company_private_page = 'pages/private/CreateE/StoreValue.html'
company_private_exempt = {'get_store_value_rules'}
@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 gvsds.apps.mc.manager 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(CompanyPrivateAccessMixin, 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, _APP_ACCESS_SHOP]
company_private_page = 'pages/private/CreateE/shop.html'
company_private_company_uuid = 'ef67bb82-a590-402e-a893-81ae6127fb08'
company_private_exempt = {'list_public', 'detail'}
@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(CompanyPrivateAccessMixin, 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, _APP_ACCESS_SHOP]
company_private_page = 'pages/private/CreateE/shop.html'
company_private_company_uuid = 'ef67bb82-a590-402e-a893-81ae6127fb08'
@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(CompanyPrivateAccessMixin, 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, _APP_ACCESS_SHOP]
company_private_page = 'pages/private/CreateE/shop.html'
company_private_company_uuid = 'ef67bb82-a590-402e-a893-81ae6127fb08'
@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'})