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 补齐限频去重状态。
2638 lines
113 KiB
Python
2638 lines
113 KiB
Python
"""模块: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'})
|