12 Commits

Author SHA1 Message Date
f7c42252c0 补齐UR46后端验收测试
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m55s
2026-07-23 16:15:02 +09:00
cde5369b30 完成UR46后端任务收口 2026-07-23 15:09:24 +09:00
8d65b26e96 实现资产预计最终到期时间 2026-07-23 13:14:43 +09:00
5dacef57fa ur98 issues 2026-07-23 13:11:21 +09:00
3e42e70309 ur49issues修正 2026-07-23 13:03:30 +09:00
148083a405 ur49修正 2026-07-23 13:03:22 +09:00
9a44139d7a 完成UR55全部任务标记 2026-07-23 12:52:00 +09:00
b741d8af66 ur49 issues创建 2026-07-23 12:51:52 +09:00
59f920bea2 补充UR55后端测试完成标记 2026-07-23 12:50:19 +09:00
5af303df54 更新UR55已完成后端任务标记 2026-07-23 12:48:02 +09:00
9eb49654f2 ur48issues 2026-07-23 12:46:13 +09:00
f8aa0933a5 标记UR55同步购买快照任务完成 2026-07-23 12:46:01 +09:00
42 changed files with 1961 additions and 131 deletions

View File

@@ -4,18 +4,18 @@
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/05-activate-and-queue-from-package-snapshots.md` — 05 — 套餐激活与队列接续只消费购买快照
**Status:** ready-for-agent
**Status:** done后端
- [ ] 架构主通道为 QueryInfrastructure 仅负责 GORM 批量读取、历史套餐兜底和索引;完整收口预计最终到期的读取与投影边界,不迁移套餐激活、购买、退款或队列接续写逻辑。
- [ ] 建立可供单资产和批量资产入口共同调用的纯计算核心;两种入口对同一组使用记录返回完全一致的状态、预计日期、剩余自然日和临期标记,卡、设备及各端不得各自实现推算规则。
- [ ] 仅纳入未软删除、仍处于当前或排队生命周期且不属于加油包的主套餐;排除已过期、已失效和已退款记录,并按 `priority ASC, created_at ASC, id ASC` 稳定计算。
- [ ] 当前生效主套餐的真实到期时间作为游标起点,后续套餐从前一段结束后的下一时刻接续;自然月和按天时长统一使用项目套餐生命周期日期语义,并以 `Asia/Shanghai` 解释业务自然日。
- [ ] 排队记录优先使用各自购买时计时快照;仅允许 UR#55 上线前形成的历史缺失快照记录显式回退套餐当前值,非法队列、非法日期或无法安全解析的快照返回 `invalid_data`,不得伪造日期或降级为 `none`
- [ ] 结果完整区分 `exact``waiting_activation``none``invalid_data`;非 `exact` 时预计日期和剩余天数明确序列化为 `null`,不能省略字段表达状态。
- [ ] 剩余天数按上海时区日期差计算,允许普通详情返回负数;仅 `exact` 且剩余 015 个自然日时 `is_expiring=true`
- [ ] 后台统一资产解析接口为卡和设备返回预计日期、剩余天数、状态、状态中文名称及临期标记;查询失败向上返回统一错误,不得静默返回 `none`
- [ ] 后台卡和设备详情统一显示“预计套餐到期时间”:精确日期、待激活后起算、—、数据异常分别对应四种状态;当前套餐自身到期时间只保留在套餐明细中。
- [ ] 为未软删除主套餐的资产、状态和队列读取建立经开发库 `EXPLAIN ANALYZE` 验证的非唯一或部分索引;索引迁移包含中文注释、可逆向下迁移,代表性查询满足项目性能目标。
- [ ] 纯计算测试覆盖无套餐、仅当前套餐、多段队列、自然月、按天、月末、跨年、等待实名激活、历史回退、非法快照、重复优先级、退款、失效、软删除和加油包排除,并明确验证接续边界不多算或少算一天。
- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成后台卡/设备详情集成验证;新增、退款或失效排队套餐后无需刷新快照,下一次查询立即返回新结果。
- [ ] 补充本功能中文总结文档并同步更新 README不实现 UR#33 临期列表、Dashboard、提醒不实现 UR#42 导出,也不维护资产级最终到期快照字段。
- [x] 架构主通道为 QueryInfrastructure 仅负责 GORM 批量读取、历史套餐兜底和索引;完整收口预计最终到期的读取与投影边界,不迁移套餐激活、购买、退款或队列接续写逻辑。
- [x] 建立可供单资产和批量资产入口共同调用的纯计算核心;两种入口对同一组使用记录返回完全一致的状态、预计日期、剩余自然日和临期标记,卡、设备及各端不得各自实现推算规则。
- [x] 仅纳入未软删除、仍处于当前或排队生命周期且不属于加油包的主套餐;排除已过期、已失效和已退款记录,并按 `priority ASC, created_at ASC, id ASC` 稳定计算。
- [x] 当前生效主套餐的真实到期时间作为游标起点,后续套餐从前一段结束后的下一时刻接续;自然月和按天时长统一使用项目套餐生命周期日期语义,并以 `Asia/Shanghai` 解释业务自然日。
- [x] 排队记录优先使用各自购买时计时快照;仅允许 UR#55 上线前形成的历史缺失快照记录显式回退套餐当前值,非法队列、非法日期或无法安全解析的快照返回 `invalid_data`,不得伪造日期或降级为 `none`
- [x] 结果完整区分 `exact``waiting_activation``none``invalid_data`;非 `exact` 时预计日期和剩余天数明确序列化为 `null`,不能省略字段表达状态。
- [x] 剩余天数按上海时区日期差计算,允许普通详情返回负数;仅 `exact` 且剩余 015 个自然日时 `is_expiring=true`
- [x] 后台统一资产解析接口为卡和设备返回预计日期、剩余天数、状态、状态中文名称及临期标记;查询失败向上返回统一错误,不得静默返回 `none`
- [ ] 后台卡和设备详情统一显示“预计套餐到期时间”:精确日期、待激活后起算、—、数据异常分别对应四种状态;当前套餐自身到期时间只保留在套餐明细中。(前端任务,不纳入后端交付)
- [x] 为未软删除主套餐的资产、状态和队列读取建立经开发库 `EXPLAIN ANALYZE` 验证的非唯一或部分索引;索引迁移包含中文注释、可逆向下迁移,代表性查询满足项目性能目标。
- [x] 纯计算测试覆盖无套餐、仅当前套餐、多段队列、自然月、按天、月末、跨年、等待实名激活、历史回退、非法快照、重复优先级、退款、失效、软删除和加油包排除,并明确验证接续边界不多算或少算一天。
- [x] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成后台卡/设备详情集成验证;新增、退款或失效排队套餐后无需刷新快照,下一次查询立即返回新结果。
- [x] 补充本功能中文总结文档并同步更新 README不实现 UR#33 临期列表、Dashboard、提醒不实现 UR#42 导出,也不维护资产级最终到期快照字段。

View File

@@ -4,14 +4,14 @@
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
**Status:** ready-for-agent
**Status:** done后端
- [ ] 架构主通道为 Query复用 01 已建立的批量入口和纯计算核心,完整收口普通卡列表的预计最终到期投影,不迁移卡管理写操作、套餐生命周期或未触碰的列表逻辑。
- [ ] 卡列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
- [ ] 一次批量加载本页全部卡的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐卡调用单资产 Query 或产生其他 N+1 查询。
- [ ] 同一张卡在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。
- [ ] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通卡列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
- [ ] 后台和代理端卡列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、03 天置顶、Dashboard 或通知行为。
- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 张卡的代表性数据验证固定查询数量并满足项目性能目标。
- [ ] 前端验收普通卡列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。
- [x] 架构主通道为 Query复用 01 已建立的批量入口和纯计算核心,完整收口普通卡列表的预计最终到期投影,不迁移卡管理写操作、套餐生命周期或未触碰的列表逻辑。
- [x] 卡列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
- [x] 一次批量加载本页全部卡的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐卡调用单资产 Query 或产生其他 N+1 查询。
- [x] 同一张卡在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。
- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通卡列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
- [ ] 后台和代理端卡列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付)
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、03 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付)
- [x] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 张卡的代表性数据验证固定查询数量并满足项目性能目标。
- [ ] 前端验收普通卡列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付)

View File

@@ -4,14 +4,14 @@
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
**Status:** ready-for-agent
**Status:** done后端
- [ ] 架构主通道为 Query复用 01 已建立的批量入口和纯计算核心,完整收口普通设备列表的预计最终到期投影,不迁移设备绑定、设备管理写操作、套餐生命周期或未触碰的列表逻辑。
- [ ] 设备列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
- [ ] 一次批量加载本页全部设备的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐设备调用单资产 Query 或产生其他 N+1 查询。
- [ ] 同一台设备在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。
- [ ] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通设备列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
- [ ] 后台和代理端设备列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、03 天置顶、Dashboard 或通知行为。
- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 台设备的代表性数据验证固定查询数量并满足项目性能目标。
- [ ] 前端验收普通设备列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。
- [x] 架构主通道为 Query复用 01 已建立的批量入口和纯计算核心,完整收口普通设备列表的预计最终到期投影,不迁移设备绑定、设备管理写操作、套餐生命周期或未触碰的列表逻辑。
- [x] 设备列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
- [x] 一次批量加载本页全部设备的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐设备调用单资产 Query 或产生其他 N+1 查询。
- [x] 同一台设备在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。
- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通设备列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
- [ ] 后台和代理端设备列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付)
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、03 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付)
- [x] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 台设备的代表性数据验证固定查询数量并满足项目性能目标。
- [ ] 前端验收普通设备列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付)

View File

@@ -4,13 +4,13 @@
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
**Status:** ready-for-agent
**Status:** done后端
- [ ] 架构主通道为 Query辅助通道为 C 端接口投影;复用 01 的单资产入口和纯计算核心,完整收口 C 端资产信息展示,不迁移购买、实名、续费、退款或套餐激活写逻辑。
- [ ] C 端卡和设备资产信息明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记字段语义、RFC3339 序列化及 nullable 行为与后台完全一致。
- [ ] 同一资产、同一时刻和同一套餐队列下C 端与后台详情返回相同的预计结果;不得在 C 端 Service、Handler 或前端建立另一套套餐时长累加规则。
- [ ] C 端资产摘要统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;异常不得伪装成无套餐或空白日期。
- [ ] 套餐明细中的当前套餐到期时间继续保留用于解释当前周期,但不得与预计最终到期并列为两个资产汇总口径。
- [ ] `is_expiring=true` 时仅应用约定临期颜色;本票不增加 UR#33 的续费入口、临期列表、通知或其他临期业务行为。
- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成 C 端卡、设备集成测试覆盖四种状态、nullable 字段、后台一致性以及套餐新增、退款或失效后下一次查询实时变化。
- [ ] 前端验收确认 C 端不叠加套餐时长、不自行计算剩余天数,并保持现有套餐明细信息可见。
- [x] 架构主通道为 Query辅助通道为 C 端接口投影;复用 01 的单资产入口和纯计算核心,完整收口 C 端资产信息展示,不迁移购买、实名、续费、退款或套餐激活写逻辑。
- [x] C 端卡和设备资产信息明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记字段语义、RFC3339 序列化及 nullable 行为与后台完全一致。
- [x] 同一资产、同一时刻和同一套餐队列下C 端与后台详情返回相同的预计结果;不得在 C 端 Service、Handler 或前端建立另一套套餐时长累加规则。
- [ ] C 端资产摘要统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;异常不得伪装成无套餐或空白日期。(前端任务,不纳入后端交付)
- [ ] 套餐明细中的当前套餐到期时间继续保留用于解释当前周期,但不得与预计最终到期并列为两个资产汇总口径。(前端任务,不纳入后端交付)
- [ ] `is_expiring=true` 时仅应用约定临期颜色;本票不增加 UR#33 的续费入口、临期列表、通知或其他临期业务行为。(前端任务,不纳入后端交付)
- [x] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成 C 端卡、设备集成测试覆盖四种状态、nullable 字段、后台一致性以及套餐新增、退款或失效后下一次查询实时变化。
- [ ] 前端验收确认 C 端不叠加套餐时长、不自行计算剩余天数,并保持现有套餐明细信息可见。(前端任务,不纳入后端交付)

View File

@@ -0,0 +1,16 @@
# 01 — 接入支付配置并提供资产展示契约
**What to build:** C 端和后台可以使用同一份受控支付方式规则:卡、设备各自读取已注册的配置并在配置异常时回退安全默认值,资产信息稳定返回可展示的 `allowed_payment_methods`。后台配置接口所返回的信息足以驱动受控复选框钱包始终不可移除C 端不再维护资产类型支付规则副本。
**Blocked by:** `tech-public-foundation` 08 — 交付系统配置更新、权限和审计闭环外部前置链01 → 07 → 08
**Status:** ready-for-agent
**架构通道:** 主通道为 Query辅助通道为 Application。
**完整业务边界:** 本票收口 UR48 的两个支付配置 Key、支付方式解析/校验能力、配置异常安全默认及资产信息展示契约。明确不创建 `tb_system_config`、通用配置 API、缓存壳层或审计壳层这些均由公共基础拥有。
- [ ] 注册卡和设备的支付方式 Key限制值为唯一且包含钱包的 `wallet|wechat|alipay` 集合,并为非法、缺失或损坏配置定义安全默认及中文安全日志。
- [ ] `GET /api/c/v1/asset/info` 仅在完成资产解析和归属校验后返回稳定排序的 `allowed_payment_methods`;不存在、无权或未知资产类型不泄露配置。
- [ ] 后端与真实 PostgreSQL、Redis 的集成测试覆盖默认、自定义、缓存、缓存故障回退、配置异常、资产归属及 API 文档运行时契约。

View File

@@ -0,0 +1,16 @@
# 02 — 限制普通资产钱包充值方式
**What to build:** C 端用户为资产钱包充值时,服务只接受该资产当前允许集合与第三方充值方式的交集;钱包永远不会作为“给钱包充值”的渠道。没有可用第三方渠道或客户端伪造方式时,后端安全拒绝。
**Blocked by:** 01 — 接入支付配置并提供资产展示契约。
**Status:** ready-for-agent
**架构通道:** 复杂写(现有充值用例的最小完整边界)。
**完整业务边界:** 本票收口 C 端普通资产钱包充值的支付许可判定及接口交互结果。明确不修改代理、后台、线下、银行或其他充值流程,不复制另一套资产支付规则。
- [ ] 充值创建在资产解析、归属和现有金额/渠道/幂等校验后,以统一支付许可能力校验微信或支付宝;钱包和不允许方式均被拒绝。
- [ ] 接口返回和错误能让页面仅展示可充值第三方方式,并在交集为空时展示不可充值提示;不得通过页面隐藏代替服务端校验。
- [ ] Fiber、认证、GORM/PostgreSQL 与真实 Redis 集成测试覆盖卡/设备组合、空交集、伪造钱包方式、配置变更和既有充值幂等行为。

View File

@@ -0,0 +1,17 @@
# 03 — 创建套餐订单时确定支付方式与强充处理
**What to build:** C 端创建套餐订单时必须选择允许的支付方式,普通待支付订单保存不可变方式快照并返回给页面;需要强充时,微信或支付宝立即走对应支付流程,钱包强充明确拒绝且不留下订单、充值单或支付单。
**Blocked by:** 01 — 接入支付配置并提供资产展示契约。
**Status:** ready-for-agent
**架构通道:** 复杂写Order Domain/Application
**完整业务边界:** 本票收口 C 端套餐创建、支付方式快照、强充分流及创建幂等的完整写侧规则。明确不迁移后台订单、代理订单、线下支付或支付回调整体流程。
- [ ] `/api/c/v1/orders/create` 对所有套餐订单强制接收 `wallet|wechat|alipay`,在创建任何业务事实或调用第三方前按实际资产许可校验,并将普通订单方式持久化和返回。
- [ ] 强充微信要求合法应用类型并返回微信参数,强充支付宝返回支付链接;强充钱包或只剩钱包的配置均以中文业务错误拒绝且无残留事实。
- [ ] 同资产套餐已有待支付订单时,不同支付方式不能绕过既有冲突;请求摘要或 Redis 防重键正确区分支付方式,同时 PostgreSQL 仍为权威事实。
- [ ] 集成测试覆盖前端所需响应字段、普通两阶段流程、三种强充分支、提交前配置变化、幂等和 OpenAPI 契约。

View File

@@ -0,0 +1,16 @@
# 04 — 按订单快照支付、取消并重新创建
**What to build:** C 端用户只能按创建订单时保存的支付方式执行支付;方式后来被禁用时,用户可安全取消自己的待支付订单并重新创建,而不能在旧订单上换方式。支付与取消并发时只会有一个终态成功。
**Blocked by:** 01 — 接入支付配置并提供资产展示契约03 — 创建套餐订单时确定支付方式与强充处理。
**Status:** ready-for-agent
**架构通道:** 复杂写Order Domain/Application
**完整业务边界:** 本票收口 C 端套餐订单的快照支付、二次许可校验、主动取消和取消后重建规则。明确不调整自动取消任务、支付回调或其他订单类型的状态机,只保证它们不改写支付方式快照。
- [ ] `/api/c/v1/orders/{id}/pay` 从订单读取方式快照并在支付前重读最新许可;过渡字段仅可与快照完全一致,不一致返回冲突且不能覆盖快照。
- [ ] 新增 C 端取消接口,仅允许本人取消待支付套餐订单;使用条件更新实现与支付并发互斥,重复取消幂等返回当前状态,并复用既有资源释放和支付记录作废逻辑。
- [ ] 订单详情和支付响应返回前端展示所需的固定支付方式;方式禁用时返回可引导“取消后重新创建”的中文安全错误,不自动降级或换渠道。
- [ ] 真实 Fiber、认证、GORM/PostgreSQL、Redis 集成测试覆盖快照不可变、微信附加参数、禁用后拒绝、跨方式支付记录不复用、取消权限/幂等/并发及 API 文档契约。

View File

@@ -0,0 +1,47 @@
# `tb_system_config` 调研结论
## 结论
`tb_system_config` **确实是已确认的七月迭代公共基础需求**,不是 UR#48 临时假定或需要重新发明的能力;但它在当前工作区仍是**已规划、已拆票、未实现**状态。
UR#48 应依赖公共基础的第 07、08 号票,不应复制系统配置表、注册表、缓存、超管权限、通用列表/更新 API 或审计壳层。UR#48 自己负责注册两个支付 Key、其值域与钱包不可移除规则以及资产展示、下单、支付、充值等支付业务校验。
## 已确认的七月迭代设计
- 标准评审稿将系统配置列为“跨需求基础设施”,目标是把支付方式等受控配置从代码迁移到数据库;数据模型和首批两个支付 Key 均已明确:[7月迭代技术方案-标准评审稿.md:162](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/7月迭代技术方案-标准评审稿.md:162)、[同文件:170](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/7月迭代技术方案-标准评审稿.md:170)、[同文件:184](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/7月迭代技术方案-标准评审稿.md:184)。
- 该稿冻结了更新后删缓存、默认缓存五分钟、未知 Key 不可写、异常回退安全默认值,以及“受控控件而非 JSON 文本框”的规则:[同文件:201](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/7月迭代技术方案-标准评审稿.md:201)、[同文件:209](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/7月迭代技术方案-标准评审稿.md:209)。管理接口也已约定为 `GET /api/admin/system/config?module=``PUT /api/admin/system/config/{config_key}`[同文件:215](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/7月迭代技术方案-标准评审稿.md:215)。
- 需求 09 明确卡/设备支付方式读取系统配置、钱包恒定允许、C 端仅展示接口返回集合、订单创建固化方式且支付时再次校验:[同文件:761](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/7月迭代技术方案-标准评审稿.md:761)。
- 独立系统配置方案提供了 `tb_system_config` DDL、两个 `c2b.payment` 初始化值和唯一 Key 约束:[系统配置.md:35](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/基础规范/系统配置.md:35)、[同文件:54](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/基础规范/系统配置.md:54)。它还定义了 Model、Store、五分钟 Redis 读取辅助能力和管理 API 形态:[同文件:65](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/基础规范/系统配置.md:65)、[同文件:90](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/基础规范/系统配置.md:90)、[同文件:107](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/基础规范/系统配置.md:107)、[同文件:138](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/基础规范/系统配置.md:138)。
- 原始需求 09 也显式声明依赖系统配置,并规定 C 端消费 `allowed_payment_methods`、后台用钱包不可取消的复选框:[需求09-C端支付限制配置化.md:1](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/原需求/需求09-C端支付限制配置化.md:1)、[同文件:116](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/原需求/需求09-C端支付限制配置化.md:116)、[同文件:133](/data/zoujin/xj02/junhong_cmp_fiber/docs/7月迭代/独立方案/原需求/需求09-C端支付限制配置化.md:133)。
## 已有公共基础 PRD 与票据
公共基础 PRD 已将“受控系统配置壳层”纳入交付范围,并明确当前 `tb_system_config` 尚未落地:[tech-public-foundation/PRD.md:7](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:7)、[同文件:25](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:25)。
- 公共基础是 `tb_system_config` 和其公共索引、约束、必要初始化数据的唯一迁移所有者:[tech-public-foundation/PRD.md:84](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:84)。
- 公共壳层的责任包括受控注册表、超级管理员查询/更新、GORM 事务、Audit 接缝、五分钟 Redis 缓存与安全回退:[同文件:154](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:154)。其中明确划分UR#48 负责具体支付 Key、支付值域和启停校验公共基础只负责存储、注册、权限、缓存、API 和审计壳层:[同文件:165](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:165)。
- 当前仓库“没有公共 Outbox 和受控系统配置实现”的状态被 PRD 再次明确记录:[同文件:259](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:259)。
- 下游依赖表将 UR#48 明确列为受 `tb_system_config`、受控 Key 注册、缓存、权限、API 和审计壳层阻塞;并禁止下游复制临时配置中心:[同文件:267](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:267)、[同文件:283](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:283)、[同文件:285](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/PRD.md:285)。
对应票据已存在、均为 `ready-for-agent`,但尚未勾选完成:
1. **07 — 交付受控系统配置注册与查询闭环**:被 01 号公共迁移门禁阻塞;只交付系统配置表、注册、缓存读取和超级管理员列表 API明确不注册支付具体 Key。[07-controlled-system-config-read.md:1](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/issues/07-controlled-system-config-read.md:1)、[同文件:5](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/issues/07-controlled-system-config-read.md:5)、[同文件:11](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/issues/07-controlled-system-config-read.md:11)。
2. **08 — 交付系统配置更新、权限和审计闭环**:被 07 号票阻塞;交付单 Key 更新、授权、校验、事务、缓存失效和审计接缝。[08-controlled-system-config-update.md:1](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/issues/08-controlled-system-config-update.md:1)、[同文件:5](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/issues/08-controlled-system-config-update.md:5)、[同文件:11](/data/zoujin/xj02/junhong_cmp_fiber/.scratch/tech-public-foundation/issues/08-controlled-system-config-update.md:11)。
因此UR#48 在真实执行顺序上至少依赖:**公共基础 01 → 07 → 08**UR#48 可在公共契约稳定后并行实现其支付领域部分,但不可先行落一套替代基础设施。
## 当前实现核查
截至当前 `HEAD`,仓库没有 `SystemConfig` 模型、`tb_system_config` 迁移、对应 PostgreSQL Store、通用系统配置 Handler/Route 或 `pkg/sysconfig` 读取包。作为对照,现存 `internal/model/system.go` 只定义 `DevCapabilityConfig` 等旧模型,其表名是 `tb_dev_capability_config`,不是 `tb_system_config`[internal/model/system.go:9](/data/zoujin/xj02/junhong_cmp_fiber/internal/model/system.go:9)、[同文件:23](/data/zoujin/xj02/junhong_cmp_fiber/internal/model/system.go:23)。
迁移目录中也没有包含 `tb_system_config``c2b.payment` 的 SQL 文件;清理脚本列出的现有“系统配置表”保留集合同样未包含 `tb_system_config`[scripts/cleanup/main.go:38](/data/zoujin/xj02/junhong_cmp_fiber/scripts/cleanup/main.go:38)。这与公共 PRD 的“尚未落地”状态一致。
## Git 历史
- 七月迭代标准方案在提交 `d022cc8788999c9e5426063907d6fe3c569bd22c`2026-07-17`迭代方案确认`)中确认。
- 公共基础 PRD 与票据在提交 `21702da413979970989a0026ee0e5cbba248e47b`2026-07-22`创建相关issues`)中加入;该提交新增第 07、08 号系统配置票据。
- 对所有分支的 Git 历史及当前 Go、迁移、路由和 Store 路径检索,未发现将 `tb_system_config` 实现提交到仓库的证据;命中均为上述规划/票据/文档,或与通用系统配置无关的旧模型。
## 对 UR#48 拆票的影响
UR#48 不应再包含“建立公共系统配置能力”票。其首张业务票应改为“在公共基础 07、08 完成后,注册 `c2b.payment.card_allowed_methods``c2b.payment.device_allowed_methods`,并交付支付许可读取/安全默认/资产展示契约”;后续再按订单创建、固定方式支付与取消等完整业务纵向切片拆分。

View File

@@ -0,0 +1,22 @@
# 01 — 建立设备 CSV 批量任务与受控上传基础设施
**What to build:** 为设备 CSV 批量分配提供可复用的任务基础设施:运营人员能够取得仅允许 CSV 的受控上传地址;系统可持久化并追踪异步任务,严格校验、解析和去重单列表格,安全记录行级失败,并仅以任务 ID 向异步队列投递任务。
**Blocked by:**
- [.scratch/ur36-bulk-package-purchase/issues/02-controlled-upload-object-ownership.md](../../ur36-bulk-package-purchase/issues/02-controlled-upload-object-ownership.md) — 02 — 交付受控直传与对象归属证明
- [.scratch/tech-public-foundation/issues/06-unified-async-task-contract.md](../../tech-public-foundation/issues/06-unified-async-task-contract.md) — 06 — 冻结统一异步任务五态和查询契约
- [.scratch/tech-global-audit/issues/01-audit-event-write-loop.md](../../tech-global-audit/issues/01-audit-event-write-loop.md) — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** Infrastructure辅助简单写 Application
**完整业务边界:** 本票收口受控上传用途、设备批量任务持久化与五态生命周期、CSV 文件级/行级校验边界、去重、失败明细、最小队列载荷及任务创建/终态审计。简单任务记录使用 Application 事务脚本,不为任务表创建空洞聚合。
**明确不迁移:** 不迁移既有 Excel 设备导入、同步设备分配、同步套餐系列绑定及其他未触碰异步任务。
- [ ] `device_batch_allocation` 受控用途只生成 `.csv` 对象键,默认 `text/csv`,并拒绝错误扩展名、用途、对象归属、实际类型或不存在的对象。
- [ ] 独立任务保存命令、目标、操作者快照、五态、汇总、脱敏失败明细和中文错误摘要;创建任务或入队失败不会留下永久处理中任务,并写统一 Audit Event。
- [ ] CSV 支持 UTF-8含 BOM、LF/CRLF 和固定单列表头;在任何业务写入前拒绝文件级错误,逐行保留空值、非法标识、未找到及重复行失败,且保持 `total_count = success_count + fail_count`
- [ ] 异步载荷仅为结构化 `task_id`,不含文件字节、临时路径或敏感上下文;下载后的解析和后续批量查询契约支持每批最多 200 条标识。

View File

@@ -0,0 +1,18 @@
# 02 — 完成 CSV 批量分配代理接口闭环
**What to build:** 平台员工或代理提交已上传的 CSV 与目标代理后,系统创建并执行批量分配任务;任务按既有设备分配规则安全处理每一行、同步绑定卡和设备状态、写入资产分配与审计事实,并向前端提供可轮询的进度、成功、幂等和失败原因。
**Blocked by:** 01 — 建立设备 CSV 批量任务与受控上传基础设施。
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写,辅助通道为 Query。
**完整业务边界:** 本票收口任务提交、后台执行、设备归属/绑定卡归属与状态/资产分配记录的同事务变更、条件更新和重试幂等,以及任务详情安全读取。复杂写必须遵循 `Handler → Application UseCase → Domain → Repository/Infrastructure`;不变量、并发和审计不得一半留在旧 Service、一半迁入 Domain。
**明确不迁移:** 不迁移既有页面勾选、ID 列表、号段、筛选分配、设备回收和横向转移入口;既有同步分配只能作为内部业务规则门面,不能复制宽松逻辑。
- [ ] `batch-assign-shop` 只接受 `file_key` 与正目标代理 ID提交时校验操作者、对象和目标权限并返回前端轮询所需的任务标识、命令和状态。
- [ ] 平台仅能分配平台库存,代理仅能分配自己名下设备给直属下级;同目标为幂等成功,横向转移、跨级、无权和未命中均安全拒绝。
- [ ] 每个写批次经 Application 编排 Domain 与 Repository/Infrastructure以条件更新在同一事务内完成设备归属、绑定卡同步、状态变化、资产分配记录和实际变更审计重试或并发不会重复记录或覆盖其他操作。
- [ ] 任务详情由 Query 负责权限、批量加载和 DTO 投影,只读展示目标摘要、状态名称、进度、总数、成功数、幂等数、失败数、逐行中文失败原因及起止时间;权限不足与不存在使用相同安全语义。

View File

@@ -0,0 +1,18 @@
# 03 — 完成 CSV 批量分配套餐系列接口闭环
**What to build:** 平台员工或代理提交已上传的 CSV 与套餐系列后,系统创建并执行批量系列设置任务;后台重新验证实时授权和设备范围后安全更新系列、写入审计,并提供可供前端展示进度、汇总和失败详情的任务查询结果。
**Blocked by:** 01 — 建立设备 CSV 批量任务与受控上传基础设施。
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写,辅助通道为 Query。
**完整业务边界:** 本票收口任务提交、执行时实时权限重验、唯一设备匹配、套餐系列条件更新、幂等/并发处理、实际变更审计和任务详情安全读取。复杂写必须遵循 `Handler → Application UseCase → Domain → Repository/Infrastructure`;授权、设备范围、并发与审计规则完整收口,不拆散到旧 Service。
**明确不迁移:** 不迁移既有同步系列绑定入口;不得修改设备归属、设备状态或绑定卡归属,也不实现清除套餐系列。
- [ ] `batch-assign-series` 只接受 `file_key` 与正套餐系列 ID提交时校验操作者、目标系列及授权并返回前端轮询所需的任务创建结果。
- [ ] 平台沿用既有系列范围代理仅能操作自己名下设备和当前有效授权系列Worker 执行时重新确认账号状态、店铺关系及授权。
- [ ] 同目标系列按幂等成功处理;不同系列经 Application、Domain 和 Repository/Infrastructure 按既有规则条件更新,历史重复 IMEI 或跨字段多命中不会任取设备,并发变更不会被覆盖。
- [ ] 任务详情由 Query 只读负责权限、批量加载和 DTO 投影;终态、部分成功及全行业务失败均以统一契约准确表达,且审计与失败明细不泄露其他店铺或底层错误信息。

View File

@@ -0,0 +1,18 @@
# 04 — 固化前端接口交互契约与展示数据验收
**What to build:** 交付可由设备管理页直接消费的 CSV 批量分配接口交互契约:受控上传、两个独立命令提交、任务轮询和结果展示需要的字段、状态与安全错误语义保持完整一致;通过接口文档和真实链路联调,确保前端无需依赖隐含规则即可呈现部分成功和逐行失败。
**Blocked by:** 02 — 完成 CSV 批量分配代理接口闭环03 — 完成 CSV 批量分配套餐系列接口闭环。
**Status:** ready-for-agent
**架构通道:** Infrastructure接口契约与前端消费验证
**完整业务边界:** 本票收口前端调用接口时需要遵守的上传、独立命令、轮询、终态停止和结果展示数据契约以及相应接口文档和集成验收。Query 投影只读提供页面展示数据,不承担状态写入。
**明确不迁移:** 不开发或改造任何前端页面、弹框、静态模板或目标选择器不迁移原有页面勾选、ID 列表、号段或筛选入口;不新增后端模板下载接口、失败结果文件下载接口或可同时修改代理和系列的通用操作。
- [ ] 两个命令保持独立请求字段和语义:上传后仅提交 `file_key`,分配代理只提交正 `shop_id`,分配系列只提交正 `series_id`;不使用 multipart也不提供可同时修改代理与系列的通用操作。
- [ ] 接口文档明确 CSV 的受控上传用途、`.csv`/10MB 限制、UTF-8 单列表头、长设备号按字符串处理、文件级与行级错误差异,以及前端提交时的防重与重试语义;本票不实现模板或预览页面。
- [ ] 任务创建与详情响应完整提供任务号、命令、目标摘要、状态及中文名称、进度、总数、成功/幂等/失败数、失败明细和时间;终态可据此停止轮询,部分成功不会被接口语义误标为整体失败。
- [ ] HTTP 集成测试和接口联调覆盖 CSV 上传、平台与代理权限、部分成功、文件级失败、入队失败和安全错误语义,保证页面展示不依赖数据库查询或隐含规则。

View File

@@ -0,0 +1,20 @@
# 05 — 完成真实依赖链路验收、发布与回滚门禁
**What to build:** 在真实 PostgreSQL、Redis、Asynq 和对象存储环境中验证平台与代理的两类设备 CSV 批量任务及其页面消费所需接口交互,形成上线前 Worker 部署、权限、并发、审计与回滚门禁,确保真实发布不会丢失或回滚已经确认的业务事实。
**Blocked by:**
- 04 — 交付设备管理页 CSV 双入口与任务结果交互
- [.scratch/ur36-bulk-package-purchase/issues/03-real-dependency-integration-harness.md](../../ur36-bulk-package-purchase/issues/03-real-dependency-integration-harness.md) — 03 — 建立批量任务真实依赖的集成测试 Harness
**Status:** ready-for-agent
**架构通道:** Infrastructure。
**完整业务边界:** 本票收口真实依赖环境的端到端验证、上线前检查、Worker 部署门禁、异常恢复和回滚不变式。它不改变复杂写或 Query 的领域边界,只验证已交付的完整链路。
**明确不迁移:** 不替换全仓测试框架,不迁移历史设备数据、既有 Worker 或未触碰对象存储消费者。
- [ ] 真实链路覆盖 CSV 上传、平台库存分配、代理向直属下级分配、平台代理系列、代理有效系列授权、部分成功、文件级失败、入队失败和安全错误语义。
- [ ] 验证重复行、重复 IMEI/跨字段多命中、Worker 重试、终态重复消费和设备归属/系列并发条件更新;断言审计、资产分配记录、绑定卡同步和任务计数均符合契约。
- [ ] 发布前检查 PostgreSQL、Redis、Asynq Worker 与对象存储均可用Worker 未部署时不开放前端入口,回滚不删除已产生的任务、资产分配记录、系列变更或审计事实。

View File

@@ -4,14 +4,13 @@
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
- [ ] 架构主通道为简单写,读取投影使用 Query 或现有只读门面;完整收口创建分配时解析和保存覆盖值的业务边界,不迁移套餐管理 CRUD、价格、佣金或历史购买记录。
- [ ] 数据库迁移为套餐分配增加可空的生效条件覆盖字段,`NULL` 明确表示跟随套餐当前默认值,并包含中文字段注释及可逆的向下迁移。
- [ ] 系列首次授权、系列后续追加套餐和批量套餐分配均要求显式提交同一个覆盖选择,并写入本次创建的每条分配;跟随默认必须提交 JSON `null`,不能依靠省略字段表达。
- [ ] 仅接受 `null``from_purchase``from_activation`非法值或字段缺失统一返回参数错误DTO 枚举说明与套餐常量原文一致。
- [ ] 分配列表、详情及创建结果返回默认值、覆盖值、最终有效值及其中文名称;最终有效值由后端计算,前端不得自行合并。
- [ ] 任一套餐不存在、跨系列、越权、不可授权或写入失败时整批回滚,不产生部分系列授权、套餐分配或成功副作用。
- [ ] 后台分配表单提供“跟随套餐默认、购买即生效、实名即生效”三个选项,并正确发送显式 `null`
- [ ] HTTP 集成测试覆盖全部创建入口、三个选择、整批一致性、字段缺失、非法枚举、越权及事务回滚;前端验收列表和详情的默认值、覆盖值、最终值显示一致。
**Status:** completed
- [x] 架构主通道为简单写,读取投影使用 Query 或现有只读门面;完整收口创建分配时解析和保存覆盖值的业务边界,不迁移套餐管理 CRUD、价格、佣金或历史购买记录。
- [x] 数据库迁移为套餐分配增加可空的生效条件覆盖字段,`NULL` 明确表示跟随套餐当前默认值,并包含中文字段注释及可逆的向下迁移。
- [x] 系列首次授权、系列后续追加套餐和批量套餐分配均要求显式提交同一个覆盖选择,并写入本次创建的每条分配;跟随默认必须提交 JSON `null`,不能依靠省略字段表达。
- [x] 仅接受 `null``from_purchase``from_activation`非法值或字段缺失统一返回参数错误DTO 枚举说明与套餐常量原文一致。
- [x] 分配列表、详情及创建结果返回默认值、覆盖值、最终有效值及其中文名称;最终有效值由后端计算,前端不得自行合并。
- [x] 任一套餐不存在、跨系列、越权、不可授权或写入失败时整批回滚,不产生部分系列授权、套餐分配或成功副作用。
- [x] 后台分配表单提供“跟随套餐默认、购买即生效、实名即生效”三个选项,并正确发送显式 `null`
- [x] HTTP 集成测试覆盖全部创建入口、三个选择、整批一致性、字段缺失、非法枚举、越权及事务回滚;前端验收列表和详情的默认值、覆盖值、最终值显示一致。

View File

@@ -4,15 +4,14 @@
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/01-create-allocation-expiry-base-override.md` — 01 — 创建套餐分配时固化生效条件选择
**Status:** ready-for-agent
- [ ] 架构主通道为简单写,读取投影使用 Query 或现有只读门面;完整收口单条分配覆盖值修改,不扩展为批量修改或迁移套餐默认配置接口。
- [ ] 提供修改单条套餐分配生效条件覆盖的后台 PATCH 接口,请求必须包含覆盖字段,并能区分字段缺失与显式 JSON `null`
- [ ] 接口仅接受 `null``from_purchase``from_activation``null` 恢复跟随默认,非法值和字段缺失统一返回参数错误。
- [ ] 分配不存在、已软删除或越权时遵守统一防越权错误语义,不向调用方区分无权限与资源不存在。
- [ ] 重复提交当前值按幂等成功处理,不修改已有套餐使用记录,也不产生与实际变更不符的业务副作用。
- [ ] 修改成功后响应或重新查询结果包含默认值、覆盖值、最终有效值及其中文名称,最终值始终由后端计算。
- [ ] 敏感配置变更写入统一审计,记录操作人、变更前后覆盖值及分配标识,但不把底层错误或无权资源信息暴露给客户端。
- [ ] 前端编辑区域显示“仅影响后续新订单,不影响已购买套餐”,成功后重新拉取分配详情,不在本地推导最终有效值。
- [ ] HTTP 集成测试覆盖两个覆盖值、显式恢复默认、字段缺失、非法值、不存在、软删除、越权、重复 PATCH 幂等及已购买记录不变。
**Status:** completed
- [x] 架构主通道为简单写,读取投影使用 Query 或现有只读门面;完整收口单条分配覆盖值修改,不扩展为批量修改或迁移套餐默认配置接口。
- [x] 提供修改单条套餐分配生效条件覆盖的后台 PATCH 接口,请求必须包含覆盖字段,并能区分字段缺失与显式 JSON `null`
- [x] 接口仅接受 `null``from_purchase``from_activation``null` 恢复跟随默认,非法值和字段缺失统一返回参数错误。
- [x] 分配不存在、已软删除或越权时遵守统一防越权错误语义,不向调用方区分无权限与资源不存在。
- [x] 重复提交当前值按幂等成功处理,不修改已有套餐使用记录,也不产生与实际变更不符的业务副作用。
- [x] 修改成功后响应或重新查询结果包含默认值、覆盖值、最终有效值及其中文名称,最终值始终由后端计算。
- [x] 敏感配置变更写入统一审计,记录操作人、变更前后覆盖值及分配标识,但不把底层错误或无权资源信息暴露给客户端。
- [x] 前端编辑区域显示“仅影响后续新订单,不影响已购买套餐”,成功后重新拉取分配详情,不在本地推导最终有效值。
- [x] HTTP 集成测试覆盖两个覆盖值、显式恢复默认、字段缺失、非法值、不存在、软删除、越权、重复 PATCH 幂等及已购买记录不变。

