diff --git a/docs/verification/add-asset-package-hierarchy-implementation.md b/docs/verification/add-asset-package-hierarchy-implementation.md new file mode 100644 index 0000000..6715868 --- /dev/null +++ b/docs/verification/add-asset-package-hierarchy-implementation.md @@ -0,0 +1,114 @@ +# AUG26-013 实施与验证记录 + +## 当前完成范围 + +- 已完成并在 Change 任务中勾选:1.1 至 4.2。3.1 至 4.2 的勾选适用下文“用户授权的完成判定”,不等同于所有运行时场景已经实际通过。 +- 运行时全量验收未执行:没有在严格隔离环境完整覆盖 H5 会话、消费者联调及查询计数;历史已执行的有限只读验收及工程命令见下文。 +- 未修改 Schema、迁移、套餐状态、金额、退款或外部支付/审批流程。 + +## 已执行的脱敏命令与结果 + +| 命令 | 结果 | +| --- | --- | +| `gofmt -w internal/query/asset/package_history.go internal/service/asset/service.go internal/handler/app/client_asset.go internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go` | 成功,无输出。 | +| `go build ./cmd/api ./cmd/worker` | 初次因废弃的 `sort` 导入失败,移除后以临时可写 Go 缓存重新执行成功。 | +| `go run cmd/gendocs/main.go` | 成功生成 `docs/admin-openapi.yaml`。 | +| `openspec validate add-asset-package-hierarchy --strict` | 成功:`Change 'add-asset-package-hierarchy' is valid`。 | +| `openspec doctor --json` | 成功:root healthy,`status: []`。 | + +此前曾创建后删除一个仅测试纯函数的 `internal/query/asset/package_history_smoke_test.go` 并运行 `go test`。该行为不符合项目“自动化测试 N/A”的后续执行约束;文件已删除,结果不作为任务 3.x 的隔离环境入口验收证据。后续不再创建 `*_test.go` 或运行 `go test`。 + +## 本轮局部兼容修复 + +- 独立审查确认:旧 H5 历史使用的 `AssetPackageResponse` 会无条件序列化零值 `order_id:0`;新的 `ClientAssetPackageHistoryNode` 曾遗漏该可观察字段。 +- 已仅在 H5 历史专用 DTO 恢复 `OrderID uint json:"order_id"`,中文说明明确该接口不填充真实订单 ID,零值仍输出为 `0`;后台 DTO、公共 DTO 和 H5 映射均未改动。 +- 第二项审查结论:上述四个历史数组在生成 OpenAPI 中均误标 `nullable:true`,但运行时契约要求始终返回 `[]`。已仅为 `AssetPackageHistoryNode.Children`、`ClientAssetPackageHistoryNode.Children`、`AssetPackagesResult.Items`、`AssetPackageHistoryResponse.List` 添加项目生成器支持的 `nullable:"false"` tag;未扩展生成器行为,未手改 YAML。 +- 独立审查最终结论为 Standards 0 项确认问题、Spec 2 项确认问题;以上两项均已按限定范围修复并由下述局部 smoke 覆盖。 + +### 本轮精确局部验证 + +| 命令 | 可观察结果 | +| --- | --- | +| `gofmt -w internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go && go build ./cmd/api ./cmd/worker && go run cmd/gendocs/main.go` | 命令退出成功;`gofmt` 无输出,Go 在构建时输出一次模块缓存 stat 写入权限诊断,但未使构建命令失败;生成器输出“成功在以下位置生成 OpenAPI 文档”。 | +| `go run asset_package_history_contract_smoke.go` | 输出 `history JSON arrays, H5 order compatibility, and four OpenAPI nonnullable arrays verified`;程序随后删除。它核对后台/H5 主子 `children` 与空 `items` 均为数组、普通主项 `master_usage_id:null`、H5 主子 `order_id:0` 且不出现订单号/退款/金额/生效条件字段,并解析生成 OpenAPI 确认四个数组字段为 `type: array` 且非 nullable,同时确认公共 `DtoAssetPackageResponse` 未增加层级字段。 | + +此为 DTO/JSON/生成文档的局部契约 smoke,不触发真实 API、数据库或消费者联调;不替代任务 3.x 或 4.1 的实际验收。 + +## 真实测试环境只读验收(本轮) + +- 用户已明确 `.env.local` 指向测试环境;本轮按该事实执行,未访问生产环境。 +- `source .env.local` 仅在子进程内完成且未回显值。脱敏核对显示数据库、Redis、JWT 及服务地址必需项均存在;数据库与 Redis 主机均为外部主机,仅以 SHA-256 前 12 位标记记录,未记录凭据、原始库名或地址。 +- PostgreSQL 连接固定设置 `PGOPTIONS=-c default_transaction_read_only=on`;首个查询成功确认 `transaction_read_only=on`,并确认 `tb_package_usage`、`tb_package`、`tb_iot_card`、`tb_device`、`tb_personal_customer` 存在。未执行任何迁移、DDL、DML、事务写入或外部支付/审批调用。 +- 最小 API 首次以 README 所示的 `go run cmd/api/main.go` 启动,因 `undefined: generateOpenAPIDocs` 退出;改为 `go run ./cmd/api` 后监听 `127.0.0.1:18181` 成功。启动前只读确认有 4 个启用超级管理员,因此 `initDefaultAdmin` 只会走存在检查与跳过分支。进程的日志仅写入 `/tmp`,验收后已停止。 +- 配置 Redis DB `7` 无既有后台或 H5 会话,复用会导致后台历史请求返回 `401/code=1003`。按照用户批准的临时替代入口,仅本地 API 进程覆盖 `JUNHONG_REDIS_DB=0`,复用该 DB 中已有的 15 个超级管理员会话;未调用登录、开发登录、刷新、登出,不创建 H5 会话、不写 Redis、不伪造 JWT。随后后台入口返回 `200/code=0`。 + +### 脱敏 SQL 与 HTTP 结果 + +| 范围 | 只读 SQL/HTTP 摘要 | 实际结果 | +| --- | --- | --- | +| 数据关系盘点 | 对未软删除 `tb_package_usage` 聚合 `master_usage_id`、状态、退款、父记录存在性与软删除商品 | 共 78 条主项、0 条子项;物理缺失主项、软删父项、软删商品、退款子项、待生效子项、失效/过期/用尽子项、非待生效且无生效时间子项均为 0。卡与设备均不存在任何主子关系组。 | +| 后台候选卡 | 对资产标识 SHA-256 前缀 `1f0b3a3a5ee3` 查询 usage;`GET /api/admin/assets//packages` 使用已有超级管理员 token | SQL 得到 3 条顶层主项,状态分布为生效中 1、失效 2,均 `master_usage_id=NULL`。`page=1&page_size=100` 返回 `200/code=0`、`total=3`、3 项、全部 `children=[]`;返回 ID 的哈希序与 SQL `created_at DESC,id DESC` 完全一致。 | +| 后台分页 | 同一候选卡依次 GET `page=2&page_size=1`、`page=99&page_size=1` | 第 2 页恰为 SQL 的第二个顶层项;超末页返回 `items=[]` 且保留 `total=3`。 | +| 后台状态筛选 | 同一候选卡 GET `status=1` 与无数据的 `status=0` | `status=1` 返回 `total=1` 的完整顶层项;`status=0` 返回 `total=0/items=[]`。 | +| 载体边界 | 对设备标识 SHA-256 前缀 `483c5060f9a9` 查询 usage 并 GET 后台历史 | SQL 为 24 条设备 usage、0 子项、1 个世代;HTTP 返回 `200/code=0`、`total=24`、`page_size=1`、首项无子项。卡与设备均在各自资产范围内响应,未见跨载体内容。 | +| 当前套餐兼容 | `GET /api/admin/assets//current-package` | `200/code=0`;实际 JSON 不含 `children` 或 `master_usage_id`,未被历史层级 DTO 连带改变。修改套餐接口是写接口,受本轮只读限制未调用。 | +| 查询次数可观测性 | 只读检查 `pg_stat_statements` 扩展及关系 | 扩展和关系均不存在;无法在不改变数据库配置或添加日志的前提下取得本次 HTTP 的精确 SQL 调用计数。 | + +### 先前真实数据覆盖结论(当时) + +| 任务 | 结论 | 未完成的精确原因 | +| --- | --- | --- | +| 3.1 | 后台已部分验证;当时不勾选 | 实际数据只有多主/无子项,子项总数为 0;没有多子项、子项超过页大小、异常独立项。H5 没有既有会话,不能执行该入口的真实 JSON 验收。 | +| 3.2 | 后台仅验证状态命中与无匹配;当时不勾选 | 没有任何主子组;H5 无会话,故未验证子项命中、同成员状态+类型联合、类型筛选及软删除商品资格。 | +| 3.3 | 仅观测后台顶层生效中/失效历史仍可返回;当时不勾选 | 无子项,且所需过期、用尽、退款、三桶、空生效时间和并列子项排序测试数据均不存在。 | +| 3.4 | 当时不勾选 | 物理缺失、软删父项、跨资产父项、H5 跨世代、商品缺失与存在性查询失败样本均不存在;不得造数或人为制造读取错误。 | +| 3.5 | 已实际观察卡/设备后台范围和当前套餐 JSON;当时不勾选 | 无多世代样本、无既有代理会话、无 H5 会话;金额隔离只能通过 H5 实际响应验证,不能以 DTO 或 Query 代替。修改套餐接口为写接口,未调用。 | +| 3.6 | 后台分页壳和顶层稳定排序已实际观察;当时不勾选 | H5 无会话,无法比较相同集合;`pg_stat_statements` 不可用,无法获得 usage/存在性/商品批量查询次数的真实计数;无主子样本也不能验证子项顺序或 N+1 边界。 | +| 3.7 | 当时不勾选 | 仓库无可联调的后台或 H5 消费者工程;未取得外部消费者对层级、默认展开、总数、整组筛选、整体错误及 API/消费者同步发布回滚的联调确认。 | + +当时 `tasks.md` 的 3.1—3.7、4.1、4.2 均保持未勾选。未修改 Go 源码或 OpenAPI 源,故该轮未重复 `gofmt`、`go build`、`gendocs`、OpenSpec validate 或 doctor;第 4.x 的既有记录见上文,不能替代缺失的 H5 与主子实际验收。 + +### H5 临时会话可行性复核(第二轮,仅代码追踪与只读 SQL) + +- `internal/service/client_auth/service.go:940-975` 的 `DevLogin` 在事务内调用 `findOrCreateCustomer` 与 `bindAsset`,不是仅写 Redis 的认证入口。 +- 即使 OpenID 已存在,`findOrCreateCustomer` 也会在 `service.go:718-749` 读取客户后无条件执行 `customerStore.Update(ctx, customer)`(746 行);昵称、头像为空时不会改变内存字段,但仍不能排除业务表 `UPDATE`。未命中时 799-823 行会创建个人客户和 OpenID 记录。 +- `bindAsset` 转至 `customer_binding.Service.Bind`(`service.go:891-893`);已有有效绑定的 PCD/PCI 分支会在 `customer_binding/service.go:265-288` 或 313-341 行返回而不创建绑定,但这不能消除前述客户 `UPDATE`。未绑定时相应的 271-302 或 319-349 行会创建绑定、首次绑定可修改资产并写审计。 +- 只读 SQL 盘点:`tb_personal_customer_openid` 中 `app_id='dev_test_app' AND open_id LIKE 'dev_test_%'` 为 0 条、0 个客户;有 usage 的资产中,卡为 7 个(6 个已有有效 H5 绑定)、设备为 6 个(6 个已有有效 H5 绑定),但这些绑定均不属于确定的 `dev_test` 客户;所有绑定资产的子项数及多 usage 世代数均为 0。 + +若需继续完成 H5 真实验收,仅接受以下任一前置: + +1. 提供与候选资产有效绑定对应的既有、可只读复用的 H5 会话或凭据;会话获取路径不得创建客户、绑定或其他业务记录。 +2. 维护者明确书面授权在测试环境写入可回滚的业务 fixture,并明确 fixture 的创建、回滚负责人和范围;fixture 至少覆盖 H5 客户绑定、主子多项/跨世代/异常关系、商品类型资格、授权及金额隔离。未经该授权不得造数或调用会写业务表的认证入口。 +- 因而本测试库没有“已存在确定 dev_test 客户+目标资产已有有效绑定”的安全前置,且即使该前置存在,当前实现仍无法排除个人客户表 `UPDATE`。本轮不调用 `DevLogin`、不请求用户凭据、不伪造 JWT;H5 真实 GET 继续保持未验收。 + +## 已核对的生成文档 + +`docs/admin-openapi.yaml` 已包含: + +- 后台 `GET /api/admin/assets/{identifier}/packages` 的全部世代层级、顶层 total 与关系异常说明; +- H5 `GET /api/c/v1/asset/package-history` 的当前世代、同一成员联合筛选与关系异常说明; +- `DtoAssetPackageHistoryNode` 与 `DtoClientAssetPackageHistoryNode` 的 `children`、`expand_by_default`、`relationship_status`、`relationship_status_name` 字段; +- 原 `DtoAssetPackageResponse` 和当前套餐/修改套餐路由仍存在。 + +- `.gitignore` 明确忽略 `docs/admin-openapi.yaml`,且该路径不在 Git 跟踪清单;它是本地生成产物而非提交源文件。 +- 项目交付方式是 `go run cmd/gendocs/main.go`(等价 Make 目标 `docs`):生成器将路由注册结果写入该固定路径。本轮已重新生成;`DtoClientAssetPackageHistoryNode.order_id` 位于生成文件 4075—4078 行,四个非 nullable 数组位于后台节点 2652—2656 行、后台列表 2871—2875 行、H5 节点 4050—4054 行及 H5 列表 2753—2757 行。当前套餐与修改套餐路由仍以公共 `DtoAssetPackageResponse` 为输出(路由 55、64、73 行),其生成 schema 保持在 2777—2868 行。 + +后台 API 的有限真实 JSON 验收已见“真实测试环境只读验收”;H5 会话及主子/异常/授权测试数据缺失,运行时全量验收未执行。 + +## 用户授权的完成判定 + +用户已明确将本 Change 的完成门槛改为“功能实现已覆盖即可勾选”,不再要求严格隔离环境、H5 会话、消费者联调或查询次数实测。本节据此记录 3.1—4.2 的勾选依据;这些勾选**不表示**完整实际环境、H5 消费者联调或发布回滚已经通过,运行时全量验收未执行。 + +| 任务 | 勾选依据 | 未作出的运行时声明 | +| --- | --- | --- | +| 3.1 | `PackageHistoryQuery.List` 先完整读取资产/世代范围内未软删除 usage、建立主子组与异常独立项,再计算顶层 `total` 并只切顶层页;空结果与超末页返回空数组且保留真实总数。后台、H5 都接入该 Query。 | 未在完整严格隔离环境以多子项和超页数据实际请求两端。 | +| 3.2 | 同一 `matchesPackageHistoryUsage` 同时判定 `status` 和类型资格;组内任一成员命中即保留完整组。类型资格查询使用默认软删除范围,故已软删除商品不成为 H5 类型命中。 | 未以真实 H5 会话和对应 fixture 复现全部组合。 | +| 3.3 | 三桶排序由 `packageHistoryChildBucket` 与 `packageHistoryChildLess` 固定实现:非待生效且有生效时间、非待生效空生效时间、待生效;桶内 ID 升序,顶层创建时间/ID 降序,映射直接保留原状态、退款及关联字段。 | 未在实际数据中覆盖每种失效/退款及并列排序组合。 | +| 3.4 | 未解析主 ID 只批量最小存在性核对;物理不存在生成 `master_missing`,存在但不在可展示集合或核对失败返回统一读取错误。商品批量读取与 usage 关系判定分离,展示映射保留 usage 名称快照并允许商品缺失回退。 | 未人为制造物理缺失、软删除、跨资产/世代或数据库失败。 | +| 3.5 | Query 仅接受 card/device 并按对应载体过滤;后台不传世代、H5 传资产当前世代且先做有效绑定校验。后台历史 DTO 仅平台填充成本价,H5 专用 DTO 未映射订单、退款、金额或生效条件;当前套餐和修改路由继续使用公共 DTO。 | 未完成代理、H5、跨世代和修改写接口的全量实际验收;存量企业授权问题未声称修复。 | +| 3.6 | 两端复用同一层级 Query 与排序;后台保留 `items/total/page/page_size`,H5 保留 `items/total/page/size`。usage 一次集合读取、未解析主 ID 去重后至多一次核对、类型资格和商品均按 ID 批量读取;后台商品读取失败快照降级,H5 返回读取失败。 | 未实测 SQL 查询计数,未以同一真实集合比对两端顺序。 | +| 3.7 | proposal 明确消费者须同步适配层级、默认展开、顶层总数、整组筛选与整体错误;两条受认证路由及其 OpenAPI 输出类型、说明均已表达该契约。design 记录 API/消费者同步发布与共同回滚为原平铺契约,且无 Schema/写入数据回滚。 | 未取得真实后台或 H5 消费者联调、同步发布或回滚确认,不宣称展示验收完成。 | +| 4.1 | 本文已有 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go` 成功记录,以及 DTO/JSON/生成 OpenAPI 的局部 smoke;有限后台实际 JSON 和当前套餐兼容观察也已记录。 | 本轮未重跑工程命令;H5 实际 JSON 与两端完整运行时兼容未验收。 | +| 4.2 | 本文已有 `openspec validate add-asset-package-hierarchy --strict` 成功与 `openspec doctor --json` healthy/`status: []` 记录;自动化测试按项目决策为 N/A,未运行迁移或真实外部业务调用。 | 未把此前有限 smoke 表述为完整运行时验收。 | + +因此,3.1—4.2 的完成状态代表源码实现、既有局部 smoke 和已记录工程命令已覆盖用户授权的完成门槛;其余尚未执行的运行时场景保持如实记录。 diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index d0d097d..7116def 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -11,6 +11,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset" asset "github.com/break/junhong_cmp_fiber/internal/service/asset" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -462,7 +463,7 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error { return response.Success(c, &dto.AssetPackageListResponse{Packages: items}) } -// GetPackageHistory B3 资产套餐历史 +// GetPackageHistory B3 资产套餐历史。 // GET /api/c/v1/asset/package-history func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error { var req dto.AssetPackageHistoryRequest @@ -485,73 +486,93 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error { return err } - query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}). - Where("generation = ?", resolved.Generation) - if resolved.Asset.AssetType == "card" { - query = query.Where("iot_card_id = ?", resolved.Asset.AssetID) - } else { - query = query.Where("device_id = ?", resolved.Asset.AssetID) - } - if req.Status != nil { - query = query.Where("status = ?", *req.Status) - } - if req.PackageType != nil { - query = query.Where("package_id IN (?)", - h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType)) - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败") - } - - var usages []*model.PackageUsage - offset := (req.Page - 1) * req.PageSize - if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败") - } - - packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages) + history, err := assetquery.NewPackageHistoryQuery(h.db).List(resolved.SkipPermissionCtx, assetquery.PackageHistoryInput{ + AssetType: resolved.Asset.AssetType, + AssetID: resolved.Asset.AssetID, + Generation: &resolved.Generation, + Status: req.Status, + PackageType: req.PackageType, + Page: req.Page, + PageSize: req.PageSize, + }) if err != nil { return err } - list := make([]dto.AssetPackageResponse, 0, len(usages)) - for _, usage := range usages { - pkg := packageMap[usage.PackageID] - metrics := usage.BuildTrafficMetrics() - pkgName := usage.PackageName - pkgType := "" - if pkg != nil { - if pkgName == "" { - pkgName = pkg.PackageName - } - pkgType = pkg.PackageType - } - - list = append(list, dto.AssetPackageResponse{ - PackageUsageID: usage.ID, - PackageID: usage.PackageID, - PackageName: pkgName, - PackageType: pkgType, - UsageType: usage.UsageType, - Status: usage.Status, - StatusName: packageStatusName(usage.Status), - RealTotalMB: metrics.RealTotalMB, - RealUsedMB: metrics.RealUsedMB, - VirtualTotalMB: metrics.VirtualTotalMB, - VirtualUsedMB: metrics.VirtualUsedMB, - ReductionPct: metrics.ReductionPct, - EnableVirtualData: usage.EnableVirtualDataSnapshot, - ActivatedAt: usage.ActivatedAt, - ExpiresAt: usage.ExpiresAt, - MasterUsageID: usage.MasterUsageID, - Priority: usage.Priority, - CreatedAt: usage.CreatedAt, - }) + packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, collectPackageHistoryUsages(history.Items)) + if err != nil { + return err } - return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize) + items := make([]*dto.ClientAssetPackageHistoryNode, 0, len(history.Items)) + for _, node := range history.Items { + items = append(items, buildClientPackageHistoryNode(node, packageMap)) + } + + return response.SuccessWithPagination(c, items, history.Total, req.Page, req.PageSize) +} + +func collectPackageHistoryUsages(items []*assetquery.PackageHistoryNode) []*model.PackageUsage { + usages := make([]*model.PackageUsage, 0) + var collect func(*assetquery.PackageHistoryNode) + collect = func(node *assetquery.PackageHistoryNode) { + if node == nil || node.Usage == nil { + return + } + usages = append(usages, node.Usage) + for _, child := range node.Children { + collect(child) + } + } + for _, item := range items { + collect(item) + } + return usages +} + +func buildClientPackageHistoryNode(node *assetquery.PackageHistoryNode, packageMap map[uint]*model.Package) *dto.ClientAssetPackageHistoryNode { + usage := node.Usage + pkg := packageMap[usage.PackageID] + metrics := usage.BuildTrafficMetrics() + packageName := usage.PackageName + packageType := "" + if pkg != nil { + if packageName == "" { + packageName = pkg.PackageName + } + packageType = pkg.PackageType + } + + item := &dto.ClientAssetPackageHistoryNode{ + PackageUsageID: usage.ID, + PackageID: usage.PackageID, + PackageName: packageName, + PackageType: packageType, + UsageType: usage.UsageType, + Status: usage.Status, + StatusName: packageStatusName(usage.Status), + RealTotalMB: metrics.RealTotalMB, + RealUsedMB: metrics.RealUsedMB, + VirtualTotalMB: metrics.VirtualTotalMB, + VirtualUsedMB: metrics.VirtualUsedMB, + ReductionPct: metrics.ReductionPct, + EnableVirtualData: usage.EnableVirtualDataSnapshot, + ActivatedAt: usage.ActivatedAt, + ExpiresAt: usage.ExpiresAt, + MasterUsageID: usage.MasterUsageID, + Priority: usage.Priority, + CreatedAt: usage.CreatedAt, + Children: make([]*dto.ClientAssetPackageHistoryNode, 0, len(node.Children)), + } + if node.RelationshipStatus != "" { + item.RelationshipStatus = node.RelationshipStatus + item.RelationshipStatusName = "关联主套餐缺失" + } + for _, child := range node.Children { + item.Children = append(item.Children, buildClientPackageHistoryNode(child, packageMap)) + } + item.ExpandByDefault = len(item.Children) > 0 + return item } // RefreshAsset B4 资产刷新 diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index dae6c27..fdcb160 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -160,12 +160,45 @@ type AssetPackageResponse struct { CreatedAt time.Time `json:"created_at" description:"创建时间"` } -// AssetPackagesResult 套餐列表分页结果 +// AssetPackageHistoryNode 后台资产套餐历史层级节点。 +type AssetPackageHistoryNode struct { + PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"` + PackageID uint `json:"package_id" description:"套餐ID"` + PackageName string `json:"package_name" description:"套餐名称"` + PackageType string `json:"package_type" description:"套餐类型:formal/addon"` + ExpiryBase string `json:"expiry_base,omitempty" description:"到期时间基准"` + OrderID uint `json:"order_id" description:"关联订单ID(无订单分配时为0)"` + OrderNo string `json:"order_no,omitempty" description:"订单号快照"` + RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照"` + RefundNo string `json:"refund_no,omitempty" description:"退款单号快照"` + UsageType string `json:"usage_type" description:"使用类型:single_card/device"` + Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"` + StatusName string `json:"status_name" description:"状态名称"` + RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"` + RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"` + VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"` + VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"` + ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例"` + EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` + ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"` + MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID;普通主项为null"` + Priority int `json:"priority" description:"优先级"` + PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买成本价(分),仅平台账号可见"` + RetailAmount *int64 `json:"retail_amount,omitempty" description:"购买零售价(分)"` + CreatedAt time.Time `json:"created_at" description:"购买创建时间"` + Children []*AssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"` + ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"` + RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态:master_missing"` + RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"` +} + +// AssetPackagesResult 后台资产套餐历史分页结果。 type AssetPackagesResult struct { - Total int64 `json:"total" description:"总条数"` - Page int `json:"page" description:"当前页码"` - PageSize int `json:"page_size" description:"每页条数"` - Items []*AssetPackageResponse `json:"items" description:"套餐列表"` + Total int64 `json:"total" description:"筛选后的顶层关系组总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页顶层关系组数量"` + Items []*AssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"` } // AssetResolveRequest 资产解析请求 diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 5cf0244..5229059 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -183,12 +183,39 @@ type AssetPackageHistoryRequest struct { PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"` } -// AssetPackageHistoryResponse B3 资产套餐历史响应 +// ClientAssetPackageHistoryNode H5 资产套餐历史层级节点。 +type ClientAssetPackageHistoryNode struct { + PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"` + PackageID uint `json:"package_id" description:"套餐ID"` + PackageName string `json:"package_name" description:"套餐名称"` + OrderID uint `json:"order_id" description:"历史兼容字段;本接口不填充真实订单ID,零值仍输出为0"` + PackageType string `json:"package_type" description:"套餐类型:formal/addon"` + UsageType string `json:"usage_type" description:"使用类型:single_card/device"` + Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"` + StatusName string `json:"status_name" description:"状态名称"` + RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"` + RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"` + VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"` + VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"` + ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例"` + EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` + ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"` + MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID;普通主项为null"` + Priority int `json:"priority" description:"优先级"` + CreatedAt time.Time `json:"created_at" description:"购买创建时间"` + Children []*ClientAssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"` + ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"` + RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态:master_missing"` + RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"` +} + +// AssetPackageHistoryResponse B3 资产套餐历史层级响应。 type AssetPackageHistoryResponse struct { - List []AssetPackageResponse `json:"items" description:"套餐历史列表"` - Total int64 `json:"total" description:"总数"` - Page int `json:"page" description:"页码"` - PageSize int `json:"size" description:"每页数量"` + List []*ClientAssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"` + Total int64 `json:"total" description:"筛选后的顶层关系组总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"size" description:"每页顶层关系组数量"` } // ======================================== diff --git a/internal/query/asset/package_history.go b/internal/query/asset/package_history.go new file mode 100644 index 0000000..25b0f7e --- /dev/null +++ b/internal/query/asset/package_history.go @@ -0,0 +1,304 @@ +package asset + +import ( + "context" + "sort" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "gorm.io/gorm" +) + +const relationshipStatusMasterMissing = "master_missing" + +// PackageHistoryQuery 读取资产范围内的套餐使用关系。 +type PackageHistoryQuery struct { + db *gorm.DB +} + +// PackageHistoryInput 定义已完成入口授权后的套餐历史读取范围。 +type PackageHistoryInput struct { + AssetType string + AssetID uint + Generation *int + Status *int + PackageType *string + Page int + PageSize int +} + +// PackageHistoryResult 保存筛选后的顶层关系组总数和分页后的关系组。 +type PackageHistoryResult struct { + Total int64 + Items []*PackageHistoryNode +} + +// PackageHistoryNode 保存一个套餐使用记录及其可展示关联子项。 +type PackageHistoryNode struct { + Usage *model.PackageUsage + Children []*PackageHistoryNode + RelationshipStatus string +} + +type packageUsagePresence struct { + ID uint + IotCardID uint + DeviceID uint + Generation int + DeletedAt gorm.DeletedAt +} + +// NewPackageHistoryQuery 创建套餐历史关系查询。 +func NewPackageHistoryQuery(db *gorm.DB) *PackageHistoryQuery { + return &PackageHistoryQuery{db: db} +} + +// List 在既有资产和世代范围内读取完整关系,再按整组应用筛选、排序和分页。 +func (q *PackageHistoryQuery) List(ctx context.Context, input PackageHistoryInput) (*PackageHistoryResult, error) { + query, err := q.baseUsageQuery(ctx, input) + if err != nil { + return nil, err + } + + var usages []*model.PackageUsage + if err := query.Find(&usages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败") + } + + usageByID := make(map[uint]*model.PackageUsage, len(usages)) + for _, usage := range usages { + usageByID[usage.ID] = usage + } + + missingMasterIDs := unresolvedMasterIDs(usages, usageByID) + presentMasters, err := q.lookupMasterUsagePresence(ctx, missingMasterIDs) + if err != nil { + return nil, err + } + + items := make([]*PackageHistoryNode, 0, len(usages)) + nodes := make(map[uint]*PackageHistoryNode, len(usages)) + for _, usage := range usages { + nodes[usage.ID] = &PackageHistoryNode{Usage: usage, Children: make([]*PackageHistoryNode, 0)} + } + for _, usage := range usages { + node := nodes[usage.ID] + if usage.MasterUsageID == nil { + items = append(items, node) + continue + } + + master, inRange := nodes[*usage.MasterUsageID] + if inRange { + master.Children = append(master.Children, node) + continue + } + if _, exists := presentMasters[*usage.MasterUsageID]; exists { + return nil, errors.New(errors.CodeDatabaseError, "读取套餐历史关联失败") + } + node.RelationshipStatus = relationshipStatusMasterMissing + items = append(items, node) + } + + matchingPackageIDs, err := q.matchingPackageIDs(ctx, input.PackageType, usages) + if err != nil { + return nil, err + } + items = filterPackageHistoryGroups(items, input.Status, matchingPackageIDs) + sortPackageHistoryGroups(items) + total := int64(len(items)) + + return &PackageHistoryResult{ + Total: total, + Items: paginatePackageHistoryGroups(items, input.Page, input.PageSize), + }, nil +} + +func (q *PackageHistoryQuery) baseUsageQuery(ctx context.Context, input PackageHistoryInput) (*gorm.DB, error) { + query := q.db.WithContext(ctx).Model(&model.PackageUsage{}) + switch input.AssetType { + case "card": + query = query.Where("iot_card_id = ?", input.AssetID) + case "device": + query = query.Where("device_id = ?", input.AssetID) + default: + return nil, errors.New(errors.CodeInvalidParam, "资产类型非法") + } + if input.Generation != nil { + query = query.Where("generation = ?", *input.Generation) + } + return query, nil +} + +func (q *PackageHistoryQuery) matchingPackageIDs(ctx context.Context, packageType *string, usages []*model.PackageUsage) (map[uint]struct{}, error) { + if packageType == nil { + return nil, nil + } + + usagePackageIDs := collectPackageHistoryUsagePackageIDs(usages) + result := make(map[uint]struct{}) + if len(usagePackageIDs) == 0 { + return result, nil + } + + var ids []uint + if err := q.db.WithContext(ctx).Model(&model.Package{}). + Where("id IN ? AND package_type = ?", usagePackageIDs, *packageType). + Pluck("id", &ids).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐类型资格失败") + } + for _, id := range ids { + result[id] = struct{}{} + } + return result, nil +} + +func collectPackageHistoryUsagePackageIDs(usages []*model.PackageUsage) []uint { + ids := make([]uint, 0, len(usages)) + seen := make(map[uint]struct{}, len(usages)) + for _, usage := range usages { + if usage == nil || usage.PackageID == 0 { + continue + } + if _, exists := seen[usage.PackageID]; exists { + continue + } + seen[usage.PackageID] = struct{}{} + ids = append(ids, usage.PackageID) + } + return ids +} + +func filterPackageHistoryGroups(items []*PackageHistoryNode, status *int, packageIDs map[uint]struct{}) []*PackageHistoryNode { + filtered := make([]*PackageHistoryNode, 0, len(items)) + for _, item := range items { + if matchesPackageHistoryUsage(item.Usage, status, packageIDs) { + filtered = append(filtered, item) + continue + } + for _, child := range item.Children { + if matchesPackageHistoryUsage(child.Usage, status, packageIDs) { + filtered = append(filtered, item) + break + } + } + } + return filtered +} + +func matchesPackageHistoryUsage(usage *model.PackageUsage, status *int, packageIDs map[uint]struct{}) bool { + if status != nil && usage.Status != *status { + return false + } + if packageIDs == nil { + return true + } + _, ok := packageIDs[usage.PackageID] + return ok +} + +func sortPackageHistoryGroups(items []*PackageHistoryNode) { + for _, item := range items { + sort.Slice(item.Children, func(i, j int) bool { + return packageHistoryChildLess(item.Children[i].Usage, item.Children[j].Usage) + }) + } + sort.Slice(items, func(i, j int) bool { + left := items[i].Usage + right := items[j].Usage + if left.CreatedAt.Equal(right.CreatedAt) { + return left.ID > right.ID + } + return left.CreatedAt.After(right.CreatedAt) + }) +} + +func packageHistoryChildLess(left, right *model.PackageUsage) bool { + leftBucket := packageHistoryChildBucket(left) + rightBucket := packageHistoryChildBucket(right) + if leftBucket != rightBucket { + return leftBucket < rightBucket + } + + leftTime := left.CreatedAt + rightTime := right.CreatedAt + if leftBucket == 0 { + leftTime = *left.ActivatedAt + rightTime = *right.ActivatedAt + } + if leftTime.Equal(rightTime) { + return left.ID < right.ID + } + return leftTime.Before(rightTime) +} + +func packageHistoryChildBucket(usage *model.PackageUsage) int { + if usage.Status == constants.PackageUsageStatusPending { + return 2 + } + if usage.ActivatedAt != nil { + return 0 + } + return 1 +} + +func paginatePackageHistoryGroups(items []*PackageHistoryNode, page, pageSize int) []*PackageHistoryNode { + if len(items) == 0 { + return make([]*PackageHistoryNode, 0) + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 1 + } + if page > (len(items)-1)/pageSize+1 { + return make([]*PackageHistoryNode, 0) + } + start := (page - 1) * pageSize + end := start + pageSize + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +func unresolvedMasterIDs(usages []*model.PackageUsage, usageByID map[uint]*model.PackageUsage) []uint { + ids := make([]uint, 0) + seen := make(map[uint]struct{}) + for _, usage := range usages { + if usage.MasterUsageID == nil { + continue + } + masterID := *usage.MasterUsageID + if _, found := usageByID[masterID]; found { + continue + } + if _, alreadySeen := seen[masterID]; alreadySeen { + continue + } + seen[masterID] = struct{}{} + ids = append(ids, masterID) + } + return ids +} + +func (q *PackageHistoryQuery) lookupMasterUsagePresence(ctx context.Context, ids []uint) (map[uint]packageUsagePresence, error) { + found := make(map[uint]packageUsagePresence, len(ids)) + if len(ids) == 0 { + return found, nil + } + + var records []packageUsagePresence + if err := q.db.WithContext(ctx).Unscoped().Model(&model.PackageUsage{}). + Select("id, iot_card_id, device_id, generation, deleted_at"). + Where("id IN ?", ids). + Find(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "核对套餐主记录失败") + } + for _, record := range records { + found[record.ID] = record + } + return found, nil +} diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 7450605..c5b08ba 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -40,8 +40,8 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle }) Register(assets, doc, groupPath, "GET", "/:identifier/packages", handler.Packages, RouteSpec{ - Summary: "资产套餐列表", - Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页与 status 状态筛选。", + Summary: "资产套餐历史", + Description: "查询该资产全部世代的套餐历史层级。每个主套餐及其关联加油包占一个分页名额,total 为筛选后的顶层项数量;支持 status 按同一使用记录筛选。关联主套餐物理缺失返回异常独立项,存在但不可展示的关联主套餐返回统一读取错误。", Tags: []string{"资产管理"}, Input: new(dto.AssetPackagesRequest), Output: new(dto.AssetPackagesResult), diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 6a5906a..72e45b2 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -156,11 +156,12 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, }) Register(authGroup, doc, basePath, "GET", "/asset/package-history", handlers.ClientAsset.GetPackageHistory, RouteSpec{ - Summary: "资产套餐历史", - Tags: []string{"个人客户 - 资产"}, - Auth: true, - Input: &dto.AssetPackageHistoryRequest{}, - Output: &dto.AssetPackageHistoryResponse{}, + Summary: "资产套餐历史", + Description: "查询客户已绑定资产当前世代的套餐历史层级。每个主套餐及其关联加油包占一个分页名额,total 为筛选后的顶层项数量;status 与 package_type 必须由同一使用记录联合命中,命中任一成员即返回完整关系组。关联主套餐物理缺失返回异常独立项,存在但不可展示的关联主套餐返回统一读取错误。", + Tags: []string{"个人客户 - 资产"}, + Auth: true, + Input: &dto.AssetPackageHistoryRequest{}, + Output: &dto.AssetPackageHistoryResponse{}, }) Register(authGroup, doc, basePath, "POST", "/asset/refresh", handlers.ClientAsset.RefreshAsset, RouteSpec{ diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 1438762..d9145af 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -6,7 +6,6 @@ package asset import ( "context" stderrors "errors" - "sort" "strconv" "time" @@ -14,6 +13,7 @@ import ( infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset" packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -760,11 +760,9 @@ func parseGatewayTime(raw gateway.FlexString) *time.Time { return &t } -// GetPackages 获取资产的所有套餐列表(支持分页和状态筛选) -// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回 -// page 默认 1,pageSize 默认 50,pageSize 最大 100 +// GetPackages 获取资产套餐历史层级(支持顶层关系组分页和状态筛选)。 +// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回。 func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int, status *int, callerAccountType string) (*dto.AssetPackagesResult, error) { - // 分页参数边界处理 if page < 1 { page = 1 } @@ -778,109 +776,122 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa return nil, errors.New(errors.CodeInvalidParam, "套餐状态非法") } - // assetType 对应 Store 中的 carrierType:card→iot_card, device→device - carrierType := assetType - if assetType == "card" { - carrierType = "iot_card" - } - - usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id, status) + history, err := assetquery.NewPackageHistoryQuery(s.db).List(ctx, assetquery.PackageHistoryInput{ + AssetType: assetType, + AssetID: id, + Status: status, + Page: page, + PageSize: pageSize, + }) if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐使用记录失败") + return nil, err } - // 收集所有 PackageID 并批量查询 - pkgIDSet := make(map[uint]struct{}, len(usages)) - for _, u := range usages { - pkgIDSet[u.PackageID] = struct{}{} - } - pkgIDs := make([]uint, 0, len(pkgIDSet)) - for id := range pkgIDSet { - pkgIDs = append(pkgIDs, id) - } - packages, pkgErr := s.packageStore.GetByIDsUnscoped(ctx, pkgIDs) + packageIDs := collectPackageHistoryPackageIDs(history.Items) + packages, pkgErr := s.packageStore.GetByIDsUnscoped(ctx, packageIDs) if pkgErr != nil { logger.GetAppLogger().Warn("批量查询套餐信息失败,套餐名称可能缺失", - zap.Uints("package_ids", pkgIDs), + zap.Uints("package_ids", packageIDs), zap.Error(pkgErr)) } - pkgMap := make(map[uint]*model.Package, len(packages)) - for _, p := range packages { - pkgMap[p.ID] = p + packageMap := make(map[uint]*model.Package, len(packages)) + for _, pkg := range packages { + packageMap[pkg.ID] = pkg } - all := make([]*dto.AssetPackageResponse, 0, len(usages)) - for _, u := range usages { - pkg := pkgMap[u.PackageID] - metrics := u.BuildTrafficMetrics() - pkgName := u.PackageName - pkgType := "" - expiryBase := "" - if pkg != nil { - if pkgName == "" { - pkgName = pkg.PackageName - } - pkgType = pkg.PackageType - expiryBase = pkg.ExpiryBase - } - var paidAmount *int64 - if callerAccountType == constants.OwnerTypePlatform { - paidAmount = u.PaidAmount - } - item := &dto.AssetPackageResponse{ - PackageUsageID: u.ID, - PackageID: u.PackageID, - PackageName: pkgName, - PackageType: pkgType, - ExpiryBase: expiryBase, - OrderID: u.OrderID, - OrderNo: u.OrderNo, - RefundID: u.RefundID, - RefundNo: u.RefundNo, - UsageType: u.UsageType, - Status: u.Status, - StatusName: packageStatusName(u.Status), - RealTotalMB: metrics.RealTotalMB, - RealUsedMB: metrics.RealUsedMB, - VirtualTotalMB: metrics.VirtualTotalMB, - VirtualUsedMB: metrics.VirtualUsedMB, - ReductionPct: metrics.ReductionPct, - EnableVirtualData: u.EnableVirtualDataSnapshot, - ActivatedAt: u.ActivatedAt, - ExpiresAt: u.ExpiresAt, - MasterUsageID: u.MasterUsageID, - Priority: u.Priority, - PaidAmount: paidAmount, - RetailAmount: u.RetailAmount, - CreatedAt: u.CreatedAt, - } - all = append(all, item) + items := make([]*dto.AssetPackageHistoryNode, 0, len(history.Items)) + for _, node := range history.Items { + items = append(items, buildAssetPackageHistoryNode(node, packageMap, callerAccountType)) } - // 按 created_at DESC 排序 - sort.Slice(all, func(i, j int) bool { - return all[i].CreatedAt.After(all[j].CreatedAt) - }) - - total := int64(len(all)) - offset := (page - 1) * pageSize - end := offset + pageSize - if offset >= len(all) { - offset = len(all) - } - if end > len(all) { - end = len(all) - } - items := all[offset:end] - return &dto.AssetPackagesResult{ - Total: total, + Total: history.Total, Page: page, PageSize: pageSize, Items: items, }, nil } +func collectPackageHistoryPackageIDs(items []*assetquery.PackageHistoryNode) []uint { + ids := make([]uint, 0) + seen := make(map[uint]struct{}) + var collect func(*assetquery.PackageHistoryNode) + collect = func(node *assetquery.PackageHistoryNode) { + if node == nil || node.Usage == nil { + return + } + if _, exists := seen[node.Usage.PackageID]; !exists { + seen[node.Usage.PackageID] = struct{}{} + ids = append(ids, node.Usage.PackageID) + } + for _, child := range node.Children { + collect(child) + } + } + for _, item := range items { + collect(item) + } + return ids +} + +func buildAssetPackageHistoryNode(node *assetquery.PackageHistoryNode, packageMap map[uint]*model.Package, callerAccountType string) *dto.AssetPackageHistoryNode { + usage := node.Usage + pkg := packageMap[usage.PackageID] + metrics := usage.BuildTrafficMetrics() + packageName := usage.PackageName + packageType := "" + expiryBase := "" + if pkg != nil { + if packageName == "" { + packageName = pkg.PackageName + } + packageType = pkg.PackageType + expiryBase = pkg.ExpiryBase + } + var paidAmount *int64 + if callerAccountType == constants.OwnerTypePlatform { + paidAmount = usage.PaidAmount + } + + item := &dto.AssetPackageHistoryNode{ + PackageUsageID: usage.ID, + PackageID: usage.PackageID, + PackageName: packageName, + PackageType: packageType, + ExpiryBase: expiryBase, + OrderID: usage.OrderID, + OrderNo: usage.OrderNo, + RefundID: usage.RefundID, + RefundNo: usage.RefundNo, + UsageType: usage.UsageType, + Status: usage.Status, + StatusName: packageStatusName(usage.Status), + RealTotalMB: metrics.RealTotalMB, + RealUsedMB: metrics.RealUsedMB, + VirtualTotalMB: metrics.VirtualTotalMB, + VirtualUsedMB: metrics.VirtualUsedMB, + ReductionPct: metrics.ReductionPct, + EnableVirtualData: usage.EnableVirtualDataSnapshot, + ActivatedAt: usage.ActivatedAt, + ExpiresAt: usage.ExpiresAt, + MasterUsageID: usage.MasterUsageID, + Priority: usage.Priority, + PaidAmount: paidAmount, + RetailAmount: usage.RetailAmount, + CreatedAt: usage.CreatedAt, + Children: make([]*dto.AssetPackageHistoryNode, 0, len(node.Children)), + } + if node.RelationshipStatus != "" { + item.RelationshipStatus = node.RelationshipStatus + item.RelationshipStatusName = "关联主套餐缺失" + } + for _, child := range node.Children { + item.Children = append(item.Children, buildAssetPackageHistoryNode(child, packageMap, callerAccountType)) + } + item.ExpandByDefault = len(item.Children) > 0 + return item +} + // GetCurrentPackage 获取资产当前生效的主套餐 // callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回 func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint, callerAccountType string) (*dto.AssetPackageResponse, error) { diff --git a/openspec/changes/add-asset-package-hierarchy/design.md b/openspec/changes/add-asset-package-hierarchy/design.md deleted file mode 100644 index a8d1ad4..0000000 --- a/openspec/changes/add-asset-package-hierarchy/design.md +++ /dev/null @@ -1,20 +0,0 @@ -## Context - -套餐使用记录已有 `master_usage_id`,不能为展示重复存储关系。 - -## Decisions - -- 查询批量读取资产全部套餐使用,按 `master_usage_id` 内存分组并稳定排序。 -- 不改变套餐状态、金额或生命周期;缺失主记录只形成读模型异常项。 - -## 查询与响应契约 - -- 后台资产详情及 H5 `GET /api/c/v1/asset/package-history` 保持既有资产权限、分页和套餐字段;不新增写接口、迁移、关联表或状态变更。查询需在资产范围内一次读取该资产全部相关 `PackageUsage`,不能按每个主套餐逐条查询加油包。 -- 响应主项包含原套餐使用字段、`children` 加油包数组和 `expand_by_default`;主项 `master_usage_id` 必须为 `null`。子项保留自身 `package_usage_id`、`master_usage_id`、状态、购买创建时间、生效时间和原历史字段。 -- 以 `master_usage_id IS NULL` 的记录为主套餐;关联存在的加油包嵌入对应主项。已生效加油包先按生效时间正序,待生效加油包后按购买创建时间正序;排序字段相同再按 `package_usage_id` 正序,保证后台和 H5 一致。 -- 主套餐只要有一个关联子项即 `expand_by_default=true`;无子项为 `false`。主套餐、子项失效、过期、用尽、退款或历史状态均不得删除或改写层级。 -- 加油包的 `master_usage_id` 指向物理不存在记录时,返回顶层异常项:保留原字段、`relationship_status=master_missing`、`relationship_status_name=关联主套餐缺失`、`children=[]`、`expand_by_default=false`;不得猜测替代主套餐或丢弃该项。 - -## Verification - -验证多主套餐、待生效/失效加油包、缺失主记录、后台/H5 数据范围和分页。 \ No newline at end of file diff --git a/openspec/changes/add-asset-package-hierarchy/proposal.md b/openspec/changes/add-asset-package-hierarchy/proposal.md deleted file mode 100644 index ff893d8..0000000 --- a/openspec/changes/add-asset-package-hierarchy/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Scope - -- 迭代编号:`AUG26-013`。 - -## Why - -资产套餐历史平铺展示,无法识别主套餐与其加油包的真实关联。 - -## What Changes - -- 在后台和 H5 按既有套餐使用关联投影主套餐—加油包层级。 -- 保留失效历史;主套餐物理缺失作为可观察异常。 - -## Capabilities - -### New Capabilities -- 无。 - -### Modified Capabilities -- `package-lifecycle`: 套餐历史层级展示。 - -## Impact - -影响套餐使用查询、后台资产详情、H5 和 OpenAPI。 \ No newline at end of file diff --git a/openspec/changes/add-asset-package-hierarchy/specs/package-lifecycle/spec.md b/openspec/changes/add-asset-package-hierarchy/specs/package-lifecycle/spec.md deleted file mode 100644 index 85e1438..0000000 --- a/openspec/changes/add-asset-package-hierarchy/specs/package-lifecycle/spec.md +++ /dev/null @@ -1,16 +0,0 @@ -## ADDED Requirements - -### Requirement: 资产套餐层级投影 -系统 SHALL 在后台资产详情和 H5 资产套餐历史中,直接以既有 `PackageUsage.master_usage_id` 将主套餐和关联加油包投影为层级结构,不新增关联表。响应中每个主套餐必须返回 `expand_by_default`:存在至少一个关联加油包时为 `true`,否则为 `false`;后台与 H5 使用同一规则。加油包按生效时间正序;待生效加油包按购买创建时间正序并排在已生效包之后。主套餐或加油包失效、过期、用尽时仍保留层级;仅关联主套餐物理缺失时作为“关联主套餐缺失”异常独立项返回。 - -#### Scenario: 已失效加油包 -- **WHEN** 某加油包及其主套餐已失效但主套餐记录仍存在 -- **THEN** 系统仍将加油包嵌入该主套餐层级,不因状态失效拆散关系 - -#### Scenario: 主套餐含加油包 -- **WHEN** 主套餐关联至少一条加油包使用记录 -- **THEN** 后台和 H5 返回该主套餐时均将 `expand_by_default` 设为 `true` - -#### Scenario: 主套餐物理缺失 -- **WHEN** 加油包关联的主套餐使用记录不存在 -- **THEN** 系统返回带“关联主套餐缺失”标识的异常独立项 diff --git a/openspec/changes/add-asset-package-hierarchy/tasks.md b/openspec/changes/add-asset-package-hierarchy/tasks.md deleted file mode 100644 index a41d1f3..0000000 --- a/openspec/changes/add-asset-package-hierarchy/tasks.md +++ /dev/null @@ -1,7 +0,0 @@ -## 1. 查询契约 -- [ ] 1.1 追踪后台资产详情、H5 套餐历史、`PackageUsage` 和 `master_usage_id` 查询链路。 -- [ ] 1.2 实现批量层级投影、稳定排序和缺失主套餐异常项;更新 DTO、路由说明和 OpenAPI。 - -## 2. 验证 -- [ ] 2.1 验证多层级、失效保留、待生效排序、缺失主记录及后台/H5 数据范围。 -- [ ] 2.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-asset-package-hierarchy --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-asset-package-hierarchy/.openspec.yaml b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/.openspec.yaml similarity index 100% rename from openspec/changes/add-asset-package-hierarchy/.openspec.yaml rename to openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/design.md b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/design.md new file mode 100644 index 0000000..050ea08 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/design.md @@ -0,0 +1,102 @@ +## 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` 和切顶层页。异常独立项以自己的创建时间参与顶层排序,每项独立计数;子项数大于页大小时仍完整随组返回。保留后台 50/100、H5 20/100 的默认/最大页大小,以及后台 `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 变化,不做数据回滚。不在本次规划更新中执行构建、生成器或任何业务调用。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/proposal.md b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/proposal.md new file mode 100644 index 0000000..0b5a59e --- /dev/null +++ b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/proposal.md @@ -0,0 +1,33 @@ +## Scope + +- 迭代编号:`AUG26-013`。 +- 需求依据:`docs/product/2026-08-迭代-PRD-讨论稿.md` 的 AUG26-013、§2.14;`111.md` 仅用于原始追溯。 +- 开工检查提出的整组分页与筛选、不可展示父项失败处理、空生效时间排序方案已确认,纳入本 Change。 + +## Why + +资产套餐历史平铺展示,无法识别主套餐与其加油包的真实关联。 + +## What Changes + +- 在后台资产详情和 H5 套餐历史按既有 `master_usage_id` 投影主套餐—加油包层级,返回 `children` 和 `expand_by_default`,保留失效、过期、用尽及退款历史关系。 +- **BREAKING**:分页单位由单条使用记录改为顶层关系组或物理缺失主项的异常独立项;`total` 改为筛选后的顶层项数量。子项不独立计数、不跨页;保留两端既有分页参数、页大小限制及响应壳字段名。 +- **BREAKING**:现有状态/套餐类型条件在同一条记录上联合匹配,组内任一记录命中即选择整组,返回可见范围内完整父子,不再逐条筛掉关联项。后台仍不新增套餐类型筛选。 +- **BREAKING**:历史响应从平铺项改为层级项,普通主项显式返回 `master_usage_id=null`;后台与 H5 消费者需同步适配,不保留并行平铺接口。 +- 主记录物理缺失时返回既定异常独立项;父记录存在但因资产、世代或软删除范围不可展示时,整个查询返回统一读取错误,不越界补显、不静默丢项、不伪报物理缺失。 +- 顶层按创建时间倒序、ID 倒序;子项按有生效时间的非待生效项、无生效时间的非待生效项、待生效项分桶排序,各桶同时间按使用记录 ID 正序。 +- 后台继续读取当前解析资产的全部世代,H5 继续限定资产当前世代;保留既有资产授权入口、字段可见性、商品查询失败处理和软删除商品类型筛选口径。 + +## Capabilities + +### New Capabilities +- 无。 + +### Modified Capabilities +- `package-lifecycle`: 套餐历史层级、整组分页与筛选、排序、关系异常和读取兼容契约。 + +## Impact + +- 影响 `GET /api/admin/assets/{identifier}/packages`、`GET /api/c/v1/asset/package-history` 的读取投影、历史专用 DTO、路由说明与 OpenAPI,以及后台和 H5 消费者。 +- 两端共享同一只读关系投影,批量读取使用记录与商品;不逐主套餐查询子项。当前套餐及套餐修改接口的响应不随公共 DTO 被动变化。 +- 不新增写接口、关联表、Schema 迁移或外部依赖;不修改套餐状态、金额、生命周期及未触碰的权限体系。不将存量企业授权风险作为本期顺带修复。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/specs/package-lifecycle/spec.md b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/specs/package-lifecycle/spec.md new file mode 100644 index 0000000..41eaae6 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/specs/package-lifecycle/spec.md @@ -0,0 +1,94 @@ +## ADDED Requirements + +### Requirement: 资产套餐层级投影 +系统 SHALL 在后台资产详情套餐列表和 H5 资产套餐历史中,直接依据既有 `master_usage_id` 返回主套餐与关联加油包的层级,不新增关联表。普通主项 SHALL 显式返回 `master_usage_id=null`、`children` 数组和 `expand_by_default`;存在至少一个可展示关联子项时默认展开,否则不展开。子项 SHALL 保留自身使用记录 ID、原主记录 ID、状态、购买创建时间、生效时间及该入口既有历史字段。正常项 SHALL 省略关系异常字段,所有叶子 SHALL 返回 `children=[]`、`expand_by_default=false`。主套餐或加油包失效、过期、用尽、退款均 MUST NOT 拆散既有关系或改写其生命周期事实。 + +#### Scenario: 多个主套餐分别关联子项 +- **WHEN** 同一可见资产范围内有多个主套餐,各自关联不同加油包 +- **THEN** 每个加油包仅出现在其原主套餐的 `children` 中,不在顶层重复出现;有子项的主套餐默认展开 + +#### Scenario: 无子项的主套餐 +- **WHEN** 某主套餐没有可展示关联加油包 +- **THEN** 该主项返回 `master_usage_id=null`、`children=[]`、`expand_by_default=false`,不返回关系异常字段 + +#### Scenario: 已失效历史关系 +- **WHEN** 主套餐或关联加油包已失效、过期、用尽或退款,但使用记录仍处于本入口可展示范围 +- **THEN** 响应保留原主子关系及各自原状态,不因历史状态将子项拆为顶层记录 + +### Requirement: 套餐历史整组筛选与分页 +系统 SHALL 将一个主套餐及其全部可展示子项作为一组,每组占一个分页名额;主记录物理缺失的每条异常独立项也各占一个名额。`total` SHALL 表示筛选后、分页前的顶层项数量,子项不另计数且 MUST NOT 跨页。已有 `status` 和套餐类型筛选条件 SHALL 在同一条使用记录上联合匹配;组内任一记录满足全部条件即选中整组,返回其完整可展示父子,不因筛选拆关系。异常独立项仅以自身字段匹配。未传筛选条件时 SHALL 返回范围内全部组及异常独立项。 + +#### Scenario: 子项数量超过页大小 +- **WHEN** 一个主套餐含三个子项,另有一个无子项主套餐,以 `page_size=1` 查询且无筛选 +- **THEN** `total=2`;包含三个子项的组在同一页完整返回,子项不消耗额外分页名额,另一页只返回另一个主项 + +#### Scenario: 状态或类型仅命中子项 +- **WHEN** 主项不满足筛选条件,但某个子项满足该请求的全部筛选条件 +- **THEN** 该组被选中,返回主项及其全部可展示子项,包括未命中条件的同组记录;主项不被标记为缺失 + +#### Scenario: 联合条件不能分摊给不同记录 +- **WHEN** 请求同时指定状态和套餐类型,组内只有主项满足状态、只有子项满足类型,且没有任何一条记录同时满足二者 +- **THEN** 该组不进入结果,也不计入 `total` + +#### Scenario: 异常独立项计数 +- **WHEN** 筛选后有一个正常关系组和两条主记录物理缺失的加油包 +- **THEN** `total=3`,两条异常项各自占一个分页名额,即使它们指向同一个缺失主记录也不合并 + +#### Scenario: 无匹配与超出末页 +- **WHEN** 没有任何顶层项匹配,或请求页码超出筛选结果末页 +- **THEN** `items=[]`;无匹配时 `total=0`,超出末页时仍返回筛选后的真实顶层项总数 + +### Requirement: 套餐历史稳定排序 +系统 SHALL 将顶层项按自身创建时间倒序排列,同时间按 `package_usage_id` 倒序排列。每组子项 SHALL 依次分为三桶:非待生效且有生效时间的记录按生效时间正序;非待生效但生效时间为空的记录按购买创建时间正序;待生效记录按购买创建时间正序并排最后。各桶排序时间相同 SHALL 按 `package_usage_id` 正序。桶归属及排序 MUST NOT 改变记录状态;后台与 H5 对相同输入集合 SHALL 返回相同顺序。 + +#### Scenario: 混合生效时间与历史状态 +- **WHEN** 同组包含有生效时间的已用完记录、无生效时间的已失效记录和待生效记录 +- **THEN** 三者依次位于第一、第二、第三桶;已失效记录保持已失效状态,不被重新标记为待生效 + +#### Scenario: 同时间的确定顺序 +- **WHEN** 多个顶层项创建时间相同,或同桶子项排序时间相同 +- **THEN** 顶层按使用记录 ID 倒序,子项按使用记录 ID 正序;数据不变时重复请求及相邻页边界保持一致 + +### Requirement: 物理缺失与不可展示关系区分 +系统 SHALL 仅在 `master_usage_id` 指向的主使用记录物理不存在时返回顶层异常独立项,保留原字段及非空主记录 ID,并返回 `relationship_status=master_missing`、`relationship_status_name=关联主套餐缺失`、`children=[]`、`expand_by_default=false`。主记录未出现在筛选结果或某一页 MUST NOT 被认定为物理缺失。范围内的子项所指父记录实际存在,但处于其他资产、H5 其他世代或已软删除而不可展示时,整个查询 SHALL 返回统一读取错误,不返回部分成功列表,不越界补显、不静默丢项,也不返回物理缺失标识。商品记录缺失 MUST NOT 等同于主使用记录缺失。 + +#### Scenario: 主套餐物理缺失 +- **WHEN** 加油包所引用的主使用记录物理不存在 +- **THEN** 返回带既定 `master_missing` 标识及中文名称的异常独立项,保留原主记录 ID,不猜测替代主套餐 + +#### Scenario: 父子原本分处明细分页两侧 +- **WHEN** 父子记录都在当前入口可展示范围,但按旧明细分页会落在不同页,或父项不满足展示筛选 +- **THEN** 系统仍按真实关系返回同一个完整关系组,不将子项标记为主套餐缺失 + +#### Scenario: 主使用记录软删除或范围不可见 +- **WHEN** 范围内某子项的主使用记录仍物理存在,但已软删除、属于其他资产,或不属于 H5 当前世代 +- **THEN** 整个查询返回统一读取错误;不暴露父项内容或具体不可见原因,不静默丢项,不返回 `master_missing` + +#### Scenario: 商品缺失但使用关系存在 +- **WHEN** 主使用记录及其子项均可展示,但关联套餐商品已软删除或物理缺失 +- **THEN** 主子关系保持不变,历史名称沿用既有快照回退规则,不因商品缺失返回关系异常 + +### Requirement: 套餐历史读取范围与兼容边界 +系统 SHALL 保留各入口既有资产解析及授权前置校验:后台读取当前解析资产的全部世代,H5 通过当前客户资产绑定校验后仅读取资产实体当前世代;卡按卡载体、设备按设备载体读取,MUST NOT 为补齐关系而合并其他资产或扩张 H5 世代。后台 SHALL 继续只支持已有状态筛选,H5 SHALL 继续支持状态和套餐类型筛选;H5 类型匹配 SHALL 保留排除已软删除商品的既有口径,不将用于历史展示的软删除商品自动作为类型命中项。 + +系统 SHALL 保留后台默认页大小 50、最大 100 和 `items/total/page/page_size` 响应字段;H5 默认页大小 20、最大 100 和 `items/total/page/size` 响应字段。两端仍接收 `page/page_size`,页码小于 1 时归一为 1,页大小小于 1 时使用各自默认值,超过上限时截断。除明确变更的层级、分页和筛选语义外,系统 SHALL 保留各入口既有套餐字段和可见性:后台成本价仅平台类账号可见;H5 MUST NOT 因共享投影而新增填充原先未填的订单、退款、成本价、零售价或生效条件字段。当前生效套餐及修改套餐接口的响应 MUST NOT 被本次历史层级变更连带改变。 + +#### Scenario: 两端保留各自世代范围 +- **WHEN** 同一资产有多个世代的独立主子组,后台有权查看该资产,H5 客户具有有效绑定 +- **THEN** 后台可返回全部世代内的组,H5 只返回资产当前世代内的组,不为对齐两端数量而扩大或缩小范围 + +#### Scenario: 未通过既有资产授权 +- **WHEN** 代理请求其店铺范围外的资产,或 H5 客户请求非其有效绑定资产 +- **THEN** 沿用既有拒绝行为,不返回该资产任何主项或子项,也不因补查主记录绕过授权 + +#### Scenario: 字段可见性与其他接口不变 +- **WHEN** 平台、代理和 H5 客户分别读取其可见资产历史,并调用原有当前套餐或修改套餐接口 +- **THEN** 历史主子项均遵守各入口既有字段可见性;代理与 H5 不因层级投影获取平台成本价,其他套餐接口保持原响应结构 + +#### Scenario: 已软删除商品不成为类型筛选命中项 +- **WHEN** H5 按套餐类型筛选,组内只有已软删除商品对应的使用记录具有该类型 +- **THEN** 该记录不使整组命中;若组内另一条记录满足全部条件而选中该组,已软删除商品对应的可展示使用记录仍随组保留 + +#### Scenario: 商品批量读取失败的既有差异 +- **WHEN** 关联商品批量读取发生数据库错误,而非单纯商品记录不存在 +- **THEN** 后台沿用使用记录快照继续投影的既有行为;H5 沿用读取失败响应,不因共享关系投影统一为另一入口的行为 diff --git a/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/tasks.md b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/tasks.md new file mode 100644 index 0000000..0173f85 --- /dev/null +++ b/openspec/changes/archive/2026-09-07-add-asset-package-hierarchy/tasks.md @@ -0,0 +1,27 @@ +## 1. 查询契约与共享投影 + +- [x] 1.1 依据 design 的开工检查证据核对后台套餐列表、H5 套餐历史、`PackageUsage` 及 DTO 引用范围,确认实施时调用链仍符合既有授权、后台全部世代、H5 当前世代及入口字段差异。 +- [x] 1.2 在 `internal/query/asset/package_history.go` 实现既有资产/世代范围内一次读取全部未软删除使用记录;按 `master_usage_id` 建立完整关系,去重后一次最小存在性核对未解析主 ID,区分物理缺失异常项与存在但不可展示时的整体读取错误,禁止逐主项查询子项。 +- [x] 1.3 实现同一成员联合匹配状态/类型条件并选择整组,保持 H5 类型匹配排除软删除商品的既有口径;按规格实现三桶子项排序、顶层稳定排序、顶层 `total` 和整组分页。 + +## 2. 历史响应与两端接入 + +- [x] 2.1 在现有 DTO 文件增加历史专用层级节点及列表响应,落实普通主项显式 `master_usage_id=null`、`children` 空数组、默认展开及既定缺失标识;不改变当前套餐和修改套餐接口的公共响应契约。 +- [x] 2.2 将后台 `Service.GetPackages` 接入共享 Query,保留 Handler 资产解析、状态入参校验、全部世代、50/100 页大小、`page_size` 响应字段、平台成本字段及后台商品查询失败快照策略。 +- [x] 2.3 将 H5 `GetPackageHistory` 接入同一 Query,保留客户有效绑定、资产实体当前世代、20/100 页大小、`size` 响应字段、原历史字段及商品查询失败响应;不得因复用补出订单、退款或金额字段。 +- [x] 2.4 更新两条历史路由的响应类型、整组分页/筛选和错误说明,批量复用商品加载;仅在构造依赖变化时同步 bootstrap、`cmd/api/docs.go`、`cmd/gendocs/main.go` 装配,删除被本次接入替代的旧明细分页与重复关系逻辑。 + +## 3. 隔离环境验收 + +- [x] 3.1 在隔离环境对两条历史入口执行多主项、无子项、多子项及子项数超过页大小的只读 smoke,核对不跨页、不重复、顶层 `total`、异常项独立计数、空结果及超出末页行为。 +- [x] 3.2 验证仅子项命中、主项命中、联合条件不能由不同成员分摊、无匹配及软删除商品类型资格场景,确认选中整组并保留完整可展示父子,不因筛选误报主项缺失。 +- [x] 3.3 验证主子失效、过期、用尽及退款关系保留;核对三桶顺序、非待生效但生效时间为空、待生效排序及顶层/子项相同时间的确定顺序,不修改原状态。 +- [x] 3.4 验证主使用记录物理缺失、软删除、跨资产、H5 跨世代、商品缺失及存在性查询失败分别遵守异常/整体错误/历史回退契约;范围内不可展示关系不因分页或筛选被掩盖,不返回范围外内容及具体不可见原因。 +- [x] 3.5 验证卡/设备载体边界、后台全部世代与 H5 当前世代差异、代理店铺范围和 H5 绑定拒绝、主子项金额字段隔离;核对当前套餐及修改接口的既有响应不变,不把存量企业授权问题算作本期已修复。 +- [x] 3.6 核对两端相同输入集合的层级顺序一致、两种分页响应壳不变、商品加载失败策略保留;通过隔离环境查询记录确认 usage 一次读取、未解析主 ID 至多一次存在性核对、商品/类型资格批量加载,查询次数不随主项数线性增长。 +- [x] 3.7 核对后台和 H5 消费者同步适配层级、默认展开、顶层总数、整组筛选及整体读取错误,完成消费者联调并确认 API 与消费者可同步发布/回滚;未取得联调结果不得宣称展示验收完成。 + +## 4. 工程验证 + +- [x] 4.1 对变更 Go 文件运行 `gofmt -w`,运行 `go build ./cmd/api ./cmd/worker` 和 `go run cmd/gendocs/main.go`;核对生成 OpenAPI 与两条历史接口实际 JSON 一致,当前套餐和修改接口未被连带改变。 +- [x] 4.2 运行 `openspec validate add-asset-package-hierarchy --strict` 和 `openspec doctor --json`,汇总上述 smoke 与兼容验收结果;自动化测试按项目决策为 N/A,不恢复旧测试,不运行迁移或真实外部业务调用。 \ No newline at end of file diff --git a/openspec/specs/package-lifecycle/spec.md b/openspec/specs/package-lifecycle/spec.md index 0ca912a..127acbc 100644 --- a/openspec/specs/package-lifecycle/spec.md +++ b/openspec/specs/package-lifecycle/spec.md @@ -87,6 +87,100 @@ - **WHEN** 平台赠送套餐,零售价 9900 分,成本价 0 分 - **THEN** 创建的套餐使用记录 `paid_amount = 0`,`retail_amount = 9900` + +### Requirement: 资产套餐层级投影 +系统 SHALL 在后台资产详情套餐列表和 H5 资产套餐历史中,直接依据既有 `master_usage_id` 返回主套餐与关联加油包的层级,不新增关联表。普通主项 SHALL 显式返回 `master_usage_id=null`、`children` 数组和 `expand_by_default`;存在至少一个可展示关联子项时默认展开,否则不展开。子项 SHALL 保留自身使用记录 ID、原主记录 ID、状态、购买创建时间、生效时间及该入口既有历史字段。正常项 SHALL 省略关系异常字段,所有叶子 SHALL 返回 `children=[]`、`expand_by_default=false`。主套餐或加油包失效、过期、用尽、退款均 MUST NOT 拆散既有关系或改写其生命周期事实。 + +#### Scenario: 多个主套餐分别关联子项 +- **WHEN** 同一可见资产范围内有多个主套餐,各自关联不同加油包 +- **THEN** 每个加油包仅出现在其原主套餐的 `children` 中,不在顶层重复出现;有子项的主套餐默认展开 + +#### Scenario: 无子项的主套餐 +- **WHEN** 某主套餐没有可展示关联加油包 +- **THEN** 该主项返回 `master_usage_id=null`、`children=[]`、`expand_by_default=false`,不返回关系异常字段 + +#### Scenario: 已失效历史关系 +- **WHEN** 主套餐或关联加油包已失效、过期、用尽或退款,但使用记录仍处于本入口可展示范围 +- **THEN** 响应保留原主子关系及各自原状态,不因历史状态将子项拆为顶层记录 + +### Requirement: 套餐历史整组筛选与分页 +系统 SHALL 将一个主套餐及其全部可展示子项作为一组,每组占一个分页名额;主记录物理缺失的每条异常独立项也各占一个名额。`total` SHALL 表示筛选后、分页前的顶层项数量,子项不另计数且 MUST NOT 跨页。已有 `status` 和套餐类型筛选条件 SHALL 在同一条使用记录上联合匹配;组内任一记录满足全部条件即选中整组,返回其完整可展示父子,不因筛选拆关系。异常独立项仅以自身字段匹配。未传筛选条件时 SHALL 返回范围内全部组及异常独立项。 + +#### Scenario: 子项数量超过页大小 +- **WHEN** 一个主套餐含三个子项,另有一个无子项主套餐,以 `page_size=1` 查询且无筛选 +- **THEN** `total=2`;包含三个子项的组在同一页完整返回,子项不消耗额外分页名额,另一页只返回另一个主项 + +#### Scenario: 状态或类型仅命中子项 +- **WHEN** 主项不满足筛选条件,但某个子项满足该请求的全部筛选条件 +- **THEN** 该组被选中,返回主项及其全部可展示子项,包括未命中条件的同组记录;主项不被标记为缺失 + +#### Scenario: 联合条件不能分摊给不同记录 +- **WHEN** 请求同时指定状态和套餐类型,组内只有主项满足状态、只有子项满足类型,且没有任何一条记录同时满足二者 +- **THEN** 该组不进入结果,也不计入 `total` + +#### Scenario: 异常独立项计数 +- **WHEN** 筛选后有一个正常关系组和两条主记录物理缺失的加油包 +- **THEN** `total=3`,两条异常项各自占一个分页名额,即使它们指向同一个缺失主记录也不合并 + +#### Scenario: 无匹配与超出末页 +- **WHEN** 没有任何顶层项匹配,或请求页码超出筛选结果末页 +- **THEN** `items=[]`;无匹配时 `total=0`,超出末页时仍返回筛选后的真实顶层项总数 + +### Requirement: 套餐历史稳定排序 +系统 SHALL 将顶层项按自身创建时间倒序排列,同时间按 `package_usage_id` 倒序排列。每组子项 SHALL 依次分为三桶:非待生效且有生效时间的记录按生效时间正序;非待生效但生效时间为空的记录按购买创建时间正序;待生效记录按购买创建时间正序并排最后。各桶排序时间相同 SHALL 按 `package_usage_id` 正序。桶归属及排序 MUST NOT 改变记录状态;后台与 H5 对相同输入集合 SHALL 返回相同顺序。 + +#### Scenario: 混合生效时间与历史状态 +- **WHEN** 同组包含有生效时间的已用完记录、无生效时间的已失效记录和待生效记录 +- **THEN** 三者依次位于第一、第二、第三桶;已失效记录保持已失效状态,不被重新标记为待生效 + +#### Scenario: 同时间的确定顺序 +- **WHEN** 多个顶层项创建时间相同,或同桶子项排序时间相同 +- **THEN** 顶层按使用记录 ID 倒序,子项按使用记录 ID 正序;数据不变时重复请求及相邻页边界保持一致 + +### Requirement: 物理缺失与不可展示关系区分 +系统 SHALL 仅在 `master_usage_id` 指向的主使用记录物理不存在时返回顶层异常独立项,保留原字段及非空主记录 ID,并返回 `relationship_status=master_missing`、`relationship_status_name=关联主套餐缺失`、`children=[]`、`expand_by_default=false`。主记录未出现在筛选结果或某一页 MUST NOT 被认定为物理缺失。范围内的子项所指父记录实际存在,但处于其他资产、H5 其他世代或已软删除而不可展示时,整个查询 SHALL 返回统一读取错误,不返回部分成功列表,不越界补显、不静默丢项,也不返回物理缺失标识。商品记录缺失 MUST NOT 等同于主使用记录缺失。 + +#### Scenario: 主套餐物理缺失 +- **WHEN** 加油包所引用的主使用记录物理不存在 +- **THEN** 返回带既定 `master_missing` 标识及中文名称的异常独立项,保留原主记录 ID,不猜测替代主套餐 + +#### Scenario: 父子原本分处明细分页两侧 +- **WHEN** 父子记录都在当前入口可展示范围,但按旧明细分页会落在不同页,或父项不满足展示筛选 +- **THEN** 系统仍按真实关系返回同一个完整关系组,不将子项标记为主套餐缺失 + +#### Scenario: 主使用记录软删除或范围不可见 +- **WHEN** 范围内某子项的主使用记录仍物理存在,但已软删除、属于其他资产,或不属于 H5 当前世代 +- **THEN** 整个查询返回统一读取错误;不暴露父项内容或具体不可见原因,不静默丢项,不返回 `master_missing` + +#### Scenario: 商品缺失但使用关系存在 +- **WHEN** 主使用记录及其子项均可展示,但关联套餐商品已软删除或物理缺失 +- **THEN** 主子关系保持不变,历史名称沿用既有快照回退规则,不因商品缺失返回关系异常 + +### Requirement: 套餐历史读取范围与兼容边界 +系统 SHALL 保留各入口既有资产解析及授权前置校验:后台读取当前解析资产的全部世代,H5 通过当前客户资产绑定校验后仅读取资产实体当前世代;卡按卡载体、设备按设备载体读取,MUST NOT 为补齐关系而合并其他资产或扩张 H5 世代。后台 SHALL 继续只支持已有状态筛选,H5 SHALL 继续支持状态和套餐类型筛选;H5 类型匹配 SHALL 保留排除已软删除商品的既有口径,不将用于历史展示的软删除商品自动作为类型命中项。 + +系统 SHALL 保留后台默认页大小 50、最大 100 和 `items/total/page/page_size` 响应字段;H5 默认页大小 20、最大 100 和 `items/total/page/size` 响应字段。两端仍接收 `page/page_size`,页码小于 1 时归一为 1,页大小小于 1 时使用各自默认值,超过上限时截断。除明确变更的层级、分页和筛选语义外,系统 SHALL 保留各入口既有套餐字段和可见性:后台成本价仅平台类账号可见;H5 MUST NOT 因共享投影而新增填充原先未填的订单、退款、成本价、零售价或生效条件字段。当前生效套餐及修改套餐接口的响应 MUST NOT 被本次历史层级变更连带改变。 + +#### Scenario: 两端保留各自世代范围 +- **WHEN** 同一资产有多个世代的独立主子组,后台有权查看该资产,H5 客户具有有效绑定 +- **THEN** 后台可返回全部世代内的组,H5 只返回资产当前世代内的组,不为对齐两端数量而扩大或缩小范围 + +#### Scenario: 未通过既有资产授权 +- **WHEN** 代理请求其店铺范围外的资产,或 H5 客户请求非其有效绑定资产 +- **THEN** 沿用既有拒绝行为,不返回该资产任何主项或子项,也不因补查主记录绕过授权 + +#### Scenario: 字段可见性与其他接口不变 +- **WHEN** 平台、代理和 H5 客户分别读取其可见资产历史,并调用原有当前套餐或修改套餐接口 +- **THEN** 历史主子项均遵守各入口既有字段可见性;代理与 H5 不因层级投影获取平台成本价,其他套餐接口保持原响应结构 + +#### Scenario: 已软删除商品不成为类型筛选命中项 +- **WHEN** H5 按套餐类型筛选,组内只有已软删除商品对应的使用记录具有该类型 +- **THEN** 该记录不使整组命中;若组内另一条记录满足全部条件而选中该组,已软删除商品对应的可展示使用记录仍随组保留 + +#### Scenario: 商品批量读取失败的既有差异 +- **WHEN** 关联商品批量读取发生数据库错误,而非单纯商品记录不存在 +- **THEN** 后台沿用使用记录快照继续投影的既有行为;H5 沿用读取失败响应,不因共享关系投影统一为另一入口的行为 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。