Files
junhong_cmp_fiber/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/design.md

102 lines
10 KiB
Markdown
Raw 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.
## Context
动机及兼容影响见 `proposal.md`;可观察契约及验收条件以本 Change 的 `specs/package-lifecycle/spec.md` 为准。开工检查后三项方案已确认,本设计不保留影响实施的未决问题。
| 当前事实 | 代码位置 | 对设计的约束 |
| --- | --- | --- |
| 后台按载体读取全部世代,状态筛选后内存排序、计数、切页 | `internal/service/asset/service.go:766-881``internal/store/postgres/package_usage_store.go:288-301` | 保留载体与世代范围,不复用旧明细分页结果组装关系 |
| H5 按客户绑定资产当前世代查询,状态和类型筛选后数据库明细分页 | `internal/handler/app/client_asset.go:117-175,467-554,590-612` | 在完整当前代集合上建立关系,不能使用当前页判断父项缺失 |
| 使用记录包含 `gorm.Model``master_usage_id`、可空生效时间和世代 | `internal/model/package.go:62-98` | 区分物理缺失、软删除和范围外存在,明确空时间排序 |
| 商品可批量含软删除读取H5 类型筛选仍排除已软删除商品 | `internal/store/postgres/package_store.go:129-138``internal/handler/app/client_asset.go:498-500` | 展示商品集合不等于类型筛选命中集合 |
| 公共套餐 DTO 同时供历史、当前套餐和修改接口使用 | `internal/model/dto/asset_dto.go:135-168``internal/routes/asset.go:42-73` | 使用历史专用节点,避免对公共 DTO 的修改扩大响应变化 |
| 数据权限在 Store 显式过滤,不是自动 GORM 权限插件 | `internal/bootstrap/bootstrap.go:60-69``pkg/middleware/data_scope.go:23-33` | 必须保留调用入口授权,不能仅传递 context 就声称有资产隔离 |
## Goals / Non-Goals
**Goals:**
- 将主子归属、关系异常、整组筛选、排序、计数和分页收口到一个只读 Query两端相同范围输入得到相同关系结果。
- 明确两端范围和字段投影差异,不以统一实现为由统一权限、金额字段或失败策略。
- 主项数量增长不增加逐组数据库查询;完整组不跨页。
**Non-Goals:**
- 不修改套餐状态、激活、退款、失效、流量或金额计算,不新增 Schema、关联表、写接口、外部调用或依赖。
- 不将卡及其关联设备合并为新资产范围,不扩大 H5 历史世代,不缩小后台历史世代。
- 不重构其他套餐视图或当前套餐/修改套餐接口,不在本 Change 修复存量企业授权问题。
## Decisions
### 1. 一个共享关系 Query保留两端字段映射
在现有 `internal/query/asset` 下新增 `package_history.go`,负责批量读取使用记录和关系投影。输入须来自已经通过既有入口校验的资产类型、资产 ID、可选世代范围以及该入口已有筛选分页参数资产类型只接受已解析的卡或设备不能让未知载体退化为无范围查询。后台不传世代限制H5 传资产实体当前世代。
Query 返回选中顶层关系节点及 `total`,节点保留原使用记录供入口投影。后台 `Service.GetPackages` 与 H5 `GetPackageHistory` 消费同一关系规则;已有商品批量加载和字段映射保留入口差异,不在两个入口各自实现分组、排序或分页。复用 `PackageStore.GetByIDsUnscoped`,不逐节点加载商品。
拒绝在后台和 H5 分别写 assembler两端会再次出现筛选及异常判断漂移。也不复用 `CustomerViewService.GetMyUsage`:该视图只取部分状态、只保留单个主项且逐使用记录加载商品,不适合多主套餐历史。
### 2. 关系查询先于展示筛选和分页
```text
入口认证及资产校验
→ 固定资产/世代范围,一次读取未软删除的全部使用记录
→ 建立使用记录 ID 索引和 master_usage_id 关系
→ 对未解析主 ID 一次批量最小存在性核对
→ 形成完整关系组及物理缺失异常项
→ 同一成员联合匹配筛选,选中整组
→ 顶层与子项排序,计算顶层 total切顶层页
→ 各入口批量商品补充与字段投影,保留各自响应壳
```
不将 `status` 或展示分页施加到关系基础集合。H5 类型筛选的匹配资格另按既有未软删除商品查询口径取得,只需当前资产使用记录引用的商品 ID不能把 `Unscoped` 商品展示映射直接作为类型命中资格。组内任一条记录同时满足所有请求条件才使组命中,不允许父项匹配状态、子项匹配类型来拼成一次联合命中。
基础 usage 查询一次;未解析主 ID 去重后至多一次存在性查询;类型资格和商品补充均批量查询。空 ID 集合不发查询,数据库读取次数不随主项数量线性增长。不是按主项循环查询子项,也不是先切明细页再补父项。
### 3. 物理缺失判定不扩大展示权限
基础集合沿用原未软删除 usage 范围。仅对其中真实持久化引用、但不在索引中的主 ID执行一次含软删除的最小存在性核对只选择判定所需的 ID、载体、世代和删除标记。不得使用客户端任意父 ID 批量探测,不得读取或返回范围外主套餐业务内容。
- 全局物理不存在:创建既定 `master_missing` 异常独立项。
- 物理存在但不在允许展示集合:整个查询经既有全局 ErrorHandler 返回统一读取错误,不返回部分列表,不透露是其他资产、其他世代还是软删除,也不静默丢掉子项。不能借 `Unscoped` 将软删除主项重新展示。
- 存在性核对失败:按读取失败处理,不将失败当成不存在。
- 仅商品不存在:沿用名称快照等历史回退,不影响 usage 关系判定。
关系完整性在展示筛选及切页前判定;范围集合内存在不可展示父项时,不因该子项恰好不在请求页或不命中筛选而掩盖错误。代价是异常数据会阻断本次历史读取,但这比泄露范围外数据或返回错误关系更安全。
### 4. 排序、分页和响应结构
严格执行 delta spec 的三桶子项排序,不能用 `priority` 或创建时间倒序替代生效时间规则。待生效以状态判断,生效时间为空的非待生效记录进入独立中间桶,不重写其状态。顶层同创建时间按 ID 倒序,子项同桶同时间按 ID 正序,排序比较条件构成确定顺序。
先选组再计算 `total` 和切顶层页。异常独立项以自己的创建时间参与顶层排序,每项独立计数;子项数大于页大小时仍完整随组返回。保留后台 50100、H5 20100 的默认/最大页大小,以及后台 `page_size`、H5 `size` 响应差异。
历史专用节点保留该入口原有套餐字段,普通主项显式输出 `master_usage_id=null`,不继承旧字段 `omitempty` 导致缺失的行为;空子数组始终为 `[]`。正常节点省略关系异常字段,叶子不展开;只有物理缺失项输出既定异常状态及中文名称,不新增其他异常独立项状态。不要直接扩展公共 `AssetPackageResponse` 而使当前套餐及修改接口也出现层级字段。
### 5. 字段与错误兼容
- 后台继续仅向平台类账号填充 `paid_amount`按现状保留其他历史字段H5 不因复用而补出原先未填的订单、退款、金额和生效条件字段。主项和子项执行相同入口字段策略。
- 商品名称优先使用 usage 快照,空快照再回退到商品名称。商品软删除仍可用于历史展示,不等于 usage 软删除或主记录物理缺失。
- 后台商品批量读取失败仍沿用原先告警并继续快照投影的路径H5 同类错误仍返回失败。usage 读取、类型资格查询或主记录存在性核对失败不适用后台商品降级路径。
- 查询仅保留既有授权机制,不声称因此修复了企业授权隔离。后台角色允许企业账号而当前套餐链未见企业授权过滤,是静态检查发现的存量风险,未做账号运行验证;另行处理,不增加本期修复任务。
### 6. 文件归属
| 文件 | 本期职责 |
| --- | --- |
| `internal/query/asset/package_history.go`(实施时新增) | 共享关系读取、批量存在性核对、类型匹配资格、整组筛选、排序、计数和分页 |
| `internal/service/asset/service.go` | 后台接入 Query保留资产范围、平台成本字段和商品错误策略 |
| `internal/handler/app/client_asset.go` | H5 接入 Query保留客户绑定、当前世代、历史字段和统一分页响应 |
| `internal/model/dto/asset_dto.go``internal/model/dto/client_asset_dto.go` | 历史专用层级节点和列表响应;不改变其他接口公共字段契约 |
| `internal/routes/asset.go``internal/routes/personal.go` | 更新历史响应类型、分页筛选描述及异常说明 |
| `internal/bootstrap/``cmd/api/docs.go``cmd/gendocs/main.go` | 仅在 Query 注入/构造依赖变化时同步真实装配及两套文档装配 |
## Risks / Trade-offs
- [分页与筛选不向后兼容] → proposal 明确标记 BREAKING后台和 H5 消费者须同步适配,不以保留字段名冒充语义兼容。
- [全量关系集合与大组增加内存/响应体] → 只读取当前入口已有资产范围;商品和存在性核对保持批量、最小字段。页大小约束顶层项而非子项,不能截断子项破坏契约。
- [误判缺失或越界补显] → 未解析主 ID 只做内部最小存在性核对,存在但不可展示时整体失败;不返回范围外业务数据及具体原因。
- [共享 DTO 泄漏字段或影响无关接口] → 使用历史专用节点,保留入口字段投影,核对当前套餐和修改接口响应不变。
- [同时存在多个读取失败策略] → 明确只有后台商品补充读取可沿用既有快照降级,关系事实查询不得降级成缺失。
## Migration Plan
无需数据库迁移或数据回填。实现完成后,在隔离环境按 delta spec 对两条历史入口做只读 smoke核对实际 JSON、页边界、授权拒绝和查询次数按项目决定不恢复自动化测试。
发布前同步消费者对层级结构、顶层 `total`、整组筛选、默认展开和整体读取错误的处理,核对 OpenAPI 与实际响应一致。生产发布由维护者按既有运行说明执行。回滚时 API 与对应消费者一起退回原平铺契约;由于没有本期写入或 Schema 变化,不做数据回滚。不在本次规划更新中执行构建、生成器或任何业务调用。