From d2de2301a401dabbcc734efbad4a17d0f24a5697 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Fri, 7 Aug 2026 04:08:10 +0800 Subject: [PATCH] "feat: add RoleManager tree/search, OrderManager.CreateGeneric, StatsManager - RoleManager: GetPermissionsTree / MatchUserRoleIds - OrderManager.CreateGeneric: auto order-no, forced PENDING, transactional - gvsdsdk.stats: StatsManager (8 methods) + 5 model mappings (app_label=gvsdsdk)" --- __init__.py | 14 + __pycache__/__init__.cpython-312.pyc | Bin 12235 -> 12551 bytes auth/__pycache__/managers.cpython-312.pyc | Bin 36694 -> 40346 bytes auth/managers.py | 78 +++++ order/__pycache__/manager.cpython-312.pyc | Bin 18362 -> 22328 bytes order/manager.py | 94 ++++++ stats/__init__.py | 24 ++ stats/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 984 bytes stats/__pycache__/manager.cpython-312.pyc | Bin 0 -> 11841 bytes stats/__pycache__/models.cpython-312.pyc | Bin 0 -> 12727 bytes stats/manager.py | 315 +++++++++++++++++++++ stats/models.py | 235 +++++++++++++++ 12 files changed, 760 insertions(+) create mode 100644 stats/__init__.py create mode 100644 stats/__pycache__/__init__.cpython-312.pyc create mode 100644 stats/__pycache__/manager.cpython-312.pyc create mode 100644 stats/__pycache__/models.cpython-312.pyc create mode 100644 stats/manager.py create mode 100644 stats/models.py diff --git a/__init__.py b/__init__.py index c92f0dc..0b3c8e8 100644 --- a/__init__.py +++ b/__init__.py @@ -150,6 +150,11 @@ def _load_order(): return _mod +def _load_stats(): + from gvsdsdk import stats as _mod + return _mod + + def _load_models(): from gvsdsdk import models as _mod return _mod @@ -262,6 +267,14 @@ _LAZY_SYMBOLS = { 'Order': ('order', 'Order'), 'OrderItem': ('order', 'OrderItem'), + # ── 统计引擎 ────────────────────────────────────────── + 'StatsManager': ('stats', 'StatsManager'), + 'StatMetric': ('stats', 'StatMetric'), + 'StatDimension': ('stats', 'StatDimension'), + 'StatRecord': ('stats', 'StatRecord'), + 'StatRanking': ('stats', 'StatRanking'), + 'StatConfig': ('stats', 'StatConfig'), + # ── 数据模型 ────────────────────────────────────────── 'User': ('models', 'User'), 'Role': ('models', 'Role'), @@ -304,6 +317,7 @@ _LOADERS = { 'commission': _load_commission, 'payment': _load_payment, 'order': _load_order, + 'stats': _load_stats, 'models': _load_models, 'fluent': _load_fluent, 'model_base': _load_model_base, diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc index 517497d1b313de3f1b597b15f44584ece13855d3..048f1c4a2a5c3d71ad3c79ac249f0f732fd7e5ce 100644 GIT binary patch delta 4461 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 delta 4440 zcmZXW2~M7;F|$K)G_Y(-10BxiusE?YVEwmN7jkYuGgcz!o z+Q2)g9oj)Vp`Ek~>ZdMfHxX2)9;U^pmv#dJ)Cb)|L1;gPpkW$-?j-{np(u2ahM@as z7&=1x6GM&C0q{XO1dY*Q=v8zz^cuPrdL3O4y@76o-b6P;Z=qYEx6$p;J1BN1ejTB^ zpp5Q@-b437@1y&nN9h6RgLEu0)I;0LU3w4tK7EiF>O=Yne2PAXenQ`Xev`fh z{Wg6E`d#`IdYZlm{XYEw`a_CCKcgQ(e@thfKcSyOe@0`_&*?1m3;H?q7xX3cmvl~y zM3?`HeobG|Z|Jx5JNiBSf&O?b`GnRsS9j=6-KD#AkM7lT^*lYF{&Xy*t$_Y~t)*>V zl0`4n=l0dJtI`}f&uQvt_gd&L$F=={wgn?eMSne6H^rBn>Y>thUu{`k*~ZHDlgCqE zx3PG32Gg@=XQg%q!+z@Q50f5@7!9a9%BJOXXg+o>+sO{+OtUq^dl>(XMs_5piBZmM z_RpM|)@`sJXBqbG>?=42>{-?}I8U>??Zw&-cFOM3+S!+OkJiEdYj?Ac>{D5;BU|fa zs~sL~7u)8@)%|s2Lhyi(Fo9FROLAHG}PO+1(M5oW4~z z0zD%Q{zznB*dSz=!#1MqG2{)h4_)rgAvsi4AgKE`l`Skbd- zR2vu$gv@~t$#{cq^amo5KsY297MQ4y$r&_-gZjh+k&O?_fNyu0{llHP>MB7Q4dF;2 ziY6DOUM*A02BW(#3S7h1dEBe7mEo3%ZX_OoIk)RXAQN*>%&jui8wlx$Rw7_r&yILp z+70YQk8AObA~U7QAJv=vUBU5`o5t)YWJ~GIQO%Aa=q@OlPjC_y@Ps0oKac% z0Q)T0r9H_0mg~@tv6MXLx`#w|Mzi4$MS67OqI7ZJ&t^^pBLm@3L~NOC_6XaR=g=Ny zqj__+$JirzF6}sbFVC$#&d%oLX-}~KKu<7FzE^vatQ zdJx@eU>VN{-zt66dRAB|(yET?{igk#u&0Pa0H>-fs(2+Gy7FxrVFz{4^bf0J1KTj z#B7MgjItqr)IUD;*F+#y6xD=rWFcm|*F|JTS$B7MFcdB8js{SN3uA`gkfVu3{QoN7 z6fwETvhuPDv*In`hLR_J6Unu_E$k_{odag#JF;A~p6tFWUGv_Wx$g-#({!8l{(&IQ zv>AP$@q+xA9OeU=SQQx7sXP$!8zW!a=?_J8YGbW<|Dbp)(D(?F_r#W4yLi^q(e< zLw#3YQL=FgYSL zqgKQ^7QE>YFH}RkJ`M54G(Ht_TF4o;tf<5${!SGy^T-ej7kv@qwnN&Cq;ped#Ir8t zR`HonwOHrGvzPH{l9DafoVa}zPmyG^ShM3fEBJItsfo-qN$H8q3`v<5t0V4Q%V$WM znV>95vl2A>P)rOsCxO{An3HH@m*hwc;gsaESZBp&FXnDZ9*cEGd}a~%O3JlZGvb-^ zc%Gzui`5=?tl@Jd6{u;SpC_p>5u1NVi(&W$7ON-jt>8s6xX@yqDfZ%0NyUk#izHo^ z7=E#&C5bjmC9RZ0Ea$5vt+rUR;+JZPC48-{5BKd87oNls`PuSk-QbORWN zV==ExQs=)4hBNNEf^Sp_o4}aBT3)Sqvsti3ag7w@_H|09aoAy^0NRYCO%$BZ{M7oRRr_Q1O27 z^mwX=k19T3a_pco4#7afg*-8#D=(4Pu<%0JCF&Z2(Gb^A@dhxiT`{jzTm?>#XB6;_ ziZ_ArNJ@CM;>}>$e~aQ8FwVebFZSIsd$%4Sjqj0yTEDj^uqJqis=$d)?&OX zW-S`aC>XOj#!@{(ac`$uG8bMLm05?=c=;4dx_vBl&e+WSu?*K(TFzLyL;M3~6`W7b QvZW>+PQKt^zg)85e^pc}nE(I) diff --git a/auth/__pycache__/managers.cpython-312.pyc b/auth/__pycache__/managers.cpython-312.pyc index 136028d69ab593defe891d732daf75d7aa136f70..d9155b53bcdd2036f561c5db416270d8e765d233 100644 GIT binary patch delta 5442 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 delta 2550 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 J0wOqZ{|D8M*E#?I delta 1498 zcmZ{kUu;uV9LMiz>H24^?KVo=5l06DRtN@S!nSq{mbM#PYg<>_n1xWU+}o|tKknSy z%}xylY9jH$haU+D8kmsGumG7?VF8(AY7AL?^Tiw4OjL}2p3o-=#`EhytkwJQxxe!} z-`}5e?m2h%GP(X1F)!BDnK<@@7y1q@cxKEqI^uvu-S5Pu%-8rejq3${i44^Xh7vc_ zVC0OP-~zkx*#^PF98RG{u(IhUp}xeyl+oWRZV{d+anqQQJwt75-29KRySU1|;k?bX zmK2n=uH(e(HmQbwS1=oxQ)w7nE^22l zItA06PNIWJj~%}0Ji=uAJ&x^FvWuA7R*~A5&%T%uWzoQ5Pi%YKRULNa>k0b;p-{k2 zt5RKp=e-V63q|hQt>HQ}71*$Syb**j#fR6*u5Yz-9dyO%I}+WfGPHd&nHD z?`_+D3%v^rs$LRhUY6;K<`wi;m6Ll9)PDLXg1!fLdYw*O=)YEwOl9)pI~7ac*Myb% zY2PN&uUPt@Bi6sG(J{<24&wtYWC7kDXeOgDJJ3oZ%AJ8GQd7j)33w3P*tTNgc!|NS zp^a#l7%Gk$g?*t`5?8XJlf)*oS+y=g)5GZ;pNZ1+L6IiG8Ey?OWAL|#?-1W3zCd6I z{Q;3g+(rDz@TlPH%&sUX!5iTgazMElPLVAyp;E8vM|i2cg*aACX(^qPWIo5Lmurkc zdvp&utc*uR5?=|SKQkpQ%e5IX_fUtvUl0MruM849o7}@&g=?5tQ*mUHaz4qv{sinC zJg%o!CU9?srNO3-cbG|EMw~~ykC;YWMtp$y2r-LTM0|pnhaIu4$x~>aMw~@VA@Hu! zO9;G26t4lrGfY22ln?zJTB8VxkQmg}Y#-$G!K^5cHYu4N)%2JFH?Y%=< zI1+!8j6rQ8N^ZgagpVABa|!3V-%#v8;6LRLLsvF06f$BreZaw2iMS=GBTe@>WLfnW D0#%k} diff --git a/order/manager.py b/order/manager.py index d863585..7b9e527 100644 --- a/order/manager.py +++ b/order/manager.py @@ -246,6 +246,100 @@ class OrderManager: order_no, template.TemplateName, buyer_user_uuid) return order + @transaction.atomic + def CreateGeneric(self, order_title, buyer_user_uuid, order_type='default', + order_description='', seller_user_uuid=None, + total_amount=None, discount_amount=None, pay_amount=None, + currency='CNY', expire_time=None, remark='', + items=None, order_metadata=None): + """通用创建订单——自动生成单号、强制 PENDING 状态 + + 供通用 CRUD(form_service)接入,替代裸 ``model.objects.create``, + 保证订单受状态机保护(OrderStatus 恒为 PENDING,后续流转必须走状态机端点)。 + + Args: + order_title: 订单标题(必填) + buyer_user_uuid: 买家用户 UUID(必填) + order_type: 订单类型(默认 'default') + order_description: 订单描述 + seller_user_uuid: 卖家用户 UUID(可选) + total_amount: 总金额(Decimal/str;不传按 items 汇总,无 items 则为 0) + discount_amount: 优惠金额 + pay_amount: 应付金额(不传 = total - discount,最低 0) + currency: 币种 + expire_time: 过期时间 + remark: 备注 + items: 明细列表,每项 dict: + {ItemCode, ItemName, ItemDescription, ItemType, UnitPrice, Quantity, SubTotal, ItemMetadata} + order_metadata: 附加元数据 + + Returns: + Order 实例 + + Raises: + OrderError: order_title 或 buyer_user_uuid 缺失时 + """ + if not order_title: + raise OrderError('订单标题不能为空') + if not buyer_user_uuid: + raise OrderError('买家用户 UUID 不能为空') + + buyer_bytes = self._to_bytes(buyer_user_uuid) + seller_bytes = self._to_bytes(seller_user_uuid) if seller_user_uuid else None + order_no = self._generate_order_no() + + # 金额计算:优先显式传值,否则按 items 汇总 + computed_total = Decimal('0') + for item in items or []: + computed_total += Decimal(str(item.get('UnitPrice', Decimal('0')))) * Decimal(str(item.get('Quantity', 1))) + total_amount = total_amount if total_amount is not None else (computed_total or Decimal('0')) + discount_amount = discount_amount or Decimal('0') + pay_amount = pay_amount if pay_amount is not None else max(total_amount - discount_amount, Decimal('0')) + + order = Order.objects.create( + OrderUUID=_uuid.uuid4().bytes, + TenantUUID=self.TenantUUID, + CompanyUUID=self.CompanyUUID, + OrderNO=order_no, + OrderTitle=order_title, + OrderDescription=order_description, + OrderType=order_type, + BuyerUserUUID=buyer_bytes, + SellerUserUUID=seller_bytes, + TotalAmount=total_amount, + DiscountAmount=discount_amount, + PayAmount=pay_amount, + Currency=currency, + OrderStatus=Order.ORD_PENDING, + ExpireTime=expire_time, + TemplateSnapshot={}, + LevelSnapshot={}, + OrderMetadata=order_metadata or {}, + Remark=remark, + ) + + item_list = [] + for item_data in items or []: + unit_price = Decimal(str(item_data.get('UnitPrice', Decimal('0')))) + quantity = int(item_data.get('Quantity', 1)) + item_list.append(OrderItem( + ItemUUID=_uuid.uuid4().bytes, + Order=order, + ItemCode=item_data.get('ItemCode', ''), + ItemName=item_data.get('ItemName', ''), + ItemDescription=item_data.get('ItemDescription', ''), + ItemType=item_data.get('ItemType', 'default'), + UnitPrice=unit_price, + Quantity=quantity, + Subtotal=item_data.get('SubTotal') or (unit_price * quantity), + ItemMetadata=item_data.get('ItemMetadata', {}), + )) + if item_list: + OrderItem.objects.bulk_create(item_list) + + logger.info('[OrderManager] 通用创建订单: %s (buyer=%s)', order_no, buyer_user_uuid) + return order + # ========== 状态流转方法 ========== def Confirm(self, order_uuid): diff --git a/stats/__init__.py b/stats/__init__.py new file mode 100644 index 0000000..53d39ff --- /dev/null +++ b/stats/__init__.py @@ -0,0 +1,24 @@ +"""gvsdsdk.stats — 统计引擎 + +子服务器通过 StatsManager 直接调用统计能力: +原子增量、周期查询、按维度聚合、排行榜生成、周期重置、过期清理。 +""" + +__all__ = [ + 'StatsManager', + 'StatMetric', 'StatDimension', 'StatRecord', 'StatRanking', 'StatConfig', +] + + +def __getattr__(name): + if name == 'StatsManager': + from gvsdsdk.stats import manager as _m + val = getattr(_m, name) + globals()[name] = val + return val + if name in ('StatMetric', 'StatDimension', 'StatRecord', 'StatRanking', 'StatConfig'): + from gvsdsdk.stats import models as _m + val = getattr(_m, name) + globals()[name] = val + return val + raise AttributeError(f"module 'gvsdsdk.stats' has no attribute {name!r}") diff --git a/stats/__pycache__/__init__.cpython-312.pyc b/stats/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..518183bcd66b9cc6cef72cde3443080cdaac76d4 GIT binary patch literal 984 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 Y#DWO1;4j3=dpfU%EWAr#F3$6R09M}m1^@s6 literal 0 HcmV?d00001 diff --git a/stats/__pycache__/models.cpython-312.pyc b/stats/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f863c2ed729cfdb306325d6815dd41fc8751b4a4 GIT binary patch literal 12727 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 L-!Sk)F!lcecg?3O literal 0 HcmV?d00001 diff --git a/stats/manager.py b/stats/manager.py new file mode 100644 index 0000000..144c2af --- /dev/null +++ b/stats/manager.py @@ -0,0 +1,315 @@ +"""模块:统计引擎(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 + + +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 diff --git a/stats/models.py b/stats/models.py new file mode 100644 index 0000000..d9ea01b --- /dev/null +++ b/stats/models.py @@ -0,0 +1,235 @@ +"""模块:每日数据统计引擎 — SAAS 数据模型(gvsdsdk 副本) +抽象统计层:不绑定具体业务,由租户定义统计指标和维度。 +与 gvsds/apps/stats/models.py 字段、db_table 完全一致,映射 SAAS 主库同名表。 + +核心设计: + StatMetric — 统计指标定义(计数/金额/自定义) + StatDimension — 统计维度定义(按角色/按类型/按来源) + StatRecord — 统计流水表(按维度+时间窗口聚合的统计数据) + StatRanking — 排行榜快照(日榜/月榜) + StatConfig — 统计配置(周期重置、排行榜规则)""" + +import uuid +import datetime +from django.db import models +from gvsdsdk.model_base import QModel +from gvsdsdk.models import UUIDField + + +class StatMetric(QModel): + """统计指标定义——租户自定义要统计什么 + + 每个租户可定义多个指标,例如: + · dashou_jiedan_count — 打手接单数 + · dashou_shouru_amount — 打手收入金额 + · guanshi_chongzhi_count — 管事充值人数 + · platform_total_income — 平台总收入 + """ + + MET_COUNT = 'count' + MET_AMOUNT = 'amount' + MET_DURATION = 'duration' + MET_RATIO = 'ratio' + MET_CUSTOM = 'custom' + + MET_TYPE_CHOICES = [ + (MET_COUNT, '计数'), + (MET_AMOUNT, '金额/数值'), + (MET_DURATION, '时长'), + (MET_RATIO, '比率'), + (MET_CUSTOM, '自定义'), + ] + + MetricUUID = UUIDField(primary_key=True, verbose_name='指标UUID') + TenantUUID = UUIDField(verbose_name='租户UUID') + MetricCode = models.CharField(max_length=64, verbose_name='指标编码') + MetricName = models.CharField(max_length=128, verbose_name='指标名称') + MetricType = models.CharField(max_length=16, choices=MET_TYPE_CHOICES, default=MET_COUNT, verbose_name='指标类型') + Unit = models.CharField(max_length=16, default='次', verbose_name='单位') + DefaultValue = models.DecimalField(max_digits=18, decimal_places=4, default=0, verbose_name='默认值') + MetricStatus = models.SmallIntegerField(default=1, verbose_name='指标状态') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'StatMetric' + app_label = 'gvsdsdk' + verbose_name = '统计指标' + verbose_name_plural = '统计指标' + unique_together = [('TenantUUID', 'MetricCode')] + managed = False + indexes = [ + models.Index(fields=['TenantUUID', 'MetricStatus'], name='idx_statmet_tenant_status'), + ] + + def __str__(self): + return f'{self.MetricCode}:{self.MetricName}' + + +class StatDimension(QModel): + """统计维度定义——按什么切分统计 + + 每个指标可以有多个维度,例如: + · role — 按角色(打手/管事/组长/商家) + · platform — 按平台(微信/支付宝/银行卡) + · order_type — 按订单类型(平台订单/商家订单) + · source — 按来源渠道 + """ + + DimensionUUID = UUIDField(primary_key=True, verbose_name='维度UUID') + TenantUUID = UUIDField(verbose_name='租户UUID') + DimensionCode = models.CharField(max_length=64, verbose_name='维度编码') + DimensionName = models.CharField(max_length=128, verbose_name='维度名称') + DimensionStatus = models.SmallIntegerField(default=1, verbose_name='维度状态') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'StatDimension' + app_label = 'gvsdsdk' + verbose_name = '统计维度' + verbose_name_plural = '统计维度' + unique_together = [('TenantUUID', 'DimensionCode')] + managed = False + + def __str__(self): + return f'{self.DimensionCode}:{self.DimensionName}' + + +class StatRecord(QModel): + """统计记录表——按维度+时间窗口聚合的统计数据 + + 核心结构: 租户 + 日期 + 指标 + 维度值 → 数值 + + 用法: + 租户 'al_long',日期 '2026-06-11', + 指标 'dashou_jiedan_count',维度值 {'role': 'dashou', 'user_id': '123'} + → value = 5(今天打手123接了5单) + """ + + PERIOD_DAILY = 'daily' + PERIOD_HOURLY = 'hourly' + PERIOD_MONTHLY = 'monthly' + PERIOD_CUSTOM = 'custom' + + PERIOD_CHOICES = [ + (PERIOD_DAILY, '每日'), + (PERIOD_HOURLY, '每小时'), + (PERIOD_MONTHLY, '每月'), + (PERIOD_CUSTOM, '自定义'), + ] + + RecordUUID = UUIDField(primary_key=True, verbose_name='记录UUID') + TenantUUID = UUIDField(verbose_name='租户UUID') + PeriodType = models.CharField(max_length=16, choices=PERIOD_CHOICES, default=PERIOD_DAILY, verbose_name='周期类型') + PeriodKey = models.CharField(max_length=32, verbose_name='周期键') + Metric = models.ForeignKey( + StatMetric, on_delete=models.CASCADE, + related_name='records', + verbose_name='统计指标', + ) + DimensionValues = models.JSONField(default=dict, verbose_name='维度值') + Value = models.DecimalField(max_digits=18, decimal_places=4, default=0, verbose_name='数值') + UpdateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='更新时间') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'StatRecord' + app_label = 'gvsdsdk' + verbose_name = '统计记录' + verbose_name_plural = '统计记录' + unique_together = [('TenantUUID', 'PeriodType', 'PeriodKey', 'Metric', 'DimensionValues')] + managed = False + indexes = [ + models.Index(fields=['TenantUUID', 'PeriodType', 'PeriodKey'], name='idx_statreq_tenant_period'), + models.Index(fields=['Metric', 'PeriodKey'], name='idx_statreq_metric_period'), + models.Index(fields=['CreateTime'], name='idx_statreq_create_time'), + ] + + def __str__(self): + dim_str = ','.join(f'{k}={v}' for k, v in self.DimensionValues.items()) + return f'{self.PeriodKey}/{self.Metric.MetricCode}/{dim_str}={self.Value}' + + +class StatRanking(QModel): + """排行榜快照——周期性的排名记录 + + 按指标+维度分组,在指定周期内的排名快照。 + 通常由定时任务生成,前端直接查询即可。 + """ + + RNK_DAILY = 'daily' + RNK_MONTHLY = 'monthly' + + RNK_PERIOD_CHOICES = [ + (RNK_DAILY, '日榜'), + (RNK_MONTHLY, '月榜'), + ] + + RankingUUID = UUIDField(primary_key=True, verbose_name='排行UUID') + TenantUUID = UUIDField(verbose_name='租户UUID') + RankingPeriod = models.CharField(max_length=16, choices=RNK_PERIOD_CHOICES, verbose_name='排行周期') + RankingDate = models.DateField(verbose_name='排行日期') + Metric = models.ForeignKey( + StatMetric, on_delete=models.CASCADE, + related_name='rankings', + verbose_name='统计指标', + ) + DimensionFilter = models.JSONField(default=dict, verbose_name='维度筛选') + TargetID = models.CharField(max_length=128, verbose_name='目标ID') + Rank = models.IntegerField(verbose_name='排名') + RankValue = models.DecimalField(max_digits=18, decimal_places=4, verbose_name='排名数值') + TargetName = models.CharField(max_length=128, default='', verbose_name='目标名称') + TargetAvatar = models.CharField(max_length=512, default='', verbose_name='目标头像') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'StatRanking' + app_label = 'gvsdsdk' + verbose_name = '排行榜' + verbose_name_plural = '排行榜' + unique_together = [('TenantUUID', 'RankingPeriod', 'RankingDate', 'Metric', 'DimensionFilter', 'Rank')] + managed = False + indexes = [ + models.Index(fields=['TenantUUID', 'RankingPeriod', 'RankingDate', 'Metric'], name='idx_statrnk_composite'), + models.Index(fields=['TargetID', 'RankingPeriod'], name='idx_statrnk_target'), + ] + + def __str__(self): + return f'{self.RankingDate} {self.RankingPeriod} #{self.Rank} {self.TargetID}' + + +class StatConfig(QModel): + """统计配置——租户级统计行为配置 + + 定义: + · 哪些指标需要周期性重置(日清零/月清零) + · 排行榜生成规则 + · 数据保留天数 + """ + + ConfigUUID = UUIDField(primary_key=True, verbose_name='配置UUID') + TenantUUID = UUIDField(verbose_name='租户UUID') + Metric = models.ForeignKey( + StatMetric, on_delete=models.CASCADE, + related_name='configs', + verbose_name='统计指标', + ) + AutoReset = models.BooleanField(default=False, verbose_name='自动重置') + ResetCron = models.CharField(max_length=64, default='0 0 * * *', verbose_name='重置Cron表达式') + EnableRanking = models.BooleanField(default=False, verbose_name='启用排行榜') + RankingDimensionFilter = models.JSONField(default=dict, verbose_name='排行维度筛选') + RankingTopN = models.IntegerField(default=50, verbose_name='排行TopN') + RankingSortOrder = models.CharField(max_length=8, default='desc', verbose_name='排行排序方式') + RetentionDays = models.IntegerField(default=365, verbose_name='数据保留天数') + ConfigStatus = models.SmallIntegerField(default=1, verbose_name='配置状态') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + + class Meta: + db_table = 'StatConfig' + app_label = 'gvsdsdk' + verbose_name = '统计配置' + verbose_name_plural = '统计配置' + unique_together = [('TenantUUID', 'Metric')] + managed = False + + def __str__(self): + return f'StatConfig:{self.Metric.MetricCode}'