"refactor(gerp): P0 鉴权链路委托 gvsdsdk 并清理死代码

role_scope 数据权限底座、form_service 默认角色/租户管理员/删用户/审计
改经 SDK;ugm/payment/stats 裸查询委托;删除 commission/payment/stats
冗余 manager/engine;修复 user_detail 潜伏 ImportError。"
This commit is contained in:
TermiNexus committed 2026-08-07 18:10:45 +08:00
1 parent e2015164ea
commit cd9ebcddd1
15 files changed
+393 -1449

No files matched your search

-519
View File
@@ -1,519 +0,0 @@
"""模块:分佣编排器
SAAS 级分佣引擎核心入口——子服务通过此引擎触发分佣事件。
职责:
1. 接收分佣事件(Dispatch)
2. 匹配分佣规则
3. 执行分账策略计算
4. 写入 CommissionRecord 流水
5. 支持预结算(跨平台确认)
6. 支持邀请链分佣(多级分佣)"""
import uuid
import datetime
import logging
from decimal import Decimal
from typing import List, Optional, Dict, Any
from django.db.models import Sum as _AggSum
from gvsds.apps.commission.models import (
CommissionEvent,
CommissionRule,
CommissionRecord,
RateTable,
PreSettlement,
)
from gvsds.apps.commission.strategies.engine import (
CreateStrategy,
RemainderStrategy,
)
logger = logging.getLogger(__name__)
class CommissionError(Exception):
def __init__(self, message, event=None):
super().__init__(message)
self.Event = event
class CommissionEngine:
"""分佣编排器——SAAS 主服务器核心入口
用法:
engine = CommissionEngine(tenant_uuid='xxx')
# 配置分佣规则(SAAS管理员操作)
engine.ConfigureRule(
event_type='order_complete',
rule_name='平台订单打手8成',
participant_rules=[
{'role': 'worker', 'type': 'percentage', 'value': 0.80},
{'role': 'platform', 'type': 'remainder'},
],
)
# 子服务触发分佣
records = engine.Dispatch(
event_type='order_complete',
event_key='ORDER-20260611-001',
total_amount=Decimal('100.00'),
source_user_uuid='boss_001',
participants={
'worker': 'dashou_123',
'agent': 'guanshi_456',
'leader': 'zuzhang_789',
},
tier_index=1,
payload={'order_id': 'xxx', 'game': 'LoL'},
)
# 跨平台预结算
engine.PreSettle(
event_key='ORDER-CROSS-001',
external_platform='partner_club_002',
external_order_id='P-ORDER-888',
participant_id='dashou_123',
amount=Decimal('80.00'),
order_amount=Decimal('100.00'),
)
"""
def __init__(self, tenant_uuid):
self.TenantUUID = tenant_uuid
def ConfigureRule(self, event_type, rule_name, participant_rules,
rule_description='', max_invite_level=2,
rate_table_uuid=None, rule_priority=0):
"""配置分佣规则——SAAS 管理员为租户配置分佣策略
Args:
event_type: 事件类型 ('order_complete', 'member_purchase', ...)
rule_name: 规则名称
participant_rules: 参与者分账规则列表
[
{'role': 'worker', 'type': 'percentage', 'value': 0.80},
{'role': 'agent', 'type': 'fixed', 'value': 10},
{'role': 'leader', 'type': 'tiered', 'tiers': {1:5, 2:8, 3:10}},
{'role': 'platform', 'type': 'remainder'},
]
rule_description: 规则描述
max_invite_level: 邀请链最大层级
rate_table_uuid: 关联费率表UUID
rule_priority: 优先级(数字越大越优先)
Returns:
CommissionRule 实例
"""
rate_table = None
if rate_table_uuid:
try:
rate_table = RateTable.objects.get(
RateTableUUID=rate_table_uuid, TenantUUID=self.TenantUUID,
)
except RateTable.DoesNotExist:
raise CommissionError(f'费率表不存在: {rate_table_uuid}')
rule = CommissionRule.objects.create(
RuleUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
EventType=event_type,
RuleName=rule_name,
RuleDescription=rule_description,
ParticipantRules=participant_rules,
RateTable=rate_table,
MaxInviteLevel=max_invite_level,
RulePriority=rule_priority,
)
logger.info(f'分佣规则已配置: {rule_name}@tenant={self.TenantUUID}')
return rule
def ConfigureRateTable(self, table_code, table_name, entries,
table_description=''):
"""配置费率表
Args:
table_code: 表编码(如 'dashou_order_split')
table_name: 表名
entries: 费率条目字典 {key: value}
table_description: 描述
Returns:
RateTable 实例
"""
from gvsds.apps.commission.models import RateEntry
table = RateTable.objects.create(
RateTableUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
TableCode=table_code,
TableName=table_name,
TableDescription=table_description,
)
for key, value in entries.items():
RateEntry.objects.create(
EntryUUID=uuid.uuid4().bytes,
RateTable=table,
RateKey=str(key),
RateValue=Decimal(str(value)),
)
logger.info(f'费率表已创建: {table_code}@tenant={self.TenantUUID}')
return table
def Dispatch(self, event_type, event_key, total_amount,
source_user_uuid=None, participants=None,
tier_index=0, payload=None, auto_settle=True):
"""触发分佣事件——子服务调用的核心入口
Args:
event_type: 事件类型
event_key: 业务唯一键(幂等保护)
total_amount: 可分佣总金额
source_user_uuid: 触发者UUID(如付费用户)
participants: 参与者映射 {role: user_id}
tier_index: 阶梯索引(多次分红时用,第1次=1)
payload: 业务上下文(JSON)
auto_settle: 是否自动结算
Returns:
List[CommissionRecord] 分佣记录列表
Raises:
CommissionError: 重复事件或规则未配置
"""
if CommissionEvent.objects.filter(
TenantUUID=self.TenantUUID, EventKey=event_key,
).exists():
raise CommissionError(f'事件已处理: {event_key}')
total_amount = Decimal(str(total_amount))
event = CommissionEvent.objects.create(
EventUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
EventType=event_type,
EventKey=event_key,
TotalAmount=total_amount,
SourceUserUUID=source_user_uuid,
EventPayload=payload or {},
Participants=participants or {},
EventStatus=CommissionEvent.EVT_PENDING,
)
rules = CommissionRule.objects.filter(
TenantUUID=self.TenantUUID,
EventType=event_type,
RuleStatus=1,
).order_by('-RulePriority')
if not rules.exists():
event.MarkFailed()
raise CommissionError(f'未配置分佣规则: {event_type}', event=event)
event.MarkProcessing()
all_records = []
for rule in rules:
records = self._execute_rule(
event=event,
rule=rule,
total_amount=total_amount,
participants=participants or {},
tier_index=tier_index,
)
all_records.extend(records)
if auto_settle:
event.MarkSettled()
for rec in all_records:
rec.Settle()
return all_records
def _execute_rule(self, event, rule, total_amount, participants, tier_index):
"""执行单条分佣规则"""
rate_table = rule.RateTable
participant_rules = rule.ParticipantRules
records = []
allocated = Decimal('0')
remainder_rule = None
ordered_rules = sorted(
participant_rules,
key=lambda r: 0 if r.get('Type') == 'remainder' else 1,
)
for pr in ordered_rules:
if pr.get('Type') == 'remainder':
remainder_rule = pr
continue
strategy = CreateStrategy(pr, rate_table)
amount = strategy.Calculate(
total_amount,
tier_index=tier_index,
)
allocated += amount
participant_id = self._resolve_participant(pr, participants)
if amount > 0:
records.append(self._create_record(
event=event,
rule=rule,
participant_id=participant_id,
role=pr.get('Role', 'unknown'),
split_type=pr.get('Type', 'unknown'),
amount=amount,
rule_snapshot=pr,
))
if remainder_rule and allocated < total_amount:
strategy = CreateStrategy(remainder_rule, rate_table)
amount = strategy.Calculate(
total_amount,
allocated_amount=allocated,
)
participant_id = self._resolve_participant(remainder_rule, participants)
if amount > 0:
records.append(self._create_record(
event=event,
rule=rule,
participant_id=participant_id,
role=remainder_rule.get('role', 'platform'),
split_type='remainder',
amount=amount,
rule_snapshot=remainder_rule,
))
for rec in records:
rec.TierIndex = tier_index
return records
def _resolve_participant(self, participant_rule, participants):
role = participant_rule.get('Role', 'unknown')
Fallback = participant_rule.get('FallbackID', '')
return participants.get(role, Fallback or f'{role}_default')
def _create_record(self, event, rule, participant_id, role, split_type, amount, rule_snapshot):
return CommissionRecord.objects.create(
RecordUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
Event=event,
Rule=rule,
ParticipantID=str(participant_id),
ParticipantRole=role,
SplitType=split_type,
Amount=amount,
RuleSnapshot=rule_snapshot,
)
def PreSettle(self, event_key, external_platform, external_order_id,
participant_id, amount, order_amount=Decimal('0'),
participant_role='worker', confirm_deadline=None):
"""创建预结算记录——跨平台订单待确认
Args:
event_key: 业务唯一键
external_platform: 外部平台标识
external_order_id: 外部订单ID
participant_id: 应分账对象
amount: 预分金额
order_amount: 订单原金额
participant_role: 参与者角色
confirm_deadline: 确认截止时间
Returns:
PreSettlement 实例
"""
ps = PreSettlement.objects.create(
SettlementUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
EventKey=event_key,
ExternalPlatform=external_platform,
ExternalOrderID=external_order_id,
ParticipantID=participant_id,
ParticipantRole=participant_role,
Amount=Decimal(str(amount)),
OrderAmount=Decimal(str(order_amount)),
ConfirmDeadline=confirm_deadline,
)
logger.info(
f'预结算: {event_key}->{participant_id}={amount}'
f'@tenant={self.TenantUUID}'
)
return ps
def ConfirmPreSettlement(self, event_key, participant_id=None,
confirmed_by=''):
"""确认预结算——转为正式分佣记录
Args:
event_key: 业务唯一键
participant_id: 参与者ID(None=全部确认)
confirmed_by: 确认人
Returns:
int 确认条数
"""
qs = PreSettlement.objects.filter(
TenantUUID=self.TenantUUID,
EventKey=event_key,
Status=PreSettlement.PST_PENDING,
)
if participant_id:
qs = qs.filter(ParticipantID=participant_id)
count = 0
for ps in qs:
ps.Confirm(confirmed_by)
count += 1
return count
def RejectPreSettlement(self, event_key, participant_id=None,
confirmed_by=''):
"""拒绝预结算
Args:
event_key: 业务唯一键
participant_id: 参与者ID(None=全部拒绝)
confirmed_by: 操作人
Returns:
int 拒绝条数
"""
qs = PreSettlement.objects.filter(
TenantUUID=self.TenantUUID,
EventKey=event_key,
Status=PreSettlement.PST_PENDING,
)
if participant_id:
qs = qs.filter(ParticipantID=participant_id)
count = 0
for ps in qs:
ps.Reject(confirmed_by)
count += 1
return count
def CalculateFee(self, total_amount, rate_table_code, rate_key):
"""计算手续费——用于提现场景
Args:
total_amount: 提现金额
rate_table_code: 费率表编码
rate_key: 费率键
Returns:
Decimal 手续费金额
"""
total_amount = Decimal(str(total_amount))
try:
table = RateTable.objects.get(
TenantUUID=self.TenantUUID,
TableCode=rate_table_code,
TableStatus=1,
)
except RateTable.DoesNotExist:
return Decimal('0')
rate = table.GetRate(rate_key)
if rate is None:
return Decimal('0')
return total_amount * rate
def QueryRecords(self, participant_id=None, event_type=None,
status=None, start_date=None, end_date=None,
limit=50):
"""查询分佣记录
Args:
participant_id: 参与者ID
event_type: 事件类型
status: 状态
start_date: 开始日期
end_date: 结束日期
limit: 返回条数
Returns:
QuerySet[CommissionRecord]
"""
qs = CommissionRecord.objects.filter(TenantUUID=self.TenantUUID)
if participant_id:
qs = qs.filter(ParticipantID=participant_id)
if status is not None:
qs = qs.filter(Status=status)
if event_type:
qs = qs.filter(Event__EventType=event_type)
if start_date:
qs = qs.filter(CreateTime__gte=start_date)
if end_date:
qs = qs.filter(CreateTime__lte=end_date)
return qs.order_by('-CreateTime')[:limit]
def TotalByParticipant(self, participant_id, start_date=None, end_date=None):
"""统计某参与者的分佣总额"""
qs = CommissionRecord.objects.filter(
TenantUUID=self.TenantUUID,
ParticipantID=participant_id,
Status__gte=CommissionRecord.REC_SETTLED,
)
if start_date:
qs = qs.filter(CreateTime__gte=start_date)
if end_date:
qs = qs.filter(CreateTime__lte=end_date)
result = qs.aggregate(total=_AggSum('Amount'))
return result['total'] or Decimal('0')
def BuildInviteChainCommission(self, event_type, event_key, total_amount,
source_user_uuid, invite_chain,
rule_name, participant_rules):
"""邀请链分佣——按层级对受邀人行为进行多级分佣
Args:
event_type: 事件类型
event_key: 事件键
total_amount: 总金额
source_user_uuid: 行为触发者
invite_chain: 邀请链 [{user_uuid, level, role}, ...]
level=1 为直接邀请人,level=2 为间推
rule_name: 规则名称
participant_rules: 参与者规则(按层级配置)
例: [{'role': 'agent_l1', 'type': 'fixed', 'value': 10},
{'role': 'agent_l2', 'type': 'fixed', 'value': 5}]
Returns:
List[CommissionRecord]
"""
rule = CommissionRule.objects.filter(
TenantUUID=self.TenantUUID,
EventType=event_type,
RuleName=rule_name,
RuleStatus=1,
).first()
if not rule:
rule = self.ConfigureRule(
event_type=event_type,
rule_name=rule_name,
participant_rules=participant_rules,
)
participants = {
item.get('Role', f'invite_l{item.get("Level",0)}'): item.get('UserUUID', '')
for item in invite_chain
}
participants['source'] = source_user_uuid
return self.Dispatch(
event_type=event_type,
event_key=event_key,
total_amount=total_amount,
source_user_uuid=source_user_uuid,
participants=participants,
)
+17 -119
View File
@@ -56,59 +56,12 @@ def create_default_role_for_user(user, admin_user=None):
admin_user: 可选,操作管理员 User 实例(用于租户回退)
"""
try:
company_uuid = SYSTEM_COMPANY_UUID
# 角色租户优先取自系统公司所属租户
tenant_uuid = None
try:
company = Company.objects.get(CompanyUUID=company_uuid)
tenant_uuid = company.TenantUUID_id
except Company.DoesNotExist:
pass
# 回退:当前操作管理员的首个角色租户
if not tenant_uuid and admin_user is not None:
admin_ur = UserRole.objects.filter(
UserUUID=admin_user.UserUUID
).first()
if admin_ur:
role_uuid = admin_ur.RoleUUID
if role_uuid:
role = Role.objects.filter(RoleUUID=role_uuid).first()
tenant_uuid = role.TenantUUID if role else None
# 回退:系统首个租户
if not tenant_uuid:
tenant = Tenant.objects.first()
if tenant:
tenant_uuid = tenant.TenantUUID
if not tenant_uuid:
return
# 查找或创建全局唯一的"默认角色"(不再为每个用户创建专有角色)
default_role = Role.objects.filter(
TenantUUID=tenant_uuid,
RoleName='默认角色',
).first()
if not default_role:
default_role = Role.objects.create(
RoleUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
RoleName='默认角色',
RoleStatus=1,
AssignScope=0,
RoleDesc='系统默认角色,所有新用户自动关联',
)
# 避免重复关联
existing = UserRole.objects.filter(
UserUUID=user.UserUUID,
RoleUUID=default_role.RoleUUID,
).exists()
if not existing:
UserRole.objects.create(
UserRoleUUID=uuid.uuid4().bytes,
UserUUID=user.UserUUID,
RoleUUID=default_role.RoleUUID,
CompanyUUID=company_uuid,
)
from gvsdsdk.auth.managers import RoleManager
RoleManager.EnsureDefaultRoleForUser(
user.UserUUID,
SYSTEM_COMPANY_UUID,
admin_user.UserUUID if admin_user is not None else None,
)
except Exception:
pass
@@ -130,39 +83,8 @@ def create_tenant_admin_role(tenant):
Returns:
Role 实例(已创建或已存在)
"""
import uuid as _uuid
from gvsds.apps.gerp.models import Role, Permission, RolePermission
from gvsdsdk.auth.managers import RoleManager
SYSTEM_COMPANY_UUID = _uuid.UUID('00000000-0000-0000-0000-000000000001').bytes
tenant_uuid = tenant.TenantUUID
if hasattr(tenant_uuid, 'bytes'):
tenant_uuid = tenant_uuid.bytes
# 查找或创建"集团超级管理员"角色
role = Role.objects.filter(
TenantUUID=tenant_uuid,
RoleName='集团超级管理员',
).first()
if role:
return role
role = RoleManager.CreateRole(
'集团超级管理员', tenant_uuid,
AssignScope=0,
RoleDesc='集团超级管理员,管理本租户下所有事务',
)
# 分配 admin:tenant 权限(租户内管理员:关闭公司隔离,保留租户隔离)
admin_perm = Permission.objects.filter(
PermCode='admin:tenant',
PermStatus=1,
).first()
if admin_perm:
from gvsdsdk.auth.managers import RoleManager
RoleManager.AssignPermission(role.RoleUUID, admin_perm.PermUUID, SYSTEM_COMPANY_UUID)
return role
return RoleManager.EnsureTenantAdminRole(tenant.TenantUUID)
def _pascalize_record(record, model=None):
@@ -1754,36 +1676,12 @@ class FormService:
# 审计日志:删除前捕获旧数据快照和目标 UUID
old_snapshot = self._get_record_snapshot(obj) if table_name in self.AUDIT_TABLES else None
_audit_target_uuid = obj.pk if table_name in self.AUDIT_TABLES else None
# 用户删除时级联清理:默认角色 + 所有 UserRole 关联
# 用户删除时级联清理:默认角色 + 所有 UserRole 关联(委托 gvsdsdk)
if table_name == 'User':
with transaction.atomic():
user_uuid = obj.UserUUID
user_name = getattr(obj, 'UserName', '') or ''
# 查找该用户的所有 UserRole 关联
user_roles = list(UserRole.objects.filter(
UserUUID=user_uuid
))
# 识别默认角色:名称匹配 "{用户名}的默认角色" 且仅此用户一人
default_role_ids = []
for ur in user_roles:
role = Role.objects.filter(RoleUUID=ur.RoleUUID).first()
if role and getattr(role, 'RoleName', '') == f"{user_name}的默认角色":
other_count = UserRole.objects.filter(
RoleUUID=role.RoleUUID
).exclude(UserUUID=user_uuid).count()
if other_count == 0:
default_role_ids.append(role.RoleUUID)
# 删除所有 UserRole 关联
UserRole.objects.filter(UserUUID=user_uuid).delete()
# 删除默认角色(RolePermission 会被 cascade 删除)
if default_role_ids:
Role.objects.filter(RoleUUID__in=default_role_ids).delete()
# 清理 UserAvatar / User2FA / UserPending2FA(这些表用 UUIDField 而非 ForeignKey,不会自动级联)
UserAvatar.objects.filter(UserUUID=user_uuid).delete()
User2FA.objects.filter(UserUUID=user_uuid).delete()
UserPending2FA.objects.filter(UserUUID=user_uuid).delete()
# 删除用户本身
obj.delete()
from gvsdsdk.auth.managers import AdminUserManager
ok, err = AdminUserManager.DeleteUser(obj.UserUUID)
if not ok:
return {'error': err or '删除用户失败', 'field': 'PK'}
# 审计日志:记录删除操作
self._record_audit(table_name, 'delete', old_data=old_snapshot, target_uuid=_audit_target_uuid)
return True
@@ -1903,11 +1801,11 @@ class FormService:
user_uuid = uuid.UUID(user_uuid).bytes
except ValueError:
pass
AuditLog.objects.create(
AuditLogUUID=uuid.uuid4().bytes,
TenantUUID_id=tenant_uuid,
CompanyUUID_id=company_uuid,
UserUUID_id=user_uuid,
from gvsdsdk.auth.managers import AuditManager
AuditManager.Log(
TenantUUID=tenant_uuid,
CompanyUUID=company_uuid,
UserUUID=user_uuid,
TableName=table_name,
Action=action,
TargetUUID=target_uuid,
+15 -35
View File
@@ -16,8 +16,6 @@ import logging
import uuid as _uuid
from typing import Optional, NamedTuple
from gvsds.apps.gerp.models import RolePermission, UserRole, UserRoleDepartment, UserRoleWorkgroup
logger = logging.getLogger(__name__)
@@ -98,15 +96,11 @@ def GetUserScope(request) -> RoleScope:
RoleType=0,
)
# 查询当前用户在当前角色+公司下的 UserRole 记录
# 注意:gvsdsdk 的 UserRole 使用 UUIDField(非 ForeignKey),查询时用字段名而非 _id 后缀
ur_qs = UserRole.objects.filter(
UserUUID=user_uuid_bytes,
RoleUUID=role_uuid,
# 查询当前用户在当前角色+公司下的 UserRole 记录(委托 gvsdsdk)
from gvsdsdk.auth.managers import RoleManager
ur = RoleManager.GetUserRoleRelation(
user_uuid_bytes, role_uuid, company_uuid_bytes,
)
if company_uuid_bytes:
ur_qs = ur_qs.filter(CompanyUUID=company_uuid_bytes)
ur = ur_qs.first()
if not ur:
return RoleScope(
@@ -125,17 +119,16 @@ def GetUserScope(request) -> RoleScope:
workgroup_uuid = None
if role_type == 1 and ur_uuid_bytes:
try:
urd = UserRoleDepartment.objects.filter(UserRoleUUID=ur_uuid_bytes).first()
urd = RoleManager.GetUserRoleDepartment(ur_uuid_bytes)
if urd:
# DeptUUID 为 ForeignKey:用 _id 取原始列值
dept_uuid = _to_bytes(urd.DeptUUID_id)
dept_uuid = _to_bytes(urd.DeptUUID)
except Exception:
dept_uuid = None
if not dept_uuid:
try:
urw = UserRoleWorkgroup.objects.filter(UserRoleUUID=ur_uuid_bytes).first()
urw = RoleManager.GetUserRoleWorkgroup(ur_uuid_bytes)
if urw:
workgroup_uuid = _to_bytes(urw.WorkgroupUUID_id)
workgroup_uuid = _to_bytes(urw.WorkgroupUUID)
except Exception:
workgroup_uuid = None
@@ -215,27 +208,14 @@ def ApplyScopeToQueryset(qs, scope: RoleScope, table_name: str):
def _get_scope_members(scope: RoleScope):
"""获取当前作用域下的成员 UUID 列表
"""获取当前作用域下的成员 UUID 列表(委托 gvsdsdk RoleManager.GetScopeMembers)
dept_manager: 查询 UserRoleDepartment 中 DeptUUID = scope.DeptUUID 的用户
workgroup_lead: 查询 UserRoleWorkgroup 中 WorkgroupUUID = scope.WorkgroupUUID 的用户
"""
try:
if scope.Type == 'dept_manager' and scope.DeptUUID:
ur_uuids = list(UserRoleDepartment.objects.filter(
DeptUUID_id=scope.DeptUUID
).values_list('UserRoleUUID', flat=True))
elif scope.Type == 'workgroup_lead' and scope.WorkgroupUUID:
ur_uuids = list(UserRoleWorkgroup.objects.filter(
WorkgroupUUID_id=scope.WorkgroupUUID
).values_list('UserRoleUUID', flat=True))
else:
return None
if not ur_uuids:
return []
# UserRoleUUID → UserUUID(关联表存 UserRoleUUID,用户 UUID 从 UserRole 表取)
qs = UserRole.objects.filter(UserRoleUUID__in=ur_uuids)
return list(set(qs.values_list('UserUUID', flat=True)))
except Exception:
logger.warning('[RoleScope] 获取作用域成员失败: type=%s', scope.Type, exc_info=True)
return None
from gvsdsdk.auth.managers import RoleManager
if scope.Type == 'dept_manager' and scope.DeptUUID:
return RoleManager.GetScopeMembers('dept_manager', scope.DeptUUID)
if scope.Type == 'workgroup_lead' and scope.WorkgroupUUID:
return RoleManager.GetScopeMembers('workgroup_lead', scope.WorkgroupUUID)
return None
+21
View File
@@ -10,6 +10,27 @@ import logging
logger = logging.getLogger(__name__)
def _get_sub_db_connection(node):
"""获取子服务器数据库连接
基于 ``ServiceNode.ServiceMetadata['DBName']`` 匹配 settings.DATABASES 中
已配置的数据库别名(子服务器心跳上报的库名,如 'Users'/'GerpCore'/'Plans')。
Returns:
(django.db connections 连接, None) 或 (None, 错误消息)
"""
try:
db_name = (node.ServiceMetadata or {}).get('DBName', '')
if not db_name:
return None, f'服务 {node.ServiceName} 未配置数据库名(ServiceMetadata.DBName 为空)'
from django.db import connections
if db_name not in connections.databases:
return None, f'数据库 {db_name} 未在 settings.DATABASES 中配置'
return connections[db_name], None
except Exception as e:
return None, f'获取子服务器数据库连接失败: {e}'
class MSyncServerListView(ELTAuthenticatedAPIView):
"""获取 MSYNC 已注册的所有服务器列表(需 admin:saas_server 权限)"""
permission_classes = [IsAuthenticated]
+5 -5
View File
@@ -210,11 +210,11 @@ class OrderFlowView(ELTAuthenticatedAPIView):
new_snapshot['OrderStatus'] = order.OrderStatus
new_snapshot['OrderStatusLabel'] = STATUS_LABELS.get(order.OrderStatus, str(order.OrderStatus))
AuditLog.objects.create(
AuditLogUUID=_uuid.uuid4().bytes,
TenantUUID_id=_to_uuid_bytes(current_tenant_uuid),
CompanyUUID_id=_to_uuid_bytes(current_company_uuid) or _to_uuid_bytes(order.CompanyUUID),
UserUUID_id=user_uuid,
from gvsdsdk.auth.managers import AuditManager
AuditManager.Log(
TenantUUID=current_tenant_uuid,
CompanyUUID=current_company_uuid or order.CompanyUUID,
UserUUID=user_uuid,
TableName=ORDER_AUDIT_TABLE_NAME,
Action=f'{action.lower()}_status',
TargetUUID=order.OrderUUID,
-455
View File
@@ -1,455 +0,0 @@
"""模块:支付管理器
SAAS 级支付编排器——子服务通过此管理器发起支付操作。
职责:
1. 根据租户UUID + 平台查询 PaymentGatewayConfig
2. 解密密钥构建 PaymentCredentials
3. 从网关注册表获取适配器类,创建网关实例
4. 执行支付操作并记录 PaymentTransaction 流水"""
import uuid
import datetime
import logging
from decimal import Decimal
from gvsds.apps.payment.gateways.base import (
PaymentCredentials,
PaymentRequest,
PaymentResponse,
RefundRequest,
RefundResponse,
TransferRequest,
TransferResponse,
QueryResponse,
)
from gvsds.apps.payment.gateways.registry import payment_registry
from gvsds.apps.payment.models import (
PaymentGatewayConfig,
PaymentTransaction,
EncryptField,
)
logger = logging.getLogger(__name__)
class PaymentError(Exception):
def __init__(self, message, transaction=None):
super().__init__(message)
self.Transaction = transaction
class PaymentManager:
"""支付编排器——SAAS 主服务器核心入口
用法:
manager = PaymentManager(tenant_uuid='xxx')
# 配置支付网关(SAAS 管理员操作)
manager.ConfigureGateway(
platform='wechat',
app_id='wx123',
mch_id='456',
api_key='secret',
notify_url='https://xxx.gvsds.com/api/payment/notify/wechat/',
)
# 发起支付
tx = manager.Pay(
platform='wechat',
amount=Decimal('100.00'),
subject='订单支付',
method='jsapi',
)
# 查询
manager.Query(out_trade_no='PY-xxx')
# 退款
manager.Refund(out_trade_no='PY-xxx', amount=Decimal('100.00'))
# 回调处理
manager.HandleNotify(platform='wechat', raw_data=body, headers=headers)
"""
def __init__(self, tenant_uuid):
self.TenantUUID = tenant_uuid
def ConfigureGateway(self, platform, app_id='', mch_id='',
api_key='', private_key='', public_key='',
cert_path='', cert_key_path='',
notify_url='', return_url='', extra=None, set_default=False):
"""配置支付网关——SAAS 管理员为租户配置支付渠道
Args:
platform: 支付平台 ('wechat', 'alipay', 'bank')
app_id: 应用 ID
mch_id: 商户号
api_key: API 密钥(将加密存储)
private_key: 私钥(将加密存储)
public_key: 公钥(将加密存储)
cert_path: 证书路径
cert_key_path: 证书密钥路径
notify_url: 回调通知 URL
return_url: 支付完成跳转 URL
extra: 额外配置
set_default: 是否设为默认渠道
Returns:
PaymentGatewayConfig 实例
"""
config, created = PaymentGatewayConfig.objects.update_or_create(
TenantUUID=self.TenantUUID,
Platform=platform,
defaults={
'AppID': app_id,
'MchID': mch_id,
'APIKeyEncrypted': EncryptField(api_key) if api_key else '',
'PrivateKeyEncrypted': EncryptField(private_key) if private_key else '',
'PublicKeyEncrypted': EncryptField(public_key) if public_key else '',
'CertPath': cert_path,
'CertKeyPath': cert_key_path,
'NotifyURL': notify_url,
'ReturnURL': return_url,
'ConfigExtra': extra or {},
'ConfigStatus': 1,
'UpdateTime': datetime.datetime.utcnow(),
},
)
if set_default:
PaymentGatewayConfig.objects.filter(
TenantUUID=self.TenantUUID, ConfigDefault=True,
).update(ConfigDefault=False)
config.ConfigDefault = True
config.save(update_fields=['ConfigDefault'])
logger.info(f'支付网关配置: {platform}@tenant={self.TenantUUID}')
return config
def Pay(self, platform, amount, subject='', body='',
method='jsapi', open_id='', client_ip='',
extra=None, expire_minutes=30):
"""发起支付
Args:
platform: 支付平台
amount: 金额
subject: 商品标题
body: 商品描述
method: 支付方式 (jsapi/app/h5/native/miniapp)
open_id: 用户 OpenID(JSAPI 必填)
client_ip: 客户端 IP
extra: 额外参数
expire_minutes: 过期分钟数
Returns:
PaymentTransaction 实例
Raises:
PaymentError: 支付失败
"""
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
gateway = payment_registry.create(platform, creds)
out_trade_no = f'PY-{uuid.uuid4().hex[:16].upper()}'
req = PaymentRequest(
OutTradeNO=out_trade_no,
TotalAmount=Decimal(str(amount)),
Subject=subject,
Body=body,
Method=method,
OpenID=open_id,
ClientIP=client_ip,
ExpireMinutes=expire_minutes,
Extra=extra or {},
)
tx = PaymentTransaction(
TransactionUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
Platform=platform,
TransactionType=PaymentTransaction.TX_PAY,
OutTradeNO=out_trade_no,
TotalAmount=Decimal(str(amount)),
Subject=subject,
RawRequest={
'method': method,
'open_id': open_id,
'extra': extra or {},
},
)
tx.save()
resp = gateway.Pay(req)
tx.RawResponse = resp.RawResponse
tx.UpdateTime = datetime.datetime.utcnow()
if resp.Success:
tx.ChannelTransactionID = resp.TransactionID
tx.save(update_fields=['RawResponse', 'ChannelTransactionID', 'UpdateTime'])
tx._prepay_info = resp.PayInfo
return tx
else:
tx.MarkFailed()
raise PaymentError('支付发起失败', transaction=tx)
def Query(self, out_trade_no, platform=None):
"""查询订单状态
Args:
out_trade_no: 商户订单号
platform: 支付平台(不传则从交易记录推断)
Returns:
QueryResponse
"""
try:
tx = PaymentTransaction.objects.get(OutTradeNO=out_trade_no)
except PaymentTransaction.DoesNotExist:
raise PaymentError(f'交易记录不存在: {out_trade_no}')
platform = platform or tx.Platform
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
gateway = payment_registry.create(platform, creds)
result = gateway.Query(out_trade_no)
if result.IsPaid and not tx.IsSuccess:
tx.MarkSuccess(
channel_transaction_id=result.TransactionID,
notify_data={'query_result': result.RawResponse},
)
elif result.IsRefunded:
tx.TransactionStatus = PaymentTransaction.TX_CLOSED
tx.RawResponse = result.RawResponse
tx.UpdateTime = datetime.datetime.utcnow()
tx.save(update_fields=['TransactionStatus', 'RawResponse', 'UpdateTime'])
return result
def Refund(self, out_trade_no, amount, reason='', platform=None, extra=None):
"""发起退款
Args:
out_trade_no: 商户订单号
amount: 退款金额
reason: 退款原因
platform: 支付平台
extra: 额外参数
Returns:
PaymentTransaction 实例
"""
try:
pay_tx = PaymentTransaction.objects.get(OutTradeNO=out_trade_no, TransactionType=PaymentTransaction.TX_PAY)
except PaymentTransaction.DoesNotExist:
raise PaymentError(f'原始交易不存在: {out_trade_no}')
if not pay_tx.IsSuccess:
raise PaymentError(f'原始交易未支付成功,无法退款: {out_trade_no}')
platform = platform or pay_tx.Platform
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
gateway = payment_registry.create(platform, creds)
out_refund_no = f'RF-{uuid.uuid4().hex[:16].upper()}'
amount = Decimal(str(amount))
if amount > pay_tx.TotalAmount:
raise PaymentError(f'退款金额超过原始交易金额: {amount} > {pay_tx.TotalAmount}')
req = RefundRequest(
OutTradeNO=out_trade_no,
OutRefundNO=out_refund_no,
TotalAmount=pay_tx.TotalAmount,
RefundAmount=amount,
Reason=reason,
Extra=extra or {},
)
rx = PaymentTransaction(
TransactionUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
Platform=platform,
TransactionType=PaymentTransaction.TX_REFUND,
OutTradeNO=out_trade_no,
OutRefundNO=out_refund_no,
TotalAmount=amount,
RawRequest={'refund_reason': reason, 'extra': extra or {}},
)
rx.save()
resp = gateway.Refund(req)
rx.RawResponse = resp.RawResponse
if resp.Success:
rx.MarkSuccess(channel_transaction_id=resp.RefundID)
else:
rx.MarkFailed()
return rx
def Transfer(self, platform, amount, payee_account='',
payee_name='', description='', transfer_type='wallet',
extra=None):
"""企业付款到零钱/银行卡
Args:
platform: 支付平台
amount: 转账金额
payee_account: 收款账户
payee_name: 收款人姓名
description: 转账备注
transfer_type: 转账类型 (wallet/bank)
extra: 额外参数
Returns:
PaymentTransaction 实例
"""
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
gateway = payment_registry.create(platform, creds)
out_transfer_no = f'TF-{uuid.uuid4().hex[:16].upper()}'
amount = Decimal(str(amount))
req = TransferRequest(
OutTransferNO=out_transfer_no,
Amount=amount,
PayeeAccount=payee_account,
PayeeName=payee_name,
Description=description,
TransferType=transfer_type,
Extra=extra or {},
)
tx = PaymentTransaction(
TransactionUUID=uuid.uuid4().bytes,
TenantUUID=self.TenantUUID,
Platform=platform,
TransactionType=PaymentTransaction.TX_TRANSFER,
OutTradeNO=out_transfer_no,
TotalAmount=amount,
Subject=description,
RawRequest={
'payee_account': payee_account,
'payee_name': payee_name,
'transfer_type': transfer_type,
'extra': extra or {},
},
)
tx.save()
try:
resp = gateway.Transfer(req)
except NotImplementedError as e:
tx.MarkFailed(raw_response={'error': str(e)})
raise PaymentError(str(e), transaction=tx)
tx.RawResponse = resp.RawResponse
if resp.Success:
tx.MarkSuccess(channel_transaction_id=resp.TransferID)
else:
tx.MarkFailed()
return tx
def HandleNotify(self, platform, raw_data, headers=None):
"""处理支付回调通知
Args:
platform: 支付平台
raw_data: 原始回调数据 (bytes)
headers: 请求头字典
Returns:
PaymentTransaction 实例或 None
"""
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
if config is None:
logger.error(f'租户未配置支付渠道: {platform}')
return None
creds = self._build_credentials(config)
gateway = payment_registry.create(platform, creds)
if not gateway.VerifyNotify(raw_data, headers or {}):
logger.warning(f'支付回调签名验证失败: {platform}')
return None
notify_data = gateway.ParseNotify(raw_data)
out_trade_no = notify_data.get('out_trade_no', '')
if not out_trade_no:
logger.warning('回调缺少 out_trade_no')
return None
try:
tx = PaymentTransaction.objects.get(OutTradeNO=out_trade_no)
except PaymentTransaction.DoesNotExist:
logger.warning(f'回调对应交易不存在: {out_trade_no}')
return None
trade_state = notify_data.get('trade_state', '')
transaction_id = notify_data.get('transaction_id', '')
if trade_state in ('SUCCESS', 'TRADE_SUCCESS', 'TRADE_FINISHED'):
tx.MarkSuccess(
channel_transaction_id=transaction_id,
notify_data=notify_data,
)
elif trade_state in ('CLOSED', 'REVOKED', 'TRADE_CLOSED'):
tx.MarkClosed()
elif trade_state in ('REFUND', 'TRADE_REFUND'):
tx.TransactionStatus = PaymentTransaction.TX_CLOSED
tx.NotifyData = notify_data
tx.UpdateTime = datetime.datetime.utcnow()
tx.save(update_fields=['TransactionStatus', 'NotifyData', 'UpdateTime'])
return tx
def GetGatewayInstance(self, platform):
"""直接获取网关实例——供子服务自定义操作
Args:
platform: 支付平台
Returns:
AbstractPaymentGateway 实例
Raises:
PaymentError: 租户未配置支付渠道
"""
config = PaymentGatewayConfig.FromTenantAndPlatform(self.TenantUUID, platform)
if config is None:
raise PaymentError(f'租户未配置支付渠道: {platform}')
creds = self._build_credentials(config)
return payment_registry.create(platform, creds)
def _build_credentials(self, config):
"""从数据库配置构建 PaymentCredentials"""
return PaymentCredentials(
AppID=config.AppID,
MchID=config.MchID,
APIKey=config.DecryptAPIKey(),
PrivateKey=config.DecryptPrivateKey(),
PublicKey=config.DecryptPublicKey(),
CertPath=config.CertPath,
CertKeyPath=config.CertKeyPath,
NotifyURL=config.NotifyURL,
ReturnURL=config.ReturnURL,
Extra=config.ConfigExtra,
)
+1 -2
View File
@@ -33,8 +33,7 @@ class GatewayConfigView(APIView):
tenant = _get_tenant(request)
if not tenant:
return Response(_err('缺少租户标识', 401), status=401)
from gvsdsdk.payment.models import PaymentGatewayConfig
configs = PaymentGatewayConfig.objects.filter(TenantUUID=tenant, ConfigStatus=1)
configs = PaymentManager(tenant).ListConfigs(status=1)
data = GatewayConfigSerializer(configs, many=True).data
return Response(_ok(data))
-297
View File
@@ -1,297 +0,0 @@
"""模块:统计引擎
提供原子增量、周期查询、排行榜生成的底层能力。
所有数据库操作使用 select_for_update + F() 保证并发安全。"""
from django.db import transaction, connection
from django.db.models import F, Sum, Count
from gvsds.apps.stats.models import StatMetric, StatRecord
def AtomicIncrement(tenant_uuid, period_type, period_key, metric_uuid,
dimension_values, delta):
"""原子增量更新——保证并发安全的单条统计记录累加
使用 SELECT ... FOR UPDATE + INSERT ... ON DUPLICATE KEY UPDATE
在 MySQL 上实现真正的原子 UPSERT。
Args:
tenant_uuid: 租户UUID (bytes)
period_type: 周期类型 ('daily', 'hourly', 'monthly')
period_key: 周期键 ('2026-06-11' 或 '2026-06-11T14')
metric_uuid: 指标UUID (bytes)
dimension_values: 维度值字典
delta: 增量 (Decimal 或 int)
Returns:
(StatRecord, created) 元组
"""
import json
from decimal import Decimal
delta = Decimal(str(delta))
dim_json = json.dumps(dimension_values, sort_keys=True, ensure_ascii=False)
with transaction.atomic():
existing = list(
StatRecord.objects.select_for_update().filter(
TenantUUID=tenant_uuid,
PeriodType=period_type,
PeriodKey=period_key,
Metric_id=metric_uuid,
DimensionValues=dimension_values,
)[:1]
)
if existing:
record = existing[0]
record.Value = F('Value') + delta
record.save(update_fields=['Value'])
record.refresh_from_db(fields=['Value', 'UpdateTime'])
return record, False
else:
record = StatRecord.objects.create(
TenantUUID=tenant_uuid,
PeriodType=period_type,
PeriodKey=period_key,
Metric_id=metric_uuid,
DimensionValues=dimension_values,
Value=delta,
)
return record, True
def AtomicIncrementMulti(tenant_uuid, period_type, period_key,
increments):
"""批量原子增量——一次事务中更新多条统计
Args:
tenant_uuid: 租户UUID
period_type: 周期类型
period_key: 周期键
increments: [(metric_uuid, dimension_values, delta), ...]
Returns:
List[StatRecord]
"""
results = []
with transaction.atomic():
for metric_uuid, dim_values, delta in increments:
record, _ = AtomicIncrement(
tenant_uuid, period_type, period_key,
metric_uuid, dim_values, delta,
)
results.append(record)
return results
def QueryPeriod(tenant_uuid, metric_uuid, period_type, period_key,
dimension_filter=None):
"""查询特定周期的统计数据
Args:
tenant_uuid: 租户UUID
metric_uuid: 指标UUID
period_type: 周期类型
period_key: 周期键 ('2026-06-11')
dimension_filter: 维度过滤 (JSON contains)
Returns:
QuerySet[StatRecord]
"""
qs = StatRecord.objects.filter(
TenantUUID=tenant_uuid,
Metric_id=metric_uuid,
PeriodType=period_type,
PeriodKey=period_key,
)
if dimension_filter:
qs = qs.filter(DimensionValues__contains=dimension_filter)
return qs
def QueryRange(tenant_uuid, metric_uuid, period_type,
start_key, end_key, dimension_filter=None):
"""查询时间范围内的统计数据
Args:
tenant_uuid: 租户UUID
metric_uuid: 指标UUID
period_type: 周期类型
start_key: 起始周期键
end_key: 截止周期键
dimension_filter: 维度过滤
Returns:
QuerySet[StatRecord]
"""
qs = StatRecord.objects.filter(
TenantUUID=tenant_uuid,
Metric_id=metric_uuid,
PeriodType=period_type,
PeriodKey__gte=start_key,
PeriodKey__lte=end_key,
)
if dimension_filter:
qs = qs.filter(DimensionValues__contains=dimension_filter)
return qs
def AggregateByDimension(tenant_uuid, metric_uuid, period_type,
start_key, end_key, dimension_key=None):
"""按维度聚合——对指定维度求和排名
Args:
tenant_uuid: 租户UUID
metric_uuid: 指标UUID
period_type: 周期类型
start_key: 起始周期键
end_key: 截止周期键
dimension_key: 聚合的维度键(如 'user_id', 'role')
Returns:
list[{dim_value, total}], 按 total 降序
"""
qs = StatRecord.objects.filter(
TenantUUID=tenant_uuid,
Metric_id=metric_uuid,
PeriodType=period_type,
PeriodKey__gte=start_key,
PeriodKey__lte=end_key,
)
results = qs.values('DimensionValues').annotate(
total=Sum('Value'),
).order_by('-total')
aggregated = {}
for row in results:
dims = row['DimensionValues']
if dimension_key:
key = dims.get(dimension_key, '_unknown')
aggregated[key] = aggregated.get(key, 0) + row['total']
else:
for k, v in dims.items():
aggregated[f'{k}={v}'] = aggregated.get(f'{k}={v}', 0) + row['total']
return sorted(
[{'dim_value': k, 'total': v} for k, v in aggregated.items()],
key=lambda x: x['total'],
reverse=True,
)
def GenerateRanking(tenant_uuid, metric_uuid, ranking_period, ranking_date,
dimension_filter=None, top_n=50, sort_order='desc'):
"""生成排行榜快照
从 StatRecord 中按维度聚合后排序生成 StatRanking 记录。
Args:
tenant_uuid: 租户UUID
metric_uuid: 指标UUID
ranking_period: 'daily' | 'monthly'
ranking_date: 排行日期
dimension_filter: 过滤条件(如 {'role': 'dashou'})
top_n: 取前N名
sort_order: 'desc' | 'asc'
Returns:
List[StatRanking]
"""
import datetime
from gvsds.apps.stats.models import StatRanking
period_type = ranking_period
if ranking_period == 'daily':
period_key = ranking_date.strftime('%Y-%m-%d') if hasattr(ranking_date, 'strftime') else str(ranking_date)
elif ranking_period == 'monthly':
period_key = ranking_date.strftime('%Y-%m') if hasattr(ranking_date, 'strftime') else str(ranking_date)
else:
period_key = str(ranking_date)
qs = StatRecord.objects.filter(
TenantUUID=tenant_uuid,
Metric_id=metric_uuid,
PeriodType=period_type,
PeriodKey=period_key,
)
if dimension_filter:
for k, v in dimension_filter.items():
qs = qs.filter(**{f'DimensionValues__{k}': v})
items = qs.values('DimensionValues').annotate(
total=Sum('Value'),
).order_by(
'-total' if sort_order == 'desc' else 'total',
)[:top_n]
rankings = []
target_dim_key = (dimension_filter or {}).get('_target_dim', 'user_id')
for rank, item in enumerate(items, 1):
dims = item['DimensionValues']
target_id = str(dims.get(target_dim_key, '_unknown'))
rk = StatRanking.objects.create(
TenantUUID=tenant_uuid,
RankingPeriod=ranking_period,
RankingDate=ranking_date,
Metric_id=metric_uuid,
DimensionFilter=dimension_filter or {},
TargetID=target_id,
Rank=rank,
RankValue=item['total'],
TargetName=dims.get('name', target_id),
TargetAvatar=dims.get('avatar', ''),
)
rankings.append(rk)
return rankings
def ResetPeriod(tenant_uuid, metric_uuid, period_type, period_key,
dimension_filter=None):
"""重置指定周期的统计数据(用于每日/每月清零)
Args:
tenant_uuid: 租户UUID
metric_uuid: 指标UUID
period_type: 周期类型
period_key: 周期键
dimension_filter: 可选,仅重置特定维度
Returns:
int 删除的记录数
"""
qs = StatRecord.objects.filter(
TenantUUID=tenant_uuid,
Metric_id=metric_uuid,
PeriodType=period_type,
PeriodKey=period_key,
)
if dimension_filter:
qs = qs.filter(DimensionValues__contains=dimension_filter)
count, _ = qs.delete()
return count
def PurgeOld(tenant_uuid, before_date, period_type='daily'):
"""清理过期统计数据
Args:
tenant_uuid: 租户UUID
before_date: 在此之前的数据将被删除
period_type: 周期类型
Returns:
int 删除的记录数
"""
before_key = before_date.strftime('%Y-%m-%d') if hasattr(before_date, 'strftime') else str(before_date)
qs = StatRecord.objects.filter(
TenantUUID=tenant_uuid,
PeriodType=period_type,
PeriodKey__lt=before_key,
)
count, _ = qs.delete()
return count
+1 -1
View File
@@ -21,7 +21,7 @@ from gvsds.apps.stats.models import (
StatRanking,
StatConfig,
)
from gvsds.apps.stats import engine as _eng
from gvsdsdk.stats.manager import StatsManager as _eng
logger = logging.getLogger(__name__)
+8 -8
View File
@@ -20,14 +20,14 @@ def ResetDailyStats(self):
建议 beat 调度:每日 00:00 执行。
"""
from gvsds.apps.stats.models import StatMetric
from gvsds.apps.stats.engine import ResetPeriod
from gvsdsdk.stats.manager import StatsManager
today = date.today().strftime('%Y-%m-%d')
total = 0
for metric in StatMetric.objects.filter(PeriodType='daily', Status=1):
for tenant_uuid in _GetActiveTenants():
try:
total += ResetPeriod(tenant_uuid, metric.MetricUUID, 'daily', today)
total += StatsManager.ResetPeriod(tenant_uuid, metric.MetricUUID, 'daily', today)
except Exception:
logger.warning('[StatsTasks] ResetDaily 失败: tenant=%s metric=%s', tenant_uuid, metric.MetricUUID, exc_info=True)
logger.info('[StatsTasks] ResetDailyStats 完成,重置 %d 条', total)
@@ -41,14 +41,14 @@ def ResetMonthlyStats(self):
建议 beat 调度:每月 1 日 00:00 执行。
"""
from gvsds.apps.stats.models import StatMetric
from gvsds.apps.stats.engine import ResetPeriod
from gvsdsdk.stats.manager import StatsManager
month_key = date.today().strftime('%Y-%m')
total = 0
for metric in StatMetric.objects.filter(PeriodType='monthly', Status=1):
for tenant_uuid in _GetActiveTenants():
try:
total += ResetPeriod(tenant_uuid, metric.MetricUUID, 'monthly', month_key)
total += StatsManager.ResetPeriod(tenant_uuid, metric.MetricUUID, 'monthly', month_key)
except Exception:
logger.warning('[StatsTasks] ResetMonthly 失败: tenant=%s metric=%s', tenant_uuid, metric.MetricUUID, exc_info=True)
logger.info('[StatsTasks] ResetMonthlyStats 完成,重置 %d 条', total)
@@ -61,13 +61,13 @@ def PurgeOldStats(self, retention_days=90):
建议 beat 调度:每日 03:00 执行(避开清零高峰)。
"""
from gvsds.apps.stats.engine import PurgeOld
from gvsdsdk.stats.manager import StatsManager
before = date.today() - timedelta(days=retention_days)
total = 0
for tenant_uuid in _GetActiveTenants():
try:
total += PurgeOld(tenant_uuid, before, period_type='daily')
total += StatsManager.PurgeOld(tenant_uuid, before, period_type='daily')
except Exception:
logger.warning('[StatsTasks] PurgeOld 失败: tenant=%s', tenant_uuid, exc_info=True)
logger.info('[StatsTasks] PurgeOldStats 完成,清理 %d 条(保留 %d 天)', total, retention_days)
@@ -81,14 +81,14 @@ def BuildDailyRankings(self):
建议 beat 调度:每日 23:50 执行(当日数据基本定型)。
"""
from gvsds.apps.stats.models import StatMetric
from gvsds.apps.stats.engine import GenerateRanking
from gvsdsdk.stats.manager import StatsManager
today = date.today()
total = 0
for metric in StatMetric.objects.filter(EnableRanking=True, Status=1):
for tenant_uuid in _GetActiveTenants():
try:
GenerateRanking(tenant_uuid, metric.MetricUUID, 'daily', today)
StatsManager.GenerateRanking(tenant_uuid, metric.MetricUUID, 'daily', today)
total += 1
except Exception:
logger.warning('[StatsTasks] BuildRanking 失败: tenant=%s metric=%s', tenant_uuid, metric.MetricUUID, exc_info=True)
+6 -4
View File
@@ -43,7 +43,8 @@ class MetricConfigView(APIView):
tenant = _get_tenant(request)
if not tenant:
return Response(_err('缺少租户标识', 401), status=401)
metrics = StatMetric.objects.filter(TenantUUID=tenant, MetricStatus=1)
from gvsdsdk.stats.manager import StatsManager
metrics = StatsManager.ListMetrics(tenant, status=1)
return Response(_ok(StatMetricSerializer(metrics, many=True).data))
def post(self, request):
@@ -70,7 +71,8 @@ class DimensionView(APIView):
tenant = _get_tenant(request)
if not tenant:
return Response(_err('缺少租户标识', 401), status=401)
dims = StatDimension.objects.filter(TenantUUID=tenant, DimensionStatus=1)
from gvsdsdk.stats.manager import StatsManager
dims = StatsManager.ListDimensions(tenant, status=1)
return Response(_ok(StatDimensionSerializer(dims, many=True).data))
def post(self, request):
@@ -264,8 +266,8 @@ class StatConfigView(APIView):
tenant = _get_tenant(request)
if not tenant:
return Response(_err('缺少租户标识', 401), status=401)
from gvsds.apps.stats.models import StatConfig
configs = StatConfig.objects.filter(TenantUUID=tenant, ConfigStatus=1)
from gvsdsdk.stats.manager import StatsManager
configs = StatsManager.ListConfigs(tenant, status=1)
return Response(_ok(StatConfigSerializer(configs, many=True).data))
def post(self, request):
+2 -3
View File
@@ -277,8 +277,7 @@ class GetUserRolesView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
from gvsds.apps.gerp.models import Company
from gvsdsdk.auth.managers import RoleManager
from gvsdsdk.auth.managers import CompanyManager, RoleManager
# 角色不绑定公司/部门,通过 UserRole.CompanyUUID 获取关联公司
user_roles = RoleManager.GetUserRoleDetails(request.user.UserUUID)
current_role_uuid = request.COOKIES.get('CurrentRoleUUID', '')
@@ -293,7 +292,7 @@ class GetUserRolesView(APIView):
company_uuid_str = ''
if ur.CompanyUUID:
# 两步查询:UserRole.CompanyUUID 是 UUIDField,需先查 Company 表
company = Company.objects.filter(CompanyUUID=ur.CompanyUUID).first()
company = CompanyManager.GetCompanyByUUID(ur.CompanyUUID)
if company:
company_name = company.CompanyName
company_short = company.CompanyShort
+10
View File
@@ -301,6 +301,16 @@ _LAZY_SYMBOLS = {
'PositionTemplatePermission': ('models', 'PositionTemplatePermission'),
'Menu': ('models', 'Menu'),
'WorkOrder': ('models', 'WorkOrder'),
'AuditLog': ('models', 'AuditLog'),
'UserRoleDepartment': ('models', 'UserRoleDepartment'),
'UserRoleWorkgroup': ('models', 'UserRoleWorkgroup'),
'Message': ('models', 'Message'),
'UserIDCard': ('models', 'UserIDCard'),
'PersonalPage': ('models', 'PersonalPage'),
'UserSettings': ('models', 'UserSettings'),
'Tenant': ('models', 'Tenant'),
'Company': ('models', 'Company'),
'Department': ('models', 'Department'),
'FixedBinaryField': ('models', 'FixedBinaryField'),
'QModel': ('model_base', 'QModel'),
'QModelBase': ('model_base', 'QModelBase'),
+258 -1
View File
@@ -2,10 +2,13 @@ import uuid
from django.db.models import Q
from django.utils import timezone as _timezone
from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant, Company, Department, UserRoleDepartment, Message, Menu, WorkOrder
from gvsdsdk.models import Role, UserRole, UserRoleDepartment, UserRoleWorkgroup, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant, Company, Department, Message, Menu, WorkOrder, AuditLog
from gvsdsdk.auth.crypto import HashPassword, VerifyPassword
from gvsdsdk.model_utils import _GetUserModel, UUIDObj
# 系统公司 UUID(00000000-0000-0000-0000-000000000001),全局唯一
_SYSTEM_COMPANY_UUID = uuid.UUID('00000000-0000-0000-0000-000000000001').bytes
# NOTE: 类型注解暂缓——RoleManager/PositionManager 静态方法较多,全量补充注解工作量较大且易引入回归,后续单独迭代处理。
@@ -176,6 +179,68 @@ class RoleManager:
qs = qs.filter(CompanyUUID=CompanyUUID)
return list(qs)
@staticmethod
def GetUserRoleRelation(UserUUID, RoleUUID, CompanyUUID=None):
"""按 (用户, 角色[, 公司]) 查询 UserRole 关联(不存在返回 None)"""
qs = UserRole.objects.filter(
UserUUID=UUIDObj(UserUUID).bytes,
RoleUUID=UUIDObj(RoleUUID).bytes,
)
if CompanyUUID is not None:
try:
qs = qs.filter(CompanyUUID=UUIDObj(CompanyUUID).bytes)
except Exception:
qs = qs.filter(CompanyUUID=CompanyUUID)
return qs.first()
@staticmethod
def GetUserRoleDepartment(UserRoleUUID):
"""按 UserRoleUUID 查询部门归属(不存在返回 None)"""
return UserRoleDepartment.objects.filter(
UserRoleUUID=UUIDObj(UserRoleUUID).bytes,
).first()
@staticmethod
def GetUserRoleWorkgroup(UserRoleUUID):
"""按 UserRoleUUID 查询工作组归属(不存在返回 None)"""
return UserRoleWorkgroup.objects.filter(
UserRoleUUID=UUIDObj(UserRoleUUID).bytes,
).first()
@staticmethod
def GetScopeMembers(ScopeType, UUID):
"""获取作用域成员的用户 UUID 列表(去重)
Args:
ScopeType: 'dept_manager' | 'workgroup_lead'
UUID: 部门或工作组 UUID(bytes/str/UUIDObj 均可)
Returns:
list[bytes]:成员用户 UUID;无成员返回 [];未知类型或异常返回 None
"""
try:
uuid_bytes = UUIDObj(UUID).bytes
except Exception:
return None
try:
if ScopeType == 'dept_manager':
ur_uuids = list(UserRoleDepartment.objects.filter(
DeptUUID=uuid_bytes,
).values_list('UserRoleUUID', flat=True))
elif ScopeType == 'workgroup_lead':
ur_uuids = list(UserRoleWorkgroup.objects.filter(
WorkgroupUUID=uuid_bytes,
).values_list('UserRoleUUID', flat=True))
else:
return None
if not ur_uuids:
return []
# UserRoleUUID → UserUUID(关联表存 UserRoleUUID,用户 UUID 从 UserRole 表取)
qs = UserRole.objects.filter(UserRoleUUID__in=ur_uuids)
return list(set(qs.values_list('UserUUID', flat=True)))
except Exception:
return None
@staticmethod
def HasRolePermission(RoleUUID, PermUUID, CompanyUUID=None):
"""检查角色是否已拥有指定权限(同公司下防重复绑定)"""
@@ -456,6 +521,103 @@ class RoleManager:
return None, False
return False, True
@staticmethod
def EnsureDefaultRoleForUser(UserUUID, CompanyUUID, AdminUserUUID=None):
"""为新用户关联全局默认角色("默认角色"在系统租户下唯一)
租户解析顺序:系统公司所属租户 → 操作管理员角色租户 → 系统首个租户。
Args:
UserUUID: 新用户 UUID(bytes/str/UUIDObj 均可)
CompanyUUID: 公司 UUID(通常为系统公司)
AdminUserUUID: 可选,操作管理员 UUID(租户回退用)
Returns:
(UserRole 实例 or None, 错误消息 or None)
"""
company_bytes = UUIDObj(CompanyUUID).bytes
# 角色租户优先取自系统公司所属租户
tenant_uuid = None
company = Company.objects.filter(CompanyUUID=company_bytes).first()
if company is not None:
tenant_uuid = company.TenantUUID
# 回退:当前操作管理员的首个角色租户
if not tenant_uuid and AdminUserUUID:
try:
admin_ur = UserRole.objects.filter(
UserUUID=UUIDObj(AdminUserUUID).bytes,
).first()
if admin_ur and admin_ur.RoleUUID:
role = Role.objects.filter(RoleUUID=admin_ur.RoleUUID).first()
tenant_uuid = role.TenantUUID if role else None
except Exception:
tenant_uuid = None
# 回退:系统首个租户
if not tenant_uuid:
tenant = Tenant.objects.first()
tenant_uuid = tenant.TenantUUID if tenant else None
if not tenant_uuid:
return None, '无法确定租户'
# 查找或创建全局唯一的"默认角色"
default_role = Role.objects.filter(
TenantUUID=tenant_uuid,
RoleName='默认角色',
).first()
if not default_role:
default_role = Role.objects.create(
RoleUUID=uuid.uuid4().bytes,
TenantUUID=tenant_uuid,
RoleName='默认角色',
RoleStatus=1,
AssignScope=0,
RoleDesc='系统默认角色,所有新用户自动关联',
CreateTime=_timezone.now(),
)
# 避免重复关联
if RoleManager.HasRole(UserUUID, default_role.RoleUUID, CompanyUUID):
return None, None
ur = UserRole.objects.create(
UserRoleUUID=uuid.uuid4().bytes,
UserUUID=UUIDObj(UserUUID).bytes,
RoleUUID=default_role.RoleUUID,
CompanyUUID=company_bytes,
)
return ur, None
@staticmethod
def EnsureTenantAdminRole(TenantUUID):
"""为租户(集团)创建/复用"集团超级管理员"角色并分配 admin:tenant 权限
Args:
TenantUUID: 租户 UUID(bytes/str/UUIDObj 均可)
Returns:
Role 实例(已创建或已存在)
"""
tenant_bytes = UUIDObj(TenantUUID).bytes
role = Role.objects.filter(
TenantUUID=tenant_bytes,
RoleName='集团超级管理员',
).first()
if role:
return role
role = RoleManager.CreateRole(
'集团超级管理员', tenant_bytes,
AssignScope=0,
RoleDesc='集团超级管理员,管理本租户下所有事务',
)
# 分配 admin:tenant 权限(租户内管理员:关闭公司隔离,保留租户隔离)
admin_perm = RoleManager.GetPermissionByCode('admin:tenant')
if admin_perm:
RoleManager.AssignPermission(
role.RoleUUID, admin_perm.PermUUID, _SYSTEM_COMPANY_UUID,
)
return role
class PositionManager:
"""职务管理器——提供人事职务标签创建、岗位模板管理"""
@@ -1022,6 +1184,56 @@ class AuthManager:
class AdminUserManager:
"""管理端用户管理器——用户搜索、密码重置、账户状态与邮箱管理"""
@staticmethod
def DeleteUser(UserUUID):
"""删除用户并级联清理关联数据(UserRole / 专属默认角色 / UserAvatar / User2FA / UserPending2FA)
与 gvsds FormService.delete_record 用户分支零行为差异:
仅当角色名为 "{用户名}的默认角色" 且无其他用户持有时才删除角色本身。
Args:
UserUUID: 用户 UUID(bytes/str/UUIDObj 均可)
Returns:
(成功?, 错误消息 or None)
"""
User = _GetUserModel()
try:
user_bytes = UUIDObj(UserUUID).bytes
except Exception:
return False, '无效的 UserUUID'
user = User.objects.filter(UserUUID=user_bytes).first()
if user is None:
return False, '用户不存在'
from django.db import transaction
with transaction.atomic():
user_name = getattr(user, 'UserName', '') or ''
# 查找该用户的所有 UserRole 关联
user_roles = list(UserRole.objects.filter(UserUUID=user_bytes))
# 识别默认角色:名称匹配 "{用户名}的默认角色" 且仅此用户一人
default_role_ids = []
for ur in user_roles:
role = Role.objects.filter(RoleUUID=ur.RoleUUID).first()
if role and getattr(role, 'RoleName', '') == f'{user_name}的默认角色':
other_count = UserRole.objects.filter(
RoleUUID=role.RoleUUID,
).exclude(UserUUID=user_bytes).count()
if other_count == 0:
default_role_ids.append(role.RoleUUID)
# 删除所有 UserRole 关联
UserRole.objects.filter(UserUUID=user_bytes).delete()
# 删除默认角色(RolePermission 会被 cascade 删除)
if default_role_ids:
Role.objects.filter(RoleUUID__in=default_role_ids).delete()
# 清理 UserAvatar / User2FA / UserPending2FA(这些表用 UUIDField 而非 ForeignKey,不会自动级联)
UserAvatar.objects.filter(UserUUID=user_bytes).delete()
User2FA.objects.filter(UserUUID=user_bytes).delete()
UserPending2FA.objects.filter(UserUUID=user_bytes).delete()
# 删除用户本身
user.delete()
return True, None
@staticmethod
def SearchUsers(Keyword='', Offset=0, PageSize=20):
"""搜索用户(按用户名模糊匹配,分页)
@@ -1771,3 +1983,48 @@ class WorkOrderManager:
wo.save()
return WorkOrderManager._ToFlat(wo)
class AuditManager:
"""审计日志管理器——敏感表写操作快照记录(失败静默,不阻断业务)"""
@staticmethod
def Log(TenantUUID=None, CompanyUUID=None, UserUUID=None, TableName='',
Action='', TargetUUID=None, IPAddress='', OldData=None, NewData=None):
"""写入一条审计日志
Args:
TenantUUID/CompanyUUID/UserUUID: 归属与操作人 UUID(bytes/str/UUIDObj 均可,无效自动置空)
TableName: 表名
Action: 操作类型(add/modify/delete/xxx_status)
TargetUUID: 目标记录 UUID
IPAddress: 操作 IP
OldData/NewData: 变更前后数据快照
Returns:
True
"""
def _ToBytes(value):
if value is None:
return None
try:
return UUIDObj(value).bytes
except Exception:
return None
try:
AuditLog.objects.create(
AuditLogUUID=uuid.uuid4().bytes,
TenantUUID=_ToBytes(TenantUUID),
CompanyUUID=_ToBytes(CompanyUUID),
UserUUID=_ToBytes(UserUUID),
TableName=TableName or '',
Action=Action or '',
TargetUUID=_ToBytes(TargetUUID),
IPAddress=IPAddress or '',
OldData=OldData,
NewData=NewData,
)
except Exception:
pass
return True
+49
View File
@@ -537,6 +537,26 @@ class UserRoleDepartment(QModel):
verbose_name_plural = '用户-角色部门关联'
# === 用户-角色工作组关联表 ===
class UserRoleWorkgroup(QModel):
"""用户-角色对 与 工作组 的多对多关联表(映射 SAAS 主库 UserRoleWorkgroup 表)"""
UserRoleWorkgroupUUID = UUIDField(primary_key=True, db_column='UserRoleWorkgroupUUID', verbose_name='关联UUID')
UserRoleUUID = UUIDField(db_column='UserRoleUUID', verbose_name='用户-角色关联UUID')
WorkgroupUUID = UUIDField(db_column='WorkgroupUUID', verbose_name='工作组UUID')
CompanyUUID = UUIDField(db_column='CompanyUUID', verbose_name='公司UUID')
RoleType = models.SmallIntegerField(default=0, db_column='RoleType', verbose_name='角色类型')
CreateTime = models.DateTimeField(default=_UtcNow, db_column='CreateTime', verbose_name='创建时间')
class Meta:
managed = False
app_label = 'gvsdsdk'
db_table = 'UserRoleWorkgroup'
verbose_name = '用户-角色工作组关联'
verbose_name_plural = '用户-角色工作组关联'
# === 消息表 ===
class Message(QModel):
@@ -1050,3 +1070,32 @@ class WorkOrder(QModel):
def __str__(self):
return self.Title
# === 操作审计日志表 ===
class AuditLog(QModel):
"""操作审计日志表——记录敏感表写操作快照(映射 SAAS 主库 AuditLog 表)"""
AuditLogUUID = UUIDField(primary_key=True, db_column='AuditLogUUID', verbose_name='日志UUID')
TenantUUID = UUIDField(null=True, blank=True, db_column='TenantUUID', verbose_name='租户UUID')
CompanyUUID = UUIDField(null=True, blank=True, db_column='CompanyUUID', verbose_name='公司UUID')
UserUUID = UUIDField(null=True, blank=True, db_column='UserUUID', verbose_name='操作人UUID')
TableName = models.CharField(max_length=64, db_index=True, db_column='TableName', verbose_name='表名')
Action = models.CharField(max_length=16, db_column='Action', verbose_name='操作类型')
TargetUUID = UUIDField(null=True, blank=True, db_column='TargetUUID', verbose_name='目标记录UUID')
IPAddress = models.CharField(max_length=64, default='', db_column='IPAddress', verbose_name='IP地址')
OldData = models.JSONField(null=True, blank=True, db_column='OldData', verbose_name='变更前数据')
NewData = models.JSONField(null=True, blank=True, db_column='NewData', verbose_name='变更后数据')
CreateTime = models.DateTimeField(default=_UtcNow, db_index=True, db_column='CreateTime', verbose_name='创建时间')
class Meta:
managed = False
app_label = 'gvsdsdk'
db_table = 'AuditLog'
ordering = ['-CreateTime']
verbose_name = '操作审计日志'
verbose_name_plural = '操作审计日志'
def __str__(self):
return f'{self.TableName}/{self.Action}'