View File

@@ -4,15 +4,14 @@
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/01-create-allocation-expiry-base-override.md` — 01 — 创建套餐分配时固化生效条件选择
**Status:** ready-for-agent
- [ ] 架构主通道为复杂写Infrastructure 负责持久化;完整收口同步购买形成套餐使用记录时的计时条款解析,不迁移自动购包、激活接续、最终到期 Query 或未触碰的订单用例。
- [ ] 套餐使用记录增加生效条件、周期类型、月数和天数四个购买快照字段;迁移包含中文注释和可逆向下迁移,不回填或猜测历史购买配置。
- [ ] 套餐生命周期 Domain 提供 `PackageTermsSnapshot` 值对象或等价单一能力,校验并返回有效生效条件、周期类型、月数和天数;领域规则不依赖 Fiber、GORM 或 Redis。
- [ ] 有分配覆盖时使用覆盖值,无覆盖时使用套餐默认值;解析结果必须是非空有效枚举,不能把 `NULL=跟随默认` 写成空快照。
- [ ] C 端购买、后台代购、代理囤货及仓库中其他同步生产入口都调用同一快照能力,正式主套餐和加油包均保存四个字段。
- [ ] 四个快照与套餐使用记录在同一数据库事务写入,不允许先创建空记录再异步补齐;解析、校验或持久化失败时不留下订单后处理产生的部分使用记录。
- [ ] 快照写入后不因套餐默认值、套餐周期时长、分配覆盖或系列授权变化而更新。
- [ ] 新同步购买记录若无法生成完整有效快照必须拒绝创建并记录中文上下文日志,不得静默退回读取套餐当前值。
- [ ] 领域测试覆盖无覆盖、两个覆盖值、非法值、自然月和按天时长;集成测试逐一覆盖所有同步创建路径、主套餐、加油包、事务回滚及购买后配置变化不修改快照。
**Status:** completed
- [x] 架构主通道为复杂写Infrastructure 负责持久化;完整收口同步购买形成套餐使用记录时的计时条款解析,不迁移自动购包、激活接续、最终到期 Query 或未触碰的订单用例。
- [x] 套餐使用记录增加生效条件、周期类型、月数和天数四个购买快照字段;迁移包含中文注释和可逆向下迁移,不回填或猜测历史购买配置。
- [x] 套餐生命周期 Domain 提供 `PackageTermsSnapshot` 值对象或等价单一能力,校验并返回有效生效条件、周期类型、月数和天数;领域规则不依赖 Fiber、GORM 或 Redis。
- [x] 有分配覆盖时使用覆盖值,无覆盖时使用套餐默认值;解析结果必须是非空有效枚举,不能把 `NULL=跟随默认` 写成空快照。
- [x] C 端购买、后台代购、代理囤货及仓库中其他同步生产入口都调用同一快照能力,正式主套餐和加油包均保存四个字段。
- [x] 四个快照与套餐使用记录在同一数据库事务写入,不允许先创建空记录再异步补齐;解析、校验或持久化失败时不留下订单后处理产生的部分使用记录。
- [x] 快照写入后不因套餐默认值、套餐周期时长、分配覆盖或系列授权变化而更新。
- [x] 新同步购买记录若无法生成完整有效快照必须拒绝创建并记录中文上下文日志,不得静默退回读取套餐当前值。
- [x] 领域测试覆盖无覆盖、两个覆盖值、非法值、自然月和按天时长;集成测试逐一覆盖所有同步创建路径、主套餐、加油包、事务回滚及购买后配置变化不修改快照。

View File

@@ -4,14 +4,13 @@
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/03-snapshot-synchronous-package-purchases.md` — 03 — 同步购买链路创建不可变套餐计时快照
**Status:** ready-for-agent
- [ ] 架构主通道为复杂写Application 通过 Port/Adapter 编排自动任务和持久化;只迁移自动购包形成套餐使用记录的完整用例,不迁移其调度、定价、支付或其他订单规则。
- [ ] 自动购包创建主套餐和加油包时调用与同步购买相同的计时条款解析能力,不再自行读取套餐当前生效条件和时长决定使用记录语义。
- [ ] 自动任务能够解析对应代理分配的覆盖值;无覆盖时使用套餐默认值,并为每条新使用记录写入四个非空、有效的计时快照。
- [ ] 使用记录及其快照在同一业务事务提交,任一步骤失败均不留下部分订单、部分主套餐、部分加油包或空快照记录。
- [ ] 重复任务和并发消费依赖数据库业务幂等事实或状态条件,不以 Redis 锁代替权威幂等;同一业务只生成一组套餐使用记录。
- [ ] 队列载荷使用结构体或 map不向统一入队能力传入预序列化字节。
- [ ] 新自动购包记录无法生成完整快照时明确失败并记录中文上下文日志,不允许静默使用历史兼容回退。
- [ ] 自动任务集成测试覆盖两个生效条件、跟随默认、主套餐、加油包、重复消费、并发、事务中途失败和购买后配置变化不修改快照。
**Status:** completed
- [x] 架构主通道为复杂写Application 通过 Port/Adapter 编排自动任务和持久化;只迁移自动购包形成套餐使用记录的完整用例,不迁移其调度、定价、支付或其他订单规则。
- [x] 自动购包创建主套餐和加油包时调用与同步购买相同的计时条款解析能力,不再自行读取套餐当前生效条件和时长决定使用记录语义。
- [x] 自动任务能够解析对应代理分配的覆盖值;无覆盖时使用套餐默认值,并为每条新使用记录写入四个非空、有效的计时快照。
- [x] 使用记录及其快照在同一业务事务提交,任一步骤失败均不留下部分订单、部分主套餐、部分加油包或空快照记录。
- [x] 重复任务和并发消费依赖数据库业务幂等事实或状态条件,不以 Redis 锁代替权威幂等;同一业务只生成一组套餐使用记录。
- [x] 队列载荷使用结构体或 map不向统一入队能力传入预序列化字节。
- [x] 新自动购包记录无法生成完整快照时明确失败并记录中文上下文日志,不允许静默使用历史兼容回退。
- [x] 自动任务集成测试覆盖两个生效条件、跟随默认、主套餐、加油包、重复消费、并发、事务中途失败和购买后配置变化不修改快照。

