"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:
1 parent
e2015164ea
commit
cd9ebcddd1
15 files changed
+393
-1449
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
Reference in new issue
Block a user