Compare commits
9 Commits
c7f8b4c702
...
b2efe3bac8
| Author | SHA1 | Date | |
|---|---|---|---|
| b2efe3bac8 | |||
| 992757c056 | |||
| 2df44d8e3c | |||
| d5a35f6360 | |||
| 4aaae07cee | |||
| 8dde55ea6d | |||
| 147f3eb775 | |||
| f7e0f07692 | |||
| 9818537239 |
@@ -0,0 +1,13 @@
|
||||
# 01 — 首次批量授权与候选套餐选择
|
||||
|
||||
**What to build:** 授权人员可以从现有系列套餐列表一次选择多个套餐,并在创建系列授权时原子地完成首次套餐授权。界面清楚区分当前上级成本价、目标代理授权成本价和建议零售价;后端拒绝空套餐、重复套餐、跨系列、赠送套餐、非法价格及越权请求,任一项失败均不留下系列授权、套餐授权、价格历史或成功审计。
|
||||
|
||||
**适用架构通道:** 主通道为 Application 事务脚本;辅助通道为 Query 与前端。完整业务边界为“创建系列授权并首次授权套餐”;不迁移既有佣金、强充、零售价继承逻辑,不新增候选读取接口。
|
||||
|
||||
**Blocked by:** None — can start immediately.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
- [ ] 现有套餐列表可支撑首次多套餐选择,并按调用者视角准确标注成本价与建议零售价,不扩大数据或字段权限。
|
||||
- [ ] 创建请求要求 1 至 100 个唯一套餐;所有套餐通过系列、赠送、状态、上级授权、价格及直属下级权限校验后,才在同一事务创建系列与套餐授权。
|
||||
- [ ] 通过 HTTP 集成测试同时验证响应与数据库状态,覆盖成功、多项失败回滚、参数错误和越权;首次授权界面可完成提交并显示结果。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 02 — 后续套餐授权的原子命令契约
|
||||
|
||||
**What to build:** 后续套餐管理接口以必填的操作类型明确区分新增授权、修改成本价和移除授权。每次请求只执行一种命令且整批原子:同价新增安全幂等、不同价新增明确冲突;调价和移除遇到下级授权等前置条件时整批拒绝。成功操作记录价格历史和审计,权限、并发和幂等行为不会泄露底层唯一约束错误。
|
||||
|
||||
**适用架构通道:** Application 事务脚本。完整业务边界为“系列授权下套餐授权的后续批量命令”,包括命令解析、批量加载、权限与业务校验、条件写入、价格历史及审计;不迁移现有整店/系列批量调价,不自动回收下级授权,也不修改订单、套餐使用或零售价。
|
||||
|
||||
**Blocked by:** None — can start immediately.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
- [ ] 后续管理请求必须携带 `authorize`、`update_cost` 或 `remove` 之一,套餐数量限制为 1 至 100 且 ID 唯一;旧的混合新增、改价、移除语义不再生效。
|
||||
- [ ] 三种命令均在一个 PostgreSQL 事务内完成批量校验与写入,正确处理权限、赠送套餐、跨系列、绝对成本价、下级依赖、同价幂等及不同价冲突。
|
||||
- [ ] 集成测试覆盖并发新增、全量回滚、价格历史、审计、错误码和越权安全语义,并验证响应包含实际操作数、幂等数与相关套餐标识。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 03 — 后续授权管理批量界面与发布验收
|
||||
|
||||
**What to build:** 授权人员在同一批量表格中管理既有系列授权:套餐列表与授权详情按套餐标识合并,已授权项在新增模式置灰,授权详情独有的存量项仍只读展示;调价和移除只允许选择已授权套餐。界面提交明确命令,提供批量摘要、空态、失败态及并发冲突后的刷新提示,并具备可安全发布和回滚的验收材料。
|
||||
|
||||
**适用架构通道:** 主通道为 Query 与前端;辅助通道为 Application API 集成。完整业务边界为“后续授权管理的读取组合、命令交互与上线验收”;不新增候选读取接口,不扩大现有数据或价格字段权限,不重构未触碰的套餐列表。
|
||||
|
||||
**Blocked by:** 01 — 首次批量授权与候选套餐选择; 02 — 后续套餐授权的原子命令契约.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
- [ ] 后续管理页面并行复用现有套餐列表和授权详情,准确展示三类价格、授权状态及受权限变化影响的存量只读项。
|
||||
- [ ] 新增、调价、移除三种模式只提交对应命令;提交期间防重,冲突时保留输入并要求刷新确认,且响应与失败状态对用户清晰可见。
|
||||
- [ ] 完成 Fiber 到 PostgreSQL 的端到端验收、前后端同批切换核查、上线前数据异常核查和保留业务事实的回滚说明。
|
||||
@@ -0,0 +1,12 @@
|
||||
# 01 — 保障设备当前绑卡唯一性
|
||||
|
||||
**What to build:** 为设备当前有效绑卡建立可发布的数据完整性保障:发布前发现存量重复时中止并交由人工修复,发布后同一设备最多保留一条当前有效绑定,且不自动删除任何绑定事实。
|
||||
|
||||
**Blocked by:** None — can start immediately.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
- [ ] 迁移门禁能检测存量重复的当前有效绑定,并在发现异常时阻止发布而不改写数据。
|
||||
- [ ] PostgreSQL 部分唯一索引仅约束未软删除、有效且当前的绑定记录,且不引入外键。
|
||||
- [ ] 集成测试证明唯一绑定可写入,重复绑定被拒绝,软删除或非当前记录不受误伤。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 02 — 建立限速 Gateway 渠道适配与受控映射
|
||||
|
||||
**What to build:** 让中国电信和中国广电卡可按逻辑账号、运营商及稳定业务档位,安全解析出经核实的上游编码,并通过一次 `/flow-card/speedLimit` 调用得到规范化的成功、明确失败或结果未知结论;任何缺失或不支持的映射均在调用前拒绝。
|
||||
|
||||
**Blocked by:** None — can start immediately.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
- [ ] 渠道配置以非敏感逻辑账号、运营商和 `speed_level` 唯一解析编码;不得以凭据为映射键、回退默认编码或记录凭据。
|
||||
- [ ] Adapter 将卡号和编码封装为渠道所需请求,分别规范化明确成功、明确失败、格式异常、超时和连接中断。
|
||||
- [ ] 此操作每个请求最多产生一次上游调用尝试;网络结果不明必须返回未知,不能使用共享客户端的自动重试。
|
||||
- [ ] 假渠道契约测试覆盖请求包装、响应解析、映射缺失与敏感信息脱敏;生产发布前具备两家运营商真实账号映射及协议核实记录。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 03 — 提供统一资产手动限速接口与审计
|
||||
|
||||
**What to build:** 后台人员可通过统一资产标识向卡或设备提交固定语义档位。卡直接使用自身 ICCID;设备仅可使用唯一当前有效绑卡的 ICCID。系统在上游调用前完成权限、资产、运营商和映射校验,并向前端返回可安全展示的操作结果及真实目标卡号。
|
||||
|
||||
**Blocked by:** 01 — 保障设备当前绑卡唯一性;02 — 建立限速 Gateway 渠道适配与受控映射。
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
- [ ] `POST /api/admin/assets/{identifier}/speed-limit` 仅接受十个约定的 `speed_level` 枚举;非法值不泄露校验细节,前端不能提交任意速率或上游编码。
|
||||
- [ ] 卡、设备、无当前卡、多当前卡、软删除绑定和越权资产均被正确处理,且渠道 `cardNo` 从不使用 IMEI、序列号或虚拟号。
|
||||
- [ ] 响应包含资产类型与 ID、实际 ICCID、语义档位、实际编码、`applied_speed` 和 `channel_raw_value`,且不得把目标描述成当前实际限速。
|
||||
- [ ] 每次成功、明确失败或未知尝试均留下以同一请求 ID 关联的 Integration Log 与 Audit Event;卡号、凭据和响应敏感字段均脱敏。
|
||||
- [ ] 中国联通、中国移动、未知运营商和缺失映射在调用前明确拒绝;旧设备限速路由及错误的 IMEI 渠道调用在同批发布中下线。
|
||||
- [ ] HTTP 集成测试穿过认证、权限、Application、假渠道及统一错误处理,覆盖成功、拒绝、明确失败和未知结果。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 04 — 提供资产详情限速能力展示契约
|
||||
|
||||
**What to build:** 卡和设备详情为前端提供可直接驱动“手动限速”交互的能力信息:是否支持、不可用的中文原因和可选择的语义档位;设备还提供本次操作实际作用的当前 ICCID。该数据只表达操作能力,不声称渠道当前实际限速。
|
||||
|
||||
**Blocked by:** 01 — 保障设备当前绑卡唯一性;02 — 建立限速 Gateway 渠道适配与受控映射。
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
- [ ] 卡、设备详情返回由后端权限、运营商、当前绑卡和映射共同判定的 `speed_limit_capability`。
|
||||
- [ ] 支持时返回固定业务档位及中文标签,供前端渲染和提交;不返回可编辑速率或渠道编码。
|
||||
- [ ] 不支持、无当前卡或映射缺失时返回可展示的中文原因,前端可据此禁用入口,但操作接口仍会独立重新校验。
|
||||
- [ ] 设备详情返回实际当前 ICCID,供前端在二次确认和未知结果提示中准确展示。
|
||||
- [ ] 接口契约与集成测试验证能力字段、当前 ICCID、权限过滤及“不展示当前实际限速”的语义。
|
||||
35
AGENTS.md
35
AGENTS.md
@@ -349,5 +349,40 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
|
||||
|
||||
---
|
||||
|
||||
## 测试代码规范(集成测试)
|
||||
|
||||
以下规则专门适用于 `_test.go` 文件,与生产代码规范有局部差异:
|
||||
|
||||
### 常量与魔法数字
|
||||
|
||||
- 测试文件内的魔法数字(如 `0x7fffffff`)在文件顶部定义**局部常量**即可,不要求进 `pkg/constants/`:
|
||||
```go
|
||||
// 文件顶部,仅限测试内使用
|
||||
const testIDMask = 0x7fffffff
|
||||
```
|
||||
- 跨多个测试文件共用的常量或工具函数才提取到 `internal/testutil/`
|
||||
|
||||
### 测试 Fixture Helper
|
||||
|
||||
- `createXxxCard`、`createXxxPackage` 等 fixture 函数优先放 `internal/testutil/`
|
||||
- 同一 package 内 ≤3 处结构类似的 helper(如 ICCID 前缀不同)不强制提取,差异本身就是测试设计意图
|
||||
- 已在 `testutil` 中提供的能力(`NewPostgresTransaction`、`NewRedisClient`、`StringPointer`)必须复用,禁止本地重复定义
|
||||
|
||||
### 数据库触发器操作
|
||||
|
||||
- `ALTER TABLE ... DISABLE TRIGGER` 只允许在测试事务内使用(事务回滚后触发器状态自动恢复),必须加注释说明原因
|
||||
|
||||
### HTTP 集成测试断言
|
||||
|
||||
- 必须同时断言 **HTTP 响应体**和 **DB 状态**;Spec 承诺返回的字段不能只靠 DB 查询替代
|
||||
- 响应体断言至少验证所有 spec 要求的顶层字段非空或值正确,不必断言每个字段的精确中文名称
|
||||
|
||||
### 层边界说明
|
||||
|
||||
- 旧模块 `internal/service/xxx` 可以继续 import `internal/store/postgres`,这是项目触碰式迁移的既有模式
|
||||
- DDD 层边界禁令(禁止 import Fiber / GORM / Redis)**只对 `internal/domain/` 生效**,service 层不受此约束
|
||||
|
||||
---
|
||||
|
||||
|
||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||
|
||||
@@ -234,8 +234,10 @@ default:
|
||||
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md)
|
||||
- **批量同步**:卡状态、实名状态、流量使用情况
|
||||
- **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md)
|
||||
- **批量换货脚本**:支持从双列 CSV 读取新旧资产映射,固定执行直接换货和全量数据迁移,提供预演、批次冲突拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_exchange/README.md) 与 [功能总结](docs/批量换货脚本/功能总结.md)
|
||||
- **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
|
||||
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
|
||||
- **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md)
|
||||
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)
|
||||
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
|
||||
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
|
||||
|
||||
@@ -2011,6 +2011,10 @@ components:
|
||||
type: object
|
||||
DtoBatchAllocatePackagesRequest:
|
||||
properties:
|
||||
expiry_base_override:
|
||||
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
nullable: true
|
||||
type: string
|
||||
one_time_commission_amount:
|
||||
description: 该代理能拿到的一次性佣金(分)
|
||||
minimum: 0
|
||||
@@ -2030,6 +2034,24 @@ components:
|
||||
- shop_id
|
||||
- series_id
|
||||
type: object
|
||||
DtoBatchAllocatePackagesResponse:
|
||||
properties:
|
||||
allocated_count:
|
||||
description: 成功分配数量
|
||||
type: integer
|
||||
allocations:
|
||||
description: 本次新建套餐分配及其生效条件
|
||||
items:
|
||||
$ref: '#/components/schemas/DtoShopPackageAllocationTermsResponse'
|
||||
nullable: true
|
||||
type: array
|
||||
skipped_count:
|
||||
description: 跳过数量(已存在)
|
||||
type: integer
|
||||
total_packages:
|
||||
description: 总套餐数
|
||||
type: integer
|
||||
type: object
|
||||
DtoBatchPricingSkipped:
|
||||
properties:
|
||||
allocation_id:
|
||||
@@ -3845,6 +3867,10 @@ components:
|
||||
description: 是否启用代理强充
|
||||
nullable: true
|
||||
type: boolean
|
||||
expiry_base_override:
|
||||
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
nullable: true
|
||||
type: string
|
||||
force_recharge_amount:
|
||||
description: 代理强充金额(分)
|
||||
nullable: true
|
||||
@@ -6085,6 +6111,10 @@ components:
|
||||
type: object
|
||||
DtoManageGrantPackagesParams:
|
||||
properties:
|
||||
expiry_base_override:
|
||||
description: 新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
nullable: true
|
||||
type: string
|
||||
packages:
|
||||
description: 套餐操作列表
|
||||
items:
|
||||
@@ -6605,6 +6635,12 @@ components:
|
||||
data_reset_cycle:
|
||||
description: 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)
|
||||
type: string
|
||||
default_expiry_base:
|
||||
description: 套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
type: string
|
||||
default_expiry_base_name:
|
||||
description: 套餐默认生效条件名称(中文)
|
||||
type: string
|
||||
duration_days:
|
||||
description: 套餐天数(calendar_type=by_day时有值)
|
||||
nullable: true
|
||||
@@ -6612,6 +6648,12 @@ components:
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
type: integer
|
||||
effective_expiry_base:
|
||||
description: 最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
type: string
|
||||
effective_expiry_base_name:
|
||||
description: 最终生效条件名称(中文)
|
||||
type: string
|
||||
effective_retail_price:
|
||||
description: 生效零售价(分,仅销售链路展示使用)
|
||||
nullable: true
|
||||
@@ -6622,6 +6664,13 @@ components:
|
||||
expiry_base:
|
||||
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
||||
type: string
|
||||
expiry_base_override:
|
||||
description: 分配生效条件覆盖,null 表示跟随套餐默认值
|
||||
nullable: true
|
||||
type: string
|
||||
expiry_base_override_name:
|
||||
description: 分配生效条件覆盖名称(中文)
|
||||
type: string
|
||||
id:
|
||||
description: 套餐ID
|
||||
minimum: 0
|
||||
@@ -7991,6 +8040,32 @@ components:
|
||||
description: 总记录数
|
||||
type: integer
|
||||
type: object
|
||||
DtoShopPackageAllocationTermsResponse:
|
||||
properties:
|
||||
default_expiry_base:
|
||||
description: 套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
type: string
|
||||
default_expiry_base_name:
|
||||
description: 套餐默认生效条件名称(中文)
|
||||
type: string
|
||||
effective_expiry_base:
|
||||
description: 最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
type: string
|
||||
effective_expiry_base_name:
|
||||
description: 最终生效条件名称(中文)
|
||||
type: string
|
||||
expiry_base_override:
|
||||
description: 分配生效条件覆盖,null 表示跟随套餐默认值
|
||||
nullable: true
|
||||
type: string
|
||||
expiry_base_override_name:
|
||||
description: 分配生效条件覆盖名称(中文)
|
||||
type: string
|
||||
id:
|
||||
description: 套餐分配ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
DtoShopPageResult:
|
||||
properties:
|
||||
items:
|
||||
@@ -8153,6 +8228,25 @@ components:
|
||||
cost_price:
|
||||
description: 成本价(分)
|
||||
type: integer
|
||||
default_expiry_base:
|
||||
description: 套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
type: string
|
||||
default_expiry_base_name:
|
||||
description: 套餐默认生效条件名称(中文)
|
||||
type: string
|
||||
effective_expiry_base:
|
||||
description: 最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
type: string
|
||||
effective_expiry_base_name:
|
||||
description: 最终生效条件名称(中文)
|
||||
type: string
|
||||
expiry_base_override:
|
||||
description: 分配生效条件覆盖,null 表示跟随套餐默认值
|
||||
nullable: true
|
||||
type: string
|
||||
expiry_base_override_name:
|
||||
description: 分配生效条件覆盖名称(中文)
|
||||
type: string
|
||||
package_code:
|
||||
description: 套餐编码
|
||||
type: string
|
||||
@@ -8610,6 +8704,15 @@ components:
|
||||
nullable: true
|
||||
type: string
|
||||
type: object
|
||||
DtoUpdateAllocationExpiryBaseParams:
|
||||
properties:
|
||||
expiry_base_override:
|
||||
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- expiry_base_override
|
||||
type: object
|
||||
DtoUpdateAssetPackageExpiresAtRequest:
|
||||
properties:
|
||||
expires_at:
|
||||
@@ -22325,6 +22428,78 @@ paths:
|
||||
summary: 移除权限
|
||||
tags:
|
||||
- 角色
|
||||
/api/admin/shop-package-allocations/{id}/expiry-base:
|
||||
patch:
|
||||
parameters:
|
||||
- description: ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
description: ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoUpdateAllocationExpiryBaseParams'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoShopPackageAllocationTermsResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 请求参数错误
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 未认证或认证已过期
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 无权访问
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
description: 服务器内部错误
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 修改套餐分配生效条件覆盖
|
||||
tags:
|
||||
- 批量套餐分配
|
||||
/api/admin/shop-package-batch-allocations:
|
||||
post:
|
||||
requestBody:
|
||||
@@ -22333,6 +22508,32 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DtoBatchAllocatePackagesRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
code:
|
||||
description: 响应码
|
||||
example: 0
|
||||
type: integer
|
||||
data:
|
||||
$ref: '#/components/schemas/DtoBatchAllocatePackagesResponse'
|
||||
msg:
|
||||
description: 响应消息
|
||||
example: success
|
||||
type: string
|
||||
timestamp:
|
||||
description: 时间戳
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- msg
|
||||
- data
|
||||
- timestamp
|
||||
type: object
|
||||
description: 成功
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
|
||||
24
docs/ur55-package-expiry-base/功能总结.md
Normal file
24
docs/ur55-package-expiry-base/功能总结.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# UR#55 套餐分配生效条件覆盖与购买快照
|
||||
|
||||
## 实现范围
|
||||
|
||||
- 系列首次授权、系列追加授权和批量套餐分配必须显式提交 `expiry_base_override`,支持 `null`、`from_purchase`、`from_activation`。
|
||||
- 套餐分配响应返回套餐默认值、分配覆盖值和最终有效值及其中文名称。
|
||||
- 新增单条分配覆盖修改接口:`PATCH /api/admin/shop-package-allocations/{id}/expiry-base`,显式 `null` 恢复跟随默认,重复提交保持幂等。
|
||||
- 同步购买和自动购包创建主套餐或加油包时,在同一事务写入生效条件、周期类型、月数和天数快照。
|
||||
- 实名激活、指定激活、排队接续和退款后的下一套餐接续优先读取购买快照;仅四字段全部为空或零的历史记录允许告警回退套餐当前配置,部分缺失或非法快照直接拒绝。
|
||||
|
||||
## 数据库变更
|
||||
|
||||
- `tb_shop_package_allocation.expiry_base_override`:可空字符串,`NULL` 表示跟随套餐默认值。
|
||||
- `tb_package_usage`:新增 `expiry_base_snapshot`、`calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`。
|
||||
- 历史套餐使用记录不回填,保留空值和零值作为历史未快照标记。
|
||||
|
||||
## 发布与回滚
|
||||
|
||||
发布时先部署兼容读取快照和历史回退的应用,再执行 162、163 迁移,并在同一维护窗口切换所有写入路径。回滚迁移只删除本需求新增字段和约束,不修改套餐使用状态或历史业务数据。
|
||||
|
||||
## 验证
|
||||
|
||||
- 领域测试覆盖覆盖值解析、自然月/按天快照、快照优先、历史回退和异常快照拒绝。
|
||||
- 已执行全仓 `go test ./...`、OpenAPI 文档生成以及迁移向上、单步向下、重新向上验证。
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
||||
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
||||
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
||||
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/payment"
|
||||
@@ -265,7 +265,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
Package: packageService,
|
||||
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
||||
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
|
||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop),
|
||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit),
|
||||
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
||||
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
|
||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||
|
||||
65
internal/domain/package/terms.go
Normal file
65
internal/domain/package/terms.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package package 提供套餐生命周期领域规则。
|
||||
package packagedomain
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// TermsSnapshot 套餐购买时不可变计时条款。
|
||||
type TermsSnapshot struct {
|
||||
ExpiryBase string
|
||||
CalendarType string
|
||||
DurationMonths int
|
||||
DurationDays int
|
||||
}
|
||||
|
||||
// ResolveTermsSnapshot 解析并校验套餐购买时计时条款。
|
||||
func ResolveTermsSnapshot(pkg *model.Package, allocation *model.ShopPackageAllocation) (TermsSnapshot, error) {
|
||||
if pkg == nil {
|
||||
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐计时条款缺失")
|
||||
}
|
||||
expiryBase := pkg.ExpiryBase
|
||||
if allocation != nil && allocation.ExpiryBaseOverride != nil {
|
||||
expiryBase = *allocation.ExpiryBaseOverride
|
||||
}
|
||||
if expiryBase != constants.PackageExpiryBaseFromActivation && expiryBase != constants.PackageExpiryBaseFromPurchase {
|
||||
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐生效条件无效")
|
||||
}
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
if pkg.DurationMonths <= 0 {
|
||||
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐自然月时长无效")
|
||||
}
|
||||
return TermsSnapshot{ExpiryBase: expiryBase, CalendarType: pkg.CalendarType, DurationMonths: pkg.DurationMonths}, nil
|
||||
}
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeByDay && pkg.DurationDays > 0 {
|
||||
return TermsSnapshot{ExpiryBase: expiryBase, CalendarType: pkg.CalendarType, DurationDays: pkg.DurationDays}, nil
|
||||
}
|
||||
return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐按天时长无效")
|
||||
}
|
||||
|
||||
// Apply 将计时条款写入套餐使用记录。
|
||||
func (s TermsSnapshot) Apply(usage *model.PackageUsage) {
|
||||
usage.ExpiryBaseSnapshot = s.ExpiryBase
|
||||
usage.CalendarTypeSnapshot = s.CalendarType
|
||||
usage.DurationMonthsSnapshot = s.DurationMonths
|
||||
usage.DurationDaysSnapshot = s.DurationDays
|
||||
}
|
||||
|
||||
// IsValid 判断快照是否完整有效。
|
||||
func (s TermsSnapshot) IsValid() bool {
|
||||
if s.ExpiryBase != constants.PackageExpiryBaseFromActivation && s.ExpiryBase != constants.PackageExpiryBaseFromPurchase {
|
||||
return false
|
||||
}
|
||||
return (s.CalendarType == constants.PackageCalendarTypeNaturalMonth && s.DurationMonths > 0) ||
|
||||
(s.CalendarType == constants.PackageCalendarTypeByDay && s.DurationDays > 0)
|
||||
}
|
||||
|
||||
// TermsSnapshotFromUsage 从使用记录读取计时条款。
|
||||
func TermsSnapshotFromUsage(usage *model.PackageUsage) TermsSnapshot {
|
||||
return TermsSnapshot{
|
||||
ExpiryBase: usage.ExpiryBaseSnapshot, CalendarType: usage.CalendarTypeSnapshot,
|
||||
DurationMonths: usage.DurationMonthsSnapshot, DurationDays: usage.DurationDaysSnapshot,
|
||||
}
|
||||
}
|
||||
41
internal/domain/package/terms_test.go
Normal file
41
internal/domain/package/terms_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package packagedomain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestResolveTermsSnapshot 验证默认、覆盖及两种周期规则。
|
||||
func TestResolveTermsSnapshot(t *testing.T) {
|
||||
overridePurchase := constants.PackageExpiryBaseFromPurchase
|
||||
overrideActivation := constants.PackageExpiryBaseFromActivation
|
||||
tests := []struct {
|
||||
name string
|
||||
pkg model.Package
|
||||
allocation *model.ShopPackageAllocation
|
||||
wantErr bool
|
||||
wantExpiryBase string
|
||||
}{
|
||||
{name: "自然月默认", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeNaturalMonth, DurationMonths: 12}, wantExpiryBase: constants.PackageExpiryBaseFromActivation},
|
||||
{name: "from_purchase 覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &overridePurchase}, wantExpiryBase: constants.PackageExpiryBaseFromPurchase},
|
||||
{name: "from_activation 覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromPurchase, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 14}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &overrideActivation}, wantExpiryBase: constants.PackageExpiryBaseFromActivation},
|
||||
{name: "非法生效条件", pkg: model.Package{ExpiryBase: "invalid", CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, wantErr: true},
|
||||
{name: "非法按天时长", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromPurchase, CalendarType: constants.PackageCalendarTypeByDay}, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ResolveTermsSnapshot(&test.pkg, test.allocation)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("错误状态不符合预期:%v", err)
|
||||
}
|
||||
if !test.wantErr && !got.IsValid() {
|
||||
t.Fatalf("快照应有效:%+v", got)
|
||||
}
|
||||
if !test.wantErr && test.wantExpiryBase != "" && got.ExpiryBase != test.wantExpiryBase {
|
||||
t.Fatalf("生效条件不符预期:want=%s got=%s", test.wantExpiryBase, got.ExpiryBase)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
@@ -18,15 +21,46 @@ func NewShopPackageBatchAllocationHandler(service *batchAllocationService.Servic
|
||||
}
|
||||
|
||||
// BatchAllocate 批量分配套餐
|
||||
// POST /api/admin/shop-package-allocations/batch
|
||||
func (h *ShopPackageBatchAllocationHandler) BatchAllocate(c *fiber.Ctx) error {
|
||||
var req dto.BatchAllocatePackagesRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
|
||||
|
||||
if err := h.service.BatchAllocate(c.UserContext(), &req); err != nil {
|
||||
result, err := h.service.BatchAllocate(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// UpdateExpiryBase 修改套餐分配生效条件覆盖。
|
||||
// PATCH /api/admin/shop-package-allocations/:id/expiry-base
|
||||
func (h *ShopPackageBatchAllocationHandler) UpdateExpiryBase(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var req dto.UpdateAllocationExpiryBaseRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
|
||||
result, err := h.service.UpdateExpiryBase(c.UserContext(), uint(id), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func hasJSONField(body []byte, field string) bool {
|
||||
var object map[string]any
|
||||
if err := sonic.Unmarshal(body, &object); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := object[field]
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
batchAllocationService "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
||||
grantService "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase 验证批量分配显式选择并固化到全部记录。
|
||||
func TestBatchAllocatePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
field string
|
||||
expected *string
|
||||
}{
|
||||
{name: "跟随默认", field: "null"},
|
||||
{name: "购买即生效", field: `"from_purchase"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase)},
|
||||
{name: "实名即生效", field: `"from_activation"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromActivation)},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + `,"expiry_base_override":` + testCase.field + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("批量分配失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var allocations []model.ShopPackageAllocation
|
||||
if err := tx.Where("shop_id = ?", fixture.shop.ID).Order("package_id").Find(&allocations).Error; err != nil {
|
||||
t.Fatalf("查询套餐分配失败:%v", err)
|
||||
}
|
||||
if len(allocations) != len(fixture.packages) {
|
||||
t.Fatalf("期望创建 %d 条分配,实际 %d", len(fixture.packages), len(allocations))
|
||||
}
|
||||
for _, allocation := range allocations {
|
||||
if !nullableStringEqual(allocation.ExpiryBaseOverride, testCase.expected) {
|
||||
t.Fatalf("分配 %d 的覆盖值错误:%v", allocation.ID, allocation.ExpiryBaseOverride)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举统一拒绝且不写入。
|
||||
func TestBatchAllocatePackagesHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) {
|
||||
for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} {
|
||||
t.Run(bodySuffix, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(fixture.shop.ID), 10) + `,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) + bodySuffix + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-package-allocations/batch", body)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("期望参数错误,实际 status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", fixture.shop.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败请求不应留下套餐分配:count=%d err=%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAllocationExpiryBaseHTTP 验证 PATCH 的恢复默认、幂等、权限和历史快照隔离。
|
||||
func TestUpdateAllocationExpiryBaseHTTP(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
allocation := &model.ShopPackageAllocation{ShopID: fixture.shop.ID, PackageID: fixture.packages[0].ID, AllocatorShopID: 101, CostPrice: 1, RetailPrice: 2, ExpiryBaseOverride: &override, Status: constants.StatusEnabled, ShelfStatus: 1}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐分配失败:%v", err)
|
||||
}
|
||||
usage := completeExpiryBaseTestUsage(fixture.packages[0].ID)
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建既有购买记录失败:%v", err)
|
||||
}
|
||||
app := fixture.newApp(constants.UserTypeSuperAdmin, 0)
|
||||
path := "/api/admin/shop-package-allocations/" + strconv.FormatUint(uint64(allocation.ID), 10) + "/expiry-base"
|
||||
|
||||
// 首次 PATCH 验证响应体包含 spec 要求的所有生效条件字段
|
||||
{
|
||||
status, respBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":"from_activation"}`)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("首次 PATCH 失败:status=%d body=%s", status, respBody)
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
DefaultExpiryBase string `json:"default_expiry_base"`
|
||||
DefaultExpiryBaseName string `json:"default_expiry_base_name"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override"`
|
||||
ExpiryBaseOverrideName string `json:"expiry_base_override_name"`
|
||||
EffectiveExpiryBase string `json:"effective_expiry_base"`
|
||||
EffectiveExpiryBaseName string `json:"effective_expiry_base_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(respBody, &resp); err != nil {
|
||||
t.Fatalf("解析 PATCH 响应失败:%v", err)
|
||||
}
|
||||
d := resp.Data
|
||||
if d.DefaultExpiryBase == "" || d.DefaultExpiryBaseName == "" ||
|
||||
d.ExpiryBaseOverride == nil || d.ExpiryBaseOverrideName == "" ||
|
||||
d.EffectiveExpiryBase == "" || d.EffectiveExpiryBaseName == "" {
|
||||
t.Fatalf("PATCH 响应缺少 spec 要求的生效条件字段:%+v", d)
|
||||
}
|
||||
}
|
||||
// 幂等 + 恢复默认
|
||||
for _, body := range []string{`{"expiry_base_override":"from_activation"}`, `{"expiry_base_override":null}`} {
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("修改覆盖值失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
}
|
||||
var refreshed model.ShopPackageAllocation
|
||||
if err := tx.First(&refreshed, allocation.ID).Error; err != nil || refreshed.ExpiryBaseOverride != nil {
|
||||
t.Fatalf("显式 null 应恢复跟随默认:allocation=%+v err=%v", refreshed, err)
|
||||
}
|
||||
var refreshedUsage model.PackageUsage
|
||||
if err := tx.First(&refreshedUsage, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询既有购买记录失败:%v", err)
|
||||
}
|
||||
if refreshedUsage.ExpiryBaseSnapshot != usage.ExpiryBaseSnapshot || refreshedUsage.CalendarTypeSnapshot != usage.CalendarTypeSnapshot || refreshedUsage.DurationDaysSnapshot != usage.DurationDaysSnapshot {
|
||||
t.Fatalf("修改分配不应改变既有购买快照:before=%+v after=%+v", usage, refreshedUsage)
|
||||
}
|
||||
|
||||
for _, body := range []string{`{}`, `{"expiry_base_override":"invalid"}`} {
|
||||
status, _ := expiryBaseHTTPRequest(t, app, http.MethodPatch, path, body)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("字段缺失或非法值应返回 400,body=%s status=%d", body, status)
|
||||
}
|
||||
}
|
||||
forbiddenApp := fixture.newApp(constants.UserTypeAgent, 202)
|
||||
status, _ := expiryBaseHTTPRequest(t, forbiddenApp, http.MethodPatch, path, `{"expiry_base_override":null}`)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("越权修改应返回 403,实际 %d", status)
|
||||
}
|
||||
if err := tx.Delete(&refreshed).Error; err != nil {
|
||||
t.Fatalf("软删除分配失败:%v", err)
|
||||
}
|
||||
status, _ = expiryBaseHTTPRequest(t, app, http.MethodPatch, path, `{"expiry_base_override":null}`)
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("软删除分配应使用统一 403 语义,实际 %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
type expiryBaseHTTPFixture struct {
|
||||
tx *gorm.DB
|
||||
shop *model.Shop
|
||||
series *model.PackageSeries
|
||||
packages []*model.Package
|
||||
}
|
||||
|
||||
func newExpiryBaseHTTPFixture(t *testing.T) (*gorm.DB, *expiryBaseHTTPFixture) {
|
||||
t.Helper()
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
shop := &model.Shop{ShopName: "UR55测试店铺", ShopCode: "UR55-SHOP-" + suffix, Status: constants.StatusEnabled}
|
||||
series := &model.PackageSeries{SeriesCode: "UR55-SERIES-" + suffix, SeriesName: "UR55测试系列", Status: constants.StatusEnabled}
|
||||
if err := tx.Create(shop).Error; err != nil {
|
||||
t.Fatalf("创建测试店铺失败:%v", err)
|
||||
}
|
||||
if err := tx.Create(series).Error; err != nil {
|
||||
t.Fatalf("创建测试系列失败:%v", err)
|
||||
}
|
||||
packages := make([]*model.Package, 0, 2)
|
||||
for index, expiryBase := range []string{constants.PackageExpiryBaseFromActivation, constants.PackageExpiryBaseFromPurchase} {
|
||||
pkg := &model.Package{PackageCode: "UR55-PKG-" + suffix + "-" + strconv.Itoa(index), PackageName: "UR55测试套餐", SeriesID: series.ID, PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: 30, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, PriceConfigStatus: 2}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建测试套餐失败:%v", err)
|
||||
}
|
||||
packages = append(packages, pkg)
|
||||
}
|
||||
seriesAllocation := &model.ShopSeriesAllocation{ShopID: shop.ID, SeriesID: series.ID, Status: constants.StatusEnabled}
|
||||
if err := tx.Create(seriesAllocation).Error; err != nil {
|
||||
t.Fatalf("创建系列授权失败:%v", err)
|
||||
}
|
||||
return tx, &expiryBaseHTTPFixture{tx: tx, shop: shop, series: series, packages: packages}
|
||||
}
|
||||
|
||||
func (f *expiryBaseHTTPFixture) newApp(userType int, shopID uint) *fiber.App {
|
||||
service := batchAllocationService.New(f.tx, postgres.NewPackageStore(f.tx), postgres.NewShopPackageAllocationStore(f.tx), postgres.NewShopSeriesAllocationStore(f.tx), postgres.NewShopStore(f.tx, nil), nil)
|
||||
handler := NewShopPackageBatchAllocationHandler(service)
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
setContext := func(c *fiber.Ctx) error {
|
||||
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID})
|
||||
c.SetUserContext(ctx)
|
||||
return c.Next()
|
||||
}
|
||||
app.Post("/api/admin/shop-package-allocations/batch", setContext, handler.BatchAllocate)
|
||||
app.Patch("/api/admin/shop-package-allocations/:id/expiry-base", setContext, handler.UpdateExpiryBase)
|
||||
return app
|
||||
}
|
||||
|
||||
func expiryBaseHTTPRequest(t *testing.T, app *fiber.App, method, path, body string) (int, []byte) {
|
||||
t.Helper()
|
||||
request, err := http.NewRequestWithContext(context.Background(), method, path, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatalf("创建 HTTP 请求失败:%v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := app.Test(request, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("执行 HTTP 请求失败:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 HTTP 响应失败:%v", err)
|
||||
}
|
||||
return response.StatusCode, responseBody
|
||||
}
|
||||
|
||||
func completeExpiryBaseTestUsage(packageID uint) *model.PackageUsage {
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
return &model.PackageUsage{OrderID: unique, OrderNo: "UR55-PATCH-USAGE", PackageID: packageID, UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, PackageName: "UR55测试套餐", Generation: 1, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
}
|
||||
|
||||
func nullableStringEqual(left, right *string) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
// TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase 验证系列首次授权(含套餐)固化生效条件到分配记录。
|
||||
func TestSeriesGrantCreateHTTPRequiresExplicitExpiryBase(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
field string
|
||||
expected *string
|
||||
}{
|
||||
{name: "跟随默认", field: "null"},
|
||||
{name: "购买即生效", field: `"from_purchase"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase)},
|
||||
{name: "实名即生效", field: `"from_activation"`, expected: testutil.StringPointer(constants.PackageExpiryBaseFromActivation)},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
|
||||
if err := tx.Create(targetShop).Error; err != nil {
|
||||
t.Fatalf("创建目标店铺失败:%v", err)
|
||||
}
|
||||
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
|
||||
pkg := fixture.packages[0]
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) +
|
||||
`,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) +
|
||||
`,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` +
|
||||
`,"expiry_base_override":` + testCase.field + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("系列授权创建失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var alloc model.ShopPackageAllocation
|
||||
if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil {
|
||||
t.Fatalf("查询套餐分配失败:%v", err)
|
||||
}
|
||||
if !nullableStringEqual(alloc.ExpiryBaseOverride, testCase.expected) {
|
||||
t.Fatalf("系列授权覆盖值错误:got=%v want=%v", alloc.ExpiryBaseOverride, testCase.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase 验证字段缺失与非法枚举拒绝系列授权创建。
|
||||
func TestSeriesGrantCreateHTTPRejectsMissingAndInvalidExpiryBase(t *testing.T) {
|
||||
for _, bodySuffix := range []string{"", `,"expiry_base_override":"invalid"`} {
|
||||
t.Run(bodySuffix, func(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
targetShop := &model.Shop{ShopName: "UR55授权目标店铺", ShopCode: "UR55-TARGET-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
|
||||
if err := tx.Create(targetShop).Error; err != nil {
|
||||
t.Fatalf("创建目标店铺失败:%v", err)
|
||||
}
|
||||
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
|
||||
pkg := fixture.packages[0]
|
||||
body := `{"shop_id":` + strconv.FormatUint(uint64(targetShop.ID), 10) +
|
||||
`,"series_id":` + strconv.FormatUint(uint64(fixture.series.ID), 10) +
|
||||
`,"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":100}]` +
|
||||
bodySuffix + `}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPost, "/api/admin/shop-series-grants", body)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("期望 400,实际 status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", targetShop.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败请求不应留下套餐分配:count=%d err=%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase 验证后续追加套餐固化生效条件到新分配记录。
|
||||
func TestSeriesGrantManagePackagesHTTPRequiresExplicitExpiryBase(t *testing.T) {
|
||||
tx, fixture := newExpiryBaseHTTPFixture(t)
|
||||
targetShop := &model.Shop{ShopName: "UR55追加目标店铺", ShopCode: "UR55-MANAGE-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.StatusEnabled}
|
||||
if err := tx.Create(targetShop).Error; err != nil {
|
||||
t.Fatalf("创建目标店铺失败:%v", err)
|
||||
}
|
||||
// 先创建不含套餐的系列授权
|
||||
seriesAlloc := &model.ShopSeriesAllocation{ShopID: targetShop.ID, SeriesID: fixture.series.ID, AllocatorShopID: 0, Status: constants.StatusEnabled}
|
||||
if err := tx.Create(seriesAlloc).Error; err != nil {
|
||||
t.Fatalf("创建系列授权失败:%v", err)
|
||||
}
|
||||
app := fixture.newGrantApp(constants.UserTypeSuperAdmin, 0, tx)
|
||||
path := "/api/admin/shop-series-grants/" + strconv.FormatUint(uint64(seriesAlloc.ID), 10) + "/packages"
|
||||
pkg := fixture.packages[0]
|
||||
|
||||
// 有效追加(from_purchase)
|
||||
body := `{"packages":[{"package_id":` + strconv.FormatUint(uint64(pkg.ID), 10) + `,"cost_price":50}],"expiry_base_override":"from_purchase"}`
|
||||
status, responseBody := expiryBaseHTTPRequest(t, app, http.MethodPut, path, body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("追加套餐失败:status=%d body=%s", status, responseBody)
|
||||
}
|
||||
var alloc model.ShopPackageAllocation
|
||||
if err := tx.Where("shop_id = ? AND package_id = ?", targetShop.ID, pkg.ID).First(&alloc).Error; err != nil {
|
||||
t.Fatalf("查询新建套餐分配失败:%v", err)
|
||||
}
|
||||
expected := constants.PackageExpiryBaseFromPurchase
|
||||
if !nullableStringEqual(alloc.ExpiryBaseOverride, &expected) {
|
||||
t.Fatalf("追加套餐覆盖值错误:%v", alloc.ExpiryBaseOverride)
|
||||
}
|
||||
|
||||
// 缺失 expiry_base_override 字段应返回 400
|
||||
for _, badBody := range []string{
|
||||
`{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}]}`,
|
||||
`{"packages":[{"package_id":` + strconv.FormatUint(uint64(fixture.packages[1].ID), 10) + `,"cost_price":50}],"expiry_base_override":"invalid"}`,
|
||||
} {
|
||||
s, _ := expiryBaseHTTPRequest(t, app, http.MethodPut, path, badBody)
|
||||
if s != http.StatusBadRequest {
|
||||
t.Fatalf("缺失/非法 expiry_base_override 应返回 400,实际 %d", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *expiryBaseHTTPFixture) newGrantApp(userType int, shopID uint, tx *gorm.DB) *fiber.App {
|
||||
svc := grantService.New(
|
||||
tx,
|
||||
postgres.NewShopSeriesAllocationStore(tx),
|
||||
postgres.NewShopPackageAllocationStore(tx),
|
||||
postgres.NewShopPackageAllocationPriceHistoryStore(tx),
|
||||
postgres.NewShopStore(tx, nil),
|
||||
postgres.NewPackageStore(tx),
|
||||
postgres.NewPackageSeriesStore(tx),
|
||||
zap.NewNop(),
|
||||
)
|
||||
handler := NewShopSeriesGrantHandler(svc)
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
setContext := func(c *fiber.Ctx) error {
|
||||
ctx := middleware.SetUserContext(c.UserContext(), &middleware.UserContextInfo{UserID: 9001, UserType: userType, Username: "UR55测试账号", ShopID: shopID})
|
||||
c.SetUserContext(ctx)
|
||||
return c.Next()
|
||||
}
|
||||
app.Post("/api/admin/shop-series-grants", setContext, handler.Create)
|
||||
app.Put("/api/admin/shop-series-grants/:id/packages", setContext, handler.ManagePackages)
|
||||
return app
|
||||
}
|
||||
|
||||
var _ = errors.CodeSuccess
|
||||
@@ -28,6 +28,7 @@ func (h *ShopSeriesGrantHandler) Create(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
|
||||
|
||||
result, err := h.service.Create(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
@@ -83,7 +84,6 @@ func (h *ShopSeriesGrantHandler) Update(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.Update(c.UserContext(), uint(id), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -105,6 +105,7 @@ func (h *ShopSeriesGrantHandler) ManagePackages(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
|
||||
|
||||
result, err := h.service.ManagePackages(c.UserContext(), uint(id), &req)
|
||||
if err != nil {
|
||||
|
||||
@@ -106,6 +106,12 @@ type PackageResponse struct {
|
||||
DurationDays *int `json:"duration_days,omitempty" description:"套餐天数(calendar_type=by_day时有值)"`
|
||||
DataResetCycle string `json:"data_reset_cycle" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"`
|
||||
ExpiryBase string `json:"expiry_base" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
||||
DefaultExpiryBase string `json:"default_expiry_base" description:"套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
DefaultExpiryBaseName string `json:"default_expiry_base_name" description:"套餐默认生效条件名称(中文)"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" description:"分配生效条件覆盖,null 表示跟随套餐默认值"`
|
||||
ExpiryBaseOverrideName string `json:"expiry_base_override_name" description:"分配生效条件覆盖名称(中文)"`
|
||||
EffectiveExpiryBase string `json:"effective_expiry_base" description:"最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
EffectiveExpiryBaseName string `json:"effective_expiry_base_name" description:"最终生效条件名称(中文)"`
|
||||
}
|
||||
|
||||
// UpdatePackageParams 更新套餐聚合参数
|
||||
|
||||
@@ -12,12 +12,37 @@ type BatchAllocatePackagesRequest struct {
|
||||
SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"`
|
||||
PriceAdjustment *PriceAdjustment `json:"price_adjustment" validate:"omitempty" description:"可选加价配置"`
|
||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount" validate:"omitempty,min=0" minimum:"0" description:"该代理能拿到的一次性佣金(分)"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
ExpiryBaseOverrideSet bool `json:"-"`
|
||||
}
|
||||
|
||||
// BatchAllocatePackagesResponse 批量分配套餐响应
|
||||
type BatchAllocatePackagesResponse struct {
|
||||
TotalPackages int `json:"total_packages" description:"总套餐数"`
|
||||
AllocatedCount int `json:"allocated_count" description:"成功分配数量"`
|
||||
SkippedCount int `json:"skipped_count" description:"跳过数量(已存在)"`
|
||||
PackageIDs []uint `json:"package_ids" description:"分配的套餐ID列表"`
|
||||
TotalPackages int `json:"total_packages" description:"总套餐数"`
|
||||
AllocatedCount int `json:"allocated_count" description:"成功分配数量"`
|
||||
SkippedCount int `json:"skipped_count" description:"跳过数量(已存在)"`
|
||||
Allocations []ShopPackageAllocationTermsResponse `json:"allocations" description:"本次新建套餐分配及其生效条件"`
|
||||
}
|
||||
|
||||
// UpdateAllocationExpiryBaseRequest 修改套餐分配生效条件覆盖请求。
|
||||
type UpdateAllocationExpiryBaseRequest struct {
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
ExpiryBaseOverrideSet bool `json:"-"`
|
||||
}
|
||||
|
||||
// ShopPackageAllocationTermsResponse 套餐分配计时条款响应。
|
||||
type ShopPackageAllocationTermsResponse struct {
|
||||
ID uint `json:"id" description:"套餐分配ID"`
|
||||
DefaultExpiryBase string `json:"default_expiry_base" description:"套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
DefaultExpiryBaseName string `json:"default_expiry_base_name" description:"套餐默认生效条件名称(中文)"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" description:"分配生效条件覆盖,null 表示跟随套餐默认值"`
|
||||
ExpiryBaseOverrideName string `json:"expiry_base_override_name" description:"分配生效条件覆盖名称(中文)"`
|
||||
EffectiveExpiryBase string `json:"effective_expiry_base" description:"最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
EffectiveExpiryBaseName string `json:"effective_expiry_base_name" description:"最终生效条件名称(中文)"`
|
||||
}
|
||||
|
||||
// UpdateAllocationExpiryBaseParams 修改套餐分配生效条件覆盖聚合参数。
|
||||
type UpdateAllocationExpiryBaseParams struct {
|
||||
IDReq
|
||||
UpdateAllocationExpiryBaseRequest
|
||||
}
|
||||
|
||||
@@ -9,12 +9,18 @@ type GrantPackageItem struct {
|
||||
|
||||
// ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息)
|
||||
type ShopSeriesGrantPackageItem struct {
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
ShelfStatus int `json:"shelf_status" description:"上架状态 1-上架 2-下架"`
|
||||
Status int `json:"status" description:"分配状态 0=禁用 1=启用"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
ShelfStatus int `json:"shelf_status" description:"上架状态 1-上架 2-下架"`
|
||||
Status int `json:"status" description:"分配状态 0=禁用 1=启用"`
|
||||
DefaultExpiryBase string `json:"default_expiry_base" description:"套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
DefaultExpiryBaseName string `json:"default_expiry_base_name" description:"套餐默认生效条件名称(中文)"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" description:"分配生效条件覆盖,null 表示跟随套餐默认值"`
|
||||
ExpiryBaseOverrideName string `json:"expiry_base_override_name" description:"分配生效条件覆盖名称(中文)"`
|
||||
EffectiveExpiryBase string `json:"effective_expiry_base" description:"最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
EffectiveExpiryBaseName string `json:"effective_expiry_base_name" description:"最终生效条件名称(中文)"`
|
||||
}
|
||||
|
||||
// GrantCommissionTierItem 梯度佣金档位(operator/dimension/stat_scope 仅出现在响应中,来自 PackageSeries 全局配置)
|
||||
@@ -57,6 +63,8 @@ type CreateShopSeriesGrantRequest struct {
|
||||
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"`
|
||||
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"`
|
||||
Packages []GrantPackageItem `json:"packages,omitempty" description:"初始授权套餐列表"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
ExpiryBaseOverrideSet bool `json:"-"`
|
||||
}
|
||||
|
||||
// UpdateShopSeriesGrantRequest 更新系列授权请求
|
||||
@@ -69,7 +77,9 @@ type UpdateShopSeriesGrantRequest struct {
|
||||
|
||||
// ManageGrantPackagesRequest 管理授权套餐请求
|
||||
type ManageGrantPackagesRequest struct {
|
||||
Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"`
|
||||
Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"`
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
|
||||
ExpiryBaseOverrideSet bool `json:"-"`
|
||||
}
|
||||
|
||||
// ShopSeriesGrantListRequest 系列授权列表查询请求
|
||||
|
||||
@@ -75,6 +75,10 @@ type PackageUsage struct {
|
||||
VirtualTotalMBSnapshot int64 `gorm:"column:virtual_total_mb_snapshot;type:bigint;default:0;comment:虚总量快照(MB,业务停机阈值)" json:"virtual_total_mb_snapshot"`
|
||||
DisplayGainRatioSnapshot float64 `gorm:"column:display_gain_ratio_snapshot;type:decimal(18,6);default:1.0;comment:展示倍率快照(real_total_mb/virtual_total_mb)" json:"display_gain_ratio_snapshot"`
|
||||
EnableVirtualDataSnapshot bool `gorm:"column:enable_virtual_data_snapshot;type:boolean;default:false;comment:是否启用虚流量快照" json:"enable_virtual_data_snapshot"`
|
||||
ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:购买时生效条件快照" json:"expiry_base_snapshot"`
|
||||
CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:购买时周期类型快照" json:"calendar_type_snapshot"`
|
||||
DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;type:int;not null;default:0;comment:购买时月数快照" json:"duration_months_snapshot"`
|
||||
DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;type:int;not null;default:0;comment:购买时天数快照" json:"duration_days_snapshot"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:套餐生效时间" json:"activated_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at;comment:套餐过期时间" json:"expires_at"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"`
|
||||
|
||||
@@ -4,18 +4,21 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ShopPackageAllocation 店铺套餐分配模型
|
||||
// 记录套餐授权、价格及购买生效条件覆盖配置。
|
||||
type ShopPackageAllocation struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
||||
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
||||
AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:该代理的成本价(分)" json:"cost_price"`
|
||||
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"`
|
||||
RetailPriceConfigStatus int `gorm:"column:retail_price_config_status;type:int;default:0;not null;comment:零售价配置状态 0-未配置 1-赠送0价 2-已配置非0" json:"retail_price_config_status"`
|
||||
BaseModel `gorm:"embedded"`
|
||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
||||
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
||||
AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:该代理的成本价(分)" json:"cost_price"`
|
||||
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"`
|
||||
RetailPriceConfigStatus int `gorm:"column:retail_price_config_status;type:int;default:0;not null;comment:零售价配置状态 0-未配置 1-赠送0价 2-已配置非0" json:"retail_price_config_status"`
|
||||
ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:到期时间基准覆盖,NULL表示跟随套餐默认值" json:"expiry_base_override"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -16,7 +16,13 @@ func registerShopPackageBatchAllocationRoutes(router fiber.Router, handler *admi
|
||||
Summary: "批量分配套餐",
|
||||
Tags: []string{"批量套餐分配"},
|
||||
Input: new(dto.BatchAllocatePackagesRequest),
|
||||
Output: nil,
|
||||
Output: new(dto.BatchAllocatePackagesResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(router, doc, basePath, "PATCH", "/shop-package-allocations/:id/expiry-base", handler.UpdateExpiryBase, RouteSpec{
|
||||
Summary: "修改套餐分配生效条件覆盖",
|
||||
Tags: []string{"批量套餐分配"}, Input: new(dto.UpdateAllocationExpiryBaseParams),
|
||||
Output: new(dto.ShopPackageAllocationTermsResponse), Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
176
internal/service/order/package_terms_integration_test.go
Normal file
176
internal/service/order/package_terms_integration_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestSynchronousPurchasePersistsImmutableTermsSnapshots 验证同步主套餐和加油包固化购买时计时条款。
|
||||
func TestSynchronousPurchasePersistsImmutableTermsSnapshots(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
formal := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 45)
|
||||
addon := createOrderTermsPackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 10)
|
||||
shopID := uint(time.Now().UnixNano() & testIDMask)
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
for _, packageID := range []uint{formal.ID, addon.ID} {
|
||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: packageID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: &override}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐覆盖配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.Local)
|
||||
formalOrder := newOrderTermsOrder(formal.ID, card.ID, &shopID)
|
||||
if err := service.activateMainPackage(context.Background(), tx, formalOrder, formal, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("同步创建主套餐失败:%v", err)
|
||||
}
|
||||
addonOrder := newOrderTermsOrder(addon.ID, card.ID, &shopID)
|
||||
if err := service.activateAddonPackage(context.Background(), tx, addonOrder, addon, constants.AssetWalletResourceTypeIotCard, card.ID, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("同步创建加油包失败:%v", err)
|
||||
}
|
||||
|
||||
for _, item := range []struct {
|
||||
orderID uint
|
||||
packageID uint
|
||||
durationDay int
|
||||
}{
|
||||
{formalOrder.ID, formal.ID, 45},
|
||||
{addonOrder.ID, addon.ID, 10},
|
||||
} {
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", item.orderID, item.packageID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询套餐使用记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != item.durationDay {
|
||||
t.Fatalf("同步购买快照错误:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Package{}).Where("id IN ?", []uint{formal.ID, addon.ID}).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil {
|
||||
t.Fatalf("修改套餐当前配置失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(&model.ShopPackageAllocation{}).Where("shop_id = ?", shopID).Update("expiry_base_override", nil).Error; err != nil {
|
||||
t.Fatalf("修改分配当前配置失败:%v", err)
|
||||
}
|
||||
var usages []model.PackageUsage
|
||||
if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil {
|
||||
t.Fatalf("重新查询购买快照失败:%v", err)
|
||||
}
|
||||
if len(usages) != 2 || usages[0].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[1].ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usages[0].DurationDaysSnapshot == 99 || usages[1].DurationDaysSnapshot == 99 {
|
||||
t.Fatalf("购买后配置变化不应修改快照:%+v", usages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage 验证非法配置不会留下空快照使用记录。
|
||||
func TestSynchronousPurchaseRejectsInvalidTermsWithoutUsage(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, "invalid", 30)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err == nil {
|
||||
t.Fatal("非法计时条款必须拒绝同步创建")
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("失败路径不应留下使用记录:count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCEndPurchasePersistsSnapshotAndActivatesImmediately 验证 C 端购买无论 expiry_base 如何均写入快照并立即激活。
|
||||
func TestCEndPurchasePersistsSnapshotAndActivatesImmediately(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
// C 端购买前已做实名前置检查;测试用未实名卡以区分"C 端即时激活"与"实名才激活"
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromActivation, 20)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
now := time.Now()
|
||||
order := &model.Order{
|
||||
Model: gorm.Model{ID: uint(time.Now().UnixNano() & testIDMask)},
|
||||
OrderNo: "UR55-CEND-" + strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
BuyerType: model.BuyerTypePersonal,
|
||||
IotCardID: &card.ID,
|
||||
TotalAmount: 100,
|
||||
Generation: 1,
|
||||
}
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, now); err != nil {
|
||||
t.Fatalf("C 端购买创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询 C 端购买使用记录失败:%v", err)
|
||||
}
|
||||
// 快照必须完整
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromActivation || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 20 {
|
||||
t.Fatalf("C 端购买快照错误:%+v", usage)
|
||||
}
|
||||
// C 端购买不等实名,必须立即激活
|
||||
if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation {
|
||||
t.Fatalf("C 端购买必须立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot 验证无分配配置时使用套餐默认值写入快照。
|
||||
func TestPlatformPurchaseWithoutAllocationPersistsPackageDefaultSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createOrderTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createOrderTermsPackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 15)
|
||||
service := &Service{shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
// sellerShopID=nil 模拟平台后台代购(无代理分配)
|
||||
order := newOrderTermsOrder(pkg.ID, card.ID, nil)
|
||||
if err := service.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("平台代购创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询平台代购使用记录失败:%v", err)
|
||||
}
|
||||
// 无分配时快照应取套餐默认值 from_purchase
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 15 {
|
||||
t.Fatalf("平台代购快照应使用套餐默认值:%+v", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func createOrderTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "89" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createOrderTermsPackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ORDER-" + suffix, PackageName: "UR55同步购买测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func newOrderTermsOrder(packageID, cardID uint, sellerShopID *uint) *model.Order {
|
||||
orderID := uint(time.Now().UnixNano() & testIDMask)
|
||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-ORDER-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"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"
|
||||
@@ -2222,6 +2223,11 @@ func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, o
|
||||
|
||||
// activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑
|
||||
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
||||
terms, err := s.resolvePackageTerms(ctx, tx, pkg, order.SellerShopID)
|
||||
if err != nil {
|
||||
s.logger.Error("生成套餐计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if err := s.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2252,19 +2258,16 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
priority = 1
|
||||
activatedAt = now
|
||||
// 使用工具函数计算过期时间
|
||||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
|
||||
// 计算下次重置时间(基于套餐周期类型)
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
|
||||
|
||||
// REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态)
|
||||
// 查询卡类型(行业卡永远直接激活,不等实名)
|
||||
// 同时查询载体当前实名状态:已实名则跳过 pending,直接激活
|
||||
var cardCategory string
|
||||
// REALNAME-02: 后台囤货场景三维决策(购买路径 + expiry_base + 实名状态)
|
||||
// 查询载体当前实名状态:已实名则跳过 pending,直接激活
|
||||
var currentlyRealnamed bool
|
||||
if carrierType == "iot_card" {
|
||||
var card model.IotCard
|
||||
if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil {
|
||||
cardCategory = card.CardCategory
|
||||
if err := tx.Select("real_name_status").First(&card, carrierID).Error; err == nil {
|
||||
currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified
|
||||
}
|
||||
// err != nil 时 currentlyRealnamed 保守默认 false,不阻断购买流程
|
||||
@@ -2285,9 +2288,9 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
|
||||
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
||||
|
||||
if cardCategory != "industry" && !isCEndPurchase {
|
||||
if !isCEndPurchase {
|
||||
// 后台囤货路径:按 expiry_base 决定是否等实名
|
||||
if pkg.ExpiryBase != "from_purchase" {
|
||||
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase {
|
||||
if currentlyRealnamed {
|
||||
s.logger.Info("购买时载体已实名,直接激活套餐",
|
||||
zap.String("carrier_type", carrierType),
|
||||
@@ -2303,7 +2306,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
}
|
||||
// from_purchase:立即激活,status 已为 Active,保持不变
|
||||
}
|
||||
// 行业卡或 C 端购买:直接激活,status 已为 Active,保持不变
|
||||
// C 端购买已完成实名前置校验,直接激活。
|
||||
}
|
||||
|
||||
// 创建套餐使用记录
|
||||
@@ -2332,6 +2335,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
terms.Apply(usage)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
usage.IotCardID = carrierID
|
||||
@@ -2362,6 +2366,11 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
|
||||
// activateAddonPackage 任务 8.5-8.7: 加油包激活逻辑
|
||||
func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
|
||||
terms, err := s.resolvePackageTerms(ctx, tx, pkg, order.SellerShopID)
|
||||
if err != nil {
|
||||
s.logger.Error("生成加油包计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
// 任务 8.5-8.6: 检查是否有可挂靠主套餐(待生效、未过期的生效中或已用完)
|
||||
mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -2421,6 +2430,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
terms.Apply(usage)
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
usage.IotCardID = carrierID
|
||||
@@ -2436,6 +2446,10 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolvePackageTerms(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) {
|
||||
return packagepkg.ResolveTermsFromTx(ctx, tx, pkg, sellerShopID)
|
||||
}
|
||||
|
||||
func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
|
||||
if s.queueClient == nil {
|
||||
s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID))
|
||||
|
||||
@@ -113,18 +113,22 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
|
||||
}
|
||||
}
|
||||
|
||||
// REALNAME-04: 按 ExpiryBase 选择激活计时基准
|
||||
terms, termsErr := ResolveUsageTerms(usage, &pkg, s.logger)
|
||||
if termsErr != nil {
|
||||
return termsErr
|
||||
}
|
||||
// REALNAME-04: 按购买快照选择激活计时基准
|
||||
// from_purchase:购买时起算,用 PackageUsage 创建时间;否则用实名触发当前时刻
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
if terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
|
||||
activatedAt = usage.CreatedAt
|
||||
} else {
|
||||
activatedAt = now
|
||||
}
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
|
||||
|
||||
// 计算下次重置时间
|
||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
|
||||
|
||||
// 更新套餐使用记录
|
||||
updates := map[string]interface{}{
|
||||
@@ -552,20 +556,24 @@ func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB,
|
||||
}
|
||||
|
||||
func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error {
|
||||
terms, err := ResolveUsageTerms(usage, pkg, s.logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ExpiryBase=from_purchase 只用于"等待实名激活"场景(REALNAME-04):套餐已购买但资产未实名,
|
||||
// 计时基准按购买时间算,实名只是解锁使用权。此函数同时被"前一个主套餐到期后排队顺延"场景复用,
|
||||
// 这种情况下 usage.PendingRealnameActivation 为 false,不应该套用购买时间,否则排队等待的天数会
|
||||
// 从到期时间里被扣掉。只有当这条记录确实是因为等实名才被搁置时,才按 ExpiryBase 选基准。
|
||||
var activatedAt time.Time
|
||||
if usage.PendingRealnameActivation && pkg.ExpiryBase == "from_purchase" {
|
||||
if usage.PendingRealnameActivation && terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
|
||||
activatedAt = usage.CreatedAt
|
||||
} else {
|
||||
activatedAt = now
|
||||
}
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
|
||||
|
||||
// 计算下次重置时间
|
||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||
nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
|
||||
|
||||
// 更新套餐使用记录
|
||||
updates := map[string]interface{}{
|
||||
@@ -650,14 +658,20 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}
|
||||
|
||||
baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{})
|
||||
switch assetType {
|
||||
case "iot_card":
|
||||
baseQuery = baseQuery.Where("iot_card_id = ?", assetID)
|
||||
case "device":
|
||||
baseQuery = baseQuery.Where("device_id = ?", assetID)
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
newAssetQuery := func() (*gorm.DB, error) {
|
||||
query := s.db.WithContext(ctx).Model(&model.PackageUsage{})
|
||||
switch assetType {
|
||||
case "iot_card":
|
||||
return query.Where("iot_card_id = ?", assetID), nil
|
||||
case "device":
|
||||
return query.Where("device_id = ?", assetID), nil
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||
}
|
||||
}
|
||||
baseQuery, err := newAssetQuery()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var targets []model.PackageUsage
|
||||
@@ -708,7 +722,11 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass
|
||||
|
||||
if len(mainUsageIDs) > 0 {
|
||||
var addons []model.PackageUsage
|
||||
if err := baseQuery.
|
||||
addonQuery, queryErr := newAssetQuery()
|
||||
if queryErr != nil {
|
||||
return queryErr
|
||||
}
|
||||
if err := addonQuery.
|
||||
Where("master_usage_id IN ?", mainUsageIDs).
|
||||
Where("status IN ?", validStatuses).
|
||||
Find(&addons).Error; err != nil {
|
||||
|
||||
233
internal/service/package/activation_terms_integration_test.go
Normal file
233
internal/service/package/activation_terms_integration_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestActivateByRealnameUsesPurchasedSnapshot 验证实名激活只使用购买快照计算起止时间。
|
||||
func TestActivateByRealnameUsesPurchasedSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 99)
|
||||
purchasedAt := time.Now().Add(-48 * time.Hour).Truncate(time.Second)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 10, true, purchasedAt)
|
||||
service := NewActivationService(tx, nil, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateByRealname(context.Background(), constants.AssetTypeIotCard, card.ID); err != nil {
|
||||
t.Fatalf("实名激活失败:%v", err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询激活结果失败:%v", err)
|
||||
}
|
||||
expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, refreshed.CreatedAt, 0, 10)
|
||||
if refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil || refreshed.ActivatedAt.Unix() != refreshed.CreatedAt.Unix() || refreshed.ExpiresAt.Unix() != expectedExpiry.Unix() {
|
||||
t.Fatalf("实名激活未使用购买快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent 验证连续排队按优先级和各自快照接续。
|
||||
func TestActivateNextPendingMainPackageUsesQueueSnapshotAndIsIdempotent(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
firstPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
secondPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 90)
|
||||
first := createActivationTermsUsage(t, tx, firstPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromActivation, 3, false, time.Now().Add(-time.Hour))
|
||||
second := createActivationTermsUsage(t, tx, secondPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 4, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活队首套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || activated {
|
||||
t.Fatalf("已有生效主套餐时重复接续应幂等:activated=%v err=%v", activated, err)
|
||||
}
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("id = ?", first.ID).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
t.Fatalf("结束队首套餐失败:%v", err)
|
||||
}
|
||||
activated, err = service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("激活第二个排队套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed []model.PackageUsage
|
||||
if err := tx.Where("id IN ?", []uint{first.ID, second.ID}).Order("priority").Find(&refreshed).Error; err != nil {
|
||||
t.Fatalf("查询队列接续结果失败:%v", err)
|
||||
}
|
||||
if len(refreshed) != 2 || refreshed[1].ExpiresAt == nil || refreshed[1].ActivatedAt == nil || !refreshed[1].ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed[1].ActivatedAt, 0, 4)) {
|
||||
t.Fatalf("第二个排队套餐未使用自身快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundFollowUpActivatesNextPackageFromSnapshot 验证退款失效后下一套餐按购买快照接续。
|
||||
func TestRefundFollowUpActivatesNextPackageFromSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
currentPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
nextPackage := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 60)
|
||||
current := createActivationTermsUsage(t, tx, currentPackage.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now().Add(-time.Hour))
|
||||
now := time.Now()
|
||||
if err := tx.Model(current).Updates(map[string]any{"status": constants.PackageUsageStatusActive, "activated_at": now, "expires_at": now.AddDate(0, 0, 30)}).Error; err != nil {
|
||||
t.Fatalf("设置当前生效套餐失败:%v", err)
|
||||
}
|
||||
next := createActivationTermsUsage(t, tx, nextPackage.ID, card.ID, 2, constants.PackageExpiryBaseFromActivation, 6, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.InvalidatePackagesForRefund(context.Background(), constants.AssetTypeIotCard, card.ID, current.OrderID, 77, "UR55-REFUND", ¤t.ID); err != nil {
|
||||
t.Fatalf("退款失效当前套餐失败:%v", err)
|
||||
}
|
||||
activated, err := service.ActivateNextPendingMainPackage(context.Background(), constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil || !activated {
|
||||
t.Fatalf("退款后接续下一套餐失败:activated=%v err=%v", activated, err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, next.ID).Error; err != nil {
|
||||
t.Fatalf("查询退款接续结果失败:%v", err)
|
||||
}
|
||||
if refreshed.ExpiresAt == nil || refreshed.ActivatedAt == nil || !refreshed.ExpiresAt.Equal(CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 6)) {
|
||||
t.Fatalf("退款接续未使用下一套餐快照:%+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromPurchaseActivatesImmediatelyAtPurchaseTime 验证 from_purchase 快照在购买时直接激活,不等实名。
|
||||
func TestFromPurchaseActivatesImmediatelyAtPurchaseTime(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 7)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 7, false, time.Now())
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil {
|
||||
t.Fatalf("from_purchase 直接激活失败:%v", err)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil {
|
||||
t.Fatalf("查询激活结果失败:%v", err)
|
||||
}
|
||||
if refreshed.Status != constants.PackageUsageStatusActive || refreshed.ActivatedAt == nil || refreshed.ExpiresAt == nil {
|
||||
t.Fatalf("from_purchase 套餐应已激活:status=%d activatedAt=%v expiresAt=%v", refreshed.Status, refreshed.ActivatedAt, refreshed.ExpiresAt)
|
||||
}
|
||||
if refreshed.PendingRealnameActivation {
|
||||
t.Fatal("from_purchase 不应标记待实名")
|
||||
}
|
||||
expectedExpiry := CalculateExpiryTime(constants.PackageCalendarTypeByDay, *refreshed.ActivatedAt, 0, 7)
|
||||
if !refreshed.ExpiresAt.Equal(expectedExpiry) {
|
||||
t.Fatalf("to期时间不符:want=%v got=%v", expectedExpiry, refreshed.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoricalFallbackFiresWarningAndIncrementsCounter 验证历史记录缺少快照时回退套餐当前配置并递增计数器。
|
||||
func TestHistoricalFallbackFiresWarningAndIncrementsCounter(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromActivation, 21)
|
||||
// 仅在会回滚的测试事务内禁用触发器,以构造 UR#55 上线前的历史空快照记录。
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
// 历史记录:四个快照字段全为空值/零值
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
usage := &model.PackageUsage{
|
||||
OrderID: unique, OrderNo: "UR55-HIST-" + strconv.FormatUint(uint64(unique), 10),
|
||||
PackageID: pkg.ID, PackageName: "UR55历史测试套餐",
|
||||
UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: card.ID,
|
||||
DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: 1, Generation: 1,
|
||||
// 快照字段均留空,模拟 UR#55 上线前的旧数据
|
||||
}
|
||||
if err := tx.Omit("status").Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建历史测试使用记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Update("status", constants.PackageUsageStatusPending).Error; err != nil {
|
||||
t.Fatalf("设置历史测试状态失败:%v", err)
|
||||
}
|
||||
before := HistoricalTermsFallbackCount()
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err != nil {
|
||||
t.Fatalf("历史记录兼容激活失败:%v", err)
|
||||
}
|
||||
after := HistoricalTermsFallbackCount()
|
||||
if after <= before {
|
||||
t.Fatalf("历史回退计数器未递增:before=%d after=%d", before, after)
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusActive {
|
||||
t.Fatalf("历史记录应兼容激活成功:status=%d err=%v", refreshed.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActivateSpecificPackageRejectsPartialSnapshot 验证新记录非法快照不会静默回退当前套餐配置。
|
||||
func TestActivateSpecificPackageRejectsPartialSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
card := createActivationTermsCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createActivationTermsPackage(t, tx, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
usage := createActivationTermsUsage(t, tx, pkg.ID, card.ID, 1, constants.PackageExpiryBaseFromPurchase, 30, false, time.Now())
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(usage).Update("calendar_type_snapshot", "invalid").Error; err != nil {
|
||||
t.Fatalf("构造非法快照失败:%v", err)
|
||||
}
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复快照校验触发器失败:%v", err)
|
||||
}
|
||||
service := NewActivationService(tx, redisClient, nil, nil, nil, zap.NewNop())
|
||||
if err := service.ActivateSpecificPackage(context.Background(), usage.ID); err == nil {
|
||||
t.Fatal("非法部分快照必须拒绝激活")
|
||||
}
|
||||
var refreshed model.PackageUsage
|
||||
if err := tx.First(&refreshed, usage.ID).Error; err != nil || refreshed.Status != constants.PackageUsageStatusPending {
|
||||
t.Fatalf("非法快照失败后状态必须保持待生效:status=%d err=%v", refreshed.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createActivationTermsCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "87" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建激活测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createActivationTermsPackage(t *testing.T, tx *gorm.DB, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-ACTIVATE-" + suffix, PackageName: "UR55激活测试套餐", PackageType: constants.PackageTypeFormal, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建激活测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func createActivationTermsUsage(t *testing.T, tx *gorm.DB, packageID, cardID uint, priority int, expiryBase string, durationDays int, pendingRealname bool, createdAt time.Time) *model.PackageUsage {
|
||||
t.Helper()
|
||||
unique := uint(time.Now().UnixNano() & testIDMask)
|
||||
usage := &model.PackageUsage{Model: gorm.Model{CreatedAt: createdAt}, OrderID: unique, OrderNo: "UR55-ACTIVATE-" + strconv.FormatUint(uint64(unique), 10), PackageID: packageID, PackageName: "UR55激活测试套餐", UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: cardID, DataLimitMB: 1, Status: constants.PackageUsageStatusPending, Priority: priority, PendingRealnameActivation: pendingRealname, Generation: 1, ExpiryBaseSnapshot: expiryBase, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: durationDays}
|
||||
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": constants.PackageUsageStatusPending, "pending_realname_activation": pendingRealname}).Error; err != nil {
|
||||
t.Fatalf("设置激活测试状态失败:%v", err)
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -101,18 +101,18 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
}
|
||||
|
||||
pkg := &model.Package{
|
||||
PackageCode: req.PackageCode,
|
||||
PackageName: req.PackageName,
|
||||
PackageType: req.PackageType,
|
||||
IsGift: req.IsGift,
|
||||
DurationMonths: req.DurationMonths,
|
||||
CostPrice: req.CostPrice,
|
||||
PriceConfigStatus: priceConfigStatus,
|
||||
PackageCode: req.PackageCode,
|
||||
PackageName: req.PackageName,
|
||||
PackageType: req.PackageType,
|
||||
IsGift: req.IsGift,
|
||||
DurationMonths: req.DurationMonths,
|
||||
CostPrice: req.CostPrice,
|
||||
PriceConfigStatus: priceConfigStatus,
|
||||
SuggestedRetailPrice: storedRetailPrice,
|
||||
EnableVirtualData: req.EnableVirtualData,
|
||||
CalendarType: calendarType,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
EnableVirtualData: req.EnableVirtualData,
|
||||
CalendarType: calendarType,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: 2,
|
||||
}
|
||||
if req.SeriesID != nil {
|
||||
pkg.SeriesID = *req.SeriesID
|
||||
@@ -607,30 +607,31 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
initPackageExpiryBaseFields(resp, pkg)
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
shopID := middleware.GetShopIDFromContext(ctx)
|
||||
@@ -646,6 +647,7 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
resp.ShelfStatus = allocation.ShelfStatus
|
||||
applyAllocationExpiryBase(resp, pkg, allocation)
|
||||
}
|
||||
} else {
|
||||
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
@@ -684,30 +686,31 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
}
|
||||
|
||||
resp := &dto.PackageResponse{
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
ID: pkg.ID,
|
||||
PackageCode: pkg.PackageCode,
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
ExpiryBase: pkg.ExpiryBase,
|
||||
Status: pkg.Status,
|
||||
ShelfStatus: pkg.ShelfStatus,
|
||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
initPackageExpiryBaseFields(resp, pkg)
|
||||
|
||||
if allocationMap != nil {
|
||||
if allocation, ok := allocationMap[pkg.ID]; ok {
|
||||
@@ -719,6 +722,7 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
applyAllocationExpiryBase(resp, pkg, allocation)
|
||||
resp.ShelfStatus = allocation.ShelfStatus
|
||||
}
|
||||
} else {
|
||||
@@ -734,6 +738,21 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
return resp
|
||||
}
|
||||
|
||||
// initPackageExpiryBaseFields 初始化响应中生效条件默认字段(无分配覆盖时跟随套餐默认)。
|
||||
func initPackageExpiryBaseFields(resp *dto.PackageResponse, pkg *model.Package) {
|
||||
resp.DefaultExpiryBase = pkg.ExpiryBase
|
||||
resp.DefaultExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase)
|
||||
resp.EffectiveExpiryBase = pkg.ExpiryBase
|
||||
resp.EffectiveExpiryBaseName = ExpiryBaseName(pkg.ExpiryBase)
|
||||
}
|
||||
|
||||
func applyAllocationExpiryBase(resp *dto.PackageResponse, pkg *model.Package, allocation *model.ShopPackageAllocation) {
|
||||
resp.ExpiryBaseOverride = allocation.ExpiryBaseOverride
|
||||
resp.ExpiryBaseOverrideName = ExpiryBaseOverrideName(allocation.ExpiryBaseOverride)
|
||||
resp.EffectiveExpiryBase = EffectiveExpiryBase(pkg, allocation)
|
||||
resp.EffectiveExpiryBaseName = ExpiryBaseName(resp.EffectiveExpiryBase)
|
||||
}
|
||||
|
||||
// fillCommissionInfo 填充返佣信息到响应中
|
||||
func (s *Service) fillCommissionInfo(resp *dto.PackageResponse, seriesID uint, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) {
|
||||
seriesAllocation, hasAllocation := seriesAllocationMap[seriesID]
|
||||
|
||||
49
internal/service/package/terms.go
Normal file
49
internal/service/package/terms.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ValidateExpiryBaseOverride 校验显式提交的分配生效条件覆盖。
|
||||
func ValidateExpiryBaseOverride(value *string, submitted bool) (*string, error) {
|
||||
if !submitted {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "生效条件覆盖字段未提交,须显式传值(null 或合法枚举)")
|
||||
}
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *value != constants.PackageExpiryBaseFromActivation && *value != constants.PackageExpiryBaseFromPurchase {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "生效条件覆盖值非法,合法值为:购买即生效、实名激活时生效")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// EffectiveExpiryBase 返回套餐分配最终采用的生效条件。
|
||||
func EffectiveExpiryBase(pkg *model.Package, allocation *model.ShopPackageAllocation) string {
|
||||
if allocation != nil && allocation.ExpiryBaseOverride != nil {
|
||||
return *allocation.ExpiryBaseOverride
|
||||
}
|
||||
return pkg.ExpiryBase
|
||||
}
|
||||
|
||||
// ExpiryBaseName 返回生效条件中文名称。
|
||||
func ExpiryBaseName(value string) string {
|
||||
switch value {
|
||||
case constants.PackageExpiryBaseFromActivation:
|
||||
return "实名激活时生效"
|
||||
case constants.PackageExpiryBaseFromPurchase:
|
||||
return "购买即生效"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// ExpiryBaseOverrideName 返回覆盖值中文名称,空值表示跟随套餐默认。
|
||||
func ExpiryBaseOverrideName(value *string) string {
|
||||
if value == nil {
|
||||
return "跟随套餐默认"
|
||||
}
|
||||
return ExpiryBaseName(*value)
|
||||
}
|
||||
57
internal/service/package/terms_test.go
Normal file
57
internal/service/package/terms_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestValidateExpiryBaseOverride 验证覆盖字段必须显式提交且仅接受既定枚举。
|
||||
func TestValidateExpiryBaseOverride(t *testing.T) {
|
||||
fromPurchase := constants.PackageExpiryBaseFromPurchase
|
||||
invalid := "from_realname"
|
||||
tests := []struct {
|
||||
name string
|
||||
value *string
|
||||
submitted bool
|
||||
wantValue *string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "字段缺失", wantErr: true},
|
||||
{name: "跟随默认", submitted: true},
|
||||
{name: "购买即生效", value: &fromPurchase, submitted: true, wantValue: &fromPurchase},
|
||||
{name: "非法枚举", value: &invalid, submitted: true, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ValidateExpiryBaseOverride(test.value, test.submitted)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("错误状态不符合预期:%v", err)
|
||||
}
|
||||
if test.wantValue != nil && (got == nil || *got != *test.wantValue) {
|
||||
t.Fatalf("覆盖值不符合预期:%v", got)
|
||||
}
|
||||
if test.wantValue == nil && !test.wantErr && got != nil {
|
||||
t.Fatalf("期望跟随默认,实际为:%v", *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveExpiryBase 验证覆盖优先于套餐默认值。
|
||||
func TestEffectiveExpiryBase(t *testing.T) {
|
||||
pkg := &model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation}
|
||||
if got := EffectiveExpiryBase(pkg, nil); got != constants.PackageExpiryBaseFromActivation {
|
||||
t.Fatalf("无覆盖时应使用套餐默认值,实际为 %s", got)
|
||||
}
|
||||
override := constants.PackageExpiryBaseFromPurchase
|
||||
allocation := &model.ShopPackageAllocation{ExpiryBaseOverride: &override}
|
||||
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
|
||||
t.Fatalf("有覆盖时应使用覆盖值,实际为 %s", got)
|
||||
}
|
||||
pkg.ExpiryBase = constants.PackageExpiryBaseFromPurchase
|
||||
if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase {
|
||||
t.Fatalf("套餐默认值变化不应改变覆盖结果,实际为 %s", got)
|
||||
}
|
||||
}
|
||||
67
internal/service/package/usage_terms.go
Normal file
67
internal/service/package/usage_terms.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
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/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var historicalTermsFallbackCount atomic.Uint64
|
||||
|
||||
// ResolveUsageTerms 优先读取使用记录快照,仅对完整空快照的历史记录显式回退。
|
||||
func ResolveUsageTerms(usage *model.PackageUsage, pkg *model.Package, logger *zap.Logger) (packagedomain.TermsSnapshot, error) {
|
||||
terms := packagedomain.TermsSnapshotFromUsage(usage)
|
||||
if terms.IsValid() {
|
||||
return terms, nil
|
||||
}
|
||||
if !isEmptyHistoricalTerms(usage) {
|
||||
if logger != nil {
|
||||
logger.Error("套餐使用记录计时快照异常", zap.Uint("package_usage_id", usage.ID), zap.Uint("package_id", usage.PackageID))
|
||||
}
|
||||
return packagedomain.TermsSnapshot{}, errors.New(errors.CodeInternalError, "套餐使用记录计时快照异常")
|
||||
}
|
||||
fallback, err := packagedomain.ResolveTermsSnapshot(pkg, nil)
|
||||
if err != nil {
|
||||
return packagedomain.TermsSnapshot{}, err
|
||||
}
|
||||
historicalTermsFallbackCount.Add(1)
|
||||
if logger != nil {
|
||||
logger.Warn("历史套餐使用记录缺少计时快照,回退套餐当前配置",
|
||||
zap.Uint("package_usage_id", usage.ID), zap.Uint("package_id", usage.PackageID),
|
||||
zap.Uint64("historical_terms_fallback_count", historicalTermsFallbackCount.Load()))
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// HistoricalTermsFallbackCount 返回历史计时条款回退累计次数。
|
||||
func HistoricalTermsFallbackCount() uint64 {
|
||||
return historicalTermsFallbackCount.Load()
|
||||
}
|
||||
|
||||
// ResolveTermsFromTx 在给定事务内查询分配覆盖并解析计时条款快照。
|
||||
// 必须在事务内调用,确保分配读取与 PackageUsage 写入在同一连接,避免快照与提交值不一致。
|
||||
func ResolveTermsFromTx(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) {
|
||||
var allocation *model.ShopPackageAllocation
|
||||
if sellerShopID != nil && *sellerShopID > 0 {
|
||||
store := postgres.NewShopPackageAllocationStore(tx)
|
||||
found, err := store.GetByShopAndPackageForSystem(ctx, *sellerShopID, pkg.ID)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return packagedomain.TermsSnapshot{}, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐分配失败")
|
||||
}
|
||||
if err == nil {
|
||||
allocation = found
|
||||
}
|
||||
}
|
||||
return packagedomain.ResolveTermsSnapshot(pkg, allocation)
|
||||
}
|
||||
|
||||
func isEmptyHistoricalTerms(usage *model.PackageUsage) bool {
|
||||
return usage.ExpiryBaseSnapshot == "" && usage.CalendarTypeSnapshot == "" &&
|
||||
usage.DurationMonthsSnapshot == 0 && usage.DurationDaysSnapshot == 0
|
||||
}
|
||||
59
internal/service/package/usage_terms_test.go
Normal file
59
internal/service/package/usage_terms_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package packagepkg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestResolveUsageTerms 验证快照优先、历史完整空值回退和部分缺失拒绝。
|
||||
func TestResolveUsageTerms(t *testing.T) {
|
||||
pkg := &model.Package{
|
||||
ExpiryBase: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30,
|
||||
}
|
||||
usage := &model.PackageUsage{
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeNaturalMonth,
|
||||
DurationMonthsSnapshot: 12,
|
||||
}
|
||||
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
|
||||
if err != nil || terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.DurationMonths != 12 {
|
||||
t.Fatalf("应优先读取不可变快照:%+v, %v", terms, err)
|
||||
}
|
||||
|
||||
before := HistoricalTermsFallbackCount()
|
||||
historical := &model.PackageUsage{Model: usage.Model}
|
||||
terms, err = ResolveUsageTerms(historical, pkg, zap.NewNop())
|
||||
if err != nil || terms.DurationDays != 30 || HistoricalTermsFallbackCount() != before+1 {
|
||||
t.Fatalf("历史空快照应可观测回退:%+v, %v", terms, err)
|
||||
}
|
||||
|
||||
broken := &model.PackageUsage{ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase}
|
||||
if _, err = ResolveUsageTerms(broken, pkg, zap.NewNop()); err == nil {
|
||||
t.Fatal("部分缺失快照必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveUsageTermsKeepsPurchasedTerms 验证购买后修改套餐配置不改变已有使用记录语义。
|
||||
func TestResolveUsageTermsKeepsPurchasedTerms(t *testing.T) {
|
||||
usage := &model.PackageUsage{
|
||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase,
|
||||
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
|
||||
DurationDaysSnapshot: 90,
|
||||
}
|
||||
pkg := &model.Package{
|
||||
ExpiryBase: constants.PackageExpiryBaseFromActivation,
|
||||
CalendarType: constants.PackageCalendarTypeNaturalMonth,
|
||||
DurationMonths: 1,
|
||||
}
|
||||
terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("读取购买快照失败:%v", err)
|
||||
}
|
||||
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.CalendarType != constants.PackageCalendarTypeByDay || terms.DurationDays != 90 {
|
||||
t.Fatalf("套餐当前配置不应覆盖购买快照:%+v", terms)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package shop_package_batch_allocation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"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/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -18,6 +20,12 @@ type Service struct {
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
shopStore *postgres.ShopStore
|
||||
auditService AuditLogger
|
||||
}
|
||||
|
||||
// AuditLogger 敏感配置变更审计能力。
|
||||
type AuditLogger interface {
|
||||
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -26,6 +34,7 @@ func New(
|
||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
auditService AuditLogger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -33,61 +42,123 @@ func New(
|
||||
packageAllocationStore: packageAllocationStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
shopStore: shopStore,
|
||||
auditService: auditService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) error {
|
||||
// UpdateExpiryBase 修改单条套餐分配的生效条件覆盖。
|
||||
func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.UpdateAllocationExpiryBaseRequest) (*dto.ShopPackageAllocationTermsResponse, error) {
|
||||
override, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allocation, err := s.packageAllocationStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
pkg, err := s.packageStore.GetByID(ctx, allocation.PackageID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
before := allocation.ExpiryBaseOverride
|
||||
if !sameNullableString(before, override) {
|
||||
allocation.ExpiryBaseOverride = override
|
||||
allocation.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
if err := s.packageAllocationStore.Update(ctx, allocation); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐分配生效条件失败")
|
||||
}
|
||||
s.logExpiryBaseAudit(ctx, allocation, before, override)
|
||||
}
|
||||
return buildTermsResponse(pkg, allocation), nil
|
||||
}
|
||||
|
||||
func buildTermsResponse(pkg *model.Package, allocation *model.ShopPackageAllocation) *dto.ShopPackageAllocationTermsResponse {
|
||||
effective := packagepkg.EffectiveExpiryBase(pkg, allocation)
|
||||
return &dto.ShopPackageAllocationTermsResponse{
|
||||
ID: allocation.ID, DefaultExpiryBase: pkg.ExpiryBase,
|
||||
DefaultExpiryBaseName: packagepkg.ExpiryBaseName(pkg.ExpiryBase),
|
||||
ExpiryBaseOverride: allocation.ExpiryBaseOverride,
|
||||
ExpiryBaseOverrideName: packagepkg.ExpiryBaseOverrideName(allocation.ExpiryBaseOverride),
|
||||
EffectiveExpiryBase: effective, EffectiveExpiryBaseName: packagepkg.ExpiryBaseName(effective),
|
||||
}
|
||||
}
|
||||
|
||||
func sameNullableString(left, right *string) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func (s *Service) logExpiryBaseAudit(ctx context.Context, allocation *model.ShopPackageAllocation, before, after *string) {
|
||||
if s.auditService == nil {
|
||||
return
|
||||
}
|
||||
s.auditService.LogOperation(ctx, &model.AccountOperationLog{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperatorType: middleware.GetUserTypeFromContext(ctx),
|
||||
OperatorName: middleware.GetUsernameFromContext(ctx), OperationType: "update_package_expiry_base",
|
||||
OperationDesc: "修改套餐分配生效条件覆盖: " + strconv.FormatUint(uint64(allocation.ID), 10),
|
||||
BeforeData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": before},
|
||||
AfterData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": after},
|
||||
RequestID: middleware.GetRequestIDFromContext(ctx), IPAddress: middleware.GetIPFromContext(ctx), UserAgent: middleware.GetUserAgentFromContext(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) (*dto.BatchAllocatePackagesResponse, error) {
|
||||
expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
allocatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||
|
||||
if userType == constants.UserTypeAgent && allocatorShopID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
|
||||
return nil, errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
|
||||
}
|
||||
|
||||
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "目标店铺不存在")
|
||||
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取目标店铺失败")
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取目标店铺失败")
|
||||
}
|
||||
|
||||
if userType == constants.UserTypeAgent {
|
||||
if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
|
||||
return errors.New(errors.CodeForbidden, "只能分配给直属下级店铺")
|
||||
return nil, errors.New(errors.CodeForbidden, "只能分配给直属下级店铺")
|
||||
}
|
||||
}
|
||||
|
||||
packages, err := s.getEnabledPackagesBySeries(ctx, req.SeriesID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(packages) == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该系列下没有启用的套餐")
|
||||
return nil, errors.New(errors.CodeInvalidParam, "该系列下没有启用的套餐")
|
||||
}
|
||||
|
||||
// 检查目标店铺是否有该系列的分配
|
||||
seriesAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, req.SeriesID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInvalidParam, "目标店铺没有该系列的分配权限")
|
||||
return nil, errors.New(errors.CodeInvalidParam, "目标店铺没有该系列的分配权限")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询系列分配失败")
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询系列分配失败")
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := &dto.BatchAllocatePackagesResponse{TotalPackages: len(packages), Allocations: []dto.ShopPackageAllocationTermsResponse{}}
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txPkgAllocStore := postgres.NewShopPackageAllocationStore(tx)
|
||||
|
||||
for _, pkg := range packages {
|
||||
// 已存在该套餐的分配记录则跳过,避免唯一约束冲突
|
||||
_, existErr := txPkgAllocStore.GetByShopAndPackageForSystem(ctx, req.ShopID, pkg.ID)
|
||||
if existErr == nil {
|
||||
result.SkippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -97,23 +168,30 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
||||
}
|
||||
|
||||
allocation := &model.ShopPackageAllocation{
|
||||
BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID},
|
||||
ShopID: req.ShopID,
|
||||
PackageID: pkg.ID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: costPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID},
|
||||
ShopID: req.ShopID,
|
||||
PackageID: pkg.ID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: costPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &seriesAllocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
SeriesAllocationID: &seriesAllocation.ID,
|
||||
ExpiryBaseOverride: expiryBaseOverride,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建套餐分配失败")
|
||||
}
|
||||
result.Allocations = append(result.Allocations, *buildTermsResponse(pkg, allocation))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.AllocatedCount = len(result.Allocations)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) getEnabledPackagesBySeries(ctx context.Context, seriesID uint) ([]*model.Package, error) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"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/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -184,13 +185,20 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
|
||||
if pkg.IsGift {
|
||||
continue
|
||||
}
|
||||
effectiveBase := packagepkg.EffectiveExpiryBase(pkg, pa)
|
||||
packages = append(packages, dto.ShopSeriesGrantPackageItem{
|
||||
PackageID: pa.PackageID,
|
||||
PackageName: pkg.PackageName,
|
||||
PackageCode: pkg.PackageCode,
|
||||
CostPrice: pa.CostPrice,
|
||||
ShelfStatus: pa.ShelfStatus,
|
||||
Status: pa.Status,
|
||||
PackageID: pa.PackageID,
|
||||
PackageName: pkg.PackageName,
|
||||
PackageCode: pkg.PackageCode,
|
||||
CostPrice: pa.CostPrice,
|
||||
ShelfStatus: pa.ShelfStatus,
|
||||
Status: pa.Status,
|
||||
DefaultExpiryBase: pkg.ExpiryBase,
|
||||
DefaultExpiryBaseName: packagepkg.ExpiryBaseName(pkg.ExpiryBase),
|
||||
ExpiryBaseOverride: pa.ExpiryBaseOverride,
|
||||
ExpiryBaseOverrideName: packagepkg.ExpiryBaseOverrideName(pa.ExpiryBaseOverride),
|
||||
EffectiveExpiryBase: effectiveBase,
|
||||
EffectiveExpiryBaseName: packagepkg.ExpiryBaseName(effectiveBase),
|
||||
})
|
||||
}
|
||||
resp.Packages = packages
|
||||
@@ -201,6 +209,10 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
|
||||
// Create 创建系列授权
|
||||
// POST /api/admin/shop-series-grants
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) {
|
||||
expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||
operatorType := middleware.GetUserTypeFromContext(ctx)
|
||||
@@ -339,15 +351,16 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
}
|
||||
}
|
||||
pkgAlloc := &model.ShopPackageAllocation{
|
||||
ShopID: req.ShopID,
|
||||
PackageID: item.PackageID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: item.CostPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
ShopID: req.ShopID,
|
||||
PackageID: item.PackageID,
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: item.CostPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
ExpiryBaseOverride: expiryBaseOverride,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
}
|
||||
pkgAlloc.Creator = operatorID
|
||||
pkgAlloc.Updater = operatorID
|
||||
@@ -600,6 +613,10 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries
|
||||
// ManagePackages 管理授权套餐(新增/更新/删除)
|
||||
// PUT /api/admin/shop-series-grants/:id/packages
|
||||
func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (*dto.ShopSeriesGrantResponse, error) {
|
||||
expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||
|
||||
@@ -677,15 +694,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
}
|
||||
}
|
||||
pkgAlloc := &model.ShopPackageAllocation{
|
||||
ShopID: allocation.ShopID,
|
||||
PackageID: item.PackageID,
|
||||
AllocatorShopID: allocation.AllocatorShopID,
|
||||
CostPrice: item.CostPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
ShopID: allocation.ShopID,
|
||||
PackageID: item.PackageID,
|
||||
AllocatorShopID: allocation.AllocatorShopID,
|
||||
CostPrice: item.CostPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
ExpiryBaseOverride: expiryBaseOverride,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
}
|
||||
pkgAlloc.Creator = operatorID
|
||||
pkgAlloc.Updater = operatorID
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestPackageUsageRejectsNewEmptyTermsSnapshot 验证迁移约束拒绝上线后新增空计时快照。
|
||||
func TestPackageUsageRejectsNewEmptyTermsSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
usage := newTermsSnapshotTestUsage()
|
||||
if err := tx.Create(usage).Error; err == nil {
|
||||
t.Fatal("新增套餐使用记录缺少计时快照时必须被数据库拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackageUsageAcceptsCompleteTermsSnapshot 验证完整计时快照可与使用记录一次性落库。
|
||||
func TestPackageUsageAcceptsCompleteTermsSnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
usage := newTermsSnapshotTestUsage()
|
||||
usage.ExpiryBaseSnapshot = constants.PackageExpiryBaseFromPurchase
|
||||
usage.CalendarTypeSnapshot = constants.PackageCalendarTypeByDay
|
||||
usage.DurationDaysSnapshot = 30
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("完整计时快照应可落库:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus 验证历史空快照可继续状态流转。
|
||||
func TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("禁用快照校验触发器失败:%v", err)
|
||||
}
|
||||
usage := newTermsSnapshotTestUsage()
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
t.Fatalf("构造历史空快照记录失败:%v", err)
|
||||
}
|
||||
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||
t.Fatalf("恢复快照校验触发器失败:%v", err)
|
||||
}
|
||||
if err := tx.Model(&usage).Update("status", usage.Status).Error; err != nil {
|
||||
t.Fatalf("历史空快照记录应可继续更新状态:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTermsSnapshotTestUsage() *model.PackageUsage {
|
||||
unique := uint(time.Now().UnixNano() & 0x7fffffff)
|
||||
return &model.PackageUsage{
|
||||
OrderID: unique, OrderNo: "UR55-SNAPSHOT-TEST", PackageID: unique,
|
||||
UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique,
|
||||
DataLimitMB: 1, Status: constants.PackageUsageStatusPending,
|
||||
Priority: 1, PackageName: "UR55测试套餐", Generation: 1,
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AutoPurchasePayload 充值后自动购包任务载荷
|
||||
@@ -460,6 +462,10 @@ func (h *AutoPurchaseHandler) activatePackages(
|
||||
} else {
|
||||
return errors.New("无效的订单载体")
|
||||
}
|
||||
// 在查询既有记录前锁定载体,避免并发任务同时判定不存在而重复创建套餐使用记录。
|
||||
if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pkg := range packages {
|
||||
var existingUsage model.PackageUsage
|
||||
@@ -496,7 +502,11 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
carrierID uint,
|
||||
now time.Time,
|
||||
) error {
|
||||
_ = ctx
|
||||
terms, err := h.resolvePackageTerms(ctx, tx, pkg, order.SellerShopID)
|
||||
if err != nil {
|
||||
h.logger.Error("自动购包生成套餐计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -512,6 +522,13 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
var expiresAt time.Time
|
||||
var nextResetAt *time.Time
|
||||
var pendingRealnameActivation bool
|
||||
if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
|
||||
realnamed, realnameErr := h.isCarrierRealnamed(ctx, tx, carrierType, carrierID)
|
||||
if realnameErr != nil {
|
||||
return realnameErr
|
||||
}
|
||||
pendingRealnameActivation = !realnamed
|
||||
}
|
||||
|
||||
if hasCurrentMain {
|
||||
status = constants.PackageUsageStatusPending
|
||||
@@ -522,16 +539,17 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
Scan(&maxPriority)
|
||||
priority = maxPriority + 1
|
||||
} else {
|
||||
status = constants.PackageUsageStatusActive
|
||||
priority = 1
|
||||
activatedAt = now
|
||||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
||||
if pendingRealnameActivation {
|
||||
status = constants.PackageUsageStatusPending
|
||||
} else {
|
||||
status = constants.PackageUsageStatusActive
|
||||
activatedAt = now
|
||||
expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// REALNAME-02: 自动购包属于 C 端充值触发,不需要等实名(C 端已做前置实名检查)
|
||||
// ExpiryBase 仅影响后台囤货路径,此处无需判断
|
||||
|
||||
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
||||
retailAmount := order.TotalAmount
|
||||
usage := &model.PackageUsage{
|
||||
@@ -558,6 +576,7 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
terms.Apply(usage)
|
||||
|
||||
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
||||
usage.IotCardID = carrierID
|
||||
@@ -581,6 +600,30 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
}).Error
|
||||
}
|
||||
|
||||
// isCarrierRealnamed 查询自动购包载体是否已满足实名激活条件。
|
||||
func (h *AutoPurchaseHandler) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
|
||||
switch carrierType {
|
||||
case constants.AssetWalletResourceTypeIotCard, "card":
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Select("real_name_status").First(&card, carrierID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return card.RealNameStatus == constants.RealNameStatusVerified, nil
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
var count int64
|
||||
subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Select("iot_card_id").Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound)
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
default:
|
||||
return false, pkgerrors.New(pkgerrors.CodeInvalidParam, "无效的套餐载体类型")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AutoPurchaseHandler) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||
switch carrierType {
|
||||
case constants.AssetWalletResourceTypeIotCard, "card":
|
||||
@@ -603,7 +646,11 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
carrierID uint,
|
||||
now time.Time,
|
||||
) error {
|
||||
_ = ctx
|
||||
terms, err := h.resolvePackageTerms(ctx, tx, pkg, order.SellerShopID)
|
||||
if err != nil {
|
||||
h.logger.Error("自动购包生成加油包计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New("必须有主套餐才能购买加油包")
|
||||
@@ -649,6 +696,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
terms.Apply(usage)
|
||||
|
||||
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
||||
usage.IotCardID = carrierID
|
||||
@@ -659,6 +707,10 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
return tx.Create(usage).Error
|
||||
}
|
||||
|
||||
func (h *AutoPurchaseHandler) resolvePackageTerms(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) {
|
||||
return packagepkg.ResolveTermsFromTx(ctx, tx, pkg, sellerShopID)
|
||||
}
|
||||
|
||||
func parseLinkedPackageIDs(raw []byte) ([]uint, error) {
|
||||
var packageIDs []uint
|
||||
if len(raw) == 0 {
|
||||
|
||||
194
internal/task/auto_purchase_terms_integration_test.go
Normal file
194
internal/task/auto_purchase_terms_integration_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testIDMask 用于将纳秒时间戳截断为合法的 uint 主键范围,仅限测试用。
|
||||
const testIDMask = 0x7fffffff
|
||||
|
||||
// TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision 验证自动购包复用快照并遵守实名起算规则。
|
||||
func TestAutoPurchasePersistsTermsSnapshotsAndRealnameDecision(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
defaultBase string
|
||||
override *string
|
||||
realnameStatus int
|
||||
expectedBase string
|
||||
expectedStatus int
|
||||
expectedPending bool
|
||||
}{
|
||||
{name: "跟随默认等待实名", defaultBase: constants.PackageExpiryBaseFromActivation, realnameStatus: constants.RealNameStatusNotVerified, expectedBase: constants.PackageExpiryBaseFromActivation, expectedStatus: constants.PackageUsageStatusPending, expectedPending: true},
|
||||
{name: "覆盖购买即生效", defaultBase: constants.PackageExpiryBaseFromActivation, override: testutil.StringPointer(constants.PackageExpiryBaseFromPurchase), realnameStatus: constants.RealNameStatusNotVerified, expectedBase: constants.PackageExpiryBaseFromPurchase, expectedStatus: constants.PackageUsageStatusActive},
|
||||
{name: "覆盖实名但已实名", defaultBase: constants.PackageExpiryBaseFromPurchase, override: testutil.StringPointer(constants.PackageExpiryBaseFromActivation), realnameStatus: constants.RealNameStatusVerified, expectedBase: constants.PackageExpiryBaseFromActivation, expectedStatus: constants.PackageUsageStatusActive},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, testCase.realnameStatus)
|
||||
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, testCase.defaultBase, 31)
|
||||
shopID := uint(time.Now().UnixNano() & testIDMask)
|
||||
if testCase.override != nil {
|
||||
allocation := &model.ShopPackageAllocation{ShopID: shopID, PackageID: pkg.ID, CostPrice: 1, RetailPrice: 2, Status: constants.StatusEnabled, ShelfStatus: 1, ExpiryBaseOverride: testCase.override}
|
||||
if err := tx.Create(allocation).Error; err != nil {
|
||||
t.Fatalf("创建套餐覆盖配置失败:%v", err)
|
||||
}
|
||||
}
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, &shopID)
|
||||
if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("自动购包创建主套餐失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != testCase.expectedBase || usage.CalendarTypeSnapshot != constants.PackageCalendarTypeByDay || usage.DurationDaysSnapshot != 31 || usage.Status != testCase.expectedStatus || usage.PendingRealnameActivation != testCase.expectedPending {
|
||||
t.Fatalf("自动购包快照或实名决策错误:%+v", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseDefaultFromPurchaseActivatesImmediatelyWithoutRealname 验证套餐默认为 from_purchase 时自动购包不需要实名即激活。
|
||||
func TestAutoPurchaseDefaultFromPurchaseActivatesImmediatelyWithoutRealname(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
// 卡未实名,但套餐默认 from_purchase,应立即激活
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusNotVerified)
|
||||
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 14)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, nil)
|
||||
if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("from_purchase 默认值自动购包失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 14 {
|
||||
t.Fatalf("from_purchase 默认值快照错误:%+v", usage)
|
||||
}
|
||||
if usage.Status != constants.PackageUsageStatusActive || usage.PendingRealnameActivation {
|
||||
t.Fatalf("from_purchase 默认值未实名仍应立即激活:status=%d pendingRealname=%v", usage.Status, usage.PendingRealnameActivation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseMainAndAddonAreIdempotentByBusinessFact 验证主套餐、加油包和重复消费依赖数据库事实去重。
|
||||
func TestAutoPurchaseMainAndAddonAreIdempotentByBusinessFact(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified)
|
||||
formal := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
addon := createAutoPurchasePackage(t, tx, constants.PackageTypeAddon, constants.PackageExpiryBaseFromActivation, 7)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
formalOrder := newAutoPurchaseOrder(card.ID, nil)
|
||||
addonOrder := newAutoPurchaseOrder(card.ID, nil)
|
||||
now := time.Now()
|
||||
if err := handler.activatePackages(context.Background(), tx, formalOrder, []*model.Package{formal}, now); err != nil {
|
||||
t.Fatalf("首次自动创建主套餐失败:%v", err)
|
||||
}
|
||||
if err := handler.activatePackages(context.Background(), tx, formalOrder, []*model.Package{formal}, now); err != nil {
|
||||
t.Fatalf("重复消费应按业务事实幂等:%v", err)
|
||||
}
|
||||
if err := handler.activatePackages(context.Background(), tx, addonOrder, []*model.Package{addon}, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("自动创建加油包失败:%v", err)
|
||||
}
|
||||
var usages []model.PackageUsage
|
||||
if err := tx.Where("order_id IN ?", []uint{formalOrder.ID, addonOrder.ID}).Order("order_id").Find(&usages).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
if len(usages) != 2 {
|
||||
t.Fatalf("重复消费只应形成一组记录,实际 %d", len(usages))
|
||||
}
|
||||
for _, usage := range usages {
|
||||
if usage.ExpiryBaseSnapshot == "" || usage.CalendarTypeSnapshot == "" {
|
||||
t.Fatalf("自动购包不得产生空快照:%+v", usage)
|
||||
}
|
||||
}
|
||||
if usages[1].MasterUsageID == nil {
|
||||
t.Fatalf("加油包必须关联主套餐:%+v", usages[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseTransactionRollbackOnInvalidAddonTerms 验证同一事务中后续套餐失败时不留下部分使用记录。
|
||||
func TestAutoPurchaseTransactionRollbackOnInvalidAddonTerms(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified)
|
||||
formal := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
invalidAddon := createAutoPurchasePackage(t, tx, constants.PackageTypeAddon, "invalid", 7)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, nil)
|
||||
err := tx.Transaction(func(inner *gorm.DB) error {
|
||||
return handler.activatePackages(context.Background(), inner, order, []*model.Package{formal, invalidAddon}, time.Now())
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("非法加油包计时条款必须使业务事务失败")
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.PackageUsage{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("事务失败不应留下部分主套餐:count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoPurchaseConfigChangeDoesNotModifySnapshot 验证自动购包后修改套餐配置不改变已有使用记录的计时快照。
|
||||
func TestAutoPurchaseConfigChangeDoesNotModifySnapshot(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
card := createAutoPurchaseCard(t, tx, constants.RealNameStatusVerified)
|
||||
pkg := createAutoPurchasePackage(t, tx, constants.PackageTypeFormal, constants.PackageExpiryBaseFromPurchase, 30)
|
||||
handler := &AutoPurchaseHandler{db: tx, shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(tx), logger: zap.NewNop()}
|
||||
order := newAutoPurchaseOrder(card.ID, nil)
|
||||
if err := handler.activateMainPackage(context.Background(), tx, order, pkg, constants.AssetWalletResourceTypeIotCard, card.ID, time.Now()); err != nil {
|
||||
t.Fatalf("自动购包创建主套餐失败:%v", err)
|
||||
}
|
||||
// 购买后修改套餐的生效条件和时长
|
||||
if err := tx.Model(pkg).Updates(map[string]any{"expiry_base": constants.PackageExpiryBaseFromActivation, "duration_days": 99}).Error; err != nil {
|
||||
t.Fatalf("修改套餐配置失败:%v", err)
|
||||
}
|
||||
var usage model.PackageUsage
|
||||
if err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&usage).Error; err != nil {
|
||||
t.Fatalf("查询自动购包记录失败:%v", err)
|
||||
}
|
||||
// 快照应保留购买时的值,不随套餐修改而变化
|
||||
if usage.ExpiryBaseSnapshot != constants.PackageExpiryBaseFromPurchase || usage.DurationDaysSnapshot != 30 {
|
||||
t.Fatalf("套餐配置变更不应影响已购买快照:expiryBase=%s days=%d", usage.ExpiryBaseSnapshot, usage.DurationDaysSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func createAutoPurchaseCard(t *testing.T, tx *gorm.DB, realnameStatus int) *model.IotCard {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano()%100000000000000000, 10)
|
||||
iccid := "88" + suffix
|
||||
if len(iccid) < 19 {
|
||||
iccid += "0000000000000000000"[:19-len(iccid)]
|
||||
}
|
||||
iccid = iccid[:19]
|
||||
card := &model.IotCard{ICCID: iccid, ICCID19: iccid, CarrierID: 1, RealNameStatus: realnameStatus, AssetStatus: constants.AssetStatusInStock, Generation: 1}
|
||||
if err := tx.Create(card).Error; err != nil {
|
||||
t.Fatalf("创建自动购包测试卡失败:%v", err)
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
func createAutoPurchasePackage(t *testing.T, tx *gorm.DB, packageType, expiryBase string, durationDays int) *model.Package {
|
||||
t.Helper()
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
pkg := &model.Package{PackageCode: "UR55-AUTO-" + suffix, PackageName: "UR55自动购包测试套餐", PackageType: packageType, DurationMonths: 1, DurationDays: durationDays, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: expiryBase, Status: constants.StatusEnabled, ShelfStatus: 1, DataResetCycle: "monthly"}
|
||||
if err := tx.Create(pkg).Error; err != nil {
|
||||
t.Fatalf("创建自动购包测试套餐失败:%v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func newAutoPurchaseOrder(cardID uint, sellerShopID *uint) *model.Order {
|
||||
orderID := uint(time.Now().UnixNano() & testIDMask)
|
||||
return &model.Order{Model: gorm.Model{ID: orderID}, OrderNo: "UR55-AUTO-" + strconv.FormatUint(uint64(orderID), 10), OrderType: model.OrderTypeSingleCard, IotCardID: &cardID, SellerShopID: sellerShopID, TotalAmount: 100, Generation: 1}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package testutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -37,3 +39,28 @@ func NewPostgresTransaction(t *testing.T) *gorm.DB {
|
||||
})
|
||||
return tx
|
||||
}
|
||||
|
||||
// NewRedisClient 创建真实 Redis 测试客户端并在测试结束时关闭。
|
||||
func NewRedisClient(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_REDIS_ADDRESS") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 Redis 的集成测试")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试配置失败:%v", err)
|
||||
}
|
||||
client, err := database.NewRedisClient(database.RedisConfig{
|
||||
Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB, PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout,
|
||||
}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("连接 Redis 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
// StringPointer 返回字符串值的指针,供测试用例构造 nullable string 参数。
|
||||
func StringPointer(s string) *string { return &s }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 回滚套餐分配生效条件覆盖字段及其枚举约束。
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP CONSTRAINT IF EXISTS chk_shop_package_allocation_expiry_base_override;
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS expiry_base_override;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 为套餐分配增加可选生效条件覆盖,NULL 表示跟随套餐默认值。
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN expiry_base_override VARCHAR(30);
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override IS '到期时间基准覆盖,NULL表示跟随套餐默认值';
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD CONSTRAINT chk_shop_package_allocation_expiry_base_override
|
||||
CHECK (expiry_base_override IS NULL OR expiry_base_override IN ('from_purchase', 'from_activation'));
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 回滚套餐使用记录的购买计时条款快照字段。
|
||||
DROP TRIGGER IF EXISTS trg_validate_package_usage_terms_snapshot ON tb_package_usage;
|
||||
DROP FUNCTION IF EXISTS validate_package_usage_terms_snapshot();
|
||||
|
||||
ALTER TABLE tb_package_usage
|
||||
DROP COLUMN IF EXISTS duration_days_snapshot,
|
||||
DROP COLUMN IF EXISTS duration_months_snapshot,
|
||||
DROP COLUMN IF EXISTS calendar_type_snapshot,
|
||||
DROP COLUMN IF EXISTS expiry_base_snapshot;
|
||||
32
migrations/000163_add_package_usage_terms_snapshots.up.sql
Normal file
32
migrations/000163_add_package_usage_terms_snapshots.up.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- 为套餐使用记录增加购买时不可变计时条款快照;空值和零值保留为历史未快照标记。
|
||||
ALTER TABLE tb_package_usage
|
||||
ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '',
|
||||
ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '',
|
||||
ADD COLUMN duration_months_snapshot INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN duration_days_snapshot INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot IS '购买时生效条件快照,空字符串表示历史未快照';
|
||||
COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot IS '购买时周期类型快照,空字符串表示历史未快照';
|
||||
COMMENT ON COLUMN tb_package_usage.duration_months_snapshot IS '购买时月数快照,0表示未使用或历史未快照';
|
||||
COMMENT ON COLUMN tb_package_usage.duration_days_snapshot IS '购买时天数快照,0表示未使用或历史未快照';
|
||||
|
||||
-- 触发器只校验新记录及快照字段变更,历史空快照仍可更新状态等非快照字段。
|
||||
CREATE OR REPLACE FUNCTION validate_package_usage_terms_snapshot()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.expiry_base_snapshot NOT IN ('from_purchase', 'from_activation')
|
||||
OR NOT (
|
||||
(NEW.calendar_type_snapshot = 'natural_month' AND NEW.duration_months_snapshot > 0 AND NEW.duration_days_snapshot = 0)
|
||||
OR (NEW.calendar_type_snapshot = 'by_day' AND NEW.duration_days_snapshot > 0 AND NEW.duration_months_snapshot = 0)
|
||||
) THEN
|
||||
RAISE EXCEPTION '套餐使用记录计时快照不完整或无效';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_validate_package_usage_terms_snapshot
|
||||
BEFORE INSERT OR UPDATE OF expiry_base_snapshot, calendar_type_snapshot, duration_months_snapshot, duration_days_snapshot
|
||||
ON tb_package_usage
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_package_usage_terms_snapshot();
|
||||
@@ -143,6 +143,12 @@ const (
|
||||
PackageCalendarTypeByDay = "by_day" // 按天周期
|
||||
)
|
||||
|
||||
// 套餐生效条件常量
|
||||
const (
|
||||
PackageExpiryBaseFromActivation = "from_activation" // 实名激活时生效
|
||||
PackageExpiryBaseFromPurchase = "from_purchase" // 购买即生效
|
||||
)
|
||||
|
||||
// 套餐流量重置周期常量
|
||||
const (
|
||||
PackageDataResetDaily = "daily" // 每日重置
|
||||
|
||||
Reference in New Issue
Block a user