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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-31
|
||||
@@ -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 裁决并落地,不阻塞任务拆分。
|
||||
@@ -0,0 +1,52 @@
|
||||
## Scope
|
||||
|
||||
- 迭代编号:`AUG26-015`(需求依据 PRD §2.17「已确认的报表基线」;字段与追溯细节见 `111.md` §22)。
|
||||
|
||||
## Why
|
||||
|
||||
首期运营分析需要统一的设备激活与套餐续费统计,且历史报表不得因实时状态变化而口径漂移。当前规划把同一能力写成两套互相冲突的方案(按卡首次激活时间的实时 Query 与每日日报快照),指标集、维度集、时间语义与续费口径均与 PRD §2.17 不一致,无法直接实施。
|
||||
|
||||
此外,本系统不存在「截至某历史时点是否已实名」的查询能力,`tb_iot_card.real_name_status` 是可逆转的当前状态、`first_realname_at` 只记首次且不再更新,因此实时 Query 方案在事实层面不成立,每日快照是唯一可行解。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **能力归一**:删除并行方案与 `operations-reporting` 能力,只保留单一能力 `operations-report`;唯一方案为每日日报快照。
|
||||
- 新增三张快照表:日级头行(一日一行、快照日期唯一)、设备粒度激活行、到期事件粒度续费行,并新增每日生成任务。
|
||||
- 上线前日期不提供报表、不回填历史;不提供按日期区间批量生成快照的入口。
|
||||
- 查询与导出只读快照表,不回查设备、卡、套餐使用等实时表;累计类指标一律取所选结束日的快照,结束日无快照时为空且不就近回退。
|
||||
- 激活指标:采购数量取截至快照日系统内未删除的设备数;累计激活数取「任一当前有效关联卡已实名」的设备数;累计在网数取已实名且存在有效主套餐的设备数;活跃用户数取真流量合计大于零的设备数;累计用量取设备自身与当前有效关联卡当前有效使用记录的真已用量之和并折算 GB。
|
||||
- 续费指标:以到期日锚定,到期数与续费数按资产去重,续费资产为到期资产的子集,续费率不超过 100% 由构造保证。
|
||||
- 维度与归属:单分组维度(激活表七项、续费表六项),未选择时汇总为一行;快照行冻结店铺、业务员、用户组,并同时冻结「代理」的两个候选取值。
|
||||
- 新增六个受控入口:两张报表各自的汇总、趋势与异步导出。
|
||||
- 时间筛选统一为 `start_time`/`end_time`(带显式时区的 RFC3339 秒级、闭区间),快照日期按「零点落入区间」判定;趋势以 `granularity=day|month` 表达。
|
||||
|
||||
不破坏既有行为:全部为新增能力,未引入 **BREAKING** 变更。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改既有列表、导出、套餐、钱包、订单逻辑;不复用第 12/13/14/15 项报表或导出的粒度与冻结口径。
|
||||
- 不引入金额口径:本次不含实收续费金额,登记为后续候选。
|
||||
- 不建采购或入库台账。
|
||||
- 不回填历史,不提供补跑接口与区间批量生成入口。
|
||||
- 后端不做图表渲染与自然语言文字总结渲染,只提供数据与合计值。
|
||||
- 不修既有日流量记录表的结构漂移(独立 Change 处理,本次不以其为数据源)。
|
||||
- 不新增、不修改既有导出任务表结构,不改导出三段式流水线。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `operations-report`: 设备激活与套餐续费日报快照的生成、冻结口径、单维度分组与合计、日/月趋势、异步导出与权限数据范围。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- 无。既有 `export-task`、`operations-audit`、`identity-access` 的行为不变;导出复用既有导出任务与既有审计动作,不新增动作码。
|
||||
|
||||
## Impact
|
||||
|
||||
- Schema:新增成对迁移与三张快照表(含唯一键与索引)。
|
||||
- Worker:新增快照生成任务类型、处理器注册、队列映射与每日调度(上海时区)。
|
||||
- HTTP:新增后台路由与 Handler,需同步运行时文档装配与离线文档生成入口的占位装配。
|
||||
- 导出:新增两个导出场景常量、两个数据源实现、注册表与支持场景判定、DTO 联合类型白名单、严格时间场景判定,以及两个受控导出端点。
|
||||
- 权限与审计:新增路由组级门禁(挂功能前缀);查询不写统一审计事件;导出复用既有导出任务审计。
|
||||
- 文档与证据链:更新架构导航,并同步 `docs/verification/context-reset/` 的两份 JSON(行为 Requirement 证据链与入口—能力—Requirement 矩阵,含 6 条 HTTP 入口与 1 条异步入口)。
|
||||
@@ -0,0 +1,185 @@
|
||||
## Purpose
|
||||
|
||||
为 2026 年 8 月迭代的「报表管理」提供可验证行为契约:每日冻结设备激活与套餐续费日报快照,报表查询、日/月趋势与异步导出只读该快照,上线前日期无快照且不回填历史。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 每日报表快照与不回填历史
|
||||
|
||||
系统 SHALL 在每个上海自然日结束后为前一自然日生成一份日报快照,覆盖设备激活情况与套餐续费情况两组指标。快照 MUST 以快照日期为唯一键一日一份;同一日期重复生成 MUST 整日替换三张快照表的当日行,其结果 MUST 等于最后一次执行的结果,MUST NOT 产生重复行或第二套口径。
|
||||
|
||||
系统 MUST NOT 提供按日期区间批量生成快照的入口,MUST NOT 回填上线前日期。查询与导出 MUST 只使用快照事实,MUST NOT 回查设备、卡、套餐使用等实时事实。
|
||||
|
||||
#### Scenario: 上线前日期无快照且不回退
|
||||
|
||||
- **WHEN** 请求人选定的快照日期没有任何快照
|
||||
- **THEN** 系统返回「无快照」标记与空的实际命中快照日期集合,累计类指标及其派生指标为空,MUST NOT 用更早日期的快照或实时事实代替
|
||||
|
||||
#### Scenario: 同日重复生成口径不变
|
||||
|
||||
- **WHEN** 同一快照日期被再次生成(任务重试或受控重跑)
|
||||
- **THEN** 该日全部快照行先被整日删除再整日写入,行数与指标值等于单次生成的结果
|
||||
|
||||
#### Scenario: 生成失败与重试
|
||||
|
||||
- **WHEN** 某日快照生成失败并进入既有任务重试
|
||||
- **THEN** 重试仍以同一目标日期生成,成功前该日不残留部分口径,失败摘要不泄露内部细节
|
||||
|
||||
#### Scenario: 上线后形成连续序列
|
||||
|
||||
- **WHEN** 功能上线后连续自然日正常生成
|
||||
- **THEN** 每个已上线日期各存在一份快照,构成可推导新增指标的连续序列
|
||||
|
||||
### Requirement: 激活情况指标口径
|
||||
|
||||
系统 SHALL 按下列口径在快照中记录设备激活指标:
|
||||
|
||||
- 采购数量 MUST 为截至快照日系统内未删除的设备数。
|
||||
- 累计激活数 MUST 为「任一当前有效关联卡已实名」的设备数。
|
||||
- 激活率 MUST 为累计激活数除以采购数量;采购数量为零时该指标不可计算。
|
||||
- 新增激活数 MUST 为快照日累计激活数减去前一快照日累计激活数。
|
||||
- 累计在网数 MUST 为已实名且存在有效主套餐的设备数,有效主套餐 MUST 为主套餐、状态为生效或已用完且未退款。
|
||||
- 活跃用户数 MUST 为真流量合计大于零的设备数。
|
||||
- 累计用量 MUST 为该设备自身与当前有效关联卡的当前有效使用记录的真已用量之和,并以 1 GB = 1024 MB 折算为 GB。
|
||||
- 单用户卡均 MUST 为累计用量除以累计在网数;含零预测卡均 MUST 为累计用量除以累计在网数再按当月已过天数与当月总天数年化;不含零预测卡均 MUST 以活跃用户数为分母按同一方式年化。
|
||||
|
||||
累计类指标 MUST 取所选结束日的快照。实名状态可逆转,因此累计激活数 MAY 下降、新增激活数 MAY 为负;系统 MUST NOT 以零为下限,MUST NOT 改用首次实名时间代替当前实名状态。
|
||||
|
||||
真流量 MUST 只取套餐使用记录的真已用量,MUST NOT 使用卡级全生命周期累计、自然月累计、运营商通道累计读数、虚用量或展示用量。
|
||||
|
||||
#### Scenario: 设备多卡仅一卡已实名
|
||||
|
||||
- **WHEN** 某设备当前有效关联多张卡且仅其中一张已实名
|
||||
- **THEN** 该设备计入累计激活数一次
|
||||
|
||||
#### Scenario: 实名逆转导致累计下降
|
||||
|
||||
- **WHEN** 已实名设备在后续快照日不再满足任一关联卡已实名
|
||||
- **THEN** 当日累计激活数低于前一日,且新增激活数为负数
|
||||
|
||||
#### Scenario: 分母为零
|
||||
|
||||
- **WHEN** 采购数量为零
|
||||
- **THEN** 激活率在接口中为空值,在导出中写作「-」,其余比率与卡均同样处理
|
||||
|
||||
#### Scenario: 真流量来源受控
|
||||
|
||||
- **WHEN** 某设备的关联卡同时存在全生命周期累计、自然月累计与通道累计读数
|
||||
- **THEN** 累计用量与活跃用户数只按套餐使用记录的真已用量计算,不因上述读数改变
|
||||
|
||||
### Requirement: 套餐续费指标口径
|
||||
|
||||
系统 SHALL 以到期日锚定记录套餐续费指标:
|
||||
|
||||
- 到期事实 MUST 为未退款主套餐的到期日(上海自然日)等于快照日的记录。
|
||||
- 续费事实 MUST 为该到期资产存在另一条未退款主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
|
||||
- 到期数与续费数 MUST 按资产去重;同一资产在同一统计期内多次到期或多次续费 MUST 各计一次。
|
||||
- 续费率 MUST 为续费数除以到期数;新增未续费数 MUST 为到期数减续费数。
|
||||
- 续费资产集合 MUST 为到期资产集合的子集,使续费率不超过 100% 由构造保证;系统 MUST NOT 通过截断或钳制掩盖真实数据。
|
||||
|
||||
到期日与相关套餐事实 MUST 在生成快照时冻结,后续到期时间被修改 MUST NOT 改写已生成快照。自外部系统迁移进入本地事实的资产 MUST 与本地购买资产采用同一口径。
|
||||
|
||||
#### Scenario: 到期当天存在待生效后续主套餐
|
||||
|
||||
- **WHEN** 某资产主套餐在快照日到期且当天已存在待生效的后续主套餐
|
||||
- **THEN** 该资产计一次到期并计一次续费,续费率为 100%
|
||||
|
||||
#### Scenario: 同一资产期内两次到期
|
||||
|
||||
- **WHEN** 某资产在同一统计期内有两条主套餐分别到期
|
||||
- **THEN** 到期数与续费数各计该资产一次
|
||||
|
||||
#### Scenario: 到期时间在快照后被修改
|
||||
|
||||
- **WHEN** 某主套餐的到期时间在快照生成后被修改
|
||||
- **THEN** 已生成快照的到期日与全部指标保持不变
|
||||
|
||||
#### Scenario: 新增未续费数
|
||||
|
||||
- **WHEN** 某统计期到期资产数为 N、其中续费资产数为 M
|
||||
- **THEN** 新增未续费数为 N 减 M,且不小于零
|
||||
|
||||
### Requirement: 报表维度分组与归属冻结
|
||||
|
||||
系统 SHALL 支持单一分组维度:激活情况表按设备名称、设备型号、制造商、用户组、代理、店铺、业务员中的一个维度分组,套餐续费情况表按套餐系列、套餐名称、用户组、代理、店铺、业务员中的一个维度分组;未选择分组维度时 MUST 汇总为一行且分组列值为「全部」。系统 MUST NOT 同时按多个维度分组。
|
||||
|
||||
快照行 MUST 在生成时冻结设备所属店铺、店铺业务员与业务员所属用户组(用户组 MUST 按既有实时推导口径取值后冻结),并 MUST 同时冻结代理的两个取值:沿上级店铺上溯至根的代理店铺及其名称、归属该店铺的代理账号及其名称;查询侧 MUST 将二者映射为单一「代理」维度。设备名称、设备型号与制造商 MUST 取生成时的设备取值,为空时以固定占位展示。
|
||||
|
||||
历史快照行 MUST NOT 因后续店铺、业务员、用户组或代理变化被改写。同一快照日期、同一维度下,分组行各指标之和 MUST 等于该日头行指标值。
|
||||
|
||||
#### Scenario: 未选择分组维度
|
||||
|
||||
- **WHEN** 请求未指定分组维度
|
||||
- **THEN** 系统返回一行汇总结果,分组列值为「全部」
|
||||
|
||||
#### Scenario: 快照后归属变更
|
||||
|
||||
- **WHEN** 某设备在快照生成后变更所属店铺、业务员或用户组
|
||||
- **THEN** 已生成快照行的店铺、业务员与用户组保持生成时的取值
|
||||
|
||||
#### Scenario: 分组行之和等于头行值
|
||||
|
||||
- **WHEN** 以任一受支持维度分组查询同一快照日期
|
||||
- **THEN** 各组指标之和等于该日头行指标值
|
||||
|
||||
### Requirement: 报表查询与趋势
|
||||
|
||||
系统 SHALL 提供汇总与趋势查询。时间筛选用 `start_time` 与 `end_time` 两个可选参数,取值 MUST 为带显式时区的 RFC3339 秒级时间,区间 MUST 为闭区间(含两端),解析结果 MUST 归一为 UTC 瞬时;非法格式与开始晚于结束 MUST 以既有参数非法错误码拒绝,MUST NOT 提供宽松格式兼容或运行时开关。
|
||||
|
||||
快照日期入选规则 MUST 为:快照日期 D 入选,当且仅当 D 的零点(+08:00)落在请求区间内;请求人 MUST 以整日边界表达区间。
|
||||
|
||||
趋势查询 MUST 以 `granularity` 取值 `day` 或 `month` 表达粒度,MUST NOT 接受月份参数:按日每个快照日一点,按月每个自然月一点;累计类指标 MUST 取该期最后一个有快照日的快照值,新增类指标 MUST 取相邻期同口径之差;无快照的期 MUST NOT 出现在结果中。系统 MUST 只返回数据,图表渲染 MUST 由前端负责。
|
||||
|
||||
#### Scenario: 闭区间含两端
|
||||
|
||||
- **WHEN** 请求的开始时间与结束时间均等于某快照日零点
|
||||
- **THEN** 该快照日入选结果
|
||||
|
||||
#### Scenario: 非法时间参数
|
||||
|
||||
- **WHEN** 请求携带无时区时间、date-only、空格分隔时间或开始晚于结束的参数
|
||||
- **THEN** 系统以参数非法错误码拒绝,且不返回任何行
|
||||
|
||||
#### Scenario: 按月趋势跳过无快照月份
|
||||
|
||||
- **WHEN** 请求按月趋势且区间内某自然月没有快照
|
||||
- **THEN** 该月不出现在趋势结果中
|
||||
|
||||
#### Scenario: 累计取期末快照
|
||||
|
||||
- **WHEN** 请求自定义时间段且区间内多个日期存在快照
|
||||
- **THEN** 累计类指标取结束日快照值,新增激活数按结束日与起始日前一日的累计差值计算
|
||||
|
||||
### Requirement: 报表权限、数据范围与导出冻结
|
||||
|
||||
仅超级管理员与平台用户 SHALL 查询或导出报表;其余用户类型 MUST 被拒绝,且无权限与目标不存在 MUST NOT 形成可枚举差异。
|
||||
|
||||
查询与导出 MUST 按请求人可见店铺范围过滤快照行;越权与不存在 MUST 统一按资源不可见处理。
|
||||
|
||||
导出 MUST 复用既有异步导出任务:创建时冻结筛选条件、操作者与可见店铺范围,派发时冻结表头,执行期 MUST 只读快照表并施加冻结的店铺范围。导出列 MUST 与页面展示字段一致并包含合计行;分母为零的比率或卡均 MUST 写作「-」;导出 MUST NOT 包含顶部文字总结。执行期 MUST 按任务内冻结的账号类型复核导出资格,MUST NOT 因创建者角色、店铺归属或筛选条件变化扩大数据集或重新解释筛选。
|
||||
|
||||
#### Scenario: 无权用户类型请求报表
|
||||
|
||||
- **WHEN** 代理、企业或个人客户账号请求报表或创建报表导出
|
||||
- **THEN** 系统拒绝请求且不返回任何统计结果,不区分无权限与不存在
|
||||
|
||||
#### Scenario: 导出与列表同筛选同口径
|
||||
|
||||
- **WHEN** 以同一筛选条件调用汇总查询并创建导出
|
||||
- **THEN** 导出行集合与汇总结果一致,列与页面展示字段一致且包含合计行
|
||||
|
||||
#### Scenario: 创建后权限变化
|
||||
|
||||
- **WHEN** 导出任务创建后创建者的可见店铺范围发生变化再执行该任务
|
||||
- **THEN** 导出结果仍不超过创建时冻结的范围与筛选条件
|
||||
|
||||
#### Scenario: 执行期账号类型复核
|
||||
|
||||
- **WHEN** 导出任务执行时任务内冻结的账号类型不是超级管理员或平台用户
|
||||
- **THEN** 任务失败并记录安全失败摘要,不产出数据文件
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
`GET /api/admin/operations-reports/activation-summary`(激活情况汇总);`GET /api/admin/operations-reports/activation-trend`(激活情况日/月趋势);`POST /api/admin/operations-reports/activation-summary/export`(创建激活情况导出任务);`GET /api/admin/operations-reports/package-renewal-summary`(套餐续费汇总);`GET /api/admin/operations-reports/package-renewal-trend`(套餐续费日/月趋势);`POST /api/admin/operations-reports/package-renewal-summary/export`(创建套餐续费导出任务)。
|
||||
@@ -0,0 +1,59 @@
|
||||
## 1. 表与模型
|
||||
|
||||
- [x] 1.1 新增成对迁移 `migrations/000231_create_operations_report_snapshot.up.sql` / `.down.sql`:日级头行表(`snapshot_date` 唯一)、设备粒度激活行表(唯一键 `(snapshot_date, device_id)`)、到期事件粒度续费行表(唯一键 `(snapshot_date, expired_usage_id)`),含全部列、唯一索引、`(snapshot_date, shop_id)` 与 `(snapshot_date, series_id)` 索引与列注释,不设软删除列
|
||||
- [x] 1.2 新增 GORM 模型与表名映射(三张表),字段与迁移列逐一对齐
|
||||
- [x] 1.3 在隔离库执行 `up → down → up` 并核对三表结构与索引与迁移一致
|
||||
|
||||
## 2. 口径域与快照生成
|
||||
|
||||
- [x] 2.1 新增报表口径域:GB 换算常量(1 GB = 1024 MB,域内自持)、比率与卡均计算、分母为零语义(空值)、预测卡均的当月口径(结束日所在上海自然月、已过天数、当月总天数)
|
||||
- [x] 2.2 实现采购数量口径函数(截至快照日系统内未删除设备数),收敛为单点可切换(设计 D6)
|
||||
- [x] 2.3 实现激活口径:设备「任一当前有效关联卡已实名」、有效主套餐(主套餐 + 生效或已用完 + 未退款)、真流量合计与活跃判定;累计激活数允许下降、新增激活数允许为负
|
||||
- [x] 2.4 实现真流量取值集合(设备自身 + 当前有效关联卡的当前有效使用记录的真已用量)与禁止来源校验(不得取卡级全生命周期累计、自然月累计、通道读数、虚量与展示量)
|
||||
- [x] 2.5 实现续费口径:到期日锚定、接续主套餐判定(未退款、生效时间晚于本条到期或待生效)、按资产去重、分子为分母子集
|
||||
- [x] 2.6 新增生成用例(Application):单事务内先删该日三表行再整体写入,写入头行与两类明细行,并校验不变量(分组行之和等于头行值、续费资产数不大于到期资产数)
|
||||
- [x] 2.7 新增只读数据访问适配(基础设施):设备与设备属性、当前有效卡绑定、卡实名状态、套餐使用记录、店铺与业务员、业务用户组、套餐与套餐系列
|
||||
- [x] 2.8 新增任务类型常量、任务处理器与队列映射,并在 `pkg/queue/handler.go` 注册
|
||||
- [x] 2.9 注册每日调度 `CRON_TZ=Asia/Shanghai 30 3 * * *`,payload 只含单一目标上海自然日,并加去重窗口;确认时间点在到期处理与既有落盘、临期提醒之后
|
||||
- [x] 2.10 确认无任何按日期区间批量生成入口与补跑接口;失败重试沿用同一目标日期
|
||||
|
||||
## 3. 查询与趋势
|
||||
|
||||
- [x] 3.1 新增只读查询:单日/闭区间汇总(单分组维度、同期筛选、合计、头行合计),只读三张快照表
|
||||
- [x] 3.2 新增趋势查询:`granularity=day|month`,累计取期末快照、新增取相邻期之差,无快照的期不出现
|
||||
- [x] 3.3 新增请求与响应 DTO:时间参数复用共享严格解析器(带时区 RFC3339 秒级、闭区间、非法即拒),实现快照日期落界规则(D 零点落入区间),响应含是否有快照标记与实际命中快照日期集合
|
||||
- [x] 3.4 实现结束日无快照时的空值语义:累计类与派生指标为空、分组行返回空集、不回退更早快照
|
||||
- [x] 3.5 实现可见店铺范围过滤(按快照行店铺列),越权与不存在统一不可见
|
||||
- [x] 3.6 新增 Handler:四个查询入口(两张报表的汇总与趋势),错误交全局 ErrorHandler,响应用 `pkg/response`
|
||||
|
||||
## 4. 导出
|
||||
|
||||
- [x] 4.1 新增两个导出场景常量(设备激活情况、套餐续费情况)与显示名称映射:本仓库没有独立的「场景名 → 中文显示名」映射表,场景显示名只落在常量行内注释与导出任务 DTO 的两处 description 文案(白名单与文案改动见 4.4)
|
||||
- [x] 4.2 新增两个数据源实现(`Scene`/`Count`/`Headers`/`Fetch`):只读快照表、施加冻结店铺范围、`Count` 与 `Fetch` 同筛选构造、列等于页面字段并含合计行、分母为零写「-」、不含文字总结
|
||||
- [x] 4.3 在导出注册表登记两个数据源,并补齐支持场景判定列表
|
||||
- [x] 4.4 在导出任务 DTO 的联合类型白名单两处加入新场景名
|
||||
- [x] 4.5 判定并登记严格时间场景集合是否纳入新场景(若不纳入须在设计登记理由)
|
||||
- [x] 4.6 新增两个受控导出端点 Handler:创建期冻结筛选、操作者与可见店铺范围,非法时间在创建期拒绝
|
||||
- [x] 4.7 实现导出场景级角色自检(按任务内冻结的账号类型复核超管或平台),不通过时任务失败并写安全失败摘要
|
||||
|
||||
## 5. 权限与审计
|
||||
|
||||
- [x] 5.1 新增路由组级门禁,挂在功能前缀上(超管或平台),复用既有拒绝文案,确认未挂在后台根组
|
||||
- [x] 5.2 核对并登记查询不写统一审计事件(无业务事实变化,四类事实均不涉及)
|
||||
- [x] 5.3 核对导出复用既有导出任务审计动作与写入器,不新增动作码;确认任务行保存筛选、创建时间、操作者、结果文件与行数
|
||||
|
||||
## 6. 路由与文档
|
||||
|
||||
- [x] 6.1 新增路由注册文件(6 个端点含 RouteSpec 元数据)并在后台路由装配中挂载,静态路径先于动态路径
|
||||
- [x] 6.2 同步 Handler 装配六处:bootstrap Handlers 结构体、bootstrap 真实装配、文档工厂、运行时文档装配、离线文档生成入口与路由注册文件
|
||||
- [x] 6.3 运行 `go run cmd/gendocs/main.go` 连续两次,确认输出一致
|
||||
- [x] 6.4 更新 `ARCHITECTURE.md` 模块导航与 Spec 索引
|
||||
- [x] 6.5 同步 `docs/verification/context-reset/` 两份 JSON:行为 Requirement 证据链(6 条 Requirement 的入口/用例/持久化/验证命令)与入口—能力—Requirement 矩阵(6 条 HTTP 入口 + 1 条异步入口)
|
||||
|
||||
## 7. 验证
|
||||
|
||||
- [x] 7.1 结构验证:`gofmt -w`、`go build ./cmd/api ./cmd/worker`、`openspec validate add-operations-reports --strict`、`openspec doctor --json`
|
||||
- [x] 7.2 冒烟:在隔离环境手工入队快照任务,核对三表行数、头行与分组行不变量(分组行之和等于头行值、续费资产数不大于到期资产数)、同任务重复执行结果不变
|
||||
- [x] 7.3 场景验证(导出产物行级内容、列等于页面字段、无文字总结、分母为零写「-」已由真实运行验证;唯一未达项为「同一任务变更冻结权限类型前后的产物行数对照」,原因见验证记录 E-2):上线前日期无快照且不回退;同日重复执行口径不变;实名逆转导致累计下降且新增为负;设备多卡仅一卡已实名计一次;到期当天存在待生效后续主套餐计一次到期一次续费且续费率为 100%;同一资产期内两次到期各计一次;采购为零时激活率为空且导出为「-」;快照后归属变更不改写历史行;越权与不存在统一不可见;导出与列表同筛选同口径且创建后权限变化不扩大范围
|
||||
- [x] 7.4 越权与规模确认(导出执行期冻结账号类型自检已由真实运行验证,见验证记录 33):代理、企业与个人客户账号请求报表被拒绝;核对导出任务执行期账号类型自检生效
|
||||
- [x] 7.5 记录自动化测试按项目决策为 N/A,并保留冒烟命令与输出作为验证证据:无 `*_test.go`;证据为 `docs/verification/add-operations-reports-verification.md`(原始命令与原始输出),口径与解释登记于 design「实施登记(2026-09-17)」
|
||||
Reference in New Issue
Block a user