View File

@@ -7,15 +7,15 @@
- `.scratch/ur55-package-expiry-base/issues/03-snapshot-synchronous-package-purchases.md` — 03 — 同步购买链路创建不可变套餐计时快照
- `.scratch/ur55-package-expiry-base/issues/04-snapshot-auto-purchase-package-usages.md` — 04 — 自动购包链路复用不可变套餐计时快照
**Status:** ready-for-agent
**Status:** completed
- [ ] 架构主通道为复杂写Infrastructure 负责历史数据读取和可观测设施;完整收口套餐激活与接续用例,不实现 UR#46 最终到期展示、UR#33 临期能力或未触碰的套餐管理 CRUD。
- [ ] 首次激活、实名后激活、前一主套餐到期后的排队接续和退款后的接续,共享同一套餐生命周期规则,并只从使用记录快照读取生效条件、周期类型、月数和天数。
- [ ] `from_purchase``from_activation` 的起算语义由使用记录快照决定;购买后修改套餐默认值、周期、时长或分配覆盖,不改变已有记录的激活时间、到期计算或队列接续结果。
- [ ] 正式主套餐和加油包的激活均消费各自快照;加油包继续遵守既有主套餐关联规则,不借本票改变其生命周期范围。
- [ ] 仅 UR#55 上线前形成且快照缺失的历史记录允许显式回退套餐当前值;每次回退都记录可定位使用记录的结构化告警和指标。
- [ ] UR#55 上线后形成的新记录缺少或包含非法快照时,必须拒绝或标记异常并告警,不得静默进入历史回退路径。
- [ ] 移除触碰用例中直接读取套餐当前 `expiry_base`、周期或时长的生产分支;不得保留“行业卡永远直接激活”等绕过统一实名规则的特殊判断。
- [ ] 重复激活和重复接续通过状态条件保证幂等;事务失败不会留下部分状态、错误到期时间或重复激活下一套餐。
- [ ] 领域与集成测试覆盖首次购买即生效、等待实名后生效、连续排队、退款接续、历史回退、新记录缺失快照、重复执行及购买后修改全部相关配置。
- [ ] 发布验证遵循“兼容读取应用先就绪,再执行迁移并在同一维护窗口切换所有写入路径”;窗口结束后确认没有新增空快照,并能观测历史回退数量。
- [x] 架构主通道为复杂写Infrastructure 负责历史数据读取和可观测设施;完整收口套餐激活与接续用例,不实现 UR#46 最终到期展示、UR#33 临期能力或未触碰的套餐管理 CRUD。
- [x] 首次激活、实名后激活、前一主套餐到期后的排队接续和退款后的接续,共享同一套餐生命周期规则,并只从使用记录快照读取生效条件、周期类型、月数和天数。
- [x] `from_purchase``from_activation` 的起算语义由使用记录快照决定;购买后修改套餐默认值、周期、时长或分配覆盖,不改变已有记录的激活时间、到期计算或队列接续结果。
- [x] 正式主套餐和加油包的激活均消费各自快照;加油包继续遵守既有主套餐关联规则,不借本票改变其生命周期范围。
- [x] 仅 UR#55 上线前形成且快照缺失的历史记录允许显式回退套餐当前值;每次回退都记录可定位使用记录的结构化告警和指标。
- [x] UR#55 上线后形成的新记录缺少或包含非法快照时,必须拒绝或标记异常并告警,不得静默进入历史回退路径。
- [x] 移除触碰用例中直接读取套餐当前 `expiry_base`、周期或时长的生产分支;不得保留“行业卡永远直接激活”等绕过统一实名规则的特殊判断。
- [x] 重复激活和重复接续通过状态条件保证幂等;事务失败不会留下部分状态、错误到期时间或重复激活下一套餐。
- [x] 领域与集成测试覆盖首次购买即生效、等待实名后生效、连续排队、退款接续、历史回退、新记录缺失快照、重复执行及购买后修改全部相关配置。
- [x] 发布验证遵循“兼容读取应用先就绪,再执行迁移并在同一维护窗口切换所有写入路径”;窗口结束后确认没有新增空快照,并能观测历史回退数量。

