Files
TermiNexus 9be878b84a feat(sdk): 通用查询与工具层下沉,新增 App 体系镜像模型
P1 通用查询:auth/managers.py 新增 20 个查询方法(Role/Company/Department/Position/Menu/Tenant 六个 Manager);auth/ram.py 新增 RAMManager.ListPolicyResourceUUIDs;stats/manager.py 新增 StatsManager.ListAutoResetConfigs。

P2 App 体系:models.py 新增 App/TenantApp/CompanyApp/UserApp/AppConfig 五个 managed=False 镜像模型,字段与业务侧逐一对齐。

P3 工具下沉:新增 config.py 配置注入层与 money/json_encoder/celery_utils/exception_handler/redis_lock/ip_filter/storage/datetime_utils/string_utils 九个工具模块;model_utils.UUIDObj 增加 PascalCase 别名 Str/Bytes/UUID 与模块级转换函数。

同时移除 commission/stats/CashBook 镜像模型的重复索引声明,消除 Django models.E030。
2026-09-13 21:57:45 +08:00

606 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模块:统计引擎(gvsdsdk 副本)
提供原子增量、周期查询、排行榜生成的底层能力。
所有数据库操作使用 select_for_update + F() 保证并发安全。
与 gvsds/apps/stats/engine.py 零行为差异复制——
8 个模块级函数包装为 StatsManager 的静态方法。"""
from django.db import transaction, connection
from django.db.models import F, Sum, Count
from gvsdsdk.stats.models import StatMetric, StatRecord, StatDimension, StatConfig
class StatsManager:
"""统计能力管理器——子服务器直接调用统计能力的统一入口
用法:
record, created = StatsManager.AtomicIncrement(
tenant_uuid=tenant_uuid,
period_type='daily',
period_key='2026-06-11',
metric_uuid=metric_uuid,
dimension_values={'role': 'dashou', 'user_id': '123'},
delta=1,
)
"""
@staticmethod
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
@staticmethod
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, _ = StatsManager.AtomicIncrement(
tenant_uuid, period_type, period_key,
metric_uuid, dim_values, delta,
)
results.append(record)
return results
@staticmethod
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
@staticmethod
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
@staticmethod
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,
)
@staticmethod
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 gvsdsdk.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
@staticmethod
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
@staticmethod
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
@staticmethod
def ListMetrics(tenant_uuid, status=None):
"""列出租户下的统计指标(可按状态过滤,保持数据库顺序)"""
qs = StatMetric.objects.filter(TenantUUID=tenant_uuid)
if status is not None:
qs = qs.filter(MetricStatus=status)
return qs
@staticmethod
def ListDimensions(tenant_uuid, status=None):
"""列出租户下的统计维度(可按状态过滤,保持数据库顺序)"""
qs = StatDimension.objects.filter(TenantUUID=tenant_uuid)
if status is not None:
qs = qs.filter(DimensionStatus=status)
return qs
@staticmethod
def ListConfigs(tenant_uuid, status=None):
"""列出租户下的统计配置(可按状态过滤,保持数据库顺序)"""
qs = StatConfig.objects.filter(TenantUUID=tenant_uuid)
if status is not None:
qs = qs.filter(ConfigStatus=status)
return qs
@staticmethod
def ListAutoResetConfigs(tenant_uuid, status=1):
"""列出租户下启用"自动重置"的统计配置(供周期清零任务使用)
Args:
tenant_uuid: 租户 UUID
status: 配置状态过滤(默认 1=启用;传 None 则不过滤)
Returns:
QuerySet[StatConfig]
"""
qs = StatConfig.objects.filter(TenantUUID=tenant_uuid, AutoReset=True)
if status is not None:
qs = qs.filter(ConfigStatus=status)
return qs
# ---------- 编排层(SAAS 主服务器视图 / Celery 任务入口) ----------
@staticmethod
def DefineMetric(tenant_uuid, metric_code, metric_name, metric_type='count',
unit='次', default_value=None):
"""定义统计指标(同编码已存在则更新)
Returns:
StatMetric 实例
"""
metric, _created = StatMetric.objects.update_or_create(
TenantUUID=tenant_uuid,
MetricCode=metric_code,
defaults={
'MetricName': metric_name,
'MetricType': metric_type,
'Unit': unit,
'DefaultValue': default_value or 0,
'MetricStatus': 1,
},
)
return metric
@staticmethod
def DefineDimension(tenant_uuid, dimension_code, dimension_name):
"""定义统计维度(同编码已存在则更新)
Returns:
StatDimension 实例
"""
dim, _created = StatDimension.objects.update_or_create(
TenantUUID=tenant_uuid,
DimensionCode=dimension_code,
defaults={
'DimensionName': dimension_name,
'DimensionStatus': 1,
},
)
return dim
@staticmethod
def ConfigureAutoReset(tenant_uuid, metric_uuid, auto_reset=True,
reset_cron='0 0 * * *', enable_ranking=False,
ranking_top_n=50, retention_days=365):
"""配置指标的自动重置和排行榜
Returns:
StatConfig 实例
"""
config, _created = StatConfig.objects.update_or_create(
TenantUUID=tenant_uuid,
Metric_id=metric_uuid,
defaults={
'AutoReset': auto_reset,
'ResetCron': reset_cron,
'EnableRanking': enable_ranking,
'RankingTopN': ranking_top_n,
'RetentionDays': retention_days,
'ConfigStatus': 1,
},
)
return config
@staticmethod
def GenerateDailyRanking(tenant_uuid, metric_uuid, ranking_date=None,
dimension_filter=None, top_n=50):
"""生成日排行榜快照(先清理同日旧快照再生成)
Returns:
List[StatRanking]
"""
import datetime
from gvsdsdk.stats.models import StatRanking
if ranking_date is None:
ranking_date = datetime.date.today()
StatRanking.objects.filter(
TenantUUID=tenant_uuid,
RankingPeriod='daily',
RankingDate=ranking_date,
Metric_id=metric_uuid,
).delete()
return StatsManager.GenerateRanking(
tenant_uuid=tenant_uuid,
metric_uuid=metric_uuid,
ranking_period='daily',
ranking_date=ranking_date,
dimension_filter=dimension_filter,
top_n=top_n,
)
@staticmethod
def GenerateMonthlyRanking(tenant_uuid, metric_uuid, ranking_date=None,
dimension_filter=None, top_n=50):
"""生成月排行榜快照(先清理同日旧快照再生成)
Returns:
List[StatRanking]
"""
import datetime
from gvsdsdk.stats.models import StatRanking
if ranking_date is None:
today = datetime.date.today()
ranking_date = today.replace(day=1)
StatRanking.objects.filter(
TenantUUID=tenant_uuid,
RankingPeriod='monthly',
RankingDate=ranking_date,
Metric_id=metric_uuid,
).delete()
return StatsManager.GenerateRanking(
tenant_uuid=tenant_uuid,
metric_uuid=metric_uuid,
ranking_period='monthly',
ranking_date=ranking_date,
dimension_filter=dimension_filter,
top_n=top_n,
)
@staticmethod
def QueryRanking(tenant_uuid, ranking_period, ranking_date=None,
metric_uuid=None, top_n=None):
"""查询排行榜快照
Args:
ranking_period: 'daily' | 'monthly'
ranking_date: 排行日期
metric_uuid: 指标UUID(None=全部)
top_n: 取前N名(按指标分别截取)
Returns:
QuerySet[StatRanking]
"""
from gvsdsdk.stats.models import StatRanking
qs = StatRanking.objects.filter(
TenantUUID=tenant_uuid,
RankingPeriod=ranking_period,
)
if ranking_date:
qs = qs.filter(RankingDate=ranking_date)
if metric_uuid:
qs = qs.filter(Metric_id=metric_uuid)
qs = qs.order_by('Metric', 'Rank')
if top_n:
metric_ids = qs.values_list('Metric_id', flat=True).distinct()
filtered = StatRanking.objects.none()
for mid in metric_ids:
filtered |= qs.filter(Metric_id=mid)[:top_n]
return filtered
return qs
@staticmethod
def DailyReset(tenant_uuid, metric_uuid, target_date=None):
"""手动执行日清零(默认昨天)
Returns:
int 删除记录数
"""
import datetime
if target_date is None:
target_date = datetime.date.today() - datetime.timedelta(days=1)
period_key = target_date.strftime('%Y-%m-%d')
return StatsManager.ResetPeriod(
tenant_uuid, metric_uuid, 'daily', period_key,
)
@staticmethod
def MonthlyReset(tenant_uuid, metric_uuid, target_date=None):
"""手动执行月清零(默认上月)
Returns:
int 删除记录数
"""
import datetime
if target_date is None:
today = datetime.date.today()
last_month = today.month - 1 if today.month > 1 else 12
last_year = today.year if today.month > 1 else today.year - 1
target_date = datetime.date(last_year, last_month, 1)
period_key = target_date.strftime('%Y-%m')
return StatsManager.ResetPeriod(
tenant_uuid, metric_uuid, 'monthly', period_key,
)
@staticmethod
def RunScheduledResets(tenant_uuid, target_date=None):
"""执行所有配置了自动重置的指标的周期清零(默认清零昨天)
由 Celery Beat 定时任务调用。
Returns:
int 累计删除记录数
"""
configs = StatConfig.objects.filter(
TenantUUID=tenant_uuid,
AutoReset=True,
ConfigStatus=1,
)
total_deleted = 0
for config in configs:
total_deleted += StatsManager.DailyReset(
tenant_uuid=tenant_uuid,
metric_uuid=config.Metric_id,
target_date=target_date,
)
return total_deleted
@staticmethod
def BuildRankings(tenant_uuid, target_date=None):
"""执行所有配置了排行榜的指标的排名生成(默认今天)
由 Celery Beat 定时任务调用。
Returns:
int 累计生成的排行数
"""
configs = StatConfig.objects.filter(
TenantUUID=tenant_uuid,
EnableRanking=True,
ConfigStatus=1,
)
total_rankings = 0
for config in configs:
rankings = StatsManager.GenerateDailyRanking(
tenant_uuid=tenant_uuid,
metric_uuid=config.Metric_id,
ranking_date=target_date,
dimension_filter=config.RankingDimensionFilter or None,
top_n=config.RankingTopN,
)
total_rankings += len(rankings)
return total_rankings