All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m0s
- 未开始的主套餐(无生效、到期时间且无用量)迁移为待生效,新资产不满足实名条件(行业卡/已实名/无需实名)时标记等实名激活 - 已开始的主套餐在新资产无当前主套餐时原样迁移;新资产已有当前主套餐时改为待生效排队、清空周期与用量 - 迁移后重排新资产主套餐优先级,保证同一载体只有一个当前主套餐 - 换货审计记录套餐状态、优先级、用量的前后值 OpenSpec: fix-exchange-migration-package-state Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
6.9 KiB
Markdown
98 lines
6.9 KiB
Markdown
## Context
|
||
|
||
`migratePackageUsageWithTx`(`internal/service/exchange/migration.go:104`)在换货完成事务内,把旧资产全部待生效、生效中、已用完的套餐使用记录批量改载体(`iot_card_id` / `device_id`),其余字段不动,日流量记录只刷新 `updated_at`。
|
||
|
||
相关现状:
|
||
|
||
- 当前主套餐判断:`packagepkg.FindCurrentMainPackageForQueue` / `HasCurrentMainPackageForQueue`(`internal/service/package/addon_main_package.go`),口径为生效中或已用完、未到期。订单、自动续费、自动购包都用它决定新主套餐是否排队。
|
||
- 待生效主套餐的接续激活:
|
||
- 前一个到期:到期任务 `PackageActivationHandler.processExpiredPackage` → 激活队首。
|
||
- 无占位主套餐:`findAndActivateOrphanPackages`(`internal/polling/package_activation_handler.go:168`)定时扫描"有待生效、无生效中或已用完主套餐"的载体,取队首(优先级、创建时间、标识)提交 `ActivateSpecificPackage`;后者对标记等实名激活的套餐检查实名,未标记的直接激活,激活时从当天起重新计算有效期与重置时间,并处理复机、轮询、审计等副作用。
|
||
- 实名后:`ActivateByRealname` 激活标记等实名激活的套餐。
|
||
- `activatePendingUsage` 激活时不重置 `data_usage_mb`。
|
||
- 实名条件口径:`StopResumeService.isRealnameOK`(`internal/service/iot_card/stop_resume_service.go:332`)认行业卡、已实名、无需实名策略;套餐激活侧的 `isCarrierRealnamed` 只认已实名(另开 Change 处理,本 Change 不改)。
|
||
- 预计到期推算 `internal/query/packageexpiry/query.go` 在多个生效/已用完主套餐、待生效排在当前之前时返回数据异常。
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- 换货迁移后新资产最多一个当前主套餐,主套餐优先级连续、无重复。
|
||
- 未开始的套餐迁移后不直接生效;新资产满足实名条件时由既有机制接续激活。
|
||
- 已开始的套餐在新资产无占位时无感知继续使用。
|
||
|
||
**Non-Goals:**
|
||
|
||
- 不改换货接口、不拦截换货、不改钱包余额、累计充值、标签迁移。
|
||
- 不改订单、自动续费、自动购包、实名激活的实名判断。
|
||
- 不在代码里修复存量数据。
|
||
- 不处理加油包与主套餐的重新挂靠。
|
||
|
||
## Decisions
|
||
|
||
### D1 "已开始"的判定
|
||
|
||
满足任一即已开始:`activated_at` 非空、`expires_at` 非空、`data_usage_mb > 0`、存在日流量记录、状态为已用完。
|
||
|
||
- 老系统迁移数据存在"状态生效中、生效时间为空、到期时间固定"的记录(2026-11-04 换货单 1095–1104、1108)。固定到期日是老系统卖给客户的权益,换货不应改写,因此有到期时间即视为已开始;真正未开始的只剩从未激活的待生效套餐。
|
||
- 换货单 1107 的套餐生效时间为空但有 10328 MB 用量,需要用量与日流量兜底。
|
||
|
||
备选一:只看 `activated_at`。会把 1107 判为未开始、清空进度,拒绝。
|
||
备选二:不看 `expires_at`。会把老系统固定到期日的套餐改为待生效、实名后重算 12 个月,改变客户已有到期日,拒绝。
|
||
|
||
### D2 实名条件判断放在换货服务内
|
||
|
||
新增换货侧私有函数,按 spec 口径判断新资产是否满足实名条件:卡看行业卡、实名状态、适用实名策略;设备看设备策略与已绑定卡。
|
||
|
||
- 只影响等实名激活标记,口径需与停复机一致。
|
||
- `isRealnameOK` 是 `StopResumeService` 私有方法且只接受卡;为一处调用把它导出或抽公共包,会顺带改动停复机模块,违反"不主动重构"。
|
||
|
||
备选:复用套餐侧 `isCarrierRealnamed`。不认行业卡,迁到行业卡的套餐会永远等实名,拒绝。
|
||
|
||
### D3 换货事务内不激活,交给孤儿扫描与实名激活
|
||
|
||
未开始与排队的主套餐只写为待生效和正确的等实名激活标记。
|
||
|
||
- 新资产无占位主套餐且无需等实名:孤儿扫描下一轮提交 `ActivateSpecificPackage`,统一走激活副作用(审计、观测、优先轮询、复机)。
|
||
- 需要等实名:新资产实名后由 `ActivateByRealname` 激活。
|
||
|
||
备选:换货服务注入 `ActivationService` 在事务后立即激活。要新增依赖与失败补偿,且孤儿扫描已覆盖同一场景,拒绝。代价是激活最多延迟一个扫描周期。
|
||
|
||
### D4 排队的已开始套餐清零用量、重算完整周期
|
||
|
||
与 M15 排队购买一致:排队主套餐轮到时从激活当天起算完整周期。用量清零是因为 `activatePendingUsage` 不重置用量,不清零会把旧周期用量带入新周期。换货前用量保留在日流量记录中作为历史。与 2026-11-04 生产手工修正(46981)结果一致。
|
||
|
||
备选:保留剩余天数。需要新增"剩余时长"字段并改激活逻辑,影响面超出本 Change,拒绝。
|
||
|
||
### D5 统一重排优先级
|
||
|
||
迁移后对新资产全部未结束主套餐(待生效、生效中、已用完且未到期)按"当前主套餐 → 新资产原有待生效 → 迁入待生效"重排为 1..n。
|
||
|
||
- 现有新购排队写 `max + 1`,迁移场景下新资产原有待生效与迁入的生效主套餐可能都是 1,只追加无法消除重复。
|
||
- 孤儿扫描与预计到期推算都按优先级、创建时间、标识排序,连续编号后结果确定。
|
||
|
||
### D6 锁与事务
|
||
|
||
在换货完成事务内先对新、旧资产的主套餐记录 `SELECT ... FOR UPDATE`,再判断与写入,避免与同时进行的购买、到期接续交叉。沿用换货现有事务,失败整体回滚并按既有规则记录迁移失败。
|
||
|
||
### D7 审计
|
||
|
||
状态、优先级或用量发生变化的记录,通过换货服务现有 `auditWriter` 写套餐使用记录变更审计,前后值包含状态、优先级、用量、生效、到期、重置时间与等实名激活标记,附换货单号。仅改载体、其余不变的记录不额外写审计。
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- [激活延迟] 无需等实名的未开始套餐要等孤儿扫描下一轮才生效 → 扫描周期分钟级;换货后新资产本无占位套餐,客户影响小。
|
||
- [旧资产已过期未处理的主套餐] 原样迁移后,到期任务处理前新资产可能短暂出现两条状态生效中的记录 → 其中一条已过期,到期任务会处理;扣量与推算以未到期为准。
|
||
- [实名口径两处维护] 换货侧与停复机侧各有一份 → 在函数注释注明口径来源,后续"统一实名判断"Change 合并。
|
||
- [清零用量] 排队套餐换货前的周期内用量不再显示在套餐上 → 日流量记录保留,可追溯。
|
||
|
||
## Migration Plan
|
||
|
||
- 无数据库迁移,无配置变化。
|
||
- 按维护者生产发布流程手工发布;回滚即回退二进制。
|
||
- 存量不修正:2026-11-04 换货单 1095–1104、1108 的 11 条主套餐有固定到期日,按 D1 属于已开始,现状即为本规则下的结果。
|
||
|
||
## Open Questions
|
||
|
||
无。
|