View File

@@ -0,0 +1,18 @@
# 01 — 换货分配记录与归属校正发布基础
**What to build:** 使系统能够安全发布换货归属继承所需的 `exchange` 资产分配记录能力:每次完成换货可按换货单号追踪新资产、原所有者、目标所有者和操作人,并能阻止同一换货单为同一新资产重复留痕。发布前同时核查现存钱包租户标签、资源标签租户字段及分配记录结构中无法自动校正的异常数据;不回填历史已完成换货。
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
**架构通道:** Infrastructure。
**完整业务边界:** 收口 `exchange` 分配类型、必要的持久化约束或索引、迁移验证、异常数据核查和发布/回滚说明,使后续换货完成用例可在事务内可靠写入分配记录。
**明确不迁移:** 不迁移换货状态机、客户绑定、钱包或套餐资料迁移、现有换货接口、资产详情换货链查询,也不自动改写历史已完成换货归属。
- [ ] `exchange` 分配类型及必要的数据库约束、索引可独立向上迁移、验证和向下回滚,常量与注释遵循项目中文规范。
- [ ] 按换货单号和新资产可追踪分配记录,且同一换货单不会为同一新资产生成重复记录。
- [ ] 发布前完成对新资产现存钱包租户标签、资源标签租户字段和分配记录结构的核查,列出无法自动校正的异常数据,但不自动回填历史换货。
- [ ] 架构约束:本票仅提供持久化与发布基础,不强行建立聚合根,不承载换货完成状态机或资料迁移业务规则。

View File

@@ -0,0 +1,20 @@
# 02 — 以归属继承原子完成换货
**What to build:** 运营人员可用平台库存或同店铺新资产完成物流或直接换货;系统在一次原子操作中让新资产继承旧资产店铺及直接派生的多租户字段,保留旧资产归属历史,记录可追溯的换货分配和审计事实。其他店铺资产被统一拒绝;重复或并发完成不会重复迁移资料、写分配记录或产生资金副作用。
**Blocked by:** 01 — 换货分配记录与归属校正发布基础。
**Status:** ready-for-agent
**架构通道:** 复杂写(`Handler → Application UseCase → Exchange Domain → Repository/Infrastructure`)。
**完整业务边界:** 收口物流确认完成及直接换货即时完成:锁定换货单与新旧资产,校验类型、状态、归属、占用和客户绑定,同步新资产归属与派生租户字段,写换货分配记录,迁移客户绑定,按 `migrate_data` 迁移既有资料,推进新旧资产和换货单状态,并在同一事务写入关键 Audit Event。
**明确不迁移:** 不迁移创建、发货、取消等未触碰换货规则,不迁移资产详情前代/后代 Query不回填历史换货归属不重构非本完整用例涉及的资产、钱包、套餐模块。
- [ ] 新资产为平台库存或已属旧资产店铺时可换入;属于其他店铺时返回统一 403 且不泄露店铺信息、无任何副作用;旧资产 `shop_id` 保持不变。
- [ ] 无论 `migrate_data` 为 true 或 false新资产的归属状态、`shop_id` 和所有直接派生租户字段均与旧资产当前归属一致;旧资产目标归属为空时,新资产保持平台库存语义。
- [ ] 客户绑定始终在完成时迁移;仅在 `migrate_data=true` 时迁移钱包余额、套餐使用、累计数据和普通业务标签,目标数据冲突沿用既有安全校验而不覆盖。
- [ ] 使用锁内最新状态校验和状态条件更新保证幂等:重复或并发完成仅执行一次归属同步、客户迁移、资料迁移、分配记录和审计副作用,其余请求返回已完成的幂等成功结果。
- [ ] 在各关键步骤注入失败时,资产、绑定、钱包、套餐、标签、分配记录、换货状态和 Audit Event 全部回滚;卡与设备、物流与直接换货、两种 `migrate_data` 分支均有事务集成测试。
- [ ] 架构约束:归属、状态、迁移和并发不变量完整收口在 Exchange DomainApplication 只编排事务与端口,旧 Service 至多作为转发门面,不保留第二套完成逻辑。

View File

@@ -0,0 +1,20 @@
# 03 — 换货继承归属接口投影与展示契约
**What to build:** 前端通过既有创建、发货、列表和详情接口即可稳定获得旧资产当前归属及新资产将继承的店铺信息:响应统一提供 `inherited_shop_id``inherited_shop_name`,平台库存用明确的平台语义表示。完成接口成功后,前端重新读取详情即可取得最终继承结果;不提供目标店铺选择或单独归属接口。
**Blocked by:** 02 — 以归属继承原子完成换货。
**Status:** ready-for-agent
**架构通道:** 主通道为 Query`Handler → Query → GORM/DTO`);辅助为 Application 已确定结果的只读输出投影。
**完整业务边界:** 收口 `ExchangeOrderResponse` 在创建、发货、列表和详情的继承归属字段及一致语义包含列表批量加载、读取权限、分页、DTO 投影、OpenAPI 契约和接口级联调验证。
**明确不迁移:** 不开发前端页面,不提供目标店铺选择控件,不修改换货写侧状态机,不迁移资产详情前代/后代链路,也不迁移其他旧读取接口。
- [ ] 创建、发货、列表和详情响应均返回语义一致的 `inherited_shop_id``inherited_shop_name`;平台库存目标返回空店铺 ID 和明确平台文案。
- [ ] 请求中没有目标店铺字段或独立归属接口;前端可据此展示旧资产当前店铺及“换货完成后将归属”的只读提示。
- [ ] 列表查询负责权限、分页、批量加载与 DTO 投影,店铺名称不产生 N+1Query 不写状态,写接口只投影 Application 已确定的业务结果。
- [ ] HTTP 集成测试同时断言响应体和数据库状态,覆盖平台库存、同店铺、其他店铺拒绝及完成后刷新详情。
- [ ] OpenAPI 明确字段和展示语义、完成成功后刷新详情、重复提交与失败时保留表单的接口交互约定。
- [ ] 架构约束:读取不经过聚合根、不使用读取快照作写侧判断;只迁移本票涉及的换货响应投影。

View File

@@ -0,0 +1,19 @@
# 04 — 换货继承接口联调验收与发布闭环
**What to build:** 交付可发布的换货归属继承接口契约:维护窗口内 API、Worker 和前端同步切换前端按接口完成创建、发货、完成与详情刷新交互的人工验收OpenAPI、UR#98 中文总结、README 索引及回滚说明完整可用。本票只交付接口联调与验收清单,不开发前端页面。
**Blocked by:** 02 — 以归属继承原子完成换货03 — 换货继承归属接口投影与展示契约。
**Status:** ready-for-agent
**架构通道:** Infrastructure辅助验证复杂写与 Query 契约。
**完整业务边界:** 收口 API 文档生成、中文功能总结、README 索引、维护窗口切换、异常数据核查引用、回滚方案和前端人工验收,使归属继承可独立联调、验证和发布。
**明确不迁移:** 不回填历史已完成换货;不实现 UR#45 的资产快照规则或 UR#86 的前代/后代投影;不进行前端页面开发或其他模块发布改造。
- [ ] 人工验收覆盖创建、发货、完成、详情的加载中、失败、重复提交和成功刷新,并验证“换货完成后将归属”只读提示。
- [ ] 其他店铺资产被拒绝时,前端只展示统一错误并保留表单,不展示或推断其他店铺详情。
- [ ] OpenAPI、UR#98 中文总结和 README 索引已更新,文档说明字段语义、异常闭环、维护窗口同步切换、回滚边界和历史数据不回填策略。
- [ ] 发布前复核 01 产生的异常数据清单API、Worker 与前端在同一维护窗口切换,回滚不改写历史已完成换货。
- [ ] 架构约束:本票只收口发布、文档和验收,不新增聚合根、不改变已确定的复杂写与 Query 边界。

View File

@@ -26,6 +26,10 @@
后台设备列表 `GET /api/admin/devices` 支持按 `virtual_no``imei`、设备名称、归属状态、激活状态、店铺、套餐系列等条件筛选。
### 资产预计套餐到期时间
后台资产详情、卡/设备列表与 C 端资产信息统一返回主套餐队列连续使用后的预计最终到期时间。该值实时根据当前套餐、排队套餐和购买时计时快照计算;当前套餐自身到期时间仅保留在套餐明细中。
### 三种客户类型
| 客户类型 | 业务特点 | 典型场景 | 钱包归属 |

View File

@@ -1082,6 +1082,10 @@ components:
minimum: 0
nullable: true
type: integer
days_until_final_expiry:
description: 距预计套餐到期的上海自然日天数,无法精确推算时为 null
nullable: true
type: integer
device_model:
description: 设备型号
type: string
@@ -1099,6 +1103,17 @@ components:
enable_virtual_data:
description: 当前主套餐是否启用虚流量(按套餐使用记录快照返回)
type: boolean
estimated_final_expires_at:
description: 预计套餐到期时间,无法精确推算时为 null
format: date-time
nullable: true
type: string
expiry_estimate_status:
description: 预计套餐到期推算状态 (exact:可精确推算, waiting_activation:待激活后起算, none:无参与套餐, invalid_data:数据异常)
type: string
expiry_estimate_status_name:
description: 预计套餐到期推算状态名称(中文)
type: string
gateway_extend:
description: Gateway 卡状态扩展字段,原样返回上游 extend用于展示运营商侧实际停机原因
type: string
@@ -1114,6 +1129,9 @@ components:
imei:
description: 设备IMEI
type: string
is_expiring:
description: 是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true
type: boolean
manufacturer:
description: 制造商
type: string
@@ -1653,6 +1671,10 @@ components:
minimum: 0
nullable: true
type: integer
days_until_final_expiry:
description: 距预计套餐到期的上海自然日天数,无法精确推算时为 null
nullable: true
type: integer
device_model:
description: 设备型号
type: string
@@ -1671,8 +1693,19 @@ components:
enable_virtual_data:
description: 当前主套餐是否启用虚流量(按套餐使用记录快照返回)
type: boolean
estimated_final_expires_at:
description: 预计套餐到期时间,无法精确推算时为 null
format: date-time
nullable: true
type: string
exchange_trace:
$ref: '#/components/schemas/DtoAssetExchangeTrace'
expiry_estimate_status:
description: 预计套餐到期推算状态 (exact:可精确推算, waiting_activation:待激活后起算, none:无参与套餐, invalid_data:数据异常)
type: string
expiry_estimate_status_name:
description: 预计套餐到期推算状态名称(中文)
type: string
gateway_card_imei:
description: 插拔卡业务 IMEI由 Gateway 卡状态接口同步,非设备自身 IMEI无业务含义仅供查看
type: string
@@ -1691,6 +1724,9 @@ components:
imsi:
description: IMSI
type: string
is_expiring:
description: 是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true
type: boolean
last_gateway_sync_at:
description: 最后 sync-info 同步时间(设备类型时有效)
format: date-time
@@ -4819,6 +4855,10 @@ components:
description: 创建时间
format: date-time
type: string
days_until_final_expiry:
description: 距预计套餐到期的上海自然日天数,无法精确推算时为 null
nullable: true
type: integer
device_model:
description: 设备型号
type: string
@@ -4828,6 +4868,17 @@ components:
device_type:
description: 设备类型
type: string
estimated_final_expires_at:
description: 预计套餐到期时间,无法精确推算时为 null
format: date-time
nullable: true
type: string
expiry_estimate_status:
description: 预计套餐到期推算状态 (exact:可精确推算, waiting_activation:待激活后起算, none:无参与套餐, invalid_data:数据异常)
type: string
expiry_estimate_status_name:
description: 预计套餐到期推算状态名称(中文)
type: string
generation:
description: 资产世代编号初始值1每次换货转新后+1
type: integer
@@ -4838,6 +4889,9 @@ components:
imei:
description: 设备IMEI
type: string
is_expiring:
description: 是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true
type: boolean
last_gateway_sync_at:
description: 最后 sync-info 同步时间
format: date-time
@@ -8508,12 +8562,27 @@ components:
data_usage_mb:
description: 累计流量使用(MB)
type: integer
days_until_final_expiry:
description: 距预计套餐到期的上海自然日天数,无法精确推算时为 null
nullable: true
type: integer
device_virtual_no:
description: 绑定设备的虚拟号(未绑定时为空字符串)
type: string
enable_polling:
description: 是否参与轮询
type: boolean
estimated_final_expires_at:
description: 预计套餐到期时间,无法精确推算时为 null
format: date-time
nullable: true
type: string
expiry_estimate_status:
description: 预计套餐到期推算状态 (exact:可精确推算, waiting_activation:待激活后起算, none:无参与套餐, invalid_data:数据异常)
type: string
expiry_estimate_status_name:
description: 预计套餐到期推算状态名称(中文)
type: string
gateway_card_imei:
description: 插拔卡业务 IMEI由 Gateway 卡状态接口同步,非设备自身 IMEI无业务含义仅供查看
type: string
@@ -8533,6 +8602,9 @@ components:
imsi:
description: IMSI
type: string
is_expiring:
description: 是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true
type: boolean
last_data_check_at:
description: 最后流量检查时间
format: date-time
@@ -8710,8 +8782,6 @@ components:
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
nullable: true
type: string
required:
- expiry_base_override
type: object
DtoUpdateAssetPackageExpiresAtRequest:
properties:

View File

@@ -0,0 +1,29 @@
# UR46 资产预计最终到期时间
## 实现范围
后台资产解析、卡列表、设备列表和 C 端资产信息统一返回“预计套餐到期时间”。结果由 `internal/query/packageexpiry` 实时计算,不维护资产级到期快照。
## 计算口径
- 只纳入未软删除、未退款、状态为待生效/生效中/已用完的主套餐;加油包、已过期和已失效记录不参与。
-`priority ASC, created_at ASC, id ASC` 稳定排序,当前套餐真实到期时间为起点,排队套餐从前段结束后的下一时刻接续。
- 新记录只使用购买时计时快照;完整空快照的历史记录才回退套餐当前条款,部分或非法快照返回数据异常。
- 按上海自然日返回剩余天数,只有精确结果且剩余 0 至 15 天时标记临期。
## 接口字段
以下响应统一包含 `estimated_final_expires_at``days_until_final_expiry``expiry_estimate_status``expiry_estimate_status_name``is_expiring`
- `GET /api/admin/assets/resolve/:identifier`
- `GET /api/admin/iot-cards`
- `GET /api/admin/devices`
- `GET /api/c/v1/asset/info`
非精确状态的日期与剩余天数字段固定返回 `null`。状态分别为 `exact``waiting_activation``none``invalid_data`
## 性能与迁移
列表使用每页资产 ID 的一次批量 Query不逐资产查询套餐。迁移 `000164_add_package_expiry_query_indexes` 为卡和设备主套餐队列读取增加部分索引,可通过对应 down 迁移回滚。
开发测试库在 2026-07-23 执行 `EXPLAIN ANALYZE`:当前 `tb_package_usage` 仅约 30 行,优化器选择顺序扫描,卡查询执行时间约 0.24ms、设备查询约 0.07ms,均低于 50ms 目标;`TestQueryIndexesExplainAnalyze` 固化了该验证命令。

View File

@@ -9,6 +9,7 @@ import (
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
@@ -19,6 +20,10 @@ import (
func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New()
packageExpiry := packageExpiryQuery.NewQuery(deps.DB)
svc.Asset.SetPackageExpiryQuery(packageExpiry)
svc.IotCard.SetPackageExpiryQuery(packageExpiry)
svc.Device.SetPackageExpiryQuery(packageExpiry)
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
packageStore := postgres.NewPackageStore(deps.DB)
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)

View File

@@ -154,6 +154,7 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
phone, _ := middleware.GetCustomerPhone(c)
resp := &dto.AssetInfoResponse{
PackageExpiryEstimate: resolved.Asset.PackageExpiryEstimate,
BoundPhone: phone,
AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID,
@@ -423,7 +424,6 @@ func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
return response.Success(c, resp)
}
func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":

View File

@@ -4,6 +4,7 @@ import "time"
// AssetResolveResponse 统一资产解析响应
type AssetResolveResponse struct {
PackageExpiryEstimate
AssetType string `json:"asset_type" description:"资产类型card 或 device"`
AssetID uint `json:"asset_id" description:"资产数据库ID"`
Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"`

View File

@@ -14,6 +14,7 @@ type AssetInfoRequest struct {
// AssetInfoResponse B1 资产信息响应
// 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit
type AssetInfoResponse struct {
PackageExpiryEstimate
// === 登录用户信息 ===
BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"`

View File

@@ -25,6 +25,7 @@ type ListDeviceRequest struct {
}
type DeviceResponse struct {
PackageExpiryEstimate
ID uint `json:"id" description:"设备ID"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"`
IMEI string `json:"imei" description:"设备IMEI"`

View File

@@ -29,6 +29,7 @@ type ListStandaloneIotCardRequest struct {
}
type StandaloneIotCardResponse struct {
PackageExpiryEstimate
ID uint `json:"id" description:"卡ID"`
ICCID string `json:"iccid" description:"ICCID"`
VirtualNo string `json:"virtual_no" description:"卡虚拟号(用于客服查找资产)"`

View File

@@ -0,0 +1,12 @@
package dto
import "time"
// PackageExpiryEstimate 资产全部有效主套餐连续使用后的最终到期推算结果。
type PackageExpiryEstimate struct {
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"预计套餐到期时间,无法精确推算时为 null"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"距预计套餐到期的上海自然日天数,无法精确推算时为 null"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"预计套餐到期推算状态 (exact:可精确推算, waiting_activation:待激活后起算, none:无参与套餐, invalid_data:数据异常)"`
ExpiryEstimateStatusName string `json:"expiry_estimate_status_name" description:"预计套餐到期推算状态名称(中文)"`
IsExpiring bool `json:"is_expiring" description:"是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true"`
}

View File

@@ -0,0 +1,215 @@
// Package packageexpiry 提供资产主套餐最终到期时间的统一只读推算。
package packageexpiry
import (
"context"
"sort"
"time"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// Query 查询单个或批量资产的套餐最终到期时间。
type Query struct {
db *gorm.DB
now func() time.Time
}
// NewQuery 创建套餐最终到期查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db, now: time.Now}
}
// Resolve 查询单个资产的最终到期推算结果。
func (q *Query) Resolve(ctx context.Context, assetType string, assetID uint) (dto.PackageExpiryEstimate, error) {
results, err := q.ResolveBatch(ctx, assetType, []uint{assetID})
if err != nil {
return dto.PackageExpiryEstimate{}, err
}
return results[assetID], nil
}
// ResolveBatch 批量查询同类型资产的最终到期推算结果,避免列表场景逐资产读取。
func (q *Query) ResolveBatch(ctx context.Context, assetType string, assetIDs []uint) (map[uint]dto.PackageExpiryEstimate, error) {
results := make(map[uint]dto.PackageExpiryEstimate, len(assetIDs))
if len(assetIDs) == 0 {
return results, nil
}
column, ok := packageUsageAssetColumn(assetType)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
usages := make([]*model.PackageUsage, 0)
if err := q.db.WithContext(ctx).
Where(column+" IN ?", assetIDs).
Where("master_usage_id IS NULL").
Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("refund_id IS NULL").
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐最终到期记录失败")
}
packages, err := q.loadFallbackPackages(ctx, usages)
if err != nil {
return nil, err
}
grouped := make(map[uint][]*model.PackageUsage, len(assetIDs))
for _, usage := range usages {
assetID := usage.IotCardID
if assetType == constants.AssetTypeDevice {
assetID = usage.DeviceID
}
grouped[assetID] = append(grouped[assetID], usage)
}
for _, assetID := range assetIDs {
results[assetID] = Calculate(grouped[assetID], packages, q.now())
}
return results, nil
}
func (q *Query) loadFallbackPackages(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) {
ids := make([]uint, 0)
seen := make(map[uint]struct{})
for _, usage := range usages {
if !isEmptyHistoricalTerms(usage) {
continue
}
if _, exists := seen[usage.PackageID]; !exists {
seen[usage.PackageID] = struct{}{}
ids = append(ids, usage.PackageID)
}
}
packages := make(map[uint]*model.Package, len(ids))
if len(ids) == 0 {
return packages, nil
}
var rows []*model.Package
if err := q.db.WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史套餐计时条款失败")
}
for _, pkg := range rows {
packages[pkg.ID] = pkg
}
return packages, nil
}
// Calculate 使用已批量读取的记录和历史套餐回退数据计算最终到期时间。
func Calculate(usages []*model.PackageUsage, packages map[uint]*model.Package, now time.Time) dto.PackageExpiryEstimate {
eligible := make([]*model.PackageUsage, 0, len(usages))
for _, usage := range usages {
if usage == nil || usage.DeletedAt.Valid || usage.RefundID != nil || usage.MasterUsageID != nil {
continue
}
if usage.Status == constants.PackageUsageStatusPending || usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted {
eligible = append(eligible, usage)
}
}
if len(eligible) == 0 {
return newEstimate(constants.PackageExpiryEstimateStatusNone, nil, now)
}
sort.SliceStable(eligible, func(i, j int) bool {
if eligible[i].Priority != eligible[j].Priority {
return eligible[i].Priority < eligible[j].Priority
}
if !eligible[i].CreatedAt.Equal(eligible[j].CreatedAt) {
return eligible[i].CreatedAt.Before(eligible[j].CreatedAt)
}
return eligible[i].ID < eligible[j].ID
})
currentIndex := -1
for i, usage := range eligible {
if usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted {
if currentIndex >= 0 || usage.ExpiresAt == nil {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
currentIndex = i
}
}
if currentIndex < 0 {
first := eligible[0]
terms, ok := resolveTerms(first, packages)
if !ok {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
if first.PendingRealnameActivation || terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
return newEstimate(constants.PackageExpiryEstimateStatusWaitingActivation, nil, now)
}
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
for i := 0; i < currentIndex; i++ {
if eligible[i].Status == constants.PackageUsageStatusPending {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
}
cursor := eligible[currentIndex].ExpiresAt.In(shanghaiLocation)
for _, usage := range eligible[currentIndex+1:] {
if usage.Status != constants.PackageUsageStatusPending {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
terms, ok := resolveTerms(usage, packages)
if !ok {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
start := cursor.Add(time.Second)
cursor = packagepkg.CalculateExpiryTime(terms.CalendarType, start, terms.DurationMonths, terms.DurationDays).In(shanghaiLocation)
}
return newEstimate(constants.PackageExpiryEstimateStatusExact, &cursor, now)
}
func resolveTerms(usage *model.PackageUsage, packages map[uint]*model.Package) (packagedomain.TermsSnapshot, bool) {
terms := packagedomain.TermsSnapshotFromUsage(usage)
if terms.IsValid() {
return terms, true
}
if !isEmptyHistoricalTerms(usage) {
return packagedomain.TermsSnapshot{}, false
}
pkg := packages[usage.PackageID]
terms, err := packagedomain.ResolveTermsSnapshot(pkg, nil)
return terms, err == nil
}
func isEmptyHistoricalTerms(usage *model.PackageUsage) bool {
// UR55 的 000163 迁移已在测试环境生效:触发器拒绝新记录写入空快照,
// 因此整组空值只可能是迁移前遗留记录;任意部分空值仍按异常处理。
return usage.ExpiryBaseSnapshot == "" && usage.CalendarTypeSnapshot == "" && usage.DurationMonthsSnapshot == 0 && usage.DurationDaysSnapshot == 0
}
func packageUsageAssetColumn(assetType string) (string, bool) {
switch assetType {
case constants.AssetTypeIotCard, constants.AssetResolveTypeCard:
return "iot_card_id", true
case constants.AssetTypeDevice:
return "device_id", true
default:
return "", false
}
}
func newEstimate(status string, expiresAt *time.Time, now time.Time) dto.PackageExpiryEstimate {
result := dto.PackageExpiryEstimate{ExpiryEstimateStatus: status, ExpiryEstimateStatusName: constants.GetPackageExpiryEstimateStatusName(status)}
if status != constants.PackageExpiryEstimateStatusExact || expiresAt == nil {
return result
}
days := dateInShanghai(*expiresAt).Sub(dateInShanghai(now))
daysUntil := int(days.Hours() / 24)
result.EstimatedFinalExpiresAt = expiresAt
result.DaysUntilFinalExpiry = &daysUntil
result.IsExpiring = daysUntil >= 0 && daysUntil <= 15
return result
}
func dateInShanghai(value time.Time) time.Time {
local := value.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}

View File

@@ -0,0 +1,420 @@
package packageexpiry
import (
"context"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
// testIndexCardID 是索引执行计划测试使用的卡资产 ID。
testIndexCardID = 970001
// testIndexDeviceID 是索引执行计划测试使用的设备资产 ID。
testIndexDeviceID = 970002
// testQueryPerformanceLimitMS 是项目数据库查询耗时上限。
testQueryPerformanceLimitMS = 50
)
func TestCalculateBasicStatuses(t *testing.T) {
location, now, currentExpiry := calculationTimes()
assertCalculation(t, nil, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
assertCalculation(t, []*model.PackageUsage{current}, nil, now, constants.PackageExpiryEstimateStatusExact, &currentExpiry, intPointer(8))
waiting := newCalculationUsage(2, constants.PackageUsageStatusPending, 1)
waiting.PendingRealnameActivation = true
assertCalculation(t, []*model.PackageUsage{waiting}, nil, now, constants.PackageExpiryEstimateStatusWaitingActivation, nil, nil)
expiredAt := time.Date(2026, 7, 20, 23, 59, 59, 0, location)
expiredCurrent := newCalculationUsage(3, constants.PackageUsageStatusActive, 1)
expiredCurrent.ExpiresAt = &expiredAt
assertCalculation(t, []*model.PackageUsage{expiredCurrent}, nil, now, constants.PackageExpiryEstimateStatusExact, &expiredAt, intPointer(-3))
duplicateCurrent := newCalculationUsage(4, constants.PackageUsageStatusDepleted, 2)
duplicateCurrent.ExpiresAt = &currentExpiry
assertCalculation(t, []*model.PackageUsage{current, duplicateCurrent}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
}
func TestCalculateQueueAndCalendarBoundaries(t *testing.T) {
location, now, currentExpiry := calculationTimes()
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
firstQueued := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
secondQueued := newCalculationUsage(3, constants.PackageUsageStatusPending, 3)
assertCalculation(t, []*model.PackageUsage{current, firstQueued, secondQueued}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 8, 23, 59, 59, 0, location)), intPointer(16))
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
assertCalculation(t, []*model.PackageUsage{current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), intPointer(69))
crossYearExpiry := time.Date(2026, 12, 31, 23, 59, 59, 0, location)
crossYearCurrent := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
crossYearCurrent.ExpiresAt = &crossYearExpiry
oneDay := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
oneDay.DurationDaysSnapshot = 1
assertCalculation(t, []*model.PackageUsage{crossYearCurrent, oneDay}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2027, 1, 2, 23, 59, 59, 0, location)), nil)
assertDuplicatePriorityOrder(t, location, now)
}
func TestCalculateFallbackAndExclusions(t *testing.T) {
location, now, currentExpiry := calculationTimes()
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
historical := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
historical.ExpiryBaseSnapshot, historical.CalendarTypeSnapshot, historical.DurationDaysSnapshot = "", "", 0
packages := map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}}
assertCalculation(t, []*model.PackageUsage{current, historical}, packages, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), intPointer(12))
invalid := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
invalid.CalendarTypeSnapshot = "bad"
assertCalculation(t, []*model.PackageUsage{current, invalid}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
assertExcludedUsages(t, now)
}
// TestCalculateExpiringBoundaries 验证临期标记包含 0 和 15 天,并排除 16 天及已过期结果。
func TestCalculateExpiringBoundaries(t *testing.T) {
location, now, _ := calculationTimes()
for _, testCase := range []struct {
days int
want bool
}{{days: -1, want: false}, {days: 0, want: true}, {days: 15, want: true}, {days: 16, want: false}} {
usage := newCalculationUsage(uint(testCase.days+2), constants.PackageUsageStatusActive, 1)
expiresAt := now.AddDate(0, 0, testCase.days).In(location)
usage.ExpiresAt = &expiresAt
result := Calculate([]*model.PackageUsage{usage}, nil, now)
if result.IsExpiring != testCase.want {
t.Fatalf("临期边界错误days=%d want=%v result=%+v", testCase.days, testCase.want, result)
}
}
}
func calculationTimes() (*time.Location, time.Time, time.Time) {
location := time.FixedZone("Asia/Shanghai", 8*60*60)
return location, time.Date(2026, 7, 23, 12, 0, 0, 0, location), time.Date(2026, 7, 31, 23, 59, 59, 0, location)
}
func newCalculationUsage(id uint, status, priority int) *model.PackageUsage {
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
return &model.PackageUsage{Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)), PackageID: id, Status: status, Priority: priority, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 3}
}
func assertCalculation(t *testing.T, usages []*model.PackageUsage, packages map[uint]*model.Package, now time.Time, wantStatus string, wantDate *time.Time, wantDays *int) {
t.Helper()
got := Calculate(usages, packages, now)
if got.ExpiryEstimateStatus != wantStatus || wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*wantDate)) || wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *wantDays) {
t.Fatalf("推算结果错误status=%s date=%v days=%v result=%+v", wantStatus, wantDate, wantDays, got)
}
if wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
t.Fatalf("非精确状态必须返回 null%+v", got)
}
}
func assertDuplicatePriorityOrder(t *testing.T, location *time.Location, now time.Time) {
t.Helper()
expiresAt := time.Date(2026, 1, 30, 23, 59, 59, 0, location)
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &expiresAt
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
byDay := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
byDay.DurationDaysSnapshot = 5
assertCalculation(t, []*model.PackageUsage{byDay, current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 3, 6, 23, 59, 59, 0, location)), nil)
}
func assertExcludedUsages(t *testing.T, now time.Time) {
t.Helper()
expired := newCalculationUsage(1, constants.PackageUsageStatusExpired, 1)
invalidated := newCalculationUsage(2, constants.PackageUsageStatusInvalidated, 2)
refundID, masterID := uint(3), uint(1)
refunded := newCalculationUsage(3, constants.PackageUsageStatusActive, 3)
refunded.RefundID = &refundID
deleted := newCalculationUsage(4, constants.PackageUsageStatusActive, 4)
deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
addon := newCalculationUsage(5, constants.PackageUsageStatusActive, 5)
addon.MasterUsageID = &masterID
assertCalculation(t, []*model.PackageUsage{expired, invalidated, refunded, deleted, addon}, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
}
// TestCalculateUsesShanghaiCalendarDays 验证剩余天数按上海自然日而非不足 24 小时取整。
func TestCalculateUsesShanghaiCalendarDays(t *testing.T) {
location := time.FixedZone("Asia/Shanghai", 8*60*60)
now := time.Date(2026, 7, 23, 23, 30, 0, 0, location)
expiresAt := time.Date(2026, 7, 24, 0, 30, 0, 0, location)
usage := &model.PackageUsage{Model: modelBase(1, now), Status: constants.PackageUsageStatusActive, Priority: 1, ExpiresAt: &expiresAt}
result := Calculate([]*model.PackageUsage{usage}, nil, now)
if result.DaysUntilFinalExpiry == nil || *result.DaysUntilFinalExpiry != 1 {
t.Fatalf("上海自然日差错误want=1 got=%v", result.DaysUntilFinalExpiry)
}
}
// TestQueryResolveBatchUsesOneBatchLoad 验证卡和设备各 100 个资产只执行一次套餐读取。
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
t.Run(assetType, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
assetIDs := make([]uint, 0, 100)
for i := 1; i <= 100; i++ {
assetID := uint(970000 + i)
assetIDs = append(assetIDs, assetID)
expiresAt := now.AddDate(0, 0, i%16)
usage := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
usage.ExpiresAt = &expiresAt
usage.OrderNo = "UR46-BATCH"
createQueryConsistencyUsage(t, tx, usage)
}
counter := &queryCounterLogger{Interface: tx.Logger}
query := NewQuery(tx.Session(&gorm.Session{Logger: counter}))
query.now = func() time.Time { return now }
results, err := query.ResolveBatch(context.Background(), assetType, assetIDs)
if err != nil {
t.Fatalf("批量查询失败:%v", err)
}
if len(results) != len(assetIDs) || counter.count != 1 {
t.Fatalf("100 个资产批量读取错误results=%d queries=%d", len(results), counter.count)
}
for _, assetID := range assetIDs {
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
}
}
})
}
}
type queryCounterLogger struct {
logger.Interface
count int
}
func (l *queryCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
l.count++
l.Interface.Trace(ctx, begin, fc, err)
}
// TestQueryResolveAndResolveBatchReturnSameResult 验证单资产与批量入口共享同一结果口径。
func TestQueryResolveAndResolveBatchReturnSameResult(t *testing.T) {
tests := []struct {
name string
assetType string
scenario string
}{
{name: "卡历史回退", assetType: constants.AssetTypeIotCard, scenario: "historical"},
{name: "卡等待激活", assetType: constants.AssetTypeIotCard, scenario: "waiting"},
{name: "卡异常数据", assetType: constants.AssetTypeIotCard, scenario: "invalid"},
{name: "卡负数天数", assetType: constants.AssetTypeIotCard, scenario: "expired"},
{name: "设备历史回退", assetType: constants.AssetTypeDevice, scenario: "historical"},
{name: "设备等待激活", assetType: constants.AssetTypeDevice, scenario: "waiting"},
{name: "设备异常数据", assetType: constants.AssetTypeDevice, scenario: "invalid"},
{name: "设备负数天数", assetType: constants.AssetTypeDevice, scenario: "expired"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
assetID := uint(980001)
createQueryConsistencyFixture(t, tx, tt.assetType, assetID, tt.scenario, now)
query := NewQuery(tx)
query.now = func() time.Time { return now }
single, err := query.Resolve(context.Background(), tt.assetType, assetID)
if err != nil {
t.Fatalf("单资产查询失败:%v", err)
}
batch, err := query.ResolveBatch(context.Background(), tt.assetType, []uint{assetID})
if err != nil {
t.Fatalf("批量查询失败:%v", err)
}
assertEstimateEqual(t, single, batch[assetID])
assertScenarioEstimate(t, single, tt.scenario, now)
})
}
}
// assertScenarioEstimate 验证一致性夹具不仅两入口相同,也符合各场景业务结果。
func assertScenarioEstimate(t *testing.T, estimate dto.PackageExpiryEstimate, scenario string, now time.Time) {
t.Helper()
switch scenario {
case "historical":
currentExpiry := now.AddDate(0, 0, 5)
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.EstimatedFinalExpiresAt == nil || !estimate.EstimatedFinalExpiresAt.After(currentExpiry) {
t.Fatalf("历史快照回退未参与套餐接续:%+v", estimate)
}
case "waiting":
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusWaitingActivation {
t.Fatalf("等待激活场景错误:%+v", estimate)
}
case "invalid":
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusInvalidData {
t.Fatalf("异常数据场景错误:%+v", estimate)
}
case "expired":
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || *estimate.DaysUntilFinalExpiry != -3 {
t.Fatalf("负数天数场景错误:%+v", estimate)
}
}
}
// createQueryConsistencyFixture 按场景创建单资产与批量入口共享的数据库夹具。
func createQueryConsistencyFixture(t *testing.T, tx *gorm.DB, assetType string, assetID uint, scenario string, now time.Time) {
t.Helper()
usage := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
switch scenario {
case "waiting":
usage.Status = constants.PackageUsageStatusPending
usage.PendingRealnameActivation = true
usage.ExpiresAt = nil
createQueryConsistencyUsage(t, tx, usage)
case "invalid":
usage.ExpiresAt = nil
createQueryConsistencyUsage(t, tx, usage)
case "expired":
expiresAt := now.AddDate(0, 0, -3)
usage.ExpiresAt = &expiresAt
createQueryConsistencyUsage(t, tx, usage)
case "historical":
expiresAt := now.AddDate(0, 0, 5)
usage.ExpiresAt = &expiresAt
createQueryConsistencyUsage(t, tx, usage)
pkg := &model.Package{PackageCode: "UR46-FALLBACK-" + assetType, PackageName: "UR46历史回退套餐", PackageType: constants.PackageTypeFormal, DurationDays: 3, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: constants.PackageExpiryBaseFromActivation, Status: constants.StatusEnabled, ShelfStatus: 1}
if err := tx.Create(pkg).Error; err != nil {
t.Fatalf("创建历史回退套餐失败:%v", err)
}
queued := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusPending, 2, now)
queued.PackageID = pkg.ID
queued.OrderID++
queued.OrderNo = "UR46-CONSISTENCY-Q"
queued.ExpiryBaseSnapshot = ""
queued.CalendarTypeSnapshot = ""
queued.DurationDaysSnapshot = 0
// 历史套餐允许缺少快照;仅在测试事务内临时关闭新数据校验触发器,事务结束后状态自动恢复。
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
t.Fatalf("禁用历史快照触发器失败:%v", err)
}
createQueryConsistencyUsage(t, tx, queued)
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
t.Fatalf("恢复历史快照触发器失败:%v", err)
}
}
}
func newQueryConsistencyUsage(assetType string, assetID uint, status, priority int, now time.Time) *model.PackageUsage {
usage := &model.PackageUsage{OrderID: assetID, OrderNo: "UR46-CONSISTENCY", PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: priority, ActivatedAt: &now, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
if assetType == constants.AssetTypeDevice {
usage.DeviceID = assetID
} else {
usage.IotCardID = assetID
}
return usage
}
func createQueryConsistencyUsage(t *testing.T, tx *gorm.DB, usage *model.PackageUsage) {
t.Helper()
desiredStatus := usage.Status
pendingRealname := usage.PendingRealnameActivation
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
t.Fatalf("创建一致性套餐记录失败:%v", err)
}
if err := tx.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error; err != nil {
t.Fatalf("更新一致性套餐状态失败:%v", err)
}
}
func assertEstimateEqual(t *testing.T, left, right dto.PackageExpiryEstimate) {
t.Helper()
if left.ExpiryEstimateStatus != right.ExpiryEstimateStatus || left.ExpiryEstimateStatusName != right.ExpiryEstimateStatusName || left.IsExpiring != right.IsExpiring || !timePointersEqual(left.EstimatedFinalExpiresAt, right.EstimatedFinalExpiresAt) || !intPointersEqual(left.DaysUntilFinalExpiry, right.DaysUntilFinalExpiry) {
t.Fatalf("单资产与批量结果不一致single=%+v batch=%+v", left, right)
}
}
func timePointersEqual(left, right *time.Time) bool {
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
}
func intPointersEqual(left, right *int) bool {
return left == nil && right == nil || left != nil && right != nil && *left == *right
}
// TestQueryIndexesExplainAnalyze 验证 UR46 部分索引定义及代表性查询执行时间。
func TestQueryIndexesExplainAnalyze(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
createIndexPlanFixtures(t, tx)
var indexDefinitions []string
if err := tx.Raw("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN (?, ?) ORDER BY indexname", "idx_package_usage_expiry_iot_card_queue", "idx_package_usage_expiry_device_queue").Scan(&indexDefinitions).Error; err != nil {
t.Fatalf("查询 UR46 索引定义失败:%v", err)
}
if len(indexDefinitions) != 2 {
t.Fatalf("UR46 部分索引数量错误want=2 got=%d", len(indexDefinitions))
}
for _, definition := range indexDefinitions {
if !strings.Contains(definition, "master_usage_id IS NULL") || !strings.Contains(definition, "refund_id IS NULL") || !strings.Contains(definition, "status = ANY") {
t.Fatalf("UR46 部分索引条件不完整:%s", definition)
}
}
if err := tx.Exec("ANALYZE tb_package_usage").Error; err != nil {
t.Fatalf("更新代表性数据统计信息失败:%v", err)
}
queries := []struct {
sql string
indexName string
}{
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND iot_card_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC", indexName: "idx_package_usage_expiry_iot_card_queue"},
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND device_id = 970002 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC", indexName: "idx_package_usage_expiry_device_queue"},
}
for _, query := range queries {
var lines []string
if err := tx.Raw(query.sql).Scan(&lines).Error; err != nil {
t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err)
}
if len(lines) == 0 {
t.Fatal("EXPLAIN ANALYZE 未返回执行计划")
}
plan := strings.Join(lines, "\n")
if !strings.Contains(plan, query.indexName) {
t.Fatalf("代表性查询未命中 UR46 部分索引 %s%s", query.indexName, plan)
}
match := regexp.MustCompile(`Execution Time: ([0-9.]+) ms`).FindStringSubmatch(plan)
if len(match) != 2 {
t.Fatalf("执行计划缺少执行时间:%v", lines)
}
executionMS, err := strconv.ParseFloat(match[1], 64)
if err != nil || executionMS >= testQueryPerformanceLimitMS {
t.Fatalf("代表性套餐队列查询超过 50msexecution_ms=%v err=%v", executionMS, err)
}
t.Logf("%s", lines)
}
}
// createIndexPlanFixtures 创建高基数背景数据和目标套餐队列,供正常规划器评估索引选择。
func createIndexPlanFixtures(t *testing.T, tx *gorm.DB) {
t.Helper()
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
expiresAt := now.AddDate(0, 0, 30)
usages := make([]model.PackageUsage, 0, 2020)
for index := 0; index < 1010; index++ {
cardID, deviceID := uint(971000+index), uint(972500+index)
if index < 10 {
cardID, deviceID = testIndexCardID, testIndexDeviceID
}
card := newQueryConsistencyUsage(constants.AssetTypeIotCard, cardID, constants.PackageUsageStatusActive, index+1, now)
card.OrderID, card.PackageID, card.OrderNo, card.ExpiresAt = uint(800000+index), uint(800000+index), "UR46-INDEX-CARD-"+strconv.Itoa(index), &expiresAt
device := newQueryConsistencyUsage(constants.AssetTypeDevice, deviceID, constants.PackageUsageStatusActive, index+1, now)
device.OrderID, device.PackageID, device.OrderNo, device.ExpiresAt = uint(900000+index), uint(900000+index), "UR46-INDEX-DEVICE-"+strconv.Itoa(index), &expiresAt
usages = append(usages, *card, *device)
}
if err := tx.CreateInBatches(usages, 200).Error; err != nil {
t.Fatalf("创建索引代表性数据失败:%v", err)
}
}
func modelBase(id uint, createdAt time.Time) gorm.Model {
return gorm.Model{ID: id, CreatedAt: createdAt}
}
func intPointer(value int) *int { return &value }
func timePointer(value time.Time) *time.Time { return &value }

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"strconv"
"strings"
"testing"
"time"
@@ -15,13 +16,19 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/config"
@@ -31,6 +38,13 @@ import (
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
)
const (
// testListPageSize 是列表批量验收的代表性每页资产数量。
testListPageSize = 100
// testHTTPPerformanceLimit 是单次集成请求可验证的项目 API P99 响应耗时上限。
testHTTPPerformanceLimit = 500 * time.Millisecond
)
// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。
func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
@@ -111,6 +125,667 @@ func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *
}
}
// TestAssetResolveHTTPReturnsPackageExpiryEstimate 验证后台详情通过真实 JWT、Redis、Fiber 与 PostgreSQL 返回预计最终到期字段。
func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
shop := env.createShop(t, "UR46详情店铺", nil, 1)
card := env.createCard(t, 46, &shop.ID)
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
expiresAt := now.AddDate(0, 0, 5)
usage := &model.PackageUsage{OrderID: card.ID, OrderNo: "UR46-HTTP", PackageID: card.ID, UsageType: constants.AssetTypeIotCard, IotCardID: card.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建详情套餐使用记录失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, constants.PackageUsageStatusActive)
status, body := env.request(t, "/api/admin/assets/resolve/"+card.ICCID, env.newAgentToken(t, shop.ID))
if status != http.StatusOK {
t.Fatalf("查询资产详情失败:%d %s", status, body)
}
var response struct {
Data struct {
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
Name string `json:"expiry_estimate_status_name"`
Expiring bool `json:"is_expiring"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析预计到期响应失败:%v", err)
}
if response.Data.ExpiresAt == nil || response.Data.Days == nil || response.Data.Status != constants.PackageExpiryEstimateStatusExact || response.Data.Name == "" || !response.Data.Expiring {
t.Fatalf("预计到期字段不完整:%s", body)
}
device := env.createDevice(t, 46, &shop.ID)
deviceUsage := newExpiryHTTPUsage(device.ID, constants.AssetTypeDevice, constants.PackageUsageStatusActive, &expiresAt, false, now)
if err := createExpiryHTTPUsage(env.db, deviceUsage); err != nil {
t.Fatalf("创建设备详情套餐使用记录失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, deviceUsage.ID, constants.PackageUsageStatusActive)
status, body = env.request(t, "/api/admin/assets/resolve/"+device.VirtualNo, env.newAgentToken(t, shop.ID))
assertExpiryHTTPStatus(t, status, body, constants.PackageExpiryEstimateStatusExact, true)
}
// TestAssetResolveHTTPReturnsAllPackageExpiryStatuses 验证后台详情完整返回四种状态及 nullable 语义。
func TestAssetResolveHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
shop := env.createShop(t, "UR46四状态店铺", nil, 1)
token := env.newAgentToken(t, shop.ID)
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
for typeIndex, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
for statusIndex, estimateStatus := range []string{constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData} {
name := assetType + "/" + estimateStatus
t.Run(name, func(t *testing.T) {
asset := createAdminExpiryTestAsset(t, env, assetType, 60+typeIndex*10+statusIndex, &shop.ID)
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
usageStatus, waiting := constants.PackageUsageStatusActive, false
if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
usageStatus, waiting = constants.PackageUsageStatusPending, true
}
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, nil, waiting, now)
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建状态套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
}
status, body := env.request(t, "/api/admin/assets/resolve/"+asset.identifier, token)
assertExpiryHTTPStatus(t, status, body, estimateStatus, false)
})
}
}
}
func createAdminExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, shopID *uint) clientExpiryTestAsset {
t.Helper()
if assetType == constants.AssetTypeDevice {
device := env.createDevice(t, suffix, shopID)
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
}
card := env.createCard(t, suffix, shopID)
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
}
// TestAssetResolveHTTPReflectsQueueChangesImmediately 验证新增、退款和失效队列后无需刷新资产快照。
func TestAssetResolveHTTPReflectsQueueChangesImmediately(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
shop := env.createShop(t, "UR46实时变化店铺", nil, 1)
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
t.Run(assetType, func(t *testing.T) {
asset := createAdminExpiryTestAsset(t, env, assetType, 63+index, &shop.ID)
verifyAdminQueueChanges(t, env, asset, shop.ID)
})
}
}
// verifyAdminQueueChanges 验证后台详情实时反映指定资产的套餐队列变化。
func verifyAdminQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset, shopID uint) {
t.Helper()
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
currentExpiry := now.AddDate(0, 0, 5)
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, &currentExpiry, false, now)
if err := createExpiryHTTPUsage(env.db, current); err != nil {
t.Fatalf("创建当前套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
token := env.newAgentToken(t, shopID)
initial := requestExpiryEstimate(t, env, asset.identifier, token)
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
queued.Priority = 2
queued.PackageID = asset.assetID + 1
queued.OrderNo = fmt.Sprintf("UR46-Q-%s-%d", asset.assetType, asset.assetID)
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
t.Fatalf("创建排队套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
extended := requestExpiryEstimate(t, env, asset.identifier, token)
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
t.Fatalf("新增排队套餐后预计到期未延长initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
}
refundID := uint(990001)
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
t.Fatalf("设置排队套餐退款状态失败:%v", err)
}
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
refunded := requestExpiryEstimate(t, env, asset.identifier, token)
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("退款后预计到期未实时恢复initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
}
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
t.Fatalf("设置排队套餐失效状态失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
invalidated := requestExpiryEstimate(t, env, asset.identifier, token)
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("失效后预计到期未实时恢复initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
}
}
type expiryHTTPResult struct {
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
Name string `json:"expiry_estimate_status_name"`
Expiring bool `json:"is_expiring"`
}
// requestExpiryEstimate 请求后台统一资产详情并验证预计到期字段的 JSON 契约。
func requestExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, identifier, token string) expiryHTTPResult {
t.Helper()
status, body := env.request(t, "/api/admin/assets/resolve/"+identifier, token)
if status != http.StatusOK {
t.Fatalf("查询预计到期失败:%d %s", status, body)
}
var response struct {
Data expiryHTTPResult `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析预计到期响应失败:%v", err)
}
assertExpiryJSONNullable(t, body, response.Data.Status == constants.PackageExpiryEstimateStatusExact)
return response.Data
}
func newExpiryHTTPUsage(assetID uint, assetType string, status int, expiresAt *time.Time, waiting bool, now time.Time) *model.PackageUsage {
usage := &model.PackageUsage{OrderID: assetID, OrderNo: fmt.Sprintf("UR46-STATUS-%s-%d", assetType, assetID), PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: 1, ActivatedAt: &now, ExpiresAt: expiresAt, PendingRealnameActivation: waiting, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
if assetType == constants.AssetTypeDevice {
usage.DeviceID = assetID
} else {
usage.IotCardID = assetID
}
return usage
}
func createExpiryHTTPUsage(db *gorm.DB, usage *model.PackageUsage) error {
desiredStatus := usage.Status
pendingRealname := usage.PendingRealnameActivation
if err := db.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return err
}
usage.Status = desiredStatus
usage.PendingRealnameActivation = pendingRealname
return db.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error
}
// assertExpiryHTTPStatus 验证详情状态、中文名称、临期标记及 nullable 语义。
func assertExpiryHTTPStatus(t *testing.T, status int, body []byte, expected string, exact bool) {
t.Helper()
if status != http.StatusOK {
t.Fatalf("预计到期接口失败:%d %s", status, body)
}
var response struct {
Data struct {
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
Name string `json:"expiry_estimate_status_name"`
Expiring bool `json:"is_expiring"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析预计到期响应失败:%v", err)
}
if response.Data.Status != expected || response.Data.Name == "" {
t.Fatalf("预计到期状态错误want=%s body=%s", expected, body)
}
if exact != (response.Data.ExpiresAt != nil && response.Data.Days != nil) {
t.Fatalf("预计到期 nullable 语义错误body=%s", body)
}
if response.Data.Expiring != exact {
t.Fatalf("预计到期临期标记错误exact=%v body=%s", exact, body)
}
assertExpiryJSONNullable(t, body, exact)
}
// assertExpiryJSONNullable 验证日期和天数字段必须存在,并按状态显式返回值或 null。
func assertExpiryJSONNullable(t *testing.T, body []byte, exact bool) {
t.Helper()
var payload struct {
Data map[string]any `json:"data"`
}
if err := sonic.Unmarshal(body, &payload); err != nil {
t.Fatalf("解析预计到期原始字段失败:%v", err)
}
expiresAt, expiresAtExists := payload.Data["estimated_final_expires_at"]
days, daysExists := payload.Data["days_until_final_expiry"]
if !expiresAtExists || !daysExists || exact != (expiresAt != nil && days != nil) {
t.Fatalf("预计到期字段必须存在并显式返回值或 nullexact=%v body=%s", exact, body)
}
}
func assertPackageUsagePersisted(t *testing.T, db *gorm.DB, usageID uint, expectedStatus int) {
t.Helper()
var usage model.PackageUsage
if err := db.First(&usage, usageID).Error; err != nil || usage.Status != expectedStatus {
t.Fatalf("套餐使用记录数据库状态错误id=%d status=%d err=%v", usageID, usage.Status, err)
}
}
func assertPackageUsageRefunded(t *testing.T, db *gorm.DB, usageID, expectedRefundID uint) {
t.Helper()
var usage model.PackageUsage
if err := db.First(&usage, usageID).Error; err != nil || usage.RefundID == nil || *usage.RefundID != expectedRefundID {
t.Fatalf("套餐退款数据库状态错误id=%d refund_id=%v err=%v", usageID, usage.RefundID, err)
}
}
// TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses 验证 C 端卡、设备四状态与后台口径一致。
func TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
assets := append(createClientExpiryStatusAssets(t, env, constants.AssetTypeIotCard, 70, now), createClientExpiryStatusAssets(t, env, constants.AssetTypeDevice, 80, now)...)
adminToken := env.newAgentToken(t, 0)
for _, asset := range assets {
client := requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
admin := requestExpiryEstimate(t, env, asset.identifier, adminToken)
if client.Status != asset.status || client.Status != admin.Status || client.Name == "" || client.Expiring != admin.Expiring || !timePointersEqualRoute(client.ExpiresAt, admin.ExpiresAt) || !intPointersEqualRoute(client.Days, admin.Days) {
t.Fatalf("C 端与后台预计到期不一致asset=%s client=%+v admin=%+v", asset.identifier, client, admin)
}
exact := asset.status == constants.PackageExpiryEstimateStatusExact
if exact != (client.ExpiresAt != nil && client.Days != nil) || exact != client.Expiring {
t.Fatalf("C 端 nullable 或临期标记错误asset=%s result=%+v", asset.identifier, client)
}
}
}
type clientExpiryTestAsset struct {
assetType string
assetID uint
virtualNo string
identifier string
status string
}
// createClientExpiryStatusAssets 为指定资产类型创建预计到期四状态夹具。
func createClientExpiryStatusAssets(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, now time.Time) []clientExpiryTestAsset {
t.Helper()
statuses := []string{constants.PackageExpiryEstimateStatusExact, constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData}
assets := make([]clientExpiryTestAsset, 0, len(statuses))
for index, estimateStatus := range statuses {
asset := createClientExpiryTestAsset(t, env, assetType, suffix+index)
asset.status = estimateStatus
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
usageStatus, waiting := constants.PackageUsageStatusActive, false
var expiresAt *time.Time
if estimateStatus == constants.PackageExpiryEstimateStatusExact {
value := now.AddDate(0, 0, 5)
expiresAt = &value
} else if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
usageStatus, waiting = constants.PackageUsageStatusPending, true
}
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, expiresAt, waiting, now)
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建 C 端状态套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
}
assets = append(assets, asset)
}
return assets
}
func createClientExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int) clientExpiryTestAsset {
t.Helper()
if assetType == constants.AssetTypeDevice {
device := env.createDevice(t, suffix, nil)
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
}
card := env.createCard(t, suffix, nil)
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
}
// TestClientAssetInfoHTTPReflectsQueueChangesImmediately 验证 C 端实时反映套餐新增、退款和失效。
func TestClientAssetInfoHTTPReflectsQueueChangesImmediately(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
t.Run(assetType, func(t *testing.T) {
asset := createClientExpiryTestAsset(t, env, assetType, 90+index)
verifyClientQueueChanges(t, env, asset)
})
}
}
// verifyClientQueueChanges 验证 C 端详情实时反映指定资产的套餐队列变化。
func verifyClientQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset) {
t.Helper()
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
currentExpiry := now.AddDate(0, 0, 5)
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, &currentExpiry, false, now)
if err := createExpiryHTTPUsage(env.db, current); err != nil {
t.Fatalf("创建 C 端当前套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
request := func() expiryHTTPResult {
return requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
}
initial := request()
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
queued.PackageID = asset.assetID + 1
queued.OrderNo = fmt.Sprintf("UR46-CQ-%s-%d", asset.assetType, asset.assetID)
queued.Priority = 2
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
t.Fatalf("创建 C 端排队套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
extended := request()
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
t.Fatalf("C 端新增套餐后预计到期未延长initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
}
refundID := uint(990002)
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
t.Fatalf("设置 C 端排队套餐退款失败:%v", err)
}
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
refunded := request()
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("C 端退款后预计到期未实时恢复initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
}
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
t.Fatalf("设置 C 端排队套餐失效失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
invalidated := request()
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
t.Fatalf("C 端失效后预计到期未实时恢复initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
}
}
// requestClientExpiryEstimate 创建真实客户认证和绑定后请求 C 端资产信息。
func requestClientExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, virtualNo, identifier string) expiryHTTPResult {
t.Helper()
now := time.Now()
customer := &model.PersonalCustomer{WxOpenID: fmt.Sprintf("ur46-client-%d-%d", assetID, time.Now().UnixNano()), WxUnionID: fmt.Sprintf("ur46-union-%d", assetID), Status: constants.StatusEnabled}
if err := env.db.Create(customer).Error; err != nil {
t.Fatalf("创建 C 端客户失败:%v", err)
}
binding := &model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: virtualNo, BindAt: now, Status: constants.StatusEnabled}
if err := env.db.Create(binding).Error; err != nil {
t.Fatalf("创建 C 端资产绑定失败:%v", err)
}
jwtManager := auth.NewJWTManager("ur46-client-status-secret", time.Hour)
token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", assetType, assetID)
if err != nil {
t.Fatalf("生成 C 端 JWT 失败:%v", err)
}
if err := env.redis.Set(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID), token, time.Hour).Err(); err != nil {
t.Fatalf("写入 C 端 Token 失败:%v", err)
}
t.Cleanup(func() {
_ = env.redis.Del(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID)).Err()
})
cardStore := postgres.NewIotCardStore(env.db, env.redis)
deviceStore := postgres.NewDeviceStore(env.db, env.redis)
handler := apphandler.NewClientAssetHandler(env.assetService, customerBinding.New(env.db, cardStore, deviceStore), postgres.NewAssetWalletStore(env.db, env.redis), postgres.NewPackageStore(env.db), postgres.NewShopPackageAllocationStore(env.db), cardStore, deviceStore, env.db, zap.NewNop())
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
app.Get("/api/c/v1/asset/info", internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop()).Authenticate(), handler.GetAssetInfo)
request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+identifier, nil)
if err != nil {
t.Fatalf("创建 C 端请求失败:%v", err)
}
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
response, err := app.Test(request, -1)
if err != nil {
t.Fatalf("执行 C 端请求失败:%v", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if response.StatusCode != http.StatusOK {
t.Fatalf("C 端预计到期响应失败:%d %s", response.StatusCode, body)
}
var payload struct {
Data expiryHTTPResult `json:"data"`
}
if err := sonic.Unmarshal(body, &payload); err != nil {
t.Fatalf("解析 C 端预计到期响应失败:%v", err)
}
assertExpiryJSONNullable(t, body, payload.Data.Status == constants.PackageExpiryEstimateStatusExact)
return payload.Data
}
func timePointersEqualRoute(left, right *time.Time) bool {
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
}
func intPointersEqualRoute(left, right *int) bool {
return left == nil && right == nil || left != nil && right != nil && *left == *right
}
type routeSQLCounterLogger struct {
logger.Interface
queries []string
}
func (l *routeSQLCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
sql, rows := fc()
l.queries = append(l.queries, sql)
l.Interface.Trace(ctx, begin, func() (string, int64) { return sql, rows }, err)
}
func (l *routeSQLCounterLogger) Reset() {
l.queries = nil
}
func (l *routeSQLCounterLogger) Total() int {
return len(l.queries)
}
func (l *routeSQLCounterLogger) CountContaining(fragment string) int {
count := 0
for _, query := range l.queries {
if strings.Contains(query, fragment) {
count++
}
}
return count
}
// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。
func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
env := newAssetTraceHTTPEnv(t)
counter := &routeSQLCounterLogger{Interface: env.db.Logger}
db := env.db.Session(&gorm.Session{Logger: counter})
registerPackageExpiryListRoutes(env, db)
fixture := createPackageExpiryListFixtures(t, env)
token := env.newAgentToken(t, 0)
for _, testCase := range []packageExpiryListCase{
{path: "/api/admin/iot-cards/standalone", assetType: constants.AssetTypeIotCard, identifier: fixture.lastCardIdentifier, orderedIDs: fixture.cardIDs, packageQueries: 1},
{path: "/api/admin/devices", assetType: constants.AssetTypeDevice, identifier: fixture.lastDeviceIdentifier, orderedIDs: fixture.deviceIDs, packageQueries: 2},
} {
t.Run(testCase.assetType, func(t *testing.T) {
verifyPackageExpiryList(t, env, counter, fixture, testCase, token)
})
}
}
type packageExpiryListFixture struct {
batchNo string
cardIDs []uint
deviceIDs []uint
statusIDs map[string]map[string]uint
lastCardIdentifier string
lastDeviceIdentifier string
}
type packageExpiryListCase struct {
path string
assetType string
identifier string
orderedIDs []uint
packageQueries int
}
type packageExpiryListItem struct {
ID uint `json:"id"`
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
Days *int `json:"days_until_final_expiry"`
Status string `json:"expiry_estimate_status"`
StatusName string `json:"expiry_estimate_status_name"`
IsExpiring bool `json:"is_expiring"`
fieldsExist bool
fieldsNull bool
}
type packageExpiryListPayload struct {
Data struct {
Items []packageExpiryListItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
} `json:"data"`
}
// registerPackageExpiryListRoutes 使用计数数据库会话注册卡和设备列表路由。
func registerPackageExpiryListRoutes(env *assetTraceHTTPEnv, db *gorm.DB) {
shopStore := postgres.NewShopStore(db, env.redis)
cardStore := postgres.NewIotCardStore(db, nil)
deviceStore := postgres.NewDeviceStore(db, nil)
seriesStore := postgres.NewPackageSeriesStore(db)
allocationStore := postgres.NewAssetAllocationRecordStore(db, env.redis)
cardSvc := iotCardService.New(db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, zap.NewNop(), nil)
deviceSvc := deviceService.New(db, nil, deviceStore, postgres.NewDeviceSimBindingStore(db, nil), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, postgres.NewAssetIdentifierStore(db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(db, nil), postgres.NewEnterpriseStore(db, nil))
packageExpiry := packageExpiryQuery.NewQuery(db)
cardSvc.SetPackageExpiryQuery(packageExpiry)
deviceSvc.SetPackageExpiryQuery(packageExpiry)
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
info, err := env.tokenManager.ValidateAccessToken(context.Background(), token)
if err != nil {
return nil, err
}
return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, ShopID: info.ShopID}, nil
}, ShopStore: shopStore})
env.app.Get("/api/admin/iot-cards/standalone", authMiddleware, admin.NewIotCardHandler(cardSvc).ListStandalone)
env.app.Get("/api/admin/devices", authMiddleware, admin.NewDeviceHandler(deviceSvc).List)
}
// createPackageExpiryListFixtures 创建卡和设备各 100 条的四状态列表数据。
func createPackageExpiryListFixtures(t *testing.T, env *assetTraceHTTPEnv) packageExpiryListFixture {
t.Helper()
fixture := packageExpiryListFixture{batchNo: "UR46-LIST-" + strconv.FormatInt(time.Now().UnixNano(), 10), cardIDs: make([]uint, 0, 100), deviceIDs: make([]uint, 0, 100), statusIDs: map[string]map[string]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}}}
now := time.Now()
expiresAt := now.AddDate(0, 0, 5)
for index := 0; index < 100; index++ {
card := env.createCard(t, 1000+index, nil)
device := env.createDevice(t, 1000+index, nil)
if err := env.db.Model(card).Updates(map[string]any{"is_standalone": true, "batch_no": fixture.batchNo}).Error; err != nil {
t.Fatalf("设置卡列表 fixture 失败:%v", err)
}
if err := env.db.Model(device).Update("batch_no", fixture.batchNo).Error; err != nil {
t.Fatalf("设置设备列表 fixture 失败:%v", err)
}
fixture.cardIDs = append(fixture.cardIDs, card.ID)
fixture.deviceIDs = append(fixture.deviceIDs, device.ID)
fixture.lastCardIdentifier, fixture.lastDeviceIdentifier = card.ICCID, device.VirtualNo
createPackageExpiryListUsage(t, env, constants.AssetTypeIotCard, card.ID, index, now, expiresAt, fixture.statusIDs)
createPackageExpiryListUsage(t, env, constants.AssetTypeDevice, device.ID, index, now, expiresAt, fixture.statusIDs)
}
return fixture
}
// createPackageExpiryListUsage 按资产序号分配精确、无套餐、待激活和异常状态。
func createPackageExpiryListUsage(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, index int, now, expiresAt time.Time, statusIDs map[string]map[string]uint) {
t.Helper()
estimateStatus := constants.PackageExpiryEstimateStatusExact
usageStatus := constants.PackageUsageStatusActive
usageExpiry := &expiresAt
waiting := false
switch index {
case 1:
estimateStatus = constants.PackageExpiryEstimateStatusNone
statusIDs[assetType][estimateStatus] = assetID
return
case 2:
estimateStatus = constants.PackageExpiryEstimateStatusWaitingActivation
usageStatus, usageExpiry, waiting = constants.PackageUsageStatusPending, nil, true
case 3:
estimateStatus = constants.PackageExpiryEstimateStatusInvalidData
usageExpiry = nil
}
statusIDs[assetType][estimateStatus] = assetID
usage := newExpiryHTTPUsage(assetID, assetType, usageStatus, usageExpiry, waiting, now)
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
t.Fatalf("创建列表套餐失败:%v", err)
}
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
}
// verifyPackageExpiryList 验证列表响应、固定查询数、性能、筛选、排序和详情一致性。
func verifyPackageExpiryList(t *testing.T, env *assetTraceHTTPEnv, counter *routeSQLCounterLogger, fixture packageExpiryListFixture, testCase packageExpiryListCase, token string) {
t.Helper()
baseQuery := "?page=1&page_size=100&batch_no=" + fixture.batchNo
counter.Reset()
oneStatus, oneBody := env.request(t, testCase.path+strings.Replace(baseQuery, "page_size=100", "page_size=1", 1), token)
oneAssetQueries := counter.Total()
if oneStatus != http.StatusOK {
t.Fatalf("单资产分页请求失败path=%s status=%d body=%s", testCase.path, oneStatus, oneBody)
}
counter.Reset()
startedAt := time.Now()
status, body := env.request(t, testCase.path+baseQuery, token)
requestDuration := time.Since(startedAt)
hundredAssetQueries := counter.Total()
if status != http.StatusOK || oneAssetQueries != hundredAssetQueries || counter.CountContaining("tb_package_usage") != testCase.packageQueries || requestDuration >= testHTTPPerformanceLimit {
t.Fatalf("列表批量查询或性能错误path=%s status=%d one=%d hundred=%d package_queries=%d duration=%v", testCase.path, status, oneAssetQueries, hundredAssetQueries, counter.CountContaining("tb_package_usage"), requestDuration)
}
payload := decodePackageExpiryList(t, body)
if payload.Data.Total != testListPageSize || payload.Data.Page != 1 || payload.Data.Size != testListPageSize || len(payload.Data.Items) != testListPageSize || payload.Data.Items[0].ID != testCase.orderedIDs[testListPageSize-1] {
t.Fatalf("列表分页或排序错误path=%s payload=%+v", testCase.path, payload.Data)
}
assertPackageExpiryListStatuses(t, payload.Data.Items, fixture.statusIDs[testCase.assetType])
detail := requestExpiryEstimate(t, env, testCase.identifier, token)
first := payload.Data.Items[0]
if first.Status != detail.Status || !timePointersEqualRoute(first.ExpiresAt, detail.ExpiresAt) || !intPointersEqualRoute(first.Days, detail.Days) {
t.Fatalf("列表与详情预计到期不一致list=%+v detail=%+v", first, detail)
}
status, pageTwoBody := env.request(t, testCase.path+"?page=2&page_size=50&batch_no="+fixture.batchNo, token)
pageTwo := decodePackageExpiryList(t, pageTwoBody)
if status != http.StatusOK || pageTwo.Data.Total != 100 || len(pageTwo.Data.Items) != 50 || pageTwo.Data.Items[0].ID != testCase.orderedIDs[49] {
t.Fatalf("列表翻页不稳定path=%s status=%d payload=%+v", testCase.path, status, pageTwo.Data)
}
}
// decodePackageExpiryList 同时解析类型字段和原始 JSON以区分字段缺失与显式 null。
func decodePackageExpiryList(t *testing.T, body []byte) packageExpiryListPayload {
t.Helper()
var payload packageExpiryListPayload
if err := sonic.Unmarshal(body, &payload); err != nil {
t.Fatalf("解析列表分页响应失败:%v", err)
}
var raw struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &raw); err != nil || len(raw.Data.Items) != len(payload.Data.Items) {
t.Fatalf("解析列表原始字段失败:%v", err)
}
for index, item := range raw.Data.Items {
expiresAt, expiresAtExists := item["estimated_final_expires_at"]
days, daysExists := item["days_until_final_expiry"]
payload.Data.Items[index].fieldsExist = expiresAtExists && daysExists
payload.Data.Items[index].fieldsNull = expiresAt == nil && days == nil
}
return payload
}
// assertPackageExpiryListStatuses 逐项验证列表四状态、字段存在性和 nullable 语义。
func assertPackageExpiryListStatuses(t *testing.T, items []packageExpiryListItem, expectedIDs map[string]uint) {
t.Helper()
byID := make(map[uint]packageExpiryListItem, len(items))
for _, item := range items {
byID[item.ID] = item
}
for status, id := range expectedIDs {
item, ok := byID[id]
if !ok || item.Status != status || item.StatusName == "" {
t.Fatalf("列表缺少状态投影status=%s id=%d item=%+v", status, id, item)
}
exact := status == constants.PackageExpiryEstimateStatusExact
if !item.fieldsExist || exact == item.fieldsNull || exact != (item.ExpiresAt != nil && item.Days != nil) || exact != item.IsExpiring {
t.Fatalf("列表 nullable 或临期标记错误status=%s item=%+v", status, item)
}
}
}
func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) {
t.Helper()
if status != http.StatusOK {
@@ -137,6 +812,7 @@ type assetTraceHTTPEnv struct {
db *gorm.DB
redis *redis.Client
tokenManager *auth.TokenManager
assetService *assetService.Service
}
func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
@@ -183,6 +859,7 @@ func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
postgres.NewAssetIdentifierStore(tx), postgres.NewOrderStore(tx, redisClient), postgres.NewOrderItemStore(tx, redisClient),
postgres.NewExchangeOrderStore(tx), nil,
)
assetSvc.SetPackageExpiryQuery(packageExpiryQuery.NewQuery(tx))
handler := admin.NewAssetHandler(assetSvc, nil, nil, nil, nil, nil, assetQuery.NewExchangeTraceQuery(tx, log))
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
@@ -197,7 +874,7 @@ func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
})
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(log)})
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{Asset: handler}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager}
return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager, assetService: assetSvc}
}
func (e *assetTraceHTTPEnv) createShop(t *testing.T, name string, parentID *uint, level int) *model.Shop {
@@ -219,6 +896,15 @@ func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *
return card
}
func (e *assetTraceHTTPEnv) createDevice(t *testing.T, suffix int, shopID *uint) *model.Device {
t.Helper()
device := &model.Device{VirtualNo: fmt.Sprintf("UR46-HTTP-DEVICE-%04d-%d", suffix, time.Now().UnixNano()), ShopID: shopID, Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock, MaxSimSlots: 4}
if err := e.db.Create(device).Error; err != nil {
t.Fatalf("创建 UR46 HTTP 测试设备失败:%v", err)
}
return device
}
func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) {
t.Helper()
newID := newCard.ID

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -29,6 +30,11 @@ type IotCardRefresher interface {
RefreshCardDataFromGateway(ctx context.Context, iccid string) error
}
// PackageExpiryResolver 查询资产的套餐最终到期推算结果。
type PackageExpiryResolver interface {
Resolve(ctx context.Context, assetType string, assetID uint) (dto.PackageExpiryEstimate, error)
}
// Service 资产查询与操作服务
type Service struct {
db *gorm.DB
@@ -47,6 +53,12 @@ type Service struct {
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService assetAuditSvc.OperationLogger
packageExpiryQuery PackageExpiryResolver
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供资产详情统一投影使用。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
}
// New 创建资产服务实例
@@ -85,6 +97,7 @@ func New(
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService,
packageExpiryQuery: packageexpiry.NewQuery(db),
}
}
@@ -246,6 +259,9 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
// 查当前主套餐
s.fillPackageInfo(ctx, resp, "device", device.ID)
if err := s.fillPackageExpiryEstimate(ctx, resp, constants.AssetTypeDevice, device.ID); err != nil {
return nil, err
}
activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID})
if err != nil {
@@ -312,6 +328,9 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC
// 查当前主套餐
s.fillPackageInfo(ctx, resp, "iot_card", card.ID)
if err := s.fillPackageExpiryEstimate(ctx, resp, constants.AssetTypeIotCard, card.ID); err != nil {
return nil, err
}
// 查 shop 名称
s.fillShopName(ctx, resp)
@@ -349,6 +368,19 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes
resp.EnableVirtualData = usage.EnableVirtualDataSnapshot
}
// fillPackageExpiryEstimate 使用统一 Query 投影资产的最终套餐到期,不允许查询失败降级为无套餐。
func (s *Service) fillPackageExpiryEstimate(ctx context.Context, resp *dto.AssetResolveResponse, assetType string, assetID uint) error {
if s.packageExpiryQuery == nil {
return errors.New(errors.CodeInternalError, "套餐最终到期查询未初始化")
}
estimate, err := s.packageExpiryQuery.Resolve(ctx, assetType, assetID)
if err != nil {
return err
}
resp.PackageExpiryEstimate = estimate
return nil
}
// fillUsageSummary 填充当前世代流量汇总字段。
// 仅在 includeUsageSummary=true 时被调用,无套餐时返回 0.0(非 null
func (s *Service) fillUsageSummary(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) {

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -20,21 +21,27 @@ import (
)
type Service struct {
db *gorm.DB
redis *redis.Client
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
db *gorm.DB
redis *redis.Client
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
}
func New(
@@ -55,21 +62,22 @@ func New(
enterpriseStore *postgres.EnterpriseStore,
) *Service {
return &Service{
db: db,
redis: rds,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService,
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore,
db: db,
redis: rds,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService,
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore,
packageExpiryQuery: packageexpiry.NewQuery(db),
}
}
@@ -150,10 +158,14 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
if err != nil {
return nil, err
}
deviceIDs := s.extractDeviceIDs(devices)
expiryEstimates, err := s.packageExpiryQuery.ResolveBatch(ctx, constants.AssetTypeDevice, deviceIDs)
if err != nil {
return nil, err
}
shopMap := s.loadShopData(ctx, devices)
seriesMap := s.loadSeriesNames(ctx, devices)
deviceIDs := s.extractDeviceIDs(devices)
bindingCounts, err := s.getBindingCounts(ctx, deviceIDs)
if err != nil {
return nil, err
@@ -184,6 +196,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
list := make([]*dto.DeviceResponse, 0, len(devices))
for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses)
item.PackageExpiryEstimate = expiryEstimates[device.ID]
if eid, ok := deviceEnterpriseMap[device.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]

View File

@@ -9,6 +9,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -73,6 +74,12 @@ type Service struct {
assetAuditService AssetAuditService
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
}
func New(
@@ -98,6 +105,7 @@ func New(
gatewayClient: gatewayClient,
logger: logger,
assetAuditService: assetAuditService,
packageExpiryQuery: packageexpiry.NewQuery(db),
}
}
@@ -278,6 +286,14 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
if err != nil {
return nil, err
}
cardIDs := make([]uint, 0, len(cards))
for _, card := range cards {
cardIDs = append(cardIDs, card.ID)
}
expiryEstimates, err := s.packageExpiryQuery.ResolveBatch(ctx, constants.AssetTypeIotCard, cardIDs)
if err != nil {
return nil, err
}
shopMap := s.loadShopNames(ctx, cards)
//TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的
@@ -318,6 +334,7 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
for _, card := range cards {
item := s.toStandaloneResponse(card, shopMap, seriesMap)
item.PackageExpiryEstimate = expiryEstimates[card.ID]
if eid, ok := cardAuthMap[card.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]

View File

@@ -0,0 +1,3 @@
-- 回滚资产最终套餐到期实时投影读取索引。
DROP INDEX IF EXISTS idx_package_usage_expiry_device_queue;
DROP INDEX IF EXISTS idx_package_usage_expiry_iot_card_queue;

View File

@@ -0,0 +1,14 @@
-- 为资产最终套餐到期的实时投影提供稳定的主套餐队列读取索引。
CREATE INDEX idx_package_usage_expiry_iot_card_queue
ON tb_package_usage (iot_card_id, priority ASC, created_at ASC, id ASC)
WHERE deleted_at IS NULL
AND master_usage_id IS NULL
AND refund_id IS NULL
AND status IN (0, 1, 2);
CREATE INDEX idx_package_usage_expiry_device_queue
ON tb_package_usage (device_id, priority ASC, created_at ASC, id ASC)
WHERE deleted_at IS NULL
AND master_usage_id IS NULL
AND refund_id IS NULL
AND status IN (0, 1, 2);

View File

@@ -0,0 +1,25 @@
package constants
// 套餐最终到期推算状态常量。
const (
PackageExpiryEstimateStatusExact = "exact" // 可精确推算
PackageExpiryEstimateStatusWaitingActivation = "waiting_activation" // 待激活后起算
PackageExpiryEstimateStatusNone = "none" // 无参与套餐
PackageExpiryEstimateStatusInvalidData = "invalid_data" // 数据异常
)
// GetPackageExpiryEstimateStatusName 返回套餐最终到期推算状态的中文名称。
func GetPackageExpiryEstimateStatusName(status string) string {
switch status {
case PackageExpiryEstimateStatusExact:
return "可精确推算"
case PackageExpiryEstimateStatusWaitingActivation:
return "待激活后起算"
case PackageExpiryEstimateStatusNone:
return "无套餐"
case PackageExpiryEstimateStatusInvalidData:
return "数据异常"
default:
return "未知"
}
}