feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
- 新增成对迁移 000231 与三张快照表:日级头行、设备粒度激活行、到期事件粒度续费行,以快照日期为唯一键 - 新增每日 03:30(Asia/Shanghai)日报快照生成任务与幂等整日替换,失败重试沿用同一目标日 - 新增六条受控入口:两张报表的汇总、日/月趋势与受控导出,配套查询层只读快照事实 - 新增 operations_activation 与 operations_renewal 两个导出场景,创建期冻结筛选与可见店铺范围、派发期冻结表头、执行期只按冻结值复核资格 - 采购数量口径按系统内未删除设备数实施并在 PRD 标注,附实测差额依据 - 同步证据链 requirement-evidence.json 与入口能力矩阵、ARCHITECTURE 与验证记录 - 归档 change add-operations-reports 并新建主 Spec openspec/specs/operations-report/spec.md
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
## Context
|
||||
|
||||
本设计只补充推进方案所需的事实与约束;动机见 `proposal.md`「Why」,行为义务见 `specs/operations-report/spec.md`。
|
||||
|
||||
现状约束(全部为可复现事实,行号取本次修订时现读版本):
|
||||
|
||||
- **没有历史时点实名能力**:`tb_iot_card.real_name_status` 是当前状态列(取值 `0`/`1`,`pkg/constants/iot.go:45-46`),且可逆转(`migrations/000179_add_iot_card_realname_reversal_state.up.sql`);`first_realname_at` 仅在首次置位(`internal/domain/cardobservation/realname.go:71`、`internal/application/cardobservation/apply.go:142-143`),实名逆转后再实名不会更新。仓库内不存在任何 as-of 实名查询。因此「截至统计日已实名」只能靠每日快照冻结。
|
||||
- **设备实名与设备激活均为运行时派生**:设备表不存实名列,口径为「任一当前有效绑定卡已实名」,完整 `EXISTS` 子查询既有实现见 `internal/store/postgres/device_store.go:227-246`(带 `b.bind_status=1 AND b.deleted_at IS NULL AND c.deleted_at IS NULL AND c.real_name_status=1`)。同口径另有 `internal/exporter/device_scene.go:189-212`。**近似口径,本 Change 不采用**:`internal/application/assetautorenewal/renew.go:556-565` 与 `internal/service/package/activation_service.go:618-640` 的同名判定缺少 `deleted_at IS NULL`,与上述完整口径不一致;本 Change 一律采用带 `deleted_at IS NULL` 的完整口径。
|
||||
- **设备—卡当前有效关联**:`tb_device_sim_binding`(`internal/model/device_sim_binding.go:11-25`),有效条件 `bind_status=1 AND deleted_at IS NULL`,部分唯一索引 `(device_id, slot_position)`(`migrations/archive/000019_fix_device_sim_binding_constraints.up.sql:7-9`),一设备最多 4 卡、一卡可绑多设备。
|
||||
- **有效套餐与主套餐**:主套餐 = `master_usage_id IS NULL`;有效 = 未删除、`master_usage_id IS NULL`、`refund_id IS NULL`、`status IN (1,2)`(既有先例 `internal/query/packageexpiry/list.go:167` 与 `:195`、`internal/query/assetautorenewal/query.go:284` 与 `:434`、`internal/infrastructure/packagetrafficalert/scanner.go:20` 与 `:53`)。**不可照抄**:`internal/store/postgres/package_usage_store.go:63-72` 的 `GetCurrentMainPackage` 不带 `refund_id IS NULL`,本 Change 不采用该处口径。
|
||||
- **真流量权威列**:`tb_package_usage.data_usage_mb`(使用记录在当前重置周期内的真已用量,`internal/model/package.go:74`),由扣减路径写入(`internal/service/package/usage_service.go:156` 与 `:158`);周期字段 `data_reset_cycle`/`last_reset_at`/`next_reset_at`(`internal/model/package.go:95-97`)。**禁止**使用 `tb_iot_card.data_usage_mb`(全生命周期,`internal/model/iot_card.go:32`、`internal/domain/cardobservation/traffic.go:41-74`)、`current_month_usage_mb`、`last_gateway_reading_mb`(通道累计读数)以及 `virtual_total_mb_snapshot`/`display_gain_ratio_snapshot`(虚量与展示量)。
|
||||
- **不重复计数的既有机制**:卡载体没有生效套餐时,流量增量回退扣减到设备载体(`internal/service/package/usage_service.go:294-331`),每次增量只落在一条使用记录上。
|
||||
- **归属链路**:`tb_device.shop_id` → `tb_shop.business_owner_account_id`(`internal/model/shop.go:16`)→ `tb_account` → `tb_business_user_group_member.account_id` → `tb_business_user_group`(`internal/model/business_user_group.go:24,33-37`);用户组为实时推导、禁止冗余写入店铺(`openspec/specs/business-user-group/spec.md`)。`tb_shop` 无代理列,只有 `parent_id`(注释「NULL 表示一级代理」)与 `level`。
|
||||
- **到期事实无独立记录表**:只在 `tb_package_usage.expires_at` 与状态流转上(`internal/polling/package_activation_handler.go:241-271`,每 10 秒调度,非 Asynq 任务);到期时间可被后台改写(`PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`)。
|
||||
- **续购无独立订单类型**:`tb_order.order_type` 只有 `single_card`/`device`(`internal/model/order.go:89-92`);续购表现为同一载体新增一条主套餐使用记录(人工 `internal/service/order/service.go:2559`、自动 `internal/application/assetautorenewal/renew.go:461`),既有续购判定见 `internal/service/package/priority.go:36-52`。
|
||||
- **调度与幂等范式**:单例 `asynq.Scheduler`(`cmd/worker/main.go:851`),周期任务集中注册于 `cmd/worker/main.go:879`;可复制模板为每日流量落盘(常量 `pkg/constants/constants.go:91` → 处理器 `internal/task/daily_traffic_flush.go:38` → 注册 `pkg/queue/handler.go:387-392` → cron `cmd/worker/main.go:1005-1015`)。既有幂等手段:`asynq.Unique`、Redis 锁、条件更新状态机、快照表 `ON CONFLICT`。
|
||||
- **导出既有骨架**:场景常量 `pkg/constants/constants.go:384-404`、数据源注册表 `internal/exporter/registry.go:29-42`、支持场景判定 `:68-80`、DTO 联合白名单 `internal/model/dto/export_task_dto.go`(两处)、严格时间场景集合 `internal/exporter/time_filters.go:21-28`;创建期冻结筛选与可见店铺范围 `internal/service/export_task/service.go:106-130`,派发期冻结表头 `internal/store/postgres/export_task_store.go:149`,执行期店铺范围 `internal/exporter/filter_helpers.go:16`。
|
||||
- **门禁**:`scripts/context-health.sh:53-73` 强制主 Spec 的 Requirement 证据链与入口—能力—Requirement 矩阵双向一致,因此新增 Requirement、6 条 HTTP 入口与 1 条异步入口必须同步两份 JSON。
|
||||
|
||||
生产库只读实测(`pro_main`,2026-09-17,用于规模与口径判定):
|
||||
|
||||
| 事实 | 实测值 |
|
||||
| --- | --- |
|
||||
| `tb_device`(未删除) | 18,970 |
|
||||
| 其中 `device_name` / `device_model` / `manufacturer` 去重 | 17 / 21 / 6 |
|
||||
| 设备「任一当前关联卡已实名」 | 6,630 |
|
||||
| `tb_device_import_task`(`operation_type='import'` 且已完成)`SUM(success_count)` | **474** |
|
||||
| `tb_shop`(未删除,level1 / level2) | 1,031(1,023 / 8) |
|
||||
| `tb_account`(`user_type=3` 且带 `shop_id`) | 1,031 |
|
||||
| `tb_iot_card` / `tb_device_sim_binding` / `tb_package_usage` | 95,588 / 36,412 / 69,841 |
|
||||
| `tb_package_usage_daily_record` | 541,178 行,2026-05-08 起 128 天 |
|
||||
| `tb_card_daily_usage` | 0 行,且实际列为旧结构(`card_id`/`usage_date`/`total_data_usage`) |
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 以一份不可变的日报快照支撑两张报表的全部指标、分组、趋势与导出,历史结果不随实时状态漂移。
|
||||
- 口径逐项可追溯到既有权威列,且每项都能用一条可复现的查询验证。
|
||||
- 与既有导出任务、审计、权限和数据范围机制零冲突复用。
|
||||
|
||||
**Non-Goals(设计层边界):**
|
||||
|
||||
- 不新增到期台账、采购台账或归属历史表;不从审计与可靠事件载荷重建历史实名序列。
|
||||
- 不做跨维度筛选(「以维度 A 筛选、以维度 B 分组」);只做单一分组维度 + 同期筛选。
|
||||
- 不做金额指标、不做图表渲染、不做自然语言总结的后端渲染。
|
||||
- 不修复 `tb_card_daily_usage` 的结构漂移,不以该表为任何数据源。
|
||||
- 不改造既有导出的记录粒度、列定义与冻结语义。
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 能力归一:单一能力 + 每日快照
|
||||
|
||||
`operations-report` 为唯一能力,删除并行的「按卡首次激活时间实时 Query」方案与 `operations-reporting` 目录。依据:`real_name_status` 可逆转且无 as-of 查询(见 Context),实时 Query 无法表达「截至统计日」,且各指标来源互不相同(实名、套餐、真流量、到期),必然出现跨表实时拼接导致的历史漂移。
|
||||
|
||||
### D2 三张快照表(成对迁移 `migrations/000231_create_operations_report_snapshot.{up,down}.sql`)
|
||||
|
||||
快照表是幂等替换的事实表:**不设软删除列**(整日替换需要物理删除,`deleted_at` 会与「快照日期唯一」冲突)。
|
||||
|
||||
```sql
|
||||
-- 1. 日级头行:一日一行
|
||||
CREATE TABLE tb_operations_report_snapshot (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
snapshot_date DATE NOT NULL, -- 上海自然日
|
||||
purchased_device_count BIGINT NOT NULL DEFAULT 0,
|
||||
activated_device_count BIGINT NOT NULL DEFAULT 0,
|
||||
online_device_count BIGINT NOT NULL DEFAULT 0,
|
||||
active_device_count BIGINT NOT NULL DEFAULT 0,
|
||||
total_real_traffic_mb NUMERIC(20,2) NOT NULL DEFAULT 0,
|
||||
renewal_due_asset_count BIGINT NOT NULL DEFAULT 0,
|
||||
renewal_renewed_asset_count BIGINT NOT NULL DEFAULT 0,
|
||||
generated_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX uk_operations_report_snapshot_date ON tb_operations_report_snapshot (snapshot_date);
|
||||
|
||||
-- 2. 设备粒度激活行
|
||||
CREATE TABLE tb_operations_report_activation_row (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
snapshot_date DATE NOT NULL,
|
||||
device_id BIGINT NOT NULL,
|
||||
virtual_no VARCHAR(64) NOT NULL DEFAULT '',
|
||||
device_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
device_model VARCHAR(100) NOT NULL DEFAULT '',
|
||||
manufacturer VARCHAR(255) NOT NULL DEFAULT '',
|
||||
shop_id BIGINT,
|
||||
shop_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
root_shop_id BIGINT, -- 沿 parent_id 上溯至根的一级代理店铺
|
||||
root_shop_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
agent_account_id BIGINT, -- 归属该店铺的代理账号(user_type=3)
|
||||
agent_account_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
business_owner_account_id BIGINT,
|
||||
business_owner_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
business_user_group_id BIGINT,
|
||||
business_user_group_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
purchased BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
realnamed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
real_traffic_mb NUMERIC(20,2) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX uk_operations_report_activation_row ON tb_operations_report_activation_row (snapshot_date, device_id);
|
||||
CREATE INDEX idx_operations_report_activation_shop ON tb_operations_report_activation_row (snapshot_date, shop_id);
|
||||
|
||||
-- 3. 到期事件粒度续费行
|
||||
CREATE TABLE tb_operations_report_renewal_row (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
snapshot_date DATE NOT NULL,
|
||||
asset_type VARCHAR(20) NOT NULL, -- 沿用既有载体类型取值 device / single_card
|
||||
asset_id BIGINT NOT NULL,
|
||||
asset_identifier VARCHAR(64) NOT NULL DEFAULT '',
|
||||
expired_usage_id BIGINT NOT NULL, -- 到期的主套餐使用记录
|
||||
package_id BIGINT NOT NULL,
|
||||
package_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
series_id BIGINT,
|
||||
series_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
shop_id BIGINT,
|
||||
shop_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
root_shop_id BIGINT,
|
||||
root_shop_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
agent_account_id BIGINT,
|
||||
agent_account_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
business_owner_account_id BIGINT,
|
||||
business_owner_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
business_user_group_id BIGINT,
|
||||
business_user_group_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
renewed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX uk_operations_report_renewal_row ON tb_operations_report_renewal_row (snapshot_date, expired_usage_id);
|
||||
CREATE INDEX idx_operations_report_renewal_shop ON tb_operations_report_renewal_row (snapshot_date, shop_id);
|
||||
CREATE INDEX idx_operations_report_renewal_series ON tb_operations_report_renewal_row (snapshot_date, series_id);
|
||||
```
|
||||
|
||||
头行承担三件事,缺一不可:①「该日是否有快照」的唯一判定;②日/月趋势与新增指标的序列来源(O(1) 读取,不必扫明细行);③合计行与顶部文字总结的权威值。**可验证不变量**:同一快照日期、任一受支持维度下,分组行各指标之和等于头行对应指标;`renewal_renewed_asset_count <= renewal_due_asset_count`。
|
||||
|
||||
### D3 采用设备粒度事实行,不采用维度立方
|
||||
|
||||
备选(拒绝):按 `(快照日期, 维度类型, 维度值)` 预聚合。代价对比用实测规模计算:设备粒度约 **1.9 万行/日 ≈ 700 万行/年**;维度立方按 `设备名称(17) + 型号(21) + 制造商(6) + 店铺(1,031) + 业务员(1,031) + 用户组 + 代理(1,031)` 约 **3.3 千行/日**,仅相差约 6 倍。
|
||||
|
||||
拒绝理由是不对称的风险:维度立方只支持「同一维度的筛选与分组」,无法支持「以维度 A 筛选、以维度 B 分组」;而 PRD 明确禁止回填历史,一旦事后需要该能力即为**永久缺口**。设备粒度每日一次全量删除+插入(约 1.9 万行)[INFERENCE] 为秒级操作,绝对成本可接受,且使「分组行之和 = 头行值」「导出与页面同口径」天然成立。
|
||||
|
||||
### D4 快照生成任务:类型、payload、调度、幂等、重跑
|
||||
|
||||
- 新增任务类型常量 `TaskTypeOperationsReportSnapshot`(`operations:report:snapshot`),payload **只含单一目标上海自然日**;处理器为薄壳,调用生成用例。
|
||||
- 寄存器与队列映射按既有范式(`pkg/queue/handler.go` 注册 + `QueueForTaskType`)。
|
||||
- 调度:`CRON_TZ=Asia/Shanghai 30 3 * * *`,目标日 = 上海自然日**前一自然日**;时间点晚于每日流量落盘(02:00)、套餐临期提醒(03:00)与到期处理(10 秒级轮询)之后,使当日源事实已稳定。
|
||||
- 幂等 = **唯一键 + 整日替换 + 去重窗口**:单事务内先 `DELETE` 该日三表全部行再整体写入,并加 `asynq.Unique(23*time.Hour)`。同日重复执行的结果等于最后一次执行的结果,不产生第二套口径。
|
||||
- 失败与重跑:失败交既有任务重试机制(`MaxRetry`),重试沿用同一目标日期;某日始终未成功则当日无任何行,表现为「无快照数据」,不产生部分口径。
|
||||
|
||||
### D5 无快照表达、不回填与只读边界
|
||||
|
||||
- 响应含「是否有快照」标记与实际命中的快照日期集合。
|
||||
- 累计类指标一律取所选**结束日**的快照;结束日无快照则累计值与派生指标为空,**不得就近回退更早快照**;新增类指标任一侧无快照即为空;分组行同理返回空集。
|
||||
- 不回填:无任何按日期区间批量生成入口,调度只发前一日,不提供补跑接口。
|
||||
- 只读边界:查询与导出只允许读三张快照表,不得回查设备、卡、套餐使用等实时表。
|
||||
|
||||
### D6 采购数量口径(须 PRD 标注)
|
||||
|
||||
采用口径:**采购数量 = 截至快照日(上海自然日)系统内未删除的设备数**。与 `111.md` §22.4.1「系统录入的设备数量」一致,且不新建采购或入库台账。
|
||||
|
||||
字面口径(拒绝):`tb_device_import_task` 中 `operation_type='import'` 且已完成任务的 `success_count` 之和。生产实测 **474**,而系统内未删除设备 **18,970**;差额来自老系统迁移脚本**直接写入设备表、绕过导入任务**(`scripts/migration/lib/sql_builder.py:712`、`scripts/migration/config/mapping.yaml:11` `migration_user_id: 1`)。字面口径下激活率约 **1,399%**,指标不可用。
|
||||
|
||||
该口径实现收敛在**一个口径函数**内,切换成本为一行;同一标注同步写入 PRD §2.17。
|
||||
|
||||
### D7 激活指标口径与边界
|
||||
|
||||
- 累计激活数 = 快照时点「设备任一当前有效关联卡已实名」的设备数,复用既有 `EXISTS` 判定口径(见 Context)。
|
||||
- 累计在网数 = 已实名且存在有效主套餐(主套餐、状态为生效或已用完、未退款)的设备数。
|
||||
- 活跃用户数 = 该设备自身与当前有效关联卡当前有效使用记录的真已用量之和大于零的设备数。
|
||||
- 累计用量 = 同一合计值折算 GB(1 GB = 1024 MB)。
|
||||
- **实名可逆转边界**:累计激活数可下降、新增激活数可为负;不得取零下限,不得改用首次实名时间。
|
||||
- 分母为零:接口返回空,导出写「-」;比率与卡均保留两位小数;**激活率不设上限**(设备删除或迁移可使其超过 100%,如实呈现;PRD 只对续费率设上限)。
|
||||
- 预测卡均的「当月」= 所选结束日所在上海自然月;已过天数 = 结束日日期号;当月总天数 = 该月自然日数。
|
||||
|
||||
### D8 真流量口径、禁止来源与不重复计数
|
||||
|
||||
权威列 `tb_package_usage.data_usage_mb`;取值集合 = 该设备自身的当前有效使用记录 ∪ 该设备当前有效关联卡的当前有效使用记录。不重复计数的依据:卡载体没有生效套餐时增量回退扣减到设备载体(`internal/service/package/usage_service.go:294-331`),每次增量只落在一条记录上。
|
||||
|
||||
备选(拒绝):对日记录表按「设备当前套餐周期窗口」求和。缺点是系统性低估迁移资产——迁移把老系统累计真用量写在 `data_usage_mb` 上,而日记录序列自 2026-05-08 起才有,窗口求和取不到该基线。
|
||||
|
||||
GB 换算:在报表域内自持常量 `MBPerGB = 1024`,与 `internal/domain/carrierthreshold/threshold.go:19` 同值同源;不为一个换算常数建立与阈值域的跨域依赖。
|
||||
|
||||
### D9 续费:到期日锚定与「不超过 100%」的构造保证
|
||||
|
||||
- 到期事实 = 未退款主套餐的到期日(上海自然日)等于快照日。
|
||||
- 续费事实 = 该到期资产存在另一条**未退款**主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
|
||||
- 到期数与续费数均按资产去重;分子为分母子集 ⇒ 续费率 ≤ 100% 由构造保证。备选(拒绝):按「任意成功续购」独立计数再截断到 100%,会掩盖真实数据并破坏分子分母可比性。
|
||||
- 新增未续费数 = 到期数 − 续费数。
|
||||
- **到期日必须在快照时冻结**:到期时间不是不可变事实,后台可修改单条使用记录的到期时间(`PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`),不改写快照即产生历史漂移。
|
||||
- 迁移资产同样按本地事实计入:迁移产生了真实的主套餐记录与到期时间,不再区分来源。
|
||||
|
||||
### D10 时间参数裁决
|
||||
|
||||
固定为 `start_time`/`end_time`:带显式时区的 RFC3339 秒级、闭区间,复用第 16 项(`openspec/specs/export-time-filter/spec.md`)的共享严格解析器;**不引入自然日参数**,避免同一能力出现第二套时间约定。
|
||||
|
||||
**落界规则(写死)**:快照日期 D 入选,当且仅当 D 的零点(+08:00)落在请求区间内;请求人以整日边界表达区间。趋势用 `granularity=day|month` 表达粒度,不引入月份参数。
|
||||
|
||||
### D11 代理维度:同时冻结两个取值
|
||||
|
||||
`tb_shop` 无代理列,店铺即代理店铺(生产 1,023 个一级店铺 + 8 个二级店铺,代理账号 1,031 个且全部带 `shop_id`),两种读法在生产近乎同集。因此快照行**同时冻结**「一级代理店铺(沿上级上溯至根)及其名称」与「归属该店铺的代理账号及其名称」,查询侧映射为单一「代理」维度。理由:禁止回填历史,同时冻结是零回填可切换的最低成本。
|
||||
|
||||
### D12 归属冻结时机与已知限制
|
||||
|
||||
冻结时机 = 快照生成执行时刻,一次性冻结店铺、业务员、用户组与代理取值;**整日替换**保证同一日的行始终来自最近一次执行的同一时刻,不出现同日行混合口径。历史快照行不得因后续归属变化被改写。
|
||||
|
||||
已知限制(登记):归属是**生成时刻**的归属,不是逻辑「当天日终」的归属。系统没有归属历史表,无法事后重建日终归属,属既有事实边界;上线后若该差异被业务认定为不可接受,须另立 Change 新增归属变更历史。
|
||||
|
||||
### D13 查询、趋势与导出
|
||||
|
||||
- 六个受控入口(汇总 ×2、趋势 ×2、导出 ×2),导出使用受控端点而非通用导出创建端点,避免把「导出与列表同筛选」降级为前端约定。
|
||||
- 执行期只读快照表并施加冻结店铺范围;`Count`/`Fetch` 用同一查询构造,避免列表与导出漂移。
|
||||
- 导出列等于页面字段并含合计行;分母为零写「-」;不包含顶部文字总结(文字总结由前端按合计值渲染)。
|
||||
- 场景级角色自检:Worker 无请求上下文,导出场景必须按任务内冻结的账号类型复核资格(先例:达量预警导出的场景级门禁)。
|
||||
- 数据范围:按请求人可见店铺过滤快照行;越权与不存在统一不可见。
|
||||
|
||||
### D14 权限与审计
|
||||
|
||||
- 路由组级门禁(超级管理员或平台)**挂在功能前缀**上,不得挂在后台根组上——Fiber 的 `Group(prefix, handler)` 会把处理器落为前缀上的 USE 处理器并前置执行,从而拦截其后注册的路由(该历史缺陷已在 `398a5e4` 修复,范式见 `internal/routes/asset_auto_renewal.go:19-24`)。
|
||||
- 查询不写统一审计事件:无业务事实变化,不产生 Audit Event、Domain Ledger、Integration Log 或 Outbox 任一事实。
|
||||
- 导出复用既有导出任务审计动作与写入器,不新增动作码;导出任务行本身保存筛选、创建时间、操作者、结果文件与行数,满足可追溯要求。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [设备粒度快照表约 700 万行/年,随年限增长] → 唯一键为 `(snapshot_date, device_id)`,删除与查询均按 `snapshot_date` 前缀;`tb_package_usage_daily_record` 已有 541 万行同类数据,规模可接受;如未来需要压缩,另立 Change 增加冷热分层,不改口径。
|
||||
- [归属冻结为生成时刻而非日终] → 已在 D12 显式登记;对日报指标影响限于当日发生归属变更的设备,且历史行不被改写。
|
||||
- [采购数量口径若被改回字面读法] → 激活率将失真(实测 1,399%);已把口径收敛到单一口径函数并在 PRD §2.17 标注实测数字与依据,切换成本为一行。
|
||||
- [快照日期落界规则与直觉不同] → 以整日边界表达区间的写法必须由前端遵守;spec 以「两端均等于某日零点」与「非法格式拒绝」两个场景固定行为。
|
||||
- [迁移资产的到期与续费按本地事实计入] → 迁移写入的主套餐记录含老系统到期时间,会真实产生到期事件与续费事件;这是本地事实的真实读数,不额外排除,已在 D9 登记。
|
||||
- [导出列与页面字段一致性无自动断言] → 沿用仓库现状(逐场景人工复刻 + 注释登记),以「同筛选同口径」场景与导出冒烟验证覆盖。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
- 新增成对迁移 `000231`(三张表 + 唯一键 + 索引),无数据回填、无运行时开关;回滚按 `.down.sql` 删除三张表。
|
||||
- 发布顺序:先迁移、后发布 Worker 与 API;快照只在上线后的自然日开始产生,上线前日期天然无行。
|
||||
- 回滚策略:回滚二进制后快照表保留但不被读取;`.down.sql` 仅在确认不需要历史快照时执行。
|
||||
- 上线后门禁命令:`gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`(连续两次结果一致)、`openspec validate add-operations-reports --strict`、`openspec doctor --json`、`./scripts/context-health.sh`;自动化测试按项目决策为 N/A。
|
||||
|
||||
## 已知差异与后续候选
|
||||
|
||||
1. **实收续费金额指标**:PRD §2.17 未要求,本次不实现,登记为后续候选(若产品需要,须先定义金额口径与冻结来源)。
|
||||
2. **既有日流量记录表结构漂移**:`tb_card_daily_usage` 在两个环境均为旧结构(`card_id`/`usage_date`/`total_data_usage`/`carrier_id`)且为 0 行,而模型声明为 `iot_card_id`/`date`/`usage_mb`;原因见 `migrations/000097_create_card_daily_usage.up.sql:1`(`CREATE TABLE IF NOT EXISTS` 对已存在表无效)。**属既有缺陷,本次不修**,且本能力不以该表为数据源。
|
||||
3. **归属日终快照缺失**:见 D12,需独立 Change 才能提供日终归属。
|
||||
4. **跨维度筛选不支持**:见 D3;当前只支持单一分组维度 + 同期筛选。
|
||||
|
||||
## 实施登记(2026-09-17)
|
||||
|
||||
实施阶段对上文裁决的落地解释与已实施偏差,登记供归档与复核;本文 D1–D14 的口径均未改动。
|
||||
|
||||
1. **在网口径的取数范围**:D7 只写「存在有效主套餐」,实现取「该设备自身 ∪ 其当前有效关联卡」两条载体上的有效主套餐(与 D8 的真流量取值集合同范围)。理由:三个指标(实名、在网、真流量)由同一批事实派生,取同一范围可避免同一设备在不同指标上使用不同载体集合。备选窄口径(只认设备自身载体上的主套餐)未采用,若产品要求需另立改动。
|
||||
2. **代理维度的取值优先级**:D11 要求同时冻结两个候选取值并映射为单一维度,实现取「代理账号(归属一级代理店铺的 user_type=3 账号,多账号取编号最小者)」为展示值,账号缺失时退回「一级代理店铺」,两者都缺失时为固定占位「未设置」。两个取值在同一行的两个候选列上同时存在,切换读法不改快照结构。
|
||||
3. **快照列宽度按来源列取值**:D2 的 `virtual_no VARCHAR(64)` 与 `asset_identifier VARCHAR(64)`、`series_name VARCHAR(100)` 分别放宽为 100 / 100 / 255,与 `tb_device.virtual_no`、`tb_iot_card.iccid` 与 `tb_package_series.series_name` 的来源宽度对齐,避免快照写入静默截断来源事实。列名与语义不变。
|
||||
4. **比率与卡均的数值形态**:D7 的「保留两位小数」按**比值**(0~1)实现,接口返回 `0.05`、`1.00` 这类值,导出列的比率列名不带「(%)」(页面与导出使用同一数值,避免导出与页面出现两种刻度)。续费率 100% 对应返回 `1.00`。
|
||||
5. **汇总的基期选择**:D5「新增类指标任一侧无快照即为空」的落地规则为——传入 `start_time` 时基期取「起始日的前一自然日」(与统一时间契约要求的整日边界写法一致);未传 `start_time` 时基期取「结束日之前最近的一个有快照日」。趋势按「期」取基期:按日为前一自然日、按月为上一自然月,前一期无快照时新增为空。
|
||||
6. **多日区间的分组行与合计**:激活情况的累计与分组行一律取结束日快照,因此分组行之和恒等于头行值;续费情况的到期与续费在统计期内按资产去重,单日恒等成立,多日区间内若同一资产在期内发生归属变更,其分组之和可能大于合计(同资产落在两个分组),此差异属归属冻结口径(D12)的既定后果。
|
||||
7. **定时调度的目标日来源与重试依据(asynq v0.25.1 事实)**:`Scheduler.Register(cronspec, task, opts...)`(`asynq@v0.25.1/scheduler.go:208`)在注册时固定单个静态 `Task`,`Task.payload` 私有且无 setter,`PreEnqueueFunc`(`scheduler.go:154`)无返回值,因此 D4「payload 只含单一目标上海自然日」无法在 cron 侧字面落地:定时调度**不带载荷**,处理器按上海时区取 `PreviousDay(now)`;仓库内**没有**该任务的入队点(无补跑接口),带 `{"snapshot_date":"YYYY-MM-DD"}` 载荷的调用只能来自运维侧 asynq 控制台或临时程序,此时目标日由载荷固定。「失败重试沿用同一目标日期」的依据是**cron 时点 + 有界重试窗口**,不是「沿用同一份载荷」:默认重试延迟 `DefaultRetryDelayFunc`(`asynq@v0.25.1/server.go:400-406`,本仓库 `pkg/queue/server.go:44` 显式采用)为 `n^4 + 15 + rand(0..29)*(n+1)` 秒,`MaxRetry(3)` 的 n=0/1/2 三次重试合计 ≤ 236 秒,叠加 `Timeout(30m)` 的极端上界约 2h05m,恒落在 03:30 之后的同一上海自然日内。唯一锁只在客户端入队侧生效(`asynq@v0.25.1/client.go:380-382`、`asynq@v0.25.1/internal/rdb/rdb.go:158-176`),服务端重试路径(`processor.go:349-372 → internal/rdb/rdb.go:804-837`)不含 unique key,因此失败重试不会被 23 小时去重窗口吞掉;成功经 `Done` 释放锁(`rdb.go:338-344`),永久失败留锁至 TTL 过期(次日 02:30 到期,03:30 入队有 1 小时余量)。调整 cron 时点、`MaxRetry` 或 `Timeout` 必须重新评估该前提(已在 `cmd/worker/main.go` 注册处与任务处理器注释中写明)。**边界**:同一目标日期的重跑若落在前一次成功后的 23 小时窗口内,会被唯一键判为重复而整任务跳过(无第二套口径);需要窗口内重跑时须等服务端锁过期或先删除该任务。
|
||||
8. **生成期读取与写入事务边界**:读取源事实在写入事务之前完成,写入阶段(删除该日三表行 → 整体写入 → 不变量校验)在单事务内闭合。采购数量与设备事实分两次读取,若两者跨越了设备增删导致不一致,本次生成直接失败(不写入自相矛盾的快照),由既有重试以同一目标日期重跑。
|
||||
9. **测试环境验证**:本次验证在 `junhong_cmp_test` 上以带 `AUG26015` 标记的临时 fixture 执行(创建后即删除,测试库恢复原状),覆盖冒烟(三表行数、七维度不变量、续费子集、同日重复执行、三个唯一键冲突)与 spec 的全部场景;自动化测试按项目决策为 N/A,临时验证程序未留在仓库。
|
||||
|
||||
10. **down 守卫与 dirty 恢复**:`migrations/000231_*.down.sql` 在存在快照行时拒绝回滚(先例 `migrations/000230_add_asset_auto_renewal_attempt.down.sql`):快照是「截至某日」的冻结读数,其源事实(实名可逆转、到期时间可改写)无法事后重算,因此删除前必须显式确认。**副作用与恢复**:守卫触发时 golang-migrate 会把 `schema_migrations` 置为前一版本并标记 `dirty=t`(实测 `230|dirty=t`),此时表结构未变而版本号已回退,必须执行 `./scripts/migrate.sh force 231` 复原后才能继续迁移。**运维口径:有快照数据时不要执行 down。** 对象集与 up 严格成对:down 删除 3 张表 + 3 个主键索引 + 3 个唯一索引 + 3 个普通索引 + 3 个序列 = 15 个对象,up 后完全还原。
|
||||
11. **as-of 采购数量的剩余语义**:`CountUndeletedDevicesAsOf` 与设备明细行查询使用**同一谓词**「`created_at` 早于快照日次日 00:00(上海墙钟)且 `deleted_at IS NULL`」,两者不一致会让「采购数量 = 明细行数」的不变量立即失败。as-of 维度是设备创建时间;**删除维度按生成时刻判定**,即不重建「该日是否已删除」的历史(系统没有删除历史表)。`tb_device.created_at` 与 `tb_package_usage.expires_at` 都是 naive timestamp 列(仓库约定:naive 列存上海墙钟),因此边界以 `yyyy-MM-dd HH:mm:ss` 文本与 `::date` 比较传入,不用 `time.Time`、也不用 `AT TIME ZONE`,避免按 UTC 编码或按 UTC 解释而差一天。
|
||||
12. **已知限制(当前不可达)**:汇总的 `Totals` 恒取全库头行,与按可见店铺范围聚合的 `Items` 不对称——`SubordinateShopIDs` 只在代理账号上计算,而本能力只放行超级管理员与平台账号,因此该差异当前不可达,不为它新增无法验证的分支;若未来把该 Query 复用于带店铺范围的账号,`Totals` 与导出合计行必须同步收敛到 scope 聚合(已在 `internal/query/operationsreport/query.go` 就地注释)。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。口径歧义(采购数量、代理维度、时间参数形态)已在 D6/D10/D11 裁决并落地,不阻塞任务拆分。
|
||||
Reference in New Issue
Block a user