From cd9ebcddd181c6d3f227cdd9ac08d319c4a2a740 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 7 Aug 2026 18:10:45 +0800 Subject: [PATCH] =?UTF-8?q?"refactor(gerp):=20P0=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E5=A7=94=E6=89=98=20gvsdsdk=20=E5=B9=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit role_scope 数据权限底座、form_service 默认角色/租户管理员/删用户/审计 改经 SDK;ugm/payment/stats 裸查询委托;删除 commission/payment/stats 冗余 manager/engine;修复 user_detail 潜伏 ImportError。" --- gvsds/apps/commission/manager.py | 519 --------------------------- gvsds/apps/gerp/form_service.py | 136 +------ gvsds/apps/gerp/role_scope.py | 50 +-- gvsds/apps/gerp/views/msync_admin.py | 21 ++ gvsds/apps/gerp/views/order_flow.py | 10 +- gvsds/apps/payment/manager.py | 455 ----------------------- gvsds/apps/payment/views.py | 3 +- gvsds/apps/stats/engine.py | 297 --------------- gvsds/apps/stats/manager.py | 2 +- gvsds/apps/stats/tasks.py | 16 +- gvsds/apps/stats/views.py | 10 +- gvsds/apps/ugm/views.py | 5 +- gvsdsdk/__init__.py | 10 + gvsdsdk/auth/managers.py | 259 ++++++++++++- gvsdsdk/models.py | 49 +++ 15 files changed, 393 insertions(+), 1449 deletions(-) delete mode 100644 gvsds/apps/commission/manager.py delete mode 100644 gvsds/apps/payment/manager.py delete mode 100644 gvsds/apps/stats/engine.py diff --git a/gvsds/apps/commission/manager.py b/gvsds/apps/commission/manager.py deleted file mode 100644 index 35140f24..00000000 --- a/gvsds/apps/commission/manager.py +++ /dev/null @@ -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, - ) \ No newline at end of file diff --git a/gvsds/apps/gerp/form_service.py b/gvsds/apps/gerp/form_service.py index 736fbdba..2c3c4efe 100644 --- a/gvsds/apps/gerp/form_service.py +++ b/gvsds/apps/gerp/form_service.py @@ -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, diff --git a/gvsds/apps/gerp/role_scope.py b/gvsds/apps/gerp/role_scope.py index f9b1784d..504fbc93 100644 --- a/gvsds/apps/gerp/role_scope.py +++ b/gvsds/apps/gerp/role_scope.py @@ -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 diff --git a/gvsds/apps/gerp/views/msync_admin.py b/gvsds/apps/gerp/views/msync_admin.py index 3824afb7..096d11e4 100644 --- a/gvsds/apps/gerp/views/msync_admin.py +++ b/gvsds/apps/gerp/views/msync_admin.py @@ -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] diff --git a/gvsds/apps/gerp/views/order_flow.py b/gvsds/apps/gerp/views/order_flow.py index 19b1d191..fac9b0c2 100644 --- a/gvsds/apps/gerp/views/order_flow.py +++ b/gvsds/apps/gerp/views/order_flow.py @@ -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, diff --git a/gvsds/apps/payment/manager.py b/gvsds/apps/payment/manager.py deleted file mode 100644 index 41189fc4..00000000 --- a/gvsds/apps/payment/manager.py +++ /dev/null @@ -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, - ) \ No newline at end of file diff --git a/gvsds/apps/payment/views.py b/gvsds/apps/payment/views.py index ed47e3dd..63a1910f 100644 --- a/gvsds/apps/payment/views.py +++ b/gvsds/apps/payment/views.py @@ -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)) diff --git a/gvsds/apps/stats/engine.py b/gvsds/apps/stats/engine.py deleted file mode 100644 index d81e5640..00000000 --- a/gvsds/apps/stats/engine.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/gvsds/apps/stats/manager.py b/gvsds/apps/stats/manager.py index 2ebe3b10..2c2aa1f8 100644 --- a/gvsds/apps/stats/manager.py +++ b/gvsds/apps/stats/manager.py @@ -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__) diff --git a/gvsds/apps/stats/tasks.py b/gvsds/apps/stats/tasks.py index 80140e72..a4163939 100644 --- a/gvsds/apps/stats/tasks.py +++ b/gvsds/apps/stats/tasks.py @@ -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) diff --git a/gvsds/apps/stats/views.py b/gvsds/apps/stats/views.py index a7c7fe45..4c990e2f 100644 --- a/gvsds/apps/stats/views.py +++ b/gvsds/apps/stats/views.py @@ -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): diff --git a/gvsds/apps/ugm/views.py b/gvsds/apps/ugm/views.py index af64c98a..58b1c678 100644 --- a/gvsds/apps/ugm/views.py +++ b/gvsds/apps/ugm/views.py @@ -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 diff --git a/gvsdsdk/__init__.py b/gvsdsdk/__init__.py index e05e393c..c6ef0307 100644 --- a/gvsdsdk/__init__.py +++ b/gvsdsdk/__init__.py @@ -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'), diff --git a/gvsdsdk/auth/managers.py b/gvsdsdk/auth/managers.py index 66bc3644..a051cb6a 100644 --- a/gvsdsdk/auth/managers.py +++ b/gvsdsdk/auth/managers.py @@ -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 + diff --git a/gvsdsdk/models.py b/gvsdsdk/models.py index a08d34c1..99a60bb2 100644 --- a/gvsdsdk/models.py +++ b/gvsdsdk/models.py @@ -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}'