实现套餐生效条件覆盖与购买快照
This commit is contained in:
@@ -236,6 +236,7 @@ default:
|
|||||||
- **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md)
|
- **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md)
|
||||||
- **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
|
- **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
|
||||||
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
|
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](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)
|
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)
|
||||||
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
|
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
|
||||||
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
|
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
|
||||||
|
|||||||
@@ -2011,6 +2011,10 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
DtoBatchAllocatePackagesRequest:
|
DtoBatchAllocatePackagesRequest:
|
||||||
properties:
|
properties:
|
||||||
|
expiry_base_override:
|
||||||
|
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
one_time_commission_amount:
|
one_time_commission_amount:
|
||||||
description: 该代理能拿到的一次性佣金(分)
|
description: 该代理能拿到的一次性佣金(分)
|
||||||
minimum: 0
|
minimum: 0
|
||||||
@@ -2030,6 +2034,24 @@ components:
|
|||||||
- shop_id
|
- shop_id
|
||||||
- series_id
|
- series_id
|
||||||
type: object
|
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:
|
DtoBatchPricingSkipped:
|
||||||
properties:
|
properties:
|
||||||
allocation_id:
|
allocation_id:
|
||||||
@@ -3845,6 +3867,10 @@ components:
|
|||||||
description: 是否启用代理强充
|
description: 是否启用代理强充
|
||||||
nullable: true
|
nullable: true
|
||||||
type: boolean
|
type: boolean
|
||||||
|
expiry_base_override:
|
||||||
|
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
force_recharge_amount:
|
force_recharge_amount:
|
||||||
description: 代理强充金额(分)
|
description: 代理强充金额(分)
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -6085,6 +6111,10 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
DtoManageGrantPackagesParams:
|
DtoManageGrantPackagesParams:
|
||||||
properties:
|
properties:
|
||||||
|
expiry_base_override:
|
||||||
|
description: 新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
packages:
|
packages:
|
||||||
description: 套餐操作列表
|
description: 套餐操作列表
|
||||||
items:
|
items:
|
||||||
@@ -6605,6 +6635,12 @@ components:
|
|||||||
data_reset_cycle:
|
data_reset_cycle:
|
||||||
description: 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)
|
description: 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)
|
||||||
type: string
|
type: string
|
||||||
|
default_expiry_base:
|
||||||
|
description: 套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||||
|
type: string
|
||||||
|
default_expiry_base_name:
|
||||||
|
description: 套餐默认生效条件名称(中文)
|
||||||
|
type: string
|
||||||
duration_days:
|
duration_days:
|
||||||
description: 套餐天数(calendar_type=by_day时有值)
|
description: 套餐天数(calendar_type=by_day时有值)
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -6612,6 +6648,12 @@ components:
|
|||||||
duration_months:
|
duration_months:
|
||||||
description: 套餐时长(月数)
|
description: 套餐时长(月数)
|
||||||
type: integer
|
type: integer
|
||||||
|
effective_expiry_base:
|
||||||
|
description: 最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||||
|
type: string
|
||||||
|
effective_expiry_base_name:
|
||||||
|
description: 最终生效条件名称(中文)
|
||||||
|
type: string
|
||||||
effective_retail_price:
|
effective_retail_price:
|
||||||
description: 生效零售价(分,仅销售链路展示使用)
|
description: 生效零售价(分,仅销售链路展示使用)
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -6622,6 +6664,13 @@ components:
|
|||||||
expiry_base:
|
expiry_base:
|
||||||
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
||||||
type: string
|
type: string
|
||||||
|
expiry_base_override:
|
||||||
|
description: 分配生效条件覆盖,null 表示跟随套餐默认值
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
expiry_base_override_name:
|
||||||
|
description: 分配生效条件覆盖名称(中文)
|
||||||
|
type: string
|
||||||
id:
|
id:
|
||||||
description: 套餐ID
|
description: 套餐ID
|
||||||
minimum: 0
|
minimum: 0
|
||||||
@@ -7991,6 +8040,32 @@ components:
|
|||||||
description: 总记录数
|
description: 总记录数
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
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:
|
DtoShopPageResult:
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
@@ -8153,6 +8228,25 @@ components:
|
|||||||
cost_price:
|
cost_price:
|
||||||
description: 成本价(分)
|
description: 成本价(分)
|
||||||
type: integer
|
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:
|
package_code:
|
||||||
description: 套餐编码
|
description: 套餐编码
|
||||||
type: string
|
type: string
|
||||||
@@ -8610,6 +8704,13 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
DtoUpdateAllocationExpiryBaseParams:
|
||||||
|
properties:
|
||||||
|
expiry_base_override:
|
||||||
|
description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
DtoUpdateAssetPackageExpiresAtRequest:
|
DtoUpdateAssetPackageExpiresAtRequest:
|
||||||
properties:
|
properties:
|
||||||
expires_at:
|
expires_at:
|
||||||
@@ -22325,6 +22426,78 @@ paths:
|
|||||||
summary: 移除权限
|
summary: 移除权限
|
||||||
tags:
|
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:
|
/api/admin/shop-package-batch-allocations:
|
||||||
post:
|
post:
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -22333,6 +22506,32 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/DtoBatchAllocatePackagesRequest'
|
$ref: '#/components/schemas/DtoBatchAllocatePackagesRequest'
|
||||||
responses:
|
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":
|
"400":
|
||||||
content:
|
content:
|
||||||
application/json:
|
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"
|
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
||||||
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
||||||
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
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"
|
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||||
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
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/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/payment"
|
"github.com/break/junhong_cmp_fiber/pkg/payment"
|
||||||
@@ -265,7 +265,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
Package: packageService,
|
Package: packageService,
|
||||||
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
||||||
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, 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),
|
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),
|
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
|
||||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/domain/package/terms_test.go
Normal file
35
internal/domain/package/terms_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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) {
|
||||||
|
override := constants.PackageExpiryBaseFromPurchase
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pkg model.Package
|
||||||
|
allocation *model.ShopPackageAllocation
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "自然月默认", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeNaturalMonth, DurationMonths: 12}},
|
||||||
|
{name: "按天覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &override}},
|
||||||
|
{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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
@@ -23,10 +26,40 @@ func (h *ShopPackageBatchAllocationHandler) BatchAllocate(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
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 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func (h *ShopSeriesGrantHandler) Create(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
|
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
|
||||||
|
|
||||||
result, err := h.service.Create(c.UserContext(), &req)
|
result, err := h.service.Create(c.UserContext(), &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,7 +84,6 @@ func (h *ShopSeriesGrantHandler) Update(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.service.Update(c.UserContext(), uint(id), &req)
|
result, err := h.service.Update(c.UserContext(), uint(id), &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -105,6 +105,7 @@ func (h *ShopSeriesGrantHandler) ManagePackages(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
|
req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override")
|
||||||
|
|
||||||
result, err := h.service.ManagePackages(c.UserContext(), uint(id), &req)
|
result, err := h.service.ManagePackages(c.UserContext(), uint(id), &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ type PackageResponse struct {
|
|||||||
DurationDays *int `json:"duration_days,omitempty" description:"套餐天数(calendar_type=by_day时有值)"`
|
DurationDays *int `json:"duration_days,omitempty" description:"套餐天数(calendar_type=by_day时有值)"`
|
||||||
DataResetCycle string `json:"data_reset_cycle" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"`
|
DataResetCycle string `json:"data_reset_cycle" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"`
|
||||||
ExpiryBase string `json:"expiry_base" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
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 更新套餐聚合参数
|
// UpdatePackageParams 更新套餐聚合参数
|
||||||
|
|||||||
@@ -12,12 +12,37 @@ type BatchAllocatePackagesRequest struct {
|
|||||||
SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"`
|
SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"`
|
||||||
PriceAdjustment *PriceAdjustment `json:"price_adjustment" validate:"omitempty" description:"可选加价配置"`
|
PriceAdjustment *PriceAdjustment `json:"price_adjustment" validate:"omitempty" description:"可选加价配置"`
|
||||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount" validate:"omitempty,min=0" minimum:"0" 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:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchAllocatePackagesResponse 批量分配套餐响应
|
// BatchAllocatePackagesResponse 批量分配套餐响应
|
||||||
type BatchAllocatePackagesResponse struct {
|
type BatchAllocatePackagesResponse struct {
|
||||||
TotalPackages int `json:"total_packages" description:"总套餐数"`
|
TotalPackages int `json:"total_packages" description:"总套餐数"`
|
||||||
AllocatedCount int `json:"allocated_count" description:"成功分配数量"`
|
AllocatedCount int `json:"allocated_count" description:"成功分配数量"`
|
||||||
SkippedCount int `json:"skipped_count" description:"跳过数量(已存在)"`
|
SkippedCount int `json:"skipped_count" description:"跳过数量(已存在)"`
|
||||||
PackageIDs []uint `json:"package_ids" description:"分配的套餐ID列表"`
|
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:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 授权套餐详情(响应中的套餐信息)
|
// ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息)
|
||||||
type ShopSeriesGrantPackageItem struct {
|
type ShopSeriesGrantPackageItem struct {
|
||||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||||
PackageName string `json:"package_name" description:"套餐名称"`
|
PackageName string `json:"package_name" description:"套餐名称"`
|
||||||
PackageCode string `json:"package_code" description:"套餐编码"`
|
PackageCode string `json:"package_code" description:"套餐编码"`
|
||||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||||
ShelfStatus int `json:"shelf_status" description:"上架状态 1-上架 2-下架"`
|
ShelfStatus int `json:"shelf_status" description:"上架状态 1-上架 2-下架"`
|
||||||
Status int `json:"status" description:"分配状态 0=禁用 1=启用"`
|
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 全局配置)
|
// GrantCommissionTierItem 梯度佣金档位(operator/dimension/stat_scope 仅出现在响应中,来自 PackageSeries 全局配置)
|
||||||
@@ -57,6 +63,8 @@ type CreateShopSeriesGrantRequest struct {
|
|||||||
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"`
|
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"`
|
||||||
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"`
|
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"`
|
||||||
Packages []GrantPackageItem `json:"packages,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:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateShopSeriesGrantRequest 更新系列授权请求
|
// UpdateShopSeriesGrantRequest 更新系列授权请求
|
||||||
@@ -69,7 +77,9 @@ type UpdateShopSeriesGrantRequest struct {
|
|||||||
|
|
||||||
// ManageGrantPackagesRequest 管理授权套餐请求
|
// ManageGrantPackagesRequest 管理授权套餐请求
|
||||||
type ManageGrantPackagesRequest struct {
|
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:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShopSeriesGrantListRequest 系列授权列表查询请求
|
// 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"`
|
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"`
|
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"`
|
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"`
|
ActivatedAt *time.Time `gorm:"column:activated_at;comment:套餐生效时间" json:"activated_at"`
|
||||||
ExpiresAt *time.Time `gorm:"column:expires_at;comment:套餐过期时间" json:"expires_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"`
|
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"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ShopPackageAllocation 店铺套餐分配模型
|
||||||
|
// 记录套餐授权、价格及购买生效条件覆盖配置。
|
||||||
type ShopPackageAllocation struct {
|
type ShopPackageAllocation struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
BaseModel `gorm:"embedded"`
|
BaseModel `gorm:"embedded"`
|
||||||
ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ func registerShopPackageBatchAllocationRoutes(router fiber.Router, handler *admi
|
|||||||
Summary: "批量分配套餐",
|
Summary: "批量分配套餐",
|
||||||
Tags: []string{"批量套餐分配"},
|
Tags: []string{"批量套餐分配"},
|
||||||
Input: new(dto.BatchAllocatePackagesRequest),
|
Input: new(dto.BatchAllocatePackagesRequest),
|
||||||
Output: nil,
|
Output: new(dto.BatchAllocatePackagesResponse),
|
||||||
Auth: true,
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
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: 主套餐激活逻辑
|
// 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 {
|
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, 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 {
|
if err := s.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -2252,19 +2258,17 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
|||||||
priority = 1
|
priority = 1
|
||||||
activatedAt = now
|
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 + 实名状态)
|
// REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态)
|
||||||
// 查询卡类型(行业卡永远直接激活,不等实名)
|
// 查询卡类型(行业卡永远直接激活,不等实名)
|
||||||
// 同时查询载体当前实名状态:已实名则跳过 pending,直接激活
|
// 同时查询载体当前实名状态:已实名则跳过 pending,直接激活
|
||||||
var cardCategory string
|
|
||||||
var currentlyRealnamed bool
|
var currentlyRealnamed bool
|
||||||
if carrierType == "iot_card" {
|
if carrierType == "iot_card" {
|
||||||
var card model.IotCard
|
var card model.IotCard
|
||||||
if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil {
|
if err := tx.Select("real_name_status").First(&card, carrierID).Error; err == nil {
|
||||||
cardCategory = card.CardCategory
|
|
||||||
currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified
|
currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified
|
||||||
}
|
}
|
||||||
// err != nil 时 currentlyRealnamed 保守默认 false,不阻断购买流程
|
// err != nil 时 currentlyRealnamed 保守默认 false,不阻断购买流程
|
||||||
@@ -2285,9 +2289,9 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
|||||||
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
|
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
|
||||||
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
||||||
|
|
||||||
if cardCategory != "industry" && !isCEndPurchase {
|
if !isCEndPurchase {
|
||||||
// 后台囤货路径:按 expiry_base 决定是否等实名
|
// 后台囤货路径:按 expiry_base 决定是否等实名
|
||||||
if pkg.ExpiryBase != "from_purchase" {
|
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase {
|
||||||
if currentlyRealnamed {
|
if currentlyRealnamed {
|
||||||
s.logger.Info("购买时载体已实名,直接激活套餐",
|
s.logger.Info("购买时载体已实名,直接激活套餐",
|
||||||
zap.String("carrier_type", carrierType),
|
zap.String("carrier_type", carrierType),
|
||||||
@@ -2303,7 +2307,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
|||||||
}
|
}
|
||||||
// from_purchase:立即激活,status 已为 Active,保持不变
|
// from_purchase:立即激活,status 已为 Active,保持不变
|
||||||
}
|
}
|
||||||
// 行业卡或 C 端购买:直接激活,status 已为 Active,保持不变
|
// C 端购买已完成实名前置校验,直接激活。
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建套餐使用记录
|
// 创建套餐使用记录
|
||||||
@@ -2332,6 +2336,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
|||||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
PackageIsGift: pkg.IsGift,
|
PackageIsGift: pkg.IsGift,
|
||||||
}
|
}
|
||||||
|
terms.Apply(usage)
|
||||||
|
|
||||||
if carrierType == "iot_card" {
|
if carrierType == "iot_card" {
|
||||||
usage.IotCardID = carrierID
|
usage.IotCardID = carrierID
|
||||||
@@ -2362,6 +2367,11 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
|||||||
|
|
||||||
// activateAddonPackage 任务 8.5-8.7: 加油包激活逻辑
|
// 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 {
|
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, pkg, order.SellerShopID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("生成加油包计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
// 任务 8.5-8.6: 检查是否有可挂靠主套餐(待生效、未过期的生效中或已用完)
|
// 任务 8.5-8.6: 检查是否有可挂靠主套餐(待生效、未过期的生效中或已用完)
|
||||||
mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now)
|
mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now)
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
@@ -2421,6 +2431,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
|||||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
PackageIsGift: pkg.IsGift,
|
PackageIsGift: pkg.IsGift,
|
||||||
}
|
}
|
||||||
|
terms.Apply(usage)
|
||||||
|
|
||||||
if carrierType == "iot_card" {
|
if carrierType == "iot_card" {
|
||||||
usage.IotCardID = carrierID
|
usage.IotCardID = carrierID
|
||||||
@@ -2436,6 +2447,20 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolvePackageTerms(ctx context.Context, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) {
|
||||||
|
var allocation *model.ShopPackageAllocation
|
||||||
|
if sellerShopID != nil && *sellerShopID > 0 {
|
||||||
|
found, err := s.shopPackageAllocationStore.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 (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
|
func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
|
||||||
if s.queueClient == nil {
|
if s.queueClient == nil {
|
||||||
s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID))
|
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 创建时间;否则用实名触发当前时刻
|
// from_purchase:购买时起算,用 PackageUsage 创建时间;否则用实名触发当前时刻
|
||||||
var activatedAt time.Time
|
var activatedAt time.Time
|
||||||
if pkg.ExpiryBase == "from_purchase" {
|
if terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
|
||||||
activatedAt = usage.CreatedAt
|
activatedAt = usage.CreatedAt
|
||||||
} else {
|
} else {
|
||||||
activatedAt = now
|
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{}{
|
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 {
|
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):套餐已购买但资产未实名,
|
// ExpiryBase=from_purchase 只用于"等待实名激活"场景(REALNAME-04):套餐已购买但资产未实名,
|
||||||
// 计时基准按购买时间算,实名只是解锁使用权。此函数同时被"前一个主套餐到期后排队顺延"场景复用,
|
// 计时基准按购买时间算,实名只是解锁使用权。此函数同时被"前一个主套餐到期后排队顺延"场景复用,
|
||||||
// 这种情况下 usage.PendingRealnameActivation 为 false,不应该套用购买时间,否则排队等待的天数会
|
// 这种情况下 usage.PendingRealnameActivation 为 false,不应该套用购买时间,否则排队等待的天数会
|
||||||
// 从到期时间里被扣掉。只有当这条记录确实是因为等实名才被搁置时,才按 ExpiryBase 选基准。
|
// 从到期时间里被扣掉。只有当这条记录确实是因为等实名才被搁置时,才按 ExpiryBase 选基准。
|
||||||
var activatedAt time.Time
|
var activatedAt time.Time
|
||||||
if usage.PendingRealnameActivation && pkg.ExpiryBase == "from_purchase" {
|
if usage.PendingRealnameActivation && terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase {
|
||||||
activatedAt = usage.CreatedAt
|
activatedAt = usage.CreatedAt
|
||||||
} else {
|
} else {
|
||||||
activatedAt = now
|
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{}{
|
updates := map[string]interface{}{
|
||||||
|
|||||||
@@ -101,18 +101,18 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
|||||||
}
|
}
|
||||||
|
|
||||||
pkg := &model.Package{
|
pkg := &model.Package{
|
||||||
PackageCode: req.PackageCode,
|
PackageCode: req.PackageCode,
|
||||||
PackageName: req.PackageName,
|
PackageName: req.PackageName,
|
||||||
PackageType: req.PackageType,
|
PackageType: req.PackageType,
|
||||||
IsGift: req.IsGift,
|
IsGift: req.IsGift,
|
||||||
DurationMonths: req.DurationMonths,
|
DurationMonths: req.DurationMonths,
|
||||||
CostPrice: req.CostPrice,
|
CostPrice: req.CostPrice,
|
||||||
PriceConfigStatus: priceConfigStatus,
|
PriceConfigStatus: priceConfigStatus,
|
||||||
SuggestedRetailPrice: storedRetailPrice,
|
SuggestedRetailPrice: storedRetailPrice,
|
||||||
EnableVirtualData: req.EnableVirtualData,
|
EnableVirtualData: req.EnableVirtualData,
|
||||||
CalendarType: calendarType,
|
CalendarType: calendarType,
|
||||||
Status: constants.StatusEnabled,
|
Status: constants.StatusEnabled,
|
||||||
ShelfStatus: 2,
|
ShelfStatus: 2,
|
||||||
}
|
}
|
||||||
if req.SeriesID != nil {
|
if req.SeriesID != nil {
|
||||||
pkg.SeriesID = *req.SeriesID
|
pkg.SeriesID = *req.SeriesID
|
||||||
@@ -607,29 +607,33 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := &dto.PackageResponse{
|
resp := &dto.PackageResponse{
|
||||||
ID: pkg.ID,
|
ID: pkg.ID,
|
||||||
PackageCode: pkg.PackageCode,
|
PackageCode: pkg.PackageCode,
|
||||||
PackageName: pkg.PackageName,
|
PackageName: pkg.PackageName,
|
||||||
SeriesID: seriesID,
|
SeriesID: seriesID,
|
||||||
PackageType: pkg.PackageType,
|
PackageType: pkg.PackageType,
|
||||||
IsGift: pkg.IsGift,
|
IsGift: pkg.IsGift,
|
||||||
DurationMonths: pkg.DurationMonths,
|
DurationMonths: pkg.DurationMonths,
|
||||||
RealDataMB: pkg.RealDataMB,
|
RealDataMB: pkg.RealDataMB,
|
||||||
VirtualDataMB: pkg.VirtualDataMB,
|
VirtualDataMB: pkg.VirtualDataMB,
|
||||||
EnableVirtualData: pkg.EnableVirtualData,
|
EnableVirtualData: pkg.EnableVirtualData,
|
||||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||||
CostPrice: pkg.CostPrice,
|
CostPrice: pkg.CostPrice,
|
||||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||||
CalendarType: pkg.CalendarType,
|
CalendarType: pkg.CalendarType,
|
||||||
DurationDays: durationDays,
|
DurationDays: durationDays,
|
||||||
DataResetCycle: pkg.DataResetCycle,
|
DataResetCycle: pkg.DataResetCycle,
|
||||||
ExpiryBase: pkg.ExpiryBase,
|
ExpiryBase: pkg.ExpiryBase,
|
||||||
Status: pkg.Status,
|
DefaultExpiryBase: pkg.ExpiryBase,
|
||||||
ShelfStatus: pkg.ShelfStatus,
|
DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
EffectiveExpiryBase: pkg.ExpiryBase,
|
||||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||||
|
Status: pkg.Status,
|
||||||
|
ShelfStatus: pkg.ShelfStatus,
|
||||||
|
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
@@ -646,6 +650,7 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
|||||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||||
resp.ProfitMargin = &profitMargin
|
resp.ProfitMargin = &profitMargin
|
||||||
resp.ShelfStatus = allocation.ShelfStatus
|
resp.ShelfStatus = allocation.ShelfStatus
|
||||||
|
applyAllocationExpiryBase(resp, pkg, allocation)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||||
@@ -684,29 +689,33 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := &dto.PackageResponse{
|
resp := &dto.PackageResponse{
|
||||||
ID: pkg.ID,
|
ID: pkg.ID,
|
||||||
PackageCode: pkg.PackageCode,
|
PackageCode: pkg.PackageCode,
|
||||||
PackageName: pkg.PackageName,
|
PackageName: pkg.PackageName,
|
||||||
SeriesID: seriesID,
|
SeriesID: seriesID,
|
||||||
PackageType: pkg.PackageType,
|
PackageType: pkg.PackageType,
|
||||||
IsGift: pkg.IsGift,
|
IsGift: pkg.IsGift,
|
||||||
DurationMonths: pkg.DurationMonths,
|
DurationMonths: pkg.DurationMonths,
|
||||||
RealDataMB: pkg.RealDataMB,
|
RealDataMB: pkg.RealDataMB,
|
||||||
VirtualDataMB: pkg.VirtualDataMB,
|
VirtualDataMB: pkg.VirtualDataMB,
|
||||||
EnableVirtualData: pkg.EnableVirtualData,
|
EnableVirtualData: pkg.EnableVirtualData,
|
||||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||||
CostPrice: pkg.CostPrice,
|
CostPrice: pkg.CostPrice,
|
||||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||||
CalendarType: pkg.CalendarType,
|
CalendarType: pkg.CalendarType,
|
||||||
DurationDays: durationDays,
|
DurationDays: durationDays,
|
||||||
DataResetCycle: pkg.DataResetCycle,
|
DataResetCycle: pkg.DataResetCycle,
|
||||||
ExpiryBase: pkg.ExpiryBase,
|
ExpiryBase: pkg.ExpiryBase,
|
||||||
Status: pkg.Status,
|
DefaultExpiryBase: pkg.ExpiryBase,
|
||||||
ShelfStatus: pkg.ShelfStatus,
|
DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
EffectiveExpiryBase: pkg.ExpiryBase,
|
||||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase),
|
||||||
|
Status: pkg.Status,
|
||||||
|
ShelfStatus: pkg.ShelfStatus,
|
||||||
|
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
if allocationMap != nil {
|
if allocationMap != nil {
|
||||||
@@ -719,6 +728,7 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
|||||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||||
resp.ProfitMargin = &profitMargin
|
resp.ProfitMargin = &profitMargin
|
||||||
|
applyAllocationExpiryBase(resp, pkg, allocation)
|
||||||
resp.ShelfStatus = allocation.ShelfStatus
|
resp.ShelfStatus = allocation.ShelfStatus
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -734,6 +744,13 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 填充返佣信息到响应中
|
// fillCommissionInfo 填充返佣信息到响应中
|
||||||
func (s *Service) fillCommissionInfo(resp *dto.PackageResponse, seriesID uint, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) {
|
func (s *Service) fillCommissionInfo(resp *dto.PackageResponse, seriesID uint, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) {
|
||||||
seriesAllocation, hasAllocation := seriesAllocationMap[seriesID]
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
internal/service/package/usage_terms.go
Normal file
47
internal/service/package/usage_terms.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package packagepkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/pkg/errors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"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/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
@@ -18,6 +20,12 @@ type Service struct {
|
|||||||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||||
shopStore *postgres.ShopStore
|
shopStore *postgres.ShopStore
|
||||||
|
auditService AuditService
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditService 敏感配置变更审计能力。
|
||||||
|
type AuditService interface {
|
||||||
|
LogOperation(ctx context.Context, log *model.AccountOperationLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
@@ -26,6 +34,7 @@ func New(
|
|||||||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||||
shopStore *postgres.ShopStore,
|
shopStore *postgres.ShopStore,
|
||||||
|
auditService AuditService,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: db,
|
db: db,
|
||||||
@@ -33,61 +42,123 @@ func New(
|
|||||||
packageAllocationStore: packageAllocationStore,
|
packageAllocationStore: packageAllocationStore,
|
||||||
seriesAllocationStore: seriesAllocationStore,
|
seriesAllocationStore: seriesAllocationStore,
|
||||||
shopStore: shopStore,
|
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: fmt.Sprintf("修改套餐分配生效条件覆盖: %d", allocation.ID),
|
||||||
|
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)
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||||
if currentUserID == 0 {
|
if currentUserID == 0 {
|
||||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||||
}
|
}
|
||||||
|
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
allocatorShopID := middleware.GetShopIDFromContext(ctx)
|
allocatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
|
||||||
if userType == constants.UserTypeAgent && allocatorShopID == 0 {
|
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)
|
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
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 userType == constants.UserTypeAgent {
|
||||||
if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
|
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)
|
packages, err := s.getEnabledPackagesBySeries(ctx, req.SeriesID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(packages) == 0 {
|
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)
|
seriesAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, req.SeriesID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
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)
|
txPkgAllocStore := postgres.NewShopPackageAllocationStore(tx)
|
||||||
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
// 已存在该套餐的分配记录则跳过,避免唯一约束冲突
|
// 已存在该套餐的分配记录则跳过,避免唯一约束冲突
|
||||||
_, existErr := txPkgAllocStore.GetByShopAndPackageForSystem(ctx, req.ShopID, pkg.ID)
|
_, existErr := txPkgAllocStore.GetByShopAndPackageForSystem(ctx, req.ShopID, pkg.ID)
|
||||||
if existErr == nil {
|
if existErr == nil {
|
||||||
|
result.SkippedCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,23 +168,30 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
|||||||
}
|
}
|
||||||
|
|
||||||
allocation := &model.ShopPackageAllocation{
|
allocation := &model.ShopPackageAllocation{
|
||||||
BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID},
|
BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID},
|
||||||
ShopID: req.ShopID,
|
ShopID: req.ShopID,
|
||||||
PackageID: pkg.ID,
|
PackageID: pkg.ID,
|
||||||
AllocatorShopID: allocatorShopID,
|
AllocatorShopID: allocatorShopID,
|
||||||
CostPrice: costPrice,
|
CostPrice: costPrice,
|
||||||
RetailPrice: pkg.SuggestedRetailPrice,
|
RetailPrice: pkg.SuggestedRetailPrice,
|
||||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
SeriesAllocationID: &seriesAllocation.ID,
|
SeriesAllocationID: &seriesAllocation.ID,
|
||||||
Status: constants.StatusEnabled,
|
ExpiryBaseOverride: expiryBaseOverride,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
}
|
}
|
||||||
if err := tx.Create(allocation).Error; err != nil {
|
if err := tx.Create(allocation).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "创建套餐分配失败")
|
return errors.Wrap(errors.CodeInternalError, err, "创建套餐分配失败")
|
||||||
}
|
}
|
||||||
|
result.Allocations = append(result.Allocations, *buildTermsResponse(pkg, allocation))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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) {
|
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"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"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"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
@@ -185,12 +186,18 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
packages = append(packages, dto.ShopSeriesGrantPackageItem{
|
packages = append(packages, dto.ShopSeriesGrantPackageItem{
|
||||||
PackageID: pa.PackageID,
|
PackageID: pa.PackageID,
|
||||||
PackageName: pkg.PackageName,
|
PackageName: pkg.PackageName,
|
||||||
PackageCode: pkg.PackageCode,
|
PackageCode: pkg.PackageCode,
|
||||||
CostPrice: pa.CostPrice,
|
CostPrice: pa.CostPrice,
|
||||||
ShelfStatus: pa.ShelfStatus,
|
ShelfStatus: pa.ShelfStatus,
|
||||||
Status: pa.Status,
|
Status: pa.Status,
|
||||||
|
DefaultExpiryBase: pkg.ExpiryBase,
|
||||||
|
DefaultExpiryBaseName: packagepkg.ExpiryBaseName(pkg.ExpiryBase),
|
||||||
|
ExpiryBaseOverride: pa.ExpiryBaseOverride,
|
||||||
|
ExpiryBaseOverrideName: packagepkg.ExpiryBaseOverrideName(pa.ExpiryBaseOverride),
|
||||||
|
EffectiveExpiryBase: packagepkg.EffectiveExpiryBase(pkg, pa),
|
||||||
|
EffectiveExpiryBaseName: packagepkg.ExpiryBaseName(packagepkg.EffectiveExpiryBase(pkg, pa)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
resp.Packages = packages
|
resp.Packages = packages
|
||||||
@@ -201,6 +208,10 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
|
|||||||
// Create 创建系列授权
|
// Create 创建系列授权
|
||||||
// POST /api/admin/shop-series-grants
|
// POST /api/admin/shop-series-grants
|
||||||
func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) {
|
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)
|
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||||
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||||
operatorType := middleware.GetUserTypeFromContext(ctx)
|
operatorType := middleware.GetUserTypeFromContext(ctx)
|
||||||
@@ -339,15 +350,16 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pkgAlloc := &model.ShopPackageAllocation{
|
pkgAlloc := &model.ShopPackageAllocation{
|
||||||
ShopID: req.ShopID,
|
ShopID: req.ShopID,
|
||||||
PackageID: item.PackageID,
|
PackageID: item.PackageID,
|
||||||
AllocatorShopID: allocatorShopID,
|
AllocatorShopID: allocatorShopID,
|
||||||
CostPrice: item.CostPrice,
|
CostPrice: item.CostPrice,
|
||||||
RetailPrice: pkg.SuggestedRetailPrice,
|
RetailPrice: pkg.SuggestedRetailPrice,
|
||||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
SeriesAllocationID: &allocation.ID,
|
SeriesAllocationID: &allocation.ID,
|
||||||
Status: constants.StatusEnabled,
|
ExpiryBaseOverride: expiryBaseOverride,
|
||||||
ShelfStatus: constants.ShelfStatusOn,
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: constants.ShelfStatusOn,
|
||||||
}
|
}
|
||||||
pkgAlloc.Creator = operatorID
|
pkgAlloc.Creator = operatorID
|
||||||
pkgAlloc.Updater = operatorID
|
pkgAlloc.Updater = operatorID
|
||||||
@@ -600,6 +612,10 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries
|
|||||||
// ManagePackages 管理授权套餐(新增/更新/删除)
|
// ManagePackages 管理授权套餐(新增/更新/删除)
|
||||||
// PUT /api/admin/shop-series-grants/:id/packages
|
// PUT /api/admin/shop-series-grants/:id/packages
|
||||||
func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (*dto.ShopSeriesGrantResponse, error) {
|
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)
|
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||||
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
operatorShopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
|
||||||
@@ -677,15 +693,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pkgAlloc := &model.ShopPackageAllocation{
|
pkgAlloc := &model.ShopPackageAllocation{
|
||||||
ShopID: allocation.ShopID,
|
ShopID: allocation.ShopID,
|
||||||
PackageID: item.PackageID,
|
PackageID: item.PackageID,
|
||||||
AllocatorShopID: allocation.AllocatorShopID,
|
AllocatorShopID: allocation.AllocatorShopID,
|
||||||
CostPrice: item.CostPrice,
|
CostPrice: item.CostPrice,
|
||||||
RetailPrice: pkg.SuggestedRetailPrice,
|
RetailPrice: pkg.SuggestedRetailPrice,
|
||||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
SeriesAllocationID: &allocation.ID,
|
SeriesAllocationID: &allocation.ID,
|
||||||
Status: constants.StatusEnabled,
|
ExpiryBaseOverride: expiryBaseOverride,
|
||||||
ShelfStatus: constants.ShelfStatusOn,
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: constants.ShelfStatusOn,
|
||||||
}
|
}
|
||||||
pkgAlloc.Creator = operatorID
|
pkgAlloc.Creator = operatorID
|
||||||
pkgAlloc.Updater = operatorID
|
pkgAlloc.Updater = operatorID
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
var usage model.PackageUsage
|
||||||
|
if err := tx.Where("expiry_base_snapshot = '' AND calendar_type_snapshot = '' AND duration_months_snapshot = 0 AND duration_days_snapshot = 0").
|
||||||
|
First(&usage).Error; err != nil {
|
||||||
|
t.Skip("当前开发库没有历史空快照记录")
|
||||||
|
}
|
||||||
|
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"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
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"
|
||||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
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/service/packageprice"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AutoPurchasePayload 充值后自动购包任务载荷
|
// AutoPurchasePayload 充值后自动购包任务载荷
|
||||||
@@ -496,7 +498,11 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
carrierID uint,
|
carrierID uint,
|
||||||
now time.Time,
|
now time.Time,
|
||||||
) error {
|
) error {
|
||||||
_ = ctx
|
terms, err := h.resolvePackageTerms(ctx, 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 {
|
if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -512,6 +518,13 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
var expiresAt time.Time
|
var expiresAt time.Time
|
||||||
var nextResetAt *time.Time
|
var nextResetAt *time.Time
|
||||||
var pendingRealnameActivation bool
|
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 {
|
if hasCurrentMain {
|
||||||
status = constants.PackageUsageStatusPending
|
status = constants.PackageUsageStatusPending
|
||||||
@@ -522,16 +535,17 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
Scan(&maxPriority)
|
Scan(&maxPriority)
|
||||||
priority = maxPriority + 1
|
priority = maxPriority + 1
|
||||||
} else {
|
} else {
|
||||||
status = constants.PackageUsageStatusActive
|
|
||||||
priority = 1
|
priority = 1
|
||||||
activatedAt = now
|
if pendingRealnameActivation {
|
||||||
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
status = constants.PackageUsageStatusPending
|
||||||
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
|
} 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)
|
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
||||||
retailAmount := order.TotalAmount
|
retailAmount := order.TotalAmount
|
||||||
usage := &model.PackageUsage{
|
usage := &model.PackageUsage{
|
||||||
@@ -558,6 +572,7 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
PackageIsGift: pkg.IsGift,
|
PackageIsGift: pkg.IsGift,
|
||||||
}
|
}
|
||||||
|
terms.Apply(usage)
|
||||||
|
|
||||||
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
||||||
usage.IotCardID = carrierID
|
usage.IotCardID = carrierID
|
||||||
@@ -581,6 +596,30 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
|||||||
}).Error
|
}).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 {
|
func (h *AutoPurchaseHandler) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||||
switch carrierType {
|
switch carrierType {
|
||||||
case constants.AssetWalletResourceTypeIotCard, "card":
|
case constants.AssetWalletResourceTypeIotCard, "card":
|
||||||
@@ -603,7 +642,11 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
|||||||
carrierID uint,
|
carrierID uint,
|
||||||
now time.Time,
|
now time.Time,
|
||||||
) error {
|
) error {
|
||||||
_ = ctx
|
terms, err := h.resolvePackageTerms(ctx, 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)
|
mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now)
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return errors.New("必须有主套餐才能购买加油包")
|
return errors.New("必须有主套餐才能购买加油包")
|
||||||
@@ -649,6 +692,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
|||||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||||
PackageIsGift: pkg.IsGift,
|
PackageIsGift: pkg.IsGift,
|
||||||
}
|
}
|
||||||
|
terms.Apply(usage)
|
||||||
|
|
||||||
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
||||||
usage.IotCardID = carrierID
|
usage.IotCardID = carrierID
|
||||||
@@ -659,6 +703,20 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
|||||||
return tx.Create(usage).Error
|
return tx.Create(usage).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AutoPurchaseHandler) resolvePackageTerms(ctx context.Context, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) {
|
||||||
|
var allocation *model.ShopPackageAllocation
|
||||||
|
if sellerShopID != nil && *sellerShopID > 0 {
|
||||||
|
found, err := h.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, *sellerShopID, pkg.ID)
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
return packagedomain.TermsSnapshot{}, err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
allocation = found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return packagedomain.ResolveTermsSnapshot(pkg, allocation)
|
||||||
|
}
|
||||||
|
|
||||||
func parseLinkedPackageIDs(raw []byte) ([]uint, error) {
|
func parseLinkedPackageIDs(raw []byte) ([]uint, error) {
|
||||||
var packageIDs []uint
|
var packageIDs []uint
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
|
|||||||
@@ -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" // 按天周期
|
PackageCalendarTypeByDay = "by_day" // 按天周期
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 套餐生效条件常量
|
||||||
|
const (
|
||||||
|
PackageExpiryBaseFromActivation = "from_activation" // 实名激活时生效
|
||||||
|
PackageExpiryBaseFromPurchase = "from_purchase" // 购买即生效
|
||||||
|
)
|
||||||
|
|
||||||
// 套餐流量重置周期常量
|
// 套餐流量重置周期常量
|
||||||
const (
|
const (
|
||||||
PackageDataResetDaily = "daily" // 每日重置
|
PackageDataResetDaily = "daily" // 每日重置
|
||||||
|
|||||||
Reference in New Issue
Block a user