From cde5369b30677e65a0990059a4e1f5afdd6f36f9 Mon Sep 17 00:00:00 2001 From: break Date: Thu, 23 Jul 2026 15:06:48 +0900 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90UR46=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...min-asset-detail-estimated-final-expiry.md | 28 ++-- ...02-iot-card-list-estimated-final-expiry.md | 20 +-- .../03-device-list-estimated-final-expiry.md | 20 +-- .../04-client-asset-estimated-final-expiry.md | 18 +-- docs/admin-openapi.yaml | 74 +++++++++- docs/ur46-estimated-final-expiry/功能总结.md | 2 + internal/query/packageexpiry/query_test.go | 51 +++++++ .../asset_exchange_trace_integration_test.go | 139 +++++++++++++++++- 8 files changed, 306 insertions(+), 46 deletions(-) diff --git a/.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md b/.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md index 2fa2d2c..3cb31ea 100644 --- a/.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md +++ b/.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md @@ -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:** backend-implemented(补充验收测试后结项) -- [ ] 架构主通道为 Query,Infrastructure 仅负责 GORM 批量读取、历史套餐兜底和索引;完整收口预计最终到期的读取与投影边界,不迁移套餐激活、购买、退款或队列接续写逻辑。 -- [ ] 建立可供单资产和批量资产入口共同调用的纯计算核心;两种入口对同一组使用记录返回完全一致的状态、预计日期、剩余自然日和临期标记,卡、设备及各端不得各自实现推算规则。 -- [ ] 仅纳入未软删除、仍处于当前或排队生命周期且不属于加油包的主套餐;排除已过期、已失效和已退款记录,并按 `priority ASC, created_at ASC, id ASC` 稳定计算。 -- [ ] 当前生效主套餐的真实到期时间作为游标起点,后续套餐从前一段结束后的下一时刻接续;自然月和按天时长统一使用项目套餐生命周期日期语义,并以 `Asia/Shanghai` 解释业务自然日。 -- [ ] 排队记录优先使用各自购买时计时快照;仅允许 UR#55 上线前形成的历史缺失快照记录显式回退套餐当前值,非法队列、非法日期或无法安全解析的快照返回 `invalid_data`,不得伪造日期或降级为 `none`。 -- [ ] 结果完整区分 `exact`、`waiting_activation`、`none`、`invalid_data`;非 `exact` 时预计日期和剩余天数明确序列化为 `null`,不能省略字段表达状态。 -- [ ] 剩余天数按上海时区日期差计算,允许普通详情返回负数;仅 `exact` 且剩余 0~15 个自然日时 `is_expiring=true`。 -- [ ] 后台统一资产解析接口为卡和设备返回预计日期、剩余天数、状态、状态中文名称及临期标记;查询失败向上返回统一错误,不得静默返回 `none`。 -- [ ] 后台卡和设备详情统一显示“预计套餐到期时间”:精确日期、待激活后起算、—、数据异常分别对应四种状态;当前套餐自身到期时间只保留在套餐明细中。 -- [ ] 为未软删除主套餐的资产、状态和队列读取建立经开发库 `EXPLAIN ANALYZE` 验证的非唯一或部分索引;索引迁移包含中文注释、可逆向下迁移,代表性查询满足项目性能目标。 -- [ ] 纯计算测试覆盖无套餐、仅当前套餐、多段队列、自然月、按天、月末、跨年、等待实名激活、历史回退、非法快照、重复优先级、退款、失效、软删除和加油包排除,并明确验证接续边界不多算或少算一天。 -- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成后台卡/设备详情集成验证;新增、退款或失效排队套餐后无需刷新快照,下一次查询立即返回新结果。 -- [ ] 补充本功能中文总结文档并同步更新 README;不实现 UR#33 临期列表、Dashboard、提醒,不实现 UR#42 导出,也不维护资产级最终到期快照字段。 +- [x] 架构主通道为 Query,Infrastructure 仅负责 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` 且剩余 0~15 个自然日时 `is_expiring=true`。 +- [x] 后台统一资产解析接口为卡和设备返回预计日期、剩余天数、状态、状态中文名称及临期标记;查询失败向上返回统一错误,不得静默返回 `none`。 +- [ ] 后台卡和设备详情统一显示“预计套餐到期时间”:精确日期、待激活后起算、—、数据异常分别对应四种状态;当前套餐自身到期时间只保留在套餐明细中。(前端任务,不纳入后端交付) +- [x] 为未软删除主套餐的资产、状态和队列读取建立经开发库 `EXPLAIN ANALYZE` 验证的非唯一或部分索引;索引迁移包含中文注释、可逆向下迁移,代表性查询满足项目性能目标。 +- [ ] 纯计算测试覆盖无套餐、仅当前套餐、多段队列、自然月、按天、月末、跨年、等待实名激活、历史回退、非法快照、重复优先级、退款、失效、软删除和加油包排除,并明确验证接续边界不多算或少算一天。(后端验收测试待补) +- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成后台卡/设备详情集成验证;新增、退款或失效排队套餐后无需刷新快照,下一次查询立即返回新结果。(后端验收测试待补) +- [x] 补充本功能中文总结文档并同步更新 README;不实现 UR#33 临期列表、Dashboard、提醒,不实现 UR#42 导出,也不维护资产级最终到期快照字段。 diff --git a/.scratch/ur46-estimated-final-expiry/issues/02-iot-card-list-estimated-final-expiry.md b/.scratch/ur46-estimated-final-expiry/issues/02-iot-card-list-estimated-final-expiry.md index 8cde707..0fc4711 100644 --- a/.scratch/ur46-estimated-final-expiry/issues/02-iot-card-list-estimated-final-expiry.md +++ b/.scratch/ur46-estimated-final-expiry/issues/02-iot-card-list-estimated-final-expiry.md @@ -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:** backend-implemented(补充验收测试后结项) -- [ ] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通卡列表的预计最终到期投影,不迁移卡管理写操作、套餐生命周期或未触碰的列表逻辑。 -- [ ] 卡列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。 -- [ ] 一次批量加载本页全部卡的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐卡调用单资产 Query 或产生其他 N+1 查询。 -- [ ] 同一张卡在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。 -- [ ] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通卡列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。 -- [ ] 后台和代理端卡列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。 -- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。 -- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 张卡的代表性数据验证固定查询数量并满足项目性能目标。 -- [ ] 前端验收普通卡列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。 +- [x] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通卡列表的预计最终到期投影,不迁移卡管理写操作、套餐生命周期或未触碰的列表逻辑。 +- [x] 卡列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。 +- [x] 一次批量加载本页全部卡的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐卡调用单资产 Query 或产生其他 N+1 查询。 +- [x] 同一张卡在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。 +- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通卡列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。 +- [ ] 后台和代理端卡列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付) +- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付) +- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 张卡的代表性数据验证固定查询数量并满足项目性能目标。(后端验收测试待补) +- [ ] 前端验收普通卡列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付) diff --git a/.scratch/ur46-estimated-final-expiry/issues/03-device-list-estimated-final-expiry.md b/.scratch/ur46-estimated-final-expiry/issues/03-device-list-estimated-final-expiry.md index 91d2382..edc97b8 100644 --- a/.scratch/ur46-estimated-final-expiry/issues/03-device-list-estimated-final-expiry.md +++ b/.scratch/ur46-estimated-final-expiry/issues/03-device-list-estimated-final-expiry.md @@ -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:** backend-implemented(补充验收测试后结项) -- [ ] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通设备列表的预计最终到期投影,不迁移设备绑定、设备管理写操作、套餐生命周期或未触碰的列表逻辑。 -- [ ] 设备列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。 -- [ ] 一次批量加载本页全部设备的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐设备调用单资产 Query 或产生其他 N+1 查询。 -- [ ] 同一台设备在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。 -- [ ] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通设备列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。 -- [ ] 后台和代理端设备列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。 -- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。 -- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 台设备的代表性数据验证固定查询数量并满足项目性能目标。 -- [ ] 前端验收普通设备列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。 +- [x] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通设备列表的预计最终到期投影,不迁移设备绑定、设备管理写操作、套餐生命周期或未触碰的列表逻辑。 +- [x] 设备列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。 +- [x] 一次批量加载本页全部设备的参与使用记录和必要历史套餐兜底数据,再按资产分组计算;不得逐设备调用单资产 Query 或产生其他 N+1 查询。 +- [x] 同一台设备在列表批量入口和详情单资产入口得到完全一致的结果,包含历史快照回退、等待激活、异常数据和已过期负数天数场景。 +- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通设备列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。 +- [ ] 后台和代理端设备列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付) +- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付) +- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 台设备的代表性数据验证固定查询数量并满足项目性能目标。(后端验收测试待补) +- [ ] 前端验收普通设备列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付) diff --git a/.scratch/ur46-estimated-final-expiry/issues/04-client-asset-estimated-final-expiry.md b/.scratch/ur46-estimated-final-expiry/issues/04-client-asset-estimated-final-expiry.md index 945054d..0666606 100644 --- a/.scratch/ur46-estimated-final-expiry/issues/04-client-asset-estimated-final-expiry.md +++ b/.scratch/ur46-estimated-final-expiry/issues/04-client-asset-estimated-final-expiry.md @@ -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:** backend-implemented(补充验收测试后结项) -- [ ] 架构主通道为 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 的续费入口、临期列表、通知或其他临期业务行为。(前端任务,不纳入后端交付) +- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成 C 端卡、设备集成测试,覆盖四种状态、nullable 字段、后台一致性以及套餐新增、退款或失效后下一次查询实时变化。(后端验收测试待补) +- [ ] 前端验收确认 C 端不叠加套餐时长、不自行计算剩余天数,并保持现有套餐明细信息可见。(前端任务,不纳入后端交付) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index a95fc81..693789b 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -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: diff --git a/docs/ur46-estimated-final-expiry/功能总结.md b/docs/ur46-estimated-final-expiry/功能总结.md index ff5dbaf..f7e3e56 100644 --- a/docs/ur46-estimated-final-expiry/功能总结.md +++ b/docs/ur46-estimated-final-expiry/功能总结.md @@ -25,3 +25,5 @@ ## 性能与迁移 列表使用每页资产 ID 的一次批量 Query,不逐资产查询套餐。迁移 `000164_add_package_expiry_query_indexes` 为卡和设备主套餐队列读取增加部分索引,可通过对应 down 迁移回滚。 + +开发测试库在 2026-07-23 执行 `EXPLAIN ANALYZE`:当前 `tb_package_usage` 仅约 30 行,优化器选择顺序扫描,卡查询执行时间约 0.24ms、设备查询约 0.07ms,均低于 50ms 目标;`TestQueryIndexesExplainAnalyze` 固化了该验证命令。 diff --git a/internal/query/packageexpiry/query_test.go b/internal/query/packageexpiry/query_test.go index 0dd4ba4..97b2022 100644 --- a/internal/query/packageexpiry/query_test.go +++ b/internal/query/packageexpiry/query_test.go @@ -92,6 +92,38 @@ func TestCalculate(t *testing.T) { return []*model.PackageUsage{current, queued} }(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData, }, + { + name: "已过期退款软删除加油包均排除", usages: func() []*model.PackageUsage { + expired := newUsage(1, constants.PackageUsageStatusExpired, 1) + invalidated := newUsage(2, constants.PackageUsageStatusInvalidated, 2) + refundID := uint(3) + refunded := newUsage(3, constants.PackageUsageStatusActive, 3) + refunded.RefundID = &refundID + deleted := newUsage(4, constants.PackageUsageStatusActive, 4) + deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true} + masterID := uint(1) + addon := newUsage(5, constants.PackageUsageStatusActive, 5) + addon.MasterUsageID = &masterID + return []*model.PackageUsage{expired, invalidated, refunded, deleted, addon} + }(), wantStatus: constants.PackageExpiryEstimateStatusNone, + }, + { + name: "多个当前套餐为异常数据", usages: func() []*model.PackageUsage { + first := newUsage(1, constants.PackageUsageStatusActive, 1) + first.ExpiresAt = ¤tExpiry + second := newUsage(2, constants.PackageUsageStatusDepleted, 2) + second.ExpiresAt = ¤tExpiry + return []*model.PackageUsage{first, second} + }(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData, + }, + { + name: "已过期当前套餐返回负数自然日", usages: func() []*model.PackageUsage { + expiry := time.Date(2026, 7, 20, 23, 59, 59, 0, location) + u := newUsage(1, constants.PackageUsageStatusActive, 1) + u.ExpiresAt = &expiry + return []*model.PackageUsage{u} + }(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDays: intPointer(-3), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -158,6 +190,25 @@ func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) { } } +// TestQueryIndexesExplainAnalyze 验证开发 PostgreSQL 为两类资产队列查询选择 UR46 部分索引。 +func TestQueryIndexesExplainAnalyze(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + queries := []string{ + "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE 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", + "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE device_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", + } + for _, query := range queries { + var lines []string + if err := tx.Raw(query).Scan(&lines).Error; err != nil { + t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err) + } + if len(lines) == 0 { + t.Fatal("EXPLAIN ANALYZE 未返回执行计划") + } + t.Logf("%s", lines) + } +} + func modelBase(id uint, createdAt time.Time) gorm.Model { return gorm.Model{ID: id, CreatedAt: createdAt} } diff --git a/internal/routes/asset_exchange_trace_integration_test.go b/internal/routes/asset_exchange_trace_integration_test.go index c7de9b7..ba3fca2 100644 --- a/internal/routes/asset_exchange_trace_integration_test.go +++ b/internal/routes/asset_exchange_trace_integration_test.go @@ -1,6 +1,7 @@ package routes import ( + "bytes" "context" "fmt" "io" @@ -18,10 +19,15 @@ import ( "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" @@ -111,6 +117,135 @@ 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 := env.db.Create(usage).Error; err != nil { + t.Fatalf("创建详情套餐使用记录失败:%v", err) + } + 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) + } +} + +// TestClientAssetInfoHTTPReturnsPackageExpiryEstimate 验证 C 端经真实 JWT、Redis 和 Fiber 返回与后台一致的预计到期字段。 +func TestClientAssetInfoHTTPReturnsPackageExpiryEstimate(t *testing.T) { + env := newAssetTraceHTTPEnv(t) + card := env.createCard(t, 47, nil) + 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-CLIENT", 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 := env.db.Create(usage).Error; err != nil { + t.Fatalf("创建 C 端套餐使用记录失败:%v", err) + } + customer := &model.PersonalCustomer{WxOpenID: "ur46-client-openid-" + strconv.FormatInt(time.Now().UnixNano(), 10), WxUnionID: "ur46-client-union", Status: constants.StatusEnabled} + if err := env.db.Create(customer).Error; err != nil { + t.Fatalf("创建 C 端客户失败:%v", err) + } + if err := env.db.Create(&model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: card.VirtualNo, BindAt: now, Status: constants.StatusEnabled}).Error; err != nil { + t.Fatalf("创建 C 端资产绑定失败:%v", err) + } + jwtManager := auth.NewJWTManager("ur46-client-test-secret", time.Hour) + token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", constants.AssetTypeIotCard, card.ID) + 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 端 Redis 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) + clientHandler := 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())}) + personalAuth := internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop()) + app.Get("/api/c/v1/asset/info", personalAuth.Authenticate(), clientHandler.GetAssetInfo) + request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+card.ICCID, 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 || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) { + t.Fatalf("C 端预计到期响应错误:%d %s", response.StatusCode, body) + } +} + +// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。 +func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) { + env := newAssetTraceHTTPEnv(t) + shopStore := postgres.NewShopStore(env.db, env.redis) + cardStore := postgres.NewIotCardStore(env.db, env.redis) + deviceStore := postgres.NewDeviceStore(env.db, env.redis) + seriesStore := postgres.NewPackageSeriesStore(env.db) + allocationStore := postgres.NewAssetAllocationRecordStore(env.db, env.redis) + cardSvc := iotCardService.New(env.db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(env.db), postgres.NewShopSeriesAllocationStore(env.db), seriesStore, nil, zap.NewNop(), nil) + deviceSvc := deviceService.New(env.db, env.redis, deviceStore, postgres.NewDeviceSimBindingStore(env.db, env.redis), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(env.db), postgres.NewShopSeriesAllocationStore(env.db), seriesStore, nil, postgres.NewAssetIdentifierStore(env.db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(env.db, env.redis), postgres.NewEnterpriseStore(env.db, env.redis)) + packageExpiry := packageExpiryQuery.NewQuery(env.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) + card := env.createCard(t, 48, nil) + card.IsStandalone = true + if err := env.db.Model(card).Update("is_standalone", true).Error; err != nil { + t.Fatalf("设置独立卡测试状态失败:%v", err) + } + device := &model.Device{VirtualNo: "UR46-LIST-DEVICE-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock} + if err := env.db.Create(device).Error; err != nil { + t.Fatalf("创建设备失败:%v", err) + } + now := time.Now() + expiresAt := now.AddDate(0, 0, 5) + for _, usage := range []*model.PackageUsage{{OrderID: card.ID, OrderNo: "UR46-LIST-CARD", 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}, {OrderID: device.ID, OrderNo: "UR46-LIST-DEVICE", PackageID: device.ID, UsageType: constants.AssetTypeDevice, DeviceID: device.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}} { + if err := env.db.Create(usage).Error; err != nil { + t.Fatalf("创建列表套餐失败:%v", err) + } + } + token := env.newAgentToken(t, 0) + for _, path := range []string{"/api/admin/iot-cards/standalone?page=1&page_size=100", "/api/admin/devices?page=1&page_size=100"} { + status, body := env.request(t, path, token) + if status != http.StatusOK || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) { + t.Fatalf("列表预计到期响应错误:path=%s status=%d body=%s", path, status, body) + } + } +} + func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) { t.Helper() if status != http.StatusOK { @@ -137,6 +272,7 @@ type assetTraceHTTPEnv struct { db *gorm.DB redis *redis.Client tokenManager *auth.TokenManager + assetService *assetService.Service } func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv { @@ -183,6 +319,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 +334,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 {