This commit is contained in:
@@ -221,6 +221,7 @@ default:
|
||||
- **批量同步**:卡状态、实名状态、流量使用情况
|
||||
- **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
|
||||
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
|
||||
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)
|
||||
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
|
||||
- **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
|
||||
- **微信集成**:完整的微信公众号 OAuth 认证和微信支付功能(JSAPI + H5),使用 PowerWeChat v3 SDK;支持个人客户微信授权登录、账号绑定、微信内支付和浏览器 H5 支付;支付回调自动验证签名和幂等性处理;详见 [使用指南](docs/wechat-integration/使用指南.md) 和 [API 文档](docs/wechat-integration/API文档.md)
|
||||
|
||||
@@ -2149,7 +2149,7 @@ components:
|
||||
description: 套餐类型 (formal:正式套餐, addon:加油包)
|
||||
type: string
|
||||
retail_price:
|
||||
description: 零售价(分)
|
||||
description: 生效零售价(分)
|
||||
type: integer
|
||||
validity_days:
|
||||
description: 有效天数
|
||||
@@ -2786,6 +2786,9 @@ components:
|
||||
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
||||
example: from_activation
|
||||
type: string
|
||||
is_gift:
|
||||
description: 是否赠送套餐
|
||||
type: boolean
|
||||
package_code:
|
||||
description: 套餐编码
|
||||
maxLength: 100
|
||||
@@ -2810,7 +2813,7 @@ components:
|
||||
nullable: true
|
||||
type: integer
|
||||
suggested_retail_price:
|
||||
description: 建议售价(分)
|
||||
description: 建议售价原始值(分,普通可售套餐留空表示未配置,赠送套餐必须显式为0)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
@@ -5580,6 +5583,10 @@ components:
|
||||
duration_months:
|
||||
description: 套餐时长(月数)
|
||||
type: integer
|
||||
effective_retail_price:
|
||||
description: 生效零售价(分,仅销售链路展示使用)
|
||||
nullable: true
|
||||
type: integer
|
||||
enable_virtual_data:
|
||||
description: 是否启用虚流量
|
||||
type: boolean
|
||||
@@ -5590,6 +5597,9 @@ components:
|
||||
description: 套餐ID
|
||||
minimum: 0
|
||||
type: integer
|
||||
is_gift:
|
||||
description: 是否赠送套餐
|
||||
type: boolean
|
||||
one_time_commission_amount:
|
||||
description: 一次性佣金金额(分,代理视角)
|
||||
nullable: true
|
||||
@@ -5603,6 +5613,12 @@ components:
|
||||
package_type:
|
||||
description: 套餐类型 (formal:正式套餐, addon:附加套餐)
|
||||
type: string
|
||||
price_config_status:
|
||||
description: 价格配置状态 (0:未配置, 1:赠送0价, 2:已配置非0)
|
||||
type: integer
|
||||
price_config_status_name:
|
||||
description: 价格配置状态名称(中文)
|
||||
type: string
|
||||
profit_margin:
|
||||
description: 利润空间(分,仅代理用户可见)
|
||||
nullable: true
|
||||
@@ -5611,7 +5627,11 @@ components:
|
||||
description: 真流量额度(MB)
|
||||
type: integer
|
||||
retail_price:
|
||||
description: 代理零售价(分),仅代理用户可见
|
||||
description: 代理零售价原始值(分,仅代理用户可见,未配置时为空)
|
||||
nullable: true
|
||||
type: integer
|
||||
retail_price_config_status:
|
||||
description: 代理零售价配置状态 (0:未配置, 1:赠送0价, 2:已配置非0)
|
||||
nullable: true
|
||||
type: integer
|
||||
series_id:
|
||||
@@ -5636,7 +5656,8 @@ components:
|
||||
description: 状态名称(中文)
|
||||
type: string
|
||||
suggested_retail_price:
|
||||
description: 建议售价(分)
|
||||
description: 建议售价原始值(分,未配置时为空)
|
||||
nullable: true
|
||||
type: integer
|
||||
tier_info:
|
||||
$ref: '#/components/schemas/DtoCommissionTierInfo'
|
||||
@@ -7704,6 +7725,9 @@ components:
|
||||
description: 套餐周期类型 (natural_month:自然月, by_day:按天)
|
||||
nullable: true
|
||||
type: string
|
||||
clear_suggested_retail_price:
|
||||
description: 是否清空建议售价并恢复为未配置状态
|
||||
type: boolean
|
||||
cost_price:
|
||||
description: 成本价(分)
|
||||
minimum: 0
|
||||
@@ -7733,6 +7757,10 @@ components:
|
||||
description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)
|
||||
nullable: true
|
||||
type: string
|
||||
is_gift:
|
||||
description: 是否赠送套餐
|
||||
nullable: true
|
||||
type: boolean
|
||||
package_name:
|
||||
description: 套餐名称
|
||||
maxLength: 255
|
||||
@@ -7754,7 +7782,7 @@ components:
|
||||
nullable: true
|
||||
type: integer
|
||||
suggested_retail_price:
|
||||
description: 建议售价(分)
|
||||
description: 建议售价原始值(分,普通可售套餐留空表示未配置,赠送套餐必须显式为0)
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: integer
|
||||
@@ -7960,7 +7988,7 @@ components:
|
||||
DtoUpdateRetailPriceParams:
|
||||
properties:
|
||||
retail_price:
|
||||
description: 零售价(单位:分)
|
||||
description: 零售价原始值(分,普通可售套餐不允许显式0)
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
|
||||
108
docs/package-price-fallback-and-platform-gift-policy/功能总结.md
Normal file
108
docs/package-price-fallback-and-platform-gift-policy/功能总结.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 套餐价格回退与平台赠送策略功能总结
|
||||
|
||||
## 1. 变更背景
|
||||
|
||||
本次改动解决了套餐价格语义长期混用的问题:
|
||||
|
||||
- 普通可售套餐在建议零售价未配置时,会自动回退到成本价作为生效售价
|
||||
- 赠送套餐不再通过 `0` 价临时猜语义,而是使用独立赠送标记
|
||||
- 赠送套餐仅允许平台后台通过创建订单进行发放,不能进入代理分配、C 端购买或自动购包链路
|
||||
|
||||
## 2. 核心规则
|
||||
|
||||
### 2.1 价格配置状态
|
||||
|
||||
新增统一价格配置状态:
|
||||
|
||||
- `0`:未配置
|
||||
- `1`:赠送 0 价
|
||||
- `2`:已配置非 0
|
||||
|
||||
普通可售套餐:
|
||||
|
||||
- 建议售价留空 → 视为未配置,销售链路按成本价生效
|
||||
- 建议售价填非 0 → 按原值生效
|
||||
- 建议售价显式填 `0` → 拒绝保存
|
||||
|
||||
赠送套餐:
|
||||
|
||||
- 必须显式配置 `0`
|
||||
- 仅平台可创建和维护
|
||||
|
||||
### 2.2 后台套餐管理
|
||||
|
||||
后台创建、编辑、详情、列表现在同时返回:
|
||||
|
||||
- 原始建议售价(未配置时为空)
|
||||
- 价格配置状态
|
||||
- 是否赠送套餐
|
||||
|
||||
同时保留生效价字段,供销售链路或只读展示使用,但不会回写覆盖原始配置值。
|
||||
|
||||
### 2.3 C 端与销售链路
|
||||
|
||||
以下链路统一改为使用生效售价:
|
||||
|
||||
- C 端可购买套餐列表
|
||||
- C 端下单
|
||||
- 后台订单创建
|
||||
- 自动购包
|
||||
|
||||
赠送套餐会被以下链路拦截或隐藏:
|
||||
|
||||
- C 端可购列表
|
||||
- H5/客户端购买
|
||||
- 自动购包
|
||||
- 代理授权与批量分配
|
||||
|
||||
### 2.4 后台发放口径
|
||||
|
||||
“发放”统一解释为**平台后台创建订单**。
|
||||
|
||||
- 赠送套餐只能通过后台创建订单进入资产
|
||||
- 赠送订单仅允许平台侧发起
|
||||
- 赠送订单不要求线下支付凭证
|
||||
- 赠送加油包仍然要求目标资产先存在主套餐
|
||||
|
||||
## 3. 数据库变更
|
||||
|
||||
迁移版本:`000139_add_package_price_status_and_gift_policy`
|
||||
|
||||
主要新增:
|
||||
|
||||
- `tb_package.price_config_status`
|
||||
- `tb_package.is_gift`
|
||||
- `tb_shop_package_allocation.retail_price_config_status`
|
||||
- `tb_order_item.package_price_config_status`
|
||||
- `tb_order_item.package_is_gift`
|
||||
- `tb_package_usage.package_price_config_status`
|
||||
- `tb_package_usage.package_is_gift`
|
||||
- `tb_package_price_review_queue`
|
||||
|
||||
## 4. 历史数据回填策略
|
||||
|
||||
- 历史非 0 建议售价 → 回填为“已配置非 0”
|
||||
- 历史 `0` 且满足“后台创建订单 0 元成交,且未进入代理分配” → 回填为赠送套餐
|
||||
- 其余历史 `0` → 回填为未配置;若无法确认语义,则进入 `tb_package_price_review_queue`
|
||||
|
||||
## 5. 影响范围
|
||||
|
||||
主要涉及:
|
||||
|
||||
- `internal/service/package/service.go`
|
||||
- `internal/service/purchase_validation/service.go`
|
||||
- `internal/service/order/service.go`
|
||||
- `internal/service/client_order/service.go`
|
||||
- `internal/task/auto_purchase.go`
|
||||
- `internal/handler/app/client_asset.go`
|
||||
- `internal/service/shop_series_grant/service.go`
|
||||
- `internal/service/shop_package_batch_allocation/service.go`
|
||||
|
||||
## 6. 验证方式
|
||||
|
||||
本次变更按项目约束使用以下方式验证:
|
||||
|
||||
- `lsp_diagnostics` 静态诊断
|
||||
- `make migrate-up` 迁移执行
|
||||
- PostgreSQL 只读查询验证字段、默认值和回填结果
|
||||
- 后续手工回归按《最终验收清单》执行
|
||||
@@ -0,0 +1,44 @@
|
||||
# 套餐价格回退与平台赠送策略最终验收清单
|
||||
|
||||
## 1. 价格回退
|
||||
|
||||
- [ ] 普通可售套餐未配置建议售价时,后台详情返回 `suggested_retail_price=null` 且 `price_config_status=0`
|
||||
- [ ] 普通可售套餐未配置建议售价时,C 端可购列表展示成本价
|
||||
- [ ] 平台后台创建普通订单时,订单明细使用生效价
|
||||
- [ ] 自动购包命中未配置价格套餐时,订单明细使用生效价
|
||||
|
||||
## 2. 赠送拦截
|
||||
|
||||
- [ ] 普通可售套餐显式填写 `0` 时被拒绝
|
||||
- [ ] 赠送套餐显式填写 `0` 时允许保存
|
||||
- [ ] 赠送套餐不出现在 C 端可购列表
|
||||
- [ ] H5 / 客户端下单赠送套餐被拒绝
|
||||
- [ ] 自动购包命中赠送套餐时被拒绝
|
||||
|
||||
## 3. 平台发放
|
||||
|
||||
- [ ] 平台后台可通过创建订单发放赠送套餐
|
||||
- [ ] 赠送订单不要求线下支付凭证
|
||||
- [ ] 赠送加油包在无主套餐时发放失败
|
||||
- [ ] 赠送订单不会进入代理代购/佣金计算语义
|
||||
|
||||
## 4. 代理授权与分配
|
||||
|
||||
- [ ] 代理系列授权创建时无法加入赠送套餐
|
||||
- [ ] 系列授权详情列表不展示赠送套餐
|
||||
- [ ] 批量分配不会把赠送套餐分配给下级代理
|
||||
- [ ] 代理侧套餐列表不会看到赠送套餐
|
||||
|
||||
## 5. 历史迁移
|
||||
|
||||
- [ ] `tb_package` 已回填 `price_config_status` 与 `is_gift`
|
||||
- [ ] `tb_shop_package_allocation` 已回填 `retail_price_config_status`
|
||||
- [ ] `tb_order_item` / `tb_package_usage` 已回填赠送快照与价格状态快照
|
||||
- [ ] `tb_package_price_review_queue` 中仅保留需要人工复核的记录
|
||||
|
||||
## 6. 最终检查
|
||||
|
||||
- [ ] 相关 Go 文件 `lsp_diagnostics` 无错误
|
||||
- [ ] `make migrate-version` 为 `139`
|
||||
- [ ] `go run cmd/gendocs/main.go` 执行成功
|
||||
- [ ] `go build ./...` 执行成功
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -526,13 +527,16 @@ func buildClientPackageItem(
|
||||
if pkg == nil || pkg.Status != constants.StatusEnabled {
|
||||
return dto.ClientPackageItem{}, false
|
||||
}
|
||||
if pkg.IsGift {
|
||||
return dto.ClientPackageItem{}, false
|
||||
}
|
||||
|
||||
isAddon := pkg.PackageType == constants.PackageTypeAddon
|
||||
if isAddon && !resolved.MainPackageActived {
|
||||
return dto.ClientPackageItem{}, false
|
||||
}
|
||||
|
||||
retailPrice := pkg.SuggestedRetailPrice
|
||||
retailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
costPrice := pkg.CostPrice
|
||||
|
||||
if resolved.IsAgentChannel {
|
||||
@@ -543,7 +547,7 @@ func buildClientPackageItem(
|
||||
if allocation.ShelfStatus != constants.ShelfStatusOn || allocation.Status != constants.StatusEnabled {
|
||||
return dto.ClientPackageItem{}, false
|
||||
}
|
||||
retailPrice = allocation.RetailPrice
|
||||
retailPrice = packageprice.AllocationEffectiveRetailPrice(allocation)
|
||||
costPrice = allocation.CostPrice
|
||||
} else if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||
return dto.ClientPackageItem{}, false
|
||||
|
||||
@@ -143,7 +143,7 @@ type ClientPackageItem struct {
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
|
||||
RetailPrice int64 `json:"retail_price" description:"零售价(分)"`
|
||||
RetailPrice int64 `json:"retail_price" description:"生效零售价(分)"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
ValidityDays int `json:"validity_days" description:"有效天数"`
|
||||
IsAddon bool `json:"is_addon" description:"是否加油包"`
|
||||
|
||||
@@ -6,11 +6,12 @@ type CreatePackageRequest struct {
|
||||
PackageName string `json:"package_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType string `json:"package_type" validate:"required,oneof=formal addon" required:"true" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
IsGift bool `json:"is_gift" description:"是否赠送套餐"`
|
||||
DurationMonths int `json:"duration_months" validate:"required,min=1,max=120" required:"true" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价原始值(分,普通可售套餐留空表示未配置,赠送套餐必须显式为0)"`
|
||||
CostPrice int64 `json:"cost_price" validate:"required,min=0" required:"true" minimum:"0" description:"成本价(分)"`
|
||||
CalendarType *string `json:"calendar_type" validate:"omitempty,oneof=natural_month by_day" description:"套餐周期类型 (natural_month:自然月, by_day:按天)"`
|
||||
DurationDays *int `json:"duration_days" validate:"omitempty,min=1,max=3650" minimum:"1" maximum:"3650" description:"套餐天数(calendar_type=by_day时必填)"`
|
||||
@@ -23,11 +24,13 @@ type UpdatePackageRequest struct {
|
||||
PackageName *string `json:"package_name" validate:"omitempty,min=1,max=255" minLength:"1" maxLength:"255" description:"套餐名称"`
|
||||
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID"`
|
||||
PackageType *string `json:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
IsGift *bool `json:"is_gift" description:"是否赠送套餐"`
|
||||
DurationMonths *int `json:"duration_months" validate:"omitempty,min=1,max=120" minimum:"1" maximum:"120" description:"套餐时长(月数)"`
|
||||
RealDataMB *int64 `json:"real_data_mb" validate:"omitempty,min=0" minimum:"0" description:"真流量额度(MB)"`
|
||||
VirtualDataMB *int64 `json:"virtual_data_mb" validate:"omitempty,min=0" minimum:"0" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData *bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价(分)"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price" validate:"omitempty,min=0" minimum:"0" description:"建议售价原始值(分,普通可售套餐留空表示未配置,赠送套餐必须显式为0)"`
|
||||
ClearSuggestedRetailPrice bool `json:"clear_suggested_retail_price" description:"是否清空建议售价并恢复为未配置状态"`
|
||||
CostPrice *int64 `json:"cost_price" validate:"omitempty,min=0" minimum:"0" description:"成本价(分)"`
|
||||
CalendarType *string `json:"calendar_type" validate:"omitempty,oneof=natural_month by_day" description:"套餐周期类型 (natural_month:自然月, by_day:按天)"`
|
||||
DurationDays *int `json:"duration_days" validate:"omitempty,min=1,max=3650" minimum:"1" maximum:"3650" description:"套餐天数(calendar_type=by_day时必填)"`
|
||||
@@ -58,7 +61,7 @@ type UpdatePackageShelfStatusRequest struct {
|
||||
|
||||
// UpdateRetailPriceRequest 更新零售价请求
|
||||
type UpdateRetailPriceRequest struct {
|
||||
RetailPrice int64 `json:"retail_price" validate:"required,min=0" required:"true" minimum:"0" description:"零售价(单位:分)"`
|
||||
RetailPrice int64 `json:"retail_price" validate:"required,min=0" required:"true" minimum:"0" description:"零售价原始值(分,普通可售套餐不允许显式0)"`
|
||||
}
|
||||
|
||||
// CommissionTierInfo 返佣梯度信息
|
||||
@@ -76,12 +79,15 @@ type PackageResponse struct {
|
||||
SeriesID *uint `json:"series_id" description:"套餐系列ID"`
|
||||
SeriesName *string `json:"series_name" description:"套餐系列名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:附加套餐)"`
|
||||
IsGift bool `json:"is_gift" description:"是否赠送套餐"`
|
||||
DurationMonths int `json:"duration_months" description:"套餐时长(月数)"`
|
||||
RealDataMB int64 `json:"real_data_mb" description:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `json:"virtual_data_mb" description:"虚流量额度(MB)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real_data_mb/virtual_data_mb),启用虚流量时计算,否则为1.0"`
|
||||
SuggestedRetailPrice int64 `json:"suggested_retail_price" description:"建议售价(分)"`
|
||||
SuggestedRetailPrice *int64 `json:"suggested_retail_price,omitempty" description:"建议售价原始值(分,未配置时为空)"`
|
||||
PriceConfigStatus int `json:"price_config_status" description:"价格配置状态 (0:未配置, 1:赠送0价, 2:已配置非0)"`
|
||||
PriceConfigStatusName string `json:"price_config_status_name" description:"价格配置状态名称(中文)"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"一次性佣金金额(分,代理视角)"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
@@ -90,7 +96,9 @@ type PackageResponse struct {
|
||||
ShelfStatusName string `json:"shelf_status_name" description:"上架状态名称(中文)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
RetailPrice *int64 `json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
|
||||
RetailPrice *int64 `json:"retail_price,omitempty" description:"代理零售价原始值(分,仅代理用户可见,未配置时为空)"`
|
||||
RetailPriceConfigStatus *int `json:"retail_price_config_status,omitempty" description:"代理零售价配置状态 (0:未配置, 1:赠送0价, 2:已配置非0)"`
|
||||
EffectiveRetailPrice *int64 `json:"effective_retail_price,omitempty" description:"生效零售价(分,仅销售链路展示使用)"`
|
||||
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
||||
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
||||
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
||||
|
||||
@@ -130,6 +130,8 @@ type OrderItem struct {
|
||||
Quantity int `gorm:"column:quantity;type:int;default:1;not null;comment:数量" json:"quantity"`
|
||||
UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:单价(分)" json:"unit_price"`
|
||||
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:小计金额(分)" json:"amount"`
|
||||
PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"`
|
||||
PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -41,6 +41,8 @@ type Package struct {
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price;type:bigint;default:0;comment:建议售价(分为单位)" json:"suggested_retail_price"`
|
||||
PriceConfigStatus int `gorm:"column:price_config_status;type:int;default:0;not null;comment:价格配置状态 0-未配置 1-赠送0价 2-已配置非0" json:"price_config_status"`
|
||||
IsGift bool `gorm:"column:is_gift;type:boolean;default:false;not null;comment:是否赠送套餐 true-赠送 false-普通可售" json:"is_gift"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:2;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
CalendarType string `gorm:"column:calendar_type;type:varchar(20);default:'by_day';comment:套餐周期类型 natural_month-自然月 by_day-按天" json:"calendar_type"`
|
||||
DurationDays int `gorm:"column:duration_days;type:int;comment:套餐天数(calendar_type=by_day时必填)" json:"duration_days"`
|
||||
@@ -83,6 +85,8 @@ type PackageUsage struct {
|
||||
PendingRealnameActivation bool `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待实名激活(true-待实名后激活 false-已激活或不需实名)" json:"pending_realname_activation"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:'';comment:套餐名称快照(从Package复制,用于历史记录展示)" json:"package_name"`
|
||||
PaidAmount *int64 `gorm:"column:paid_amount;type:bigint;comment:购买实付金额快照(分,从订单复制,无订单或线下支付时为null)" json:"paid_amount,omitempty"`
|
||||
PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"`
|
||||
PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"`
|
||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
|
||||
LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
|
||||
NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`
|
||||
|
||||
@@ -15,6 +15,7 @@ type ShopPackageAllocation struct {
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
|
||||
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"`
|
||||
RetailPriceConfigStatus int `gorm:"column:retail_price_config_status;type:int;default:0;not null;comment:零售价配置状态 0-未配置 1-赠送0价 2-已配置非0" json:"retail_price_config_status"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
@@ -120,11 +121,6 @@ func (s *Service) SetResumeCallback(callback packagepkg.ResumeCallback) {
|
||||
// 与 CreateH5Order 的核心区别:后台订单不创建待支付状态,wallet 立即扣款,offline 立即激活
|
||||
// POST /api/admin/orders
|
||||
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
|
||||
// 线下支付必须上传支付凭证
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && strings.TrimSpace(req.PaymentVoucherKey) == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证")
|
||||
}
|
||||
|
||||
resolvedCard, resolvedDevice, resolveErr := s.resolveAssetByIdentifier(ctx, req.Identifier)
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
@@ -146,18 +142,23 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
|
||||
var validationResult *purchase_validation.PurchaseValidationResult
|
||||
var err error
|
||||
operatorUserType := middleware.GetUserTypeFromContext(ctx)
|
||||
validationCtx := ctx
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && (operatorUserType == constants.UserTypeSuperAdmin || operatorUserType == constants.UserTypePlatform) {
|
||||
validationCtx = purchase_validation.WithAllowGiftPackages(ctx)
|
||||
}
|
||||
|
||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||
if resolvedCard != nil {
|
||||
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, resolvedCard.ID, req.PackageIDs)
|
||||
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(validationCtx, resolvedCard.ID, req.PackageIDs)
|
||||
} else {
|
||||
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs)
|
||||
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(validationCtx, resolvedDevice.ID, req.PackageIDs)
|
||||
}
|
||||
} else {
|
||||
if resolvedCard != nil {
|
||||
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, resolvedCard.ID, req.PackageIDs)
|
||||
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(validationCtx, resolvedCard.ID, req.PackageIDs)
|
||||
} else {
|
||||
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, resolvedDevice.ID, req.PackageIDs)
|
||||
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(validationCtx, resolvedDevice.ID, req.PackageIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +169,21 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
containsGift := packageprice.ContainsGiftPackage(validationResult.Packages)
|
||||
if containsGift {
|
||||
if operatorUserType != constants.UserTypeSuperAdmin && operatorUserType != constants.UserTypePlatform {
|
||||
return nil, errors.New(errors.CodeForbidden, "仅平台侧可发放赠送套餐")
|
||||
}
|
||||
if req.PaymentMethod != model.PaymentMethodOffline {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "赠送套餐仅支持后台线下发放")
|
||||
}
|
||||
if err := packageprice.ValidateGiftAdminOrder(validationResult.Packages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && strings.TrimSpace(req.PaymentVoucherKey) == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证")
|
||||
}
|
||||
|
||||
carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID)
|
||||
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
|
||||
@@ -229,6 +245,22 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
// 根据支付方式分别处理
|
||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||
// ==== 场景 1:offline(线下支付)====
|
||||
if containsGift {
|
||||
orderBuyerType = model.BuyerTypePersonal
|
||||
orderBuyerID = 0
|
||||
totalAmount = 0
|
||||
paymentMethod = model.PaymentMethodOffline
|
||||
paymentStatus = model.PaymentStatusPaid
|
||||
paidAt = &now
|
||||
isPurchaseOnBehalf = false
|
||||
sellerShopID = nil
|
||||
sellerCostPrice = 0
|
||||
operatorID = nil
|
||||
operatorType = constants.OwnerTypePlatform
|
||||
actualPaidAmountVal := int64(0)
|
||||
actualPaidAmount = &actualPaidAmountVal
|
||||
purchaseRole = model.PurchaseRoleSelfPurchase
|
||||
} else {
|
||||
isPlatformOwned := resourceShopID == nil || *resourceShopID == 0
|
||||
|
||||
if isPlatformOwned {
|
||||
@@ -267,6 +299,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
purchaseRole = model.PurchaseRolePurchasedByPlatform
|
||||
actualPaidAmount = nil
|
||||
}
|
||||
}
|
||||
|
||||
} else if req.PaymentMethod == model.PaymentMethodWallet {
|
||||
// ==== 场景 2:代理钱包支付(wallet)====
|
||||
@@ -438,7 +471,9 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
if err := s.createOrderWithActivation(ctx, order, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.enqueueCommissionCalculation(ctx, order.ID)
|
||||
if !containsGift {
|
||||
s.enqueueCommissionCalculation(ctx, order.ID)
|
||||
}
|
||||
s.markOrderCreated(ctx, idempotencyKey, order.ID)
|
||||
return s.buildOrderResponse(order, items), nil
|
||||
|
||||
@@ -786,7 +821,7 @@ func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, un
|
||||
continue
|
||||
}
|
||||
|
||||
unitPrice := pkg.SuggestedRetailPrice
|
||||
unitPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
if price, ok := unitPriceMap[pkg.ID]; ok {
|
||||
unitPrice = price
|
||||
}
|
||||
@@ -796,12 +831,14 @@ func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, un
|
||||
Creator: operatorID,
|
||||
Updater: operatorID,
|
||||
},
|
||||
PackageID: pkg.ID,
|
||||
PackageName: pkg.PackageName,
|
||||
PackageType: &pkg.PackageType,
|
||||
Quantity: 1,
|
||||
UnitPrice: unitPrice,
|
||||
Amount: unitPrice,
|
||||
PackageID: pkg.ID,
|
||||
PackageName: pkg.PackageName,
|
||||
PackageType: &pkg.PackageType,
|
||||
Quantity: 1,
|
||||
UnitPrice: unitPrice,
|
||||
Amount: unitPrice,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -2055,6 +2092,8 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PendingRealnameActivation: pendingRealnameActivation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
@@ -2146,6 +2185,8 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
||||
ExpiresAt: &expiresAt,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
|
||||
if carrierType == "iot_card" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -42,6 +43,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
if err := validateGiftPackagePermission(ctx, req.IsGift); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, _ := s.packageStore.GetByCode(ctx, req.PackageCode)
|
||||
if existing != nil {
|
||||
@@ -91,12 +95,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
seriesName = &series.SeriesName
|
||||
}
|
||||
|
||||
priceConfigStatus, storedRetailPrice, err := packageprice.ResolvePackagePriceState(req.IsGift, req.SuggestedRetailPrice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg := &model.Package{
|
||||
PackageCode: req.PackageCode,
|
||||
PackageName: req.PackageName,
|
||||
PackageType: req.PackageType,
|
||||
IsGift: req.IsGift,
|
||||
DurationMonths: req.DurationMonths,
|
||||
CostPrice: req.CostPrice,
|
||||
PriceConfigStatus: priceConfigStatus,
|
||||
SuggestedRetailPrice: storedRetailPrice,
|
||||
EnableVirtualData: req.EnableVirtualData,
|
||||
CalendarType: calendarType,
|
||||
Status: constants.StatusEnabled,
|
||||
@@ -111,9 +123,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d
|
||||
if req.VirtualDataMB != nil {
|
||||
pkg.VirtualDataMB = *req.VirtualDataMB
|
||||
}
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
if req.DurationDays != nil {
|
||||
pkg.DurationDays = *req.DurationDays
|
||||
}
|
||||
@@ -220,6 +229,13 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
if req.PackageType != nil {
|
||||
pkg.PackageType = *req.PackageType
|
||||
}
|
||||
nextIsGift := pkg.IsGift
|
||||
if req.IsGift != nil {
|
||||
nextIsGift = *req.IsGift
|
||||
}
|
||||
if err := validateGiftPackagePermission(ctx, nextIsGift); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.DurationMonths != nil {
|
||||
pkg.DurationMonths = *req.DurationMonths
|
||||
}
|
||||
@@ -235,9 +251,6 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
if req.CostPrice != nil {
|
||||
pkg.CostPrice = *req.CostPrice
|
||||
}
|
||||
if req.SuggestedRetailPrice != nil {
|
||||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||||
}
|
||||
if req.CalendarType != nil {
|
||||
pkg.CalendarType = *req.CalendarType
|
||||
}
|
||||
@@ -251,6 +264,23 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq
|
||||
pkg.ExpiryBase = *req.ExpiryBase
|
||||
}
|
||||
|
||||
var rawSuggestedRetailPrice *int64
|
||||
switch {
|
||||
case req.ClearSuggestedRetailPrice:
|
||||
rawSuggestedRetailPrice = nil
|
||||
case req.SuggestedRetailPrice != nil:
|
||||
rawSuggestedRetailPrice = req.SuggestedRetailPrice
|
||||
default:
|
||||
rawSuggestedRetailPrice = packageprice.PackageRawSuggestedRetailPrice(pkg)
|
||||
}
|
||||
priceConfigStatus, storedRetailPrice, err := packageprice.ResolvePackagePriceState(nextIsGift, rawSuggestedRetailPrice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkg.IsGift = nextIsGift
|
||||
pkg.PriceConfigStatus = priceConfigStatus
|
||||
pkg.SuggestedRetailPrice = storedRetailPrice
|
||||
|
||||
// 校验套餐周期类型和时长配置
|
||||
if pkg.CalendarType == constants.PackageCalendarTypeNaturalMonth {
|
||||
if pkg.DurationMonths <= 0 {
|
||||
@@ -503,11 +533,25 @@ func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailP
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
|
||||
}
|
||||
|
||||
pkg, err := s.packageStore.GetByID(ctx, packageID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||||
}
|
||||
if pkg.IsGift {
|
||||
return errors.New(errors.CodeForbidden, "赠送套餐不允许代理修改零售价")
|
||||
}
|
||||
priceConfigStatus, storedRetailPrice, err := packageprice.ResolveAllocationPriceState(&retailPrice)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if retailPrice < allocation.CostPrice {
|
||||
return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价")
|
||||
}
|
||||
|
||||
if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, retailPrice, currentUserID); err != nil {
|
||||
if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, storedRetailPrice, priceConfigStatus, currentUserID); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
|
||||
}
|
||||
|
||||
@@ -568,13 +612,16 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
@@ -591,11 +638,18 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
|
||||
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
|
||||
if err == nil && allocation != nil {
|
||||
resp.CostPrice = allocation.CostPrice
|
||||
resp.RetailPrice = &allocation.RetailPrice
|
||||
profitMargin := allocation.RetailPrice - allocation.CostPrice
|
||||
resp.RetailPrice = packageprice.AllocationRawRetailPrice(allocation)
|
||||
retailPriceConfigStatus := allocation.RetailPriceConfigStatus
|
||||
resp.RetailPriceConfigStatus = &retailPriceConfigStatus
|
||||
effectiveRetailPrice := packageprice.AllocationEffectiveRetailPrice(allocation)
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
resp.ShelfStatus = allocation.ShelfStatus
|
||||
}
|
||||
} else {
|
||||
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
}
|
||||
|
||||
resp.StatusName = constants.GetStatusName(resp.Status)
|
||||
@@ -635,13 +689,16 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
PackageName: pkg.PackageName,
|
||||
SeriesID: seriesID,
|
||||
PackageType: pkg.PackageType,
|
||||
IsGift: pkg.IsGift,
|
||||
DurationMonths: pkg.DurationMonths,
|
||||
RealDataMB: pkg.RealDataMB,
|
||||
VirtualDataMB: pkg.VirtualDataMB,
|
||||
EnableVirtualData: pkg.EnableVirtualData,
|
||||
VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB),
|
||||
CostPrice: pkg.CostPrice,
|
||||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||||
SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg),
|
||||
PriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus),
|
||||
CalendarType: pkg.CalendarType,
|
||||
DurationDays: durationDays,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
@@ -655,11 +712,18 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
||||
if allocationMap != nil {
|
||||
if allocation, ok := allocationMap[pkg.ID]; ok {
|
||||
resp.CostPrice = allocation.CostPrice
|
||||
resp.RetailPrice = &allocation.RetailPrice
|
||||
profitMargin := allocation.RetailPrice - allocation.CostPrice
|
||||
resp.RetailPrice = packageprice.AllocationRawRetailPrice(allocation)
|
||||
retailPriceConfigStatus := allocation.RetailPriceConfigStatus
|
||||
resp.RetailPriceConfigStatus = &retailPriceConfigStatus
|
||||
effectiveRetailPrice := packageprice.AllocationEffectiveRetailPrice(allocation)
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
profitMargin := effectiveRetailPrice - allocation.CostPrice
|
||||
resp.ProfitMargin = &profitMargin
|
||||
resp.ShelfStatus = allocation.ShelfStatus
|
||||
}
|
||||
} else {
|
||||
effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
resp.EffectiveRetailPrice = &effectiveRetailPrice
|
||||
}
|
||||
|
||||
// 填充返佣信息(仅代理用户可见)
|
||||
@@ -728,6 +792,28 @@ func (s *Service) buildTierInfo(tiers []model.OneTimeCommissionTier, currentAmou
|
||||
return tierInfo
|
||||
}
|
||||
|
||||
func validateGiftPackagePermission(ctx context.Context, isGift bool) error {
|
||||
if !isGift {
|
||||
return nil
|
||||
}
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台侧可创建或维护赠送套餐")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func packagePriceConfigStatusName(status int) string {
|
||||
switch status {
|
||||
case constants.PackagePriceConfigStatusGiftZero:
|
||||
return "赠送0价"
|
||||
case constants.PackagePriceConfigStatusConfigured:
|
||||
return "已配置非0"
|
||||
default:
|
||||
return "未配置"
|
||||
}
|
||||
}
|
||||
|
||||
// formatAmount 格式化金额为可读字符串(分转元)
|
||||
func formatAmount(amountFen int64) string {
|
||||
yuan := float64(amountFen) / 100
|
||||
|
||||
114
internal/service/packageprice/policy.go
Normal file
114
internal/service/packageprice/policy.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package packageprice 提供套餐价格与赠送语义的统一判定逻辑
|
||||
// 负责原始价格、价格状态、生效价和赠送限制的集中计算
|
||||
package packageprice
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// ResolvePackagePriceState 解析套餐价格状态和落库存储值。
|
||||
func ResolvePackagePriceState(isGift bool, rawPrice *int64) (int, int64, error) {
|
||||
if isGift {
|
||||
if rawPrice == nil || *rawPrice != 0 {
|
||||
return 0, 0, errors.New(errors.CodeInvalidParam, "赠送套餐必须显式配置 0 元售价")
|
||||
}
|
||||
return constants.PackagePriceConfigStatusGiftZero, 0, nil
|
||||
}
|
||||
|
||||
if rawPrice == nil {
|
||||
return constants.PackagePriceConfigStatusUnconfigured, 0, nil
|
||||
}
|
||||
if *rawPrice == 0 {
|
||||
return 0, 0, errors.New(errors.CodeInvalidParam, "普通可售套餐不允许显式配置 0 元售价")
|
||||
}
|
||||
|
||||
return constants.PackagePriceConfigStatusConfigured, *rawPrice, nil
|
||||
}
|
||||
|
||||
// ResolveAllocationPriceState 解析代理分配零售价状态和落库存储值。
|
||||
func ResolveAllocationPriceState(rawPrice *int64) (int, int64, error) {
|
||||
if rawPrice == nil {
|
||||
return constants.PackagePriceConfigStatusUnconfigured, 0, nil
|
||||
}
|
||||
if *rawPrice == 0 {
|
||||
return 0, 0, errors.New(errors.CodeInvalidParam, "普通可售套餐不允许显式配置 0 元零售价")
|
||||
}
|
||||
|
||||
return constants.PackagePriceConfigStatusConfigured, *rawPrice, nil
|
||||
}
|
||||
|
||||
// PackageEffectiveRetailPrice 返回套餐生效售价。
|
||||
func PackageEffectiveRetailPrice(pkg *model.Package) int64 {
|
||||
if pkg == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch pkg.PriceConfigStatus {
|
||||
case constants.PackagePriceConfigStatusGiftZero:
|
||||
return 0
|
||||
case constants.PackagePriceConfigStatusConfigured:
|
||||
return pkg.SuggestedRetailPrice
|
||||
default:
|
||||
return pkg.CostPrice
|
||||
}
|
||||
}
|
||||
|
||||
// PackageRawSuggestedRetailPrice 返回套餐原始建议售价。
|
||||
func PackageRawSuggestedRetailPrice(pkg *model.Package) *int64 {
|
||||
if pkg == nil || pkg.PriceConfigStatus == constants.PackagePriceConfigStatusUnconfigured {
|
||||
return nil
|
||||
}
|
||||
price := pkg.SuggestedRetailPrice
|
||||
return &price
|
||||
}
|
||||
|
||||
// AllocationEffectiveRetailPrice 返回代理分配记录生效零售价。
|
||||
func AllocationEffectiveRetailPrice(allocation *model.ShopPackageAllocation) int64 {
|
||||
if allocation == nil {
|
||||
return 0
|
||||
}
|
||||
if allocation.RetailPriceConfigStatus == constants.PackagePriceConfigStatusUnconfigured {
|
||||
return allocation.CostPrice
|
||||
}
|
||||
return allocation.RetailPrice
|
||||
}
|
||||
|
||||
// AllocationRawRetailPrice 返回代理分配记录原始零售价。
|
||||
func AllocationRawRetailPrice(allocation *model.ShopPackageAllocation) *int64 {
|
||||
if allocation == nil || allocation.RetailPriceConfigStatus == constants.PackagePriceConfigStatusUnconfigured {
|
||||
return nil
|
||||
}
|
||||
price := allocation.RetailPrice
|
||||
return &price
|
||||
}
|
||||
|
||||
// ContainsGiftPackage 判断套餐列表中是否包含赠送套餐。
|
||||
func ContainsGiftPackage(packages []*model.Package) bool {
|
||||
for _, pkg := range packages {
|
||||
if pkg != nil && pkg.IsGift {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateGiftAdminOrder 校验后台赠送订单约束。
|
||||
func ValidateGiftAdminOrder(packages []*model.Package) error {
|
||||
if !ContainsGiftPackage(packages) {
|
||||
return nil
|
||||
}
|
||||
if len(packages) != 1 {
|
||||
return errors.New(errors.CodeInvalidParam, "赠送套餐必须单独创建订单")
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
if pkg == nil {
|
||||
continue
|
||||
}
|
||||
if !pkg.IsGift {
|
||||
return errors.New(errors.CodeInvalidParam, "赠送订单不能混入普通可售套餐")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,12 +4,25 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type allowGiftPackagesKey struct{}
|
||||
|
||||
// WithAllowGiftPackages 标记当前校验上下文允许后台赠送订单进入验证流程。
|
||||
func WithAllowGiftPackages(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, allowGiftPackagesKey{}, true)
|
||||
}
|
||||
|
||||
func canAllowGiftPackages(ctx context.Context) bool {
|
||||
allowed, _ := ctx.Value(allowGiftPackagesKey{}).(bool)
|
||||
return allowed
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
@@ -130,6 +143,11 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
|
||||
if pkg.SeriesID != seriesID {
|
||||
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐不在可购买范围内")
|
||||
}
|
||||
if pkg.IsGift {
|
||||
if sellerShopID > 0 || !canAllowGiftPackages(ctx) {
|
||||
return nil, 0, errors.New(errors.CodeInvalidParam, "赠送套餐仅支持后台发放")
|
||||
}
|
||||
}
|
||||
|
||||
// Package.status 为全局开关,任何渠道都必须检查
|
||||
if pkg.Status != constants.StatusEnabled {
|
||||
@@ -142,16 +160,17 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
|
||||
if allocErr != nil {
|
||||
return nil, 0, allocErr
|
||||
}
|
||||
// 零售价低于成本价时视为不可购买,防止亏损售卖
|
||||
if allocation.RetailPrice < allocation.CostPrice {
|
||||
effectiveRetailPrice := packageprice.AllocationEffectiveRetailPrice(allocation)
|
||||
// 生效零售价低于成本价时视为不可购买,防止亏损售卖
|
||||
if effectiveRetailPrice < allocation.CostPrice {
|
||||
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐价格配置异常,暂不可购买")
|
||||
}
|
||||
totalPrice += allocation.RetailPrice
|
||||
totalPrice += effectiveRetailPrice
|
||||
} else {
|
||||
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
||||
}
|
||||
totalPrice += pkg.SuggestedRetailPrice
|
||||
totalPrice += packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
}
|
||||
|
||||
packages = append(packages, pkg)
|
||||
@@ -188,9 +207,9 @@ func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, sell
|
||||
}
|
||||
return 0, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
|
||||
}
|
||||
return allocation.RetailPrice, nil
|
||||
return packageprice.AllocationEffectiveRetailPrice(allocation), nil
|
||||
}
|
||||
return pkg.SuggestedRetailPrice, nil
|
||||
return packageprice.PackageEffectiveRetailPrice(pkg), nil
|
||||
}
|
||||
|
||||
// GetCostPrice 获取代理渠道的套餐成本价
|
||||
|
||||
@@ -103,6 +103,7 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: costPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &seriesAllocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
@@ -127,7 +128,15 @@ func (s *Service) getEnabledPackagesBySeries(ctx context.Context, seriesID uint)
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐列表失败")
|
||||
}
|
||||
|
||||
return packages, nil
|
||||
filtered := make([]*model.Package, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
if pkg == nil || pkg.IsGift {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, pkg)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (s *Service) calculateAdjustedPrice(basePrice int64, adjustment *dto.PriceAdjustment) int64 {
|
||||
|
||||
@@ -181,6 +181,9 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
|
||||
if pkgErr != nil {
|
||||
continue
|
||||
}
|
||||
if pkg.IsGift {
|
||||
continue
|
||||
}
|
||||
packages = append(packages, dto.ShopSeriesGrantPackageItem{
|
||||
PackageID: pa.PackageID,
|
||||
PackageName: pkg.PackageName,
|
||||
@@ -325,6 +328,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
if pkgErr != nil || pkg.SeriesID != req.SeriesID {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
|
||||
}
|
||||
if pkg.IsGift {
|
||||
return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
|
||||
}
|
||||
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配
|
||||
if allocatorShopID > 0 {
|
||||
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocatorShopID, item.PackageID)
|
||||
@@ -338,6 +344,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
||||
AllocatorShopID: allocatorShopID,
|
||||
CostPrice: item.CostPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
@@ -660,6 +667,9 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
if pkgErr != nil || pkg.SeriesID != allocation.SeriesID {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
|
||||
}
|
||||
if pkg.IsGift {
|
||||
return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
|
||||
}
|
||||
if allocation.AllocatorShopID > 0 {
|
||||
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID)
|
||||
if authErr != nil {
|
||||
@@ -672,6 +682,7 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
||||
AllocatorShopID: allocation.AllocatorShopID,
|
||||
CostPrice: item.CostPrice,
|
||||
RetailPrice: pkg.SuggestedRetailPrice,
|
||||
RetailPriceConfigStatus: pkg.PriceConfigStatus,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
|
||||
@@ -63,6 +63,7 @@ func (s *PackageStore) List(ctx context.Context, opts *store.QueryOptions, filte
|
||||
query = query.Joins("INNER JOIN tb_shop_package_allocation ON tb_shop_package_allocation.package_id = tb_package.id AND tb_shop_package_allocation.deleted_at IS NULL").
|
||||
Where("tb_shop_package_allocation.shop_id = ? AND tb_shop_package_allocation.status = ?",
|
||||
shopID, constants.StatusEnabled)
|
||||
query = query.Where("tb_package.is_gift = ?", false)
|
||||
}
|
||||
|
||||
if packageName, ok := filters["package_name"].(string); ok && packageName != "" {
|
||||
|
||||
@@ -139,13 +139,14 @@ func (s *ShopPackageAllocationStore) UpdateShelfStatus(ctx context.Context, id u
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *ShopPackageAllocationStore) UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, updater uint) error {
|
||||
func (s *ShopPackageAllocationStore) UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, priceConfigStatus int, updater uint) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&model.ShopPackageAllocation{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"retail_price": retailPrice,
|
||||
"updater": updater,
|
||||
"retail_price": retailPrice,
|
||||
"retail_price_config_status": priceConfigStatus,
|
||||
"updater": updater,
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
@@ -337,7 +338,10 @@ func (h *AutoPurchaseHandler) loadPackages(ctx context.Context, packageIDs []uin
|
||||
|
||||
totalAmount := int64(0)
|
||||
for _, pkg := range packages {
|
||||
totalAmount += pkg.SuggestedRetailPrice
|
||||
if pkg.IsGift {
|
||||
return nil, 0, errors.New("赠送套餐不能进入自动购包链路")
|
||||
}
|
||||
totalAmount += packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
}
|
||||
|
||||
if err := validatePackageTypeMix(packages); err != nil {
|
||||
@@ -399,16 +403,19 @@ func (h *AutoPurchaseHandler) buildOrderAndItems(
|
||||
|
||||
items := make([]*model.OrderItem, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
unitPrice := packageprice.PackageEffectiveRetailPrice(pkg)
|
||||
items = append(items, &model.OrderItem{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: rechargeOrder.UserID,
|
||||
Updater: rechargeOrder.UserID,
|
||||
},
|
||||
PackageID: pkg.ID,
|
||||
PackageName: pkg.PackageName,
|
||||
Quantity: 1,
|
||||
UnitPrice: pkg.SuggestedRetailPrice,
|
||||
Amount: pkg.SuggestedRetailPrice,
|
||||
PackageID: pkg.ID,
|
||||
PackageName: pkg.PackageName,
|
||||
Quantity: 1,
|
||||
UnitPrice: unitPrice,
|
||||
Amount: unitPrice,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -525,6 +532,8 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
PendingRealnameActivation: pendingRealnameActivation,
|
||||
Generation: order.Generation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
|
||||
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
||||
@@ -604,6 +613,8 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
Generation: order.Generation,
|
||||
PaidAmount: order.ActualPaidAmount,
|
||||
PackagePriceConfigStatus: pkg.PriceConfigStatus,
|
||||
PackageIsGift: pkg.IsGift,
|
||||
}
|
||||
|
||||
if carrierType == constants.AssetWalletResourceTypeIotCard {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 回滚套餐价格状态、赠送策略及人工复核清单表
|
||||
|
||||
DROP TABLE IF EXISTS tb_package_price_review_queue;
|
||||
|
||||
ALTER TABLE tb_package_usage
|
||||
DROP COLUMN IF EXISTS package_is_gift,
|
||||
DROP COLUMN IF EXISTS package_price_config_status;
|
||||
|
||||
ALTER TABLE tb_order_item
|
||||
DROP COLUMN IF EXISTS package_is_gift,
|
||||
DROP COLUMN IF EXISTS package_price_config_status;
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
DROP COLUMN IF EXISTS retail_price_config_status;
|
||||
|
||||
ALTER TABLE tb_package
|
||||
DROP COLUMN IF EXISTS is_gift,
|
||||
DROP COLUMN IF EXISTS price_config_status;
|
||||
@@ -0,0 +1,108 @@
|
||||
-- 为套餐价格回退与赠送策略补充价格状态、赠送标记和历史快照字段
|
||||
-- 同时创建人工复核清单表,承接无法确认语义的历史 0 价数据
|
||||
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS price_config_status INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS is_gift BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN IF NOT EXISTS retail_price_config_status INT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE tb_order_item
|
||||
ADD COLUMN IF NOT EXISTS package_price_config_status INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS package_is_gift BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE tb_package_usage
|
||||
ADD COLUMN IF NOT EXISTS package_price_config_status INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS package_is_gift BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN tb_package.price_config_status IS '价格配置状态 0-未配置 1-赠送0价 2-已配置非0';
|
||||
COMMENT ON COLUMN tb_package.is_gift IS '是否赠送套餐 true-赠送 false-普通可售';
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.retail_price_config_status IS '零售价配置状态 0-未配置 1-赠送0价 2-已配置非0';
|
||||
COMMENT ON COLUMN tb_order_item.package_price_config_status IS '套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0';
|
||||
COMMENT ON COLUMN tb_order_item.package_is_gift IS '套餐是否赠送快照 true-赠送 false-普通可售';
|
||||
COMMENT ON COLUMN tb_package_usage.package_price_config_status IS '套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0';
|
||||
COMMENT ON COLUMN tb_package_usage.package_is_gift IS '套餐是否赠送快照 true-赠送 false-普通可售';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tb_package_price_review_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
package_id BIGINT NOT NULL,
|
||||
package_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
raw_suggested_retail_price BIGINT NOT NULL DEFAULT 0,
|
||||
review_reason VARCHAR(255) NOT NULL DEFAULT '',
|
||||
review_status INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tb_package_price_review_queue IS '套餐价格人工复核清单';
|
||||
COMMENT ON COLUMN tb_package_price_review_queue.package_id IS '套餐ID';
|
||||
COMMENT ON COLUMN tb_package_price_review_queue.package_name IS '套餐名称快照';
|
||||
COMMENT ON COLUMN tb_package_price_review_queue.raw_suggested_retail_price IS '原始建议售价';
|
||||
COMMENT ON COLUMN tb_package_price_review_queue.review_reason IS '复核原因';
|
||||
COMMENT ON COLUMN tb_package_price_review_queue.review_status IS '复核状态 0-待处理 1-已处理';
|
||||
|
||||
-- 非 0 售价直接视为已配置非 0
|
||||
UPDATE tb_package
|
||||
SET price_config_status = 2,
|
||||
is_gift = FALSE
|
||||
WHERE suggested_retail_price > 0;
|
||||
|
||||
-- 显式识别“后台创建订单且 0 元成交,且未进入代理分配”的赠送套餐
|
||||
UPDATE tb_package p
|
||||
SET price_config_status = 1,
|
||||
is_gift = TRUE
|
||||
WHERE p.suggested_retail_price = 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM tb_order_item oi
|
||||
JOIN tb_order o ON o.id = oi.order_id
|
||||
WHERE oi.package_id = p.id
|
||||
AND oi.unit_price = 0
|
||||
AND o.source = 'admin'
|
||||
AND o.payment_status = 2
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tb_shop_package_allocation spa
|
||||
WHERE spa.package_id = p.id
|
||||
AND spa.deleted_at IS NULL
|
||||
AND spa.allocator_shop_id > 0
|
||||
);
|
||||
|
||||
-- 其余历史 0 价统一回填为“未配置”,并进入人工复核清单
|
||||
INSERT INTO tb_package_price_review_queue (package_id, package_name, raw_suggested_retail_price, review_reason)
|
||||
SELECT p.id,
|
||||
p.package_name,
|
||||
p.suggested_retail_price,
|
||||
'历史 0 价无法确认是否为赠送,已回填为未配置'
|
||||
FROM tb_package p
|
||||
WHERE p.suggested_retail_price = 0
|
||||
AND p.price_config_status = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tb_package_price_review_queue q
|
||||
WHERE q.package_id = p.id
|
||||
);
|
||||
|
||||
-- 分配记录:显式非 0 视为已配置,其余继承套餐层语义
|
||||
UPDATE tb_shop_package_allocation spa
|
||||
SET retail_price_config_status = CASE
|
||||
WHEN spa.retail_price > 0 THEN 2
|
||||
ELSE p.price_config_status
|
||||
END
|
||||
FROM tb_package p
|
||||
WHERE p.id = spa.package_id;
|
||||
|
||||
-- 历史价格快照补齐价格状态和赠送语义
|
||||
UPDATE tb_order_item oi
|
||||
SET package_price_config_status = p.price_config_status,
|
||||
package_is_gift = p.is_gift
|
||||
FROM tb_package p
|
||||
WHERE p.id = oi.package_id;
|
||||
|
||||
UPDATE tb_package_usage pu
|
||||
SET package_price_config_status = p.price_config_status,
|
||||
package_is_gift = p.is_gift
|
||||
FROM tb_package p
|
||||
WHERE p.id = pu.package_id;
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-28
|
||||
@@ -0,0 +1,89 @@
|
||||
## Context
|
||||
|
||||
当前套餐价格在多个链路里被直接读取,后台配置、C 端展示、订单创建、自动购包和代理授权都在用同一批字段,但它们需要的其实不是同一种值。与此同时,赠送套餐已经被业务明确为独立语义,既不是普通可售套餐的别名,也不能靠 `package_type` 或数值 0 去推断。这个变更会同时影响 Handler、Service、Store、Model 和迁移脚本,必须先把语义拆开,再改各条链路。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 建立普通可售套餐的原始价与生效价分层。
|
||||
- 在建议零售价未配置时,统一回退到成本价。
|
||||
- 普通可售套餐不能显式配置 0 作为售卖价,0 只保留给赠送语义。
|
||||
- 把赠送套餐做成独立语义,平台专属,禁止代理分配和 C 端自购。
|
||||
- 保留原始配置值,避免把回退后的值写回配置页。
|
||||
- 完成历史数据与存量记录的迁移回填,并输出人工复核清单。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不重做套餐类型体系,也不把 gift 语义塞进 `package_type`。
|
||||
- 不新增外部依赖,不改用新的队列或存储组件。
|
||||
- 不在本次变更里重构整个套餐管理域,只处理价格与赠送政策相关路径。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 价格状态与价格数值分开存
|
||||
|
||||
采用独立的价格配置状态来区分“未配置”“赠送 0”“已配置为非 0”,原始建议零售价继续保留,生效售价由服务层计算。
|
||||
|
||||
**原因**:普通可售套餐不能显式卖 0,但赠送套餐仍可能使用 0,不能再用数值 0 当哨兵值。
|
||||
|
||||
**备选方案**:
|
||||
- 继续用 0 代表未配置,最简单,但会把合法 0 价和未配置混在一起,不能接受。
|
||||
- 直接覆盖原始字段为生效价,查询简单,但配置页会丢失真实原值。
|
||||
|
||||
### 2. 赠送语义独立于价格和套餐类型
|
||||
|
||||
新增独立的赠送语义标记,平台专属限制和价格规则彼此独立。
|
||||
|
||||
**原因**:平台-only、不可分配给代理、不可自购,这些都是业务权限问题,不是价格问题。
|
||||
|
||||
**备选方案**:
|
||||
- 仅靠 `package_type=formal/addon` 推断,已经被明确否决。
|
||||
- 仅靠价格为 0 推断,无法区分合法 0 价与未配置。
|
||||
|
||||
### 3. 生效价格在 Service 层统一计算
|
||||
|
||||
Handler 只接收和返回值,不做价格推导。Store 只取原始数据,Service 统一计算生效价并下发给下游。
|
||||
|
||||
**原因**:价格规则会同时影响 C 端列表、下单、自动购包和授权链路,放在 Service 层更容易复用,也更符合分层职责。
|
||||
|
||||
**备选方案**:
|
||||
- 在 Handler 里临时计算,容易散落和重复。
|
||||
- 在 Store 里拼接业务规则,会把数据访问和业务逻辑混在一起。
|
||||
|
||||
### 4. 迁移采用“先加字段,再回填,再切流”的顺序
|
||||
|
||||
先把新语义字段和状态字段上线,再用迁移脚本回填历史数据,最后让读链路切到生效价。
|
||||
|
||||
**原因**:这样可以避免一次性改动过大导致历史数据无法解释,也方便回滚。
|
||||
|
||||
**备选方案**:
|
||||
- 直接替换旧字段,风险太高,历史记录会失真。
|
||||
- 先切读后补数据,会出现短时间内的口径不一致。
|
||||
|
||||
### 5. 不引入价格缓存
|
||||
|
||||
价格计算只依赖单条套餐和分配记录,属于 O(1) 读取,不新增 Redis 缓存层。
|
||||
|
||||
**原因**:这次变更的核心是语义和一致性,不是性能瓶颈。现有分页与查询边界已经足够。
|
||||
|
||||
**备选方案**:
|
||||
- 引入缓存可以做,但会增加失效和回填复杂度,不适合作为首版方案。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [历史数据无法准确判断是否为赠送 0 价] → 迁移脚本优先读取历史发放记录和配置快照,只有能明确识别为赠送 0 的记录才保留为赠送 0,其余旧 0 一律转为未配置并输出待人工核验清单。
|
||||
- [赠送语义改动会影响多条购买链路] → 先在 Service 层统一封装判定函数,再逐步替换各入口的直接读取。
|
||||
- [价格展示口径切换后前端可能误解返回值] → 接口文档明确区分原始值、生效值和赠送语义,前后端一起切换。
|
||||
- [存量分配记录字段回填出错] → 迁移前后保留回滚脚本,按批次执行并校验记录数。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 增加价格状态字段、赠送语义字段和平台专属标记,保持原始价格字段不变。
|
||||
2. 回填存量套餐的价格状态和生效价,优先使用历史配置与发放记录判断。只有能明确识别为赠送 0 的记录才保留为赠送 0,其余旧 0 一律转为未配置,并输出人工复核清单。
|
||||
3. 回填分配记录、历史订单和自动购包相关快照,让展示链路能读取生效价。
|
||||
4. 切换 Service 层统一价格解析与赠送校验,再切换 Handler 输出。
|
||||
5. 验证完成后,清理旧的直接读取路径,保留原始值用于配置页和审计。
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- 人工复核清单只负责承接无法正向识别的旧 0 数据,不回写为赠送语义。
|
||||
- 赠送套餐历史识别优先以后台发放记录为准,补录清单只作为迁移输入,不改变判定标准。
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
当前套餐价格在后台采购、C 端展示、自动购包和分配记录里口径不一致,`suggested_retail_price` 被直接当成展示价和成交价使用,空配置、旧 0 价和赠送语义也混在一起。现在业务已经明确两件事,普通可售套餐不能显式卖 0,赠送套餐则是独立语义,必须平台专属、仅后端发放,不能再继续靠价格字段临时凑语义。
|
||||
|
||||
## What Changes
|
||||
|
||||
- feature-001-package-effective-price-fallback:普通可售套餐在建议零售价未配置时,生效售价回退到成本价,覆盖后台采购、C 端展示与下单、自动购包及相关链路。
|
||||
- feature-002-package-raw-vs-effective-price-view:配置页和编辑页继续展示原始值,销售页和购买页切换为展示生效值,避免把“配置值”和“成交值”混为一谈。
|
||||
- feature-003-gift-package-independent-semantic:新增赠送套餐独立语义,不再依赖套餐类型去硬解释赠送行为,且必须与价格语义分离。
|
||||
- feature-004-platform-only-gift-package-policy:赠送套餐仅允许平台侧创建和发放,不能分配给代理,也不能出现在 C 端可购买套餐列表中。
|
||||
- feature-005-backend-grant-not-self-purchase:赠送加油包只能通过后台发放进入用户资产,不允许用户自购或前台下单获得。
|
||||
- feature-006-history-and-migration:补齐历史数据与迁移规则,保证存量数据只保留可明确识别的“赠送 0 价”,其余旧 0 一律转为未配置并进入人工复核清单。
|
||||
- **BREAKING** 价格与赠送语义不再共享同一含义,任何依赖旧口径的列表、下单、分配和展示链路都需要按新规则调整。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `package-price-policy`:定义普通可售套餐的原始价、生效价、回退规则、展示口径和历史迁移规则。
|
||||
- `gift-package-policy`:定义赠送套餐的独立语义、平台专属限制、后端发放规则和 C 端可见性规则。
|
||||
|
||||
### Modified Capabilities
|
||||
- `package-management`:套餐配置、编辑和详情页要按原始价与生效价分层展示。
|
||||
- `package-purchase-validation`:购买校验要使用生效价,并排除赠送套餐的自购路径。
|
||||
- `client-asset-info`:C 端可购买套餐列表要按生效价展示,并隐藏不可购买的赠送套餐。
|
||||
- `client-order-purchase`:C 端列表、下单和自动购包要使用生效价,并隐藏不可购买的赠送套餐。
|
||||
- `agent-series-grant`:套餐授权和发放链路要识别赠送套餐的平台专属属性,禁止向代理分配。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响模块:`internal/service/purchase_validation/service.go`、`internal/handler/app/client_asset.go`、`internal/service/client_order/service.go`、`internal/service/order/service.go`、`internal/task/auto_purchase.go`、`internal/service/shop_package_batch_allocation/service.go`、`internal/service/shop_series_grant/service.go`。
|
||||
- 影响数据:套餐模型、分配记录、历史回填字段和迁移脚本都需要支持原始值、生效值与赠送语义的并存。
|
||||
- 影响 API:后台套餐管理接口、C 端套餐列表与下单接口、代理授权与平台发放接口都会出现返回值和校验规则变化。
|
||||
- 影响文档:需要同步更新 OpenSpec、接口文档和字段说明,确保“未配置价格”“赠送 0 价”“平台专属不可分配”三种语义不会再混用。
|
||||
- 验证方式:实现阶段按项目约束使用手工接口验证和 PostgreSQL 数据核验,不引入新的自动化测试要求。
|
||||
@@ -0,0 +1,17 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 赠送套餐不可纳入代理系列授权
|
||||
|
||||
系统 SHALL 将赠送套餐视为平台专属资产,禁止将其加入代理系列授权、套餐分配或任何下级可见的授权列表。赠送套餐的发放 MUST 走后台发放链路,而不是代理授权链路。
|
||||
|
||||
#### Scenario: 代理创建授权时包含赠送套餐
|
||||
- **WHEN** 代理在系列授权中尝试加入一个赠送套餐
|
||||
- **THEN** 系统返回错误并拒绝创建
|
||||
|
||||
#### Scenario: 平台后台发放赠送套餐
|
||||
- **WHEN** 平台管理员通过后台发放流程授予赠送套餐
|
||||
- **THEN** 系统允许创建发放记录,但不生成代理可分配权限
|
||||
|
||||
#### Scenario: 授权列表隐藏赠送套餐
|
||||
- **WHEN** 系统查询任意代理系列授权的可选套餐列表
|
||||
- **THEN** 赠送套餐 MUST 不出现在列表中
|
||||
@@ -0,0 +1,13 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: B2 可购买套餐列表接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取分配记录上的生效售价,平台渠道取套餐生效售价。若建议零售价未配置,系统 SHALL 使用成本价作为生效售价。过滤规则 MUST 同时满足:`Package.status=1`、`shelf_status` 可售、加油包前置主套餐条件成立,并且 MUST 排除赠送套餐。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id`、`package_name`、`package_type`、`retail_price`、`cost_price`、`validity`、`is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`PACKAGE_NOT_AVAILABLE/当前无可购买套餐`。
|
||||
|
||||
#### Scenario: 代理渠道价格与过滤生效
|
||||
- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐建议零售价未配置
|
||||
- **THEN** 系统仅返回可售且满足价格约束的套餐,并按生效价格升序输出
|
||||
|
||||
#### Scenario: 赠送套餐不出现在可购列表
|
||||
- **WHEN** 赠送套餐已存在于后台数据中
|
||||
- **THEN** C 端可购买套餐列表 MUST 不返回该套餐
|
||||
@@ -0,0 +1,17 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 订单与自动购包价格规则
|
||||
|
||||
系统 SHALL 在既有订单创建和自动购包流程中统一使用生效售价。普通可售套餐若建议零售价未配置,则生效售价 MUST 回退到成本价;赠送套餐 MUST NOT 通过这些自购或自动购包路径创建。
|
||||
|
||||
#### Scenario: 普通可售套餐未配置建议零售价
|
||||
- **WHEN** 订单创建或自动购包处理一个普通可售套餐,且其建议零售价未配置
|
||||
- **THEN** 系统 SHALL 使用成本价作为生效售价
|
||||
|
||||
#### Scenario: 普通可售套餐已配置建议零售价
|
||||
- **WHEN** 订单创建或自动购包处理一个普通可售套餐,且其建议零售价已配置
|
||||
- **THEN** 系统 SHALL 使用已配置的建议零售价作为生效售价
|
||||
|
||||
#### Scenario: 赠送套餐进入购买路径
|
||||
- **WHEN** 任一自购或自动购包路径命中赠送套餐
|
||||
- **THEN** 系统 MUST 拒绝创建订单
|
||||
@@ -0,0 +1,49 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 赠送套餐语义必须显式标记,且 0 价仅用于赠送语义
|
||||
|
||||
系统 MUST 将赠送套餐视为独立业务语义,不能仅通过 `package_type` 推导赠送含义。若套餐显式配置为 0 价,则该 0 价 MUST 仅用于赠送语义。
|
||||
|
||||
#### Scenario: 赠送语义不能仅靠 package_type 推导
|
||||
- **WHEN** 套餐类型为 formal 或 addon,但未显式标记为赠送
|
||||
- **THEN** 系统 MUST NOT 仅凭套餐类型判断其是否为赠送套餐
|
||||
|
||||
#### Scenario: 赠送套餐显式使用 0 价
|
||||
- **WHEN** 平台创建赠送套餐并将建议零售价显式配置为 0
|
||||
- **THEN** 系统 MUST 保留该 0 价配置并将其识别为赠送语义
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 赠送套餐仅限平台侧且不可分配给代理
|
||||
|
||||
系统 SHALL 将赠送套餐限定为平台侧能力。赠送套餐 MUST NOT 被分配给代理,也 MUST NOT 出现在任何代理可操作的套餐授权列表中。
|
||||
|
||||
#### Scenario: 平台可创建赠送套餐
|
||||
- **WHEN** 平台管理员创建赠送套餐
|
||||
- **THEN** 系统允许保存该赠送语义
|
||||
|
||||
#### Scenario: 代理不能分配赠送套餐
|
||||
- **WHEN** 代理尝试将赠送套餐加入自己的授权或分配给下级
|
||||
- **THEN** 系统返回错误并拒绝操作
|
||||
|
||||
#### Scenario: 赠送套餐不进入代理可售列表
|
||||
- **WHEN** 系统生成代理可见的套餐授权列表
|
||||
- **THEN** 赠送套餐 MUST 被过滤掉
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 赠送套餐仅能后台发放,不能前台自购
|
||||
|
||||
系统 SHALL 仅允许通过后台发放流程创建赠送套餐资产。C 端与前台购买路径 MUST NOT 直接购买赠送套餐,且赠送套餐 MUST NOT 出现在 C 端可购买套餐列表中。
|
||||
|
||||
#### Scenario: 后台发放赠送加油包
|
||||
- **WHEN** 平台通过后台发放赠送加油包
|
||||
- **THEN** 系统创建发放记录并授予目标资产
|
||||
|
||||
#### Scenario: C 端不能自购赠送套餐
|
||||
- **WHEN** 个人客户在 C 端尝试购买赠送套餐
|
||||
- **THEN** 系统返回不可购买错误
|
||||
|
||||
#### Scenario: 赠送套餐不出现在 C 端可购列表
|
||||
- **WHEN** C 端请求可购买套餐列表
|
||||
- **THEN** 系统 MUST 不返回任何赠送套餐
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 套餐配置页展示原始价格与价格状态
|
||||
|
||||
系统 SHALL 在套餐创建、编辑、详情接口中返回原始建议成本价、原始建议零售价和价格配置状态。配置页 MUST 以原始值为准展示,不得用生效价覆盖原始值。
|
||||
|
||||
普通可售套餐 MUST NOT 把显式配置的建议零售价 0 作为正常销售路径;该 0 价仅应保留给赠送语义使用。
|
||||
|
||||
#### Scenario: 编辑页展示未配置价格
|
||||
- **WHEN** 管理员打开一个建议零售价未配置的套餐编辑页
|
||||
- **THEN** 页面 SHALL 显示原始建议零售价为空或未配置状态
|
||||
|
||||
#### Scenario: 编辑页展示赠送 0 价
|
||||
- **WHEN** 管理员打开一个已标记为赠送语义且建议零售价显式配置为 0 的套餐编辑页
|
||||
- **THEN** 页面 SHALL 显示原始建议零售价为 0,并同时展示赠送语义与已配置状态
|
||||
|
||||
#### Scenario: 普通可售套餐创建时填写 0 被拒绝
|
||||
- **WHEN** 管理员创建一个普通可售套餐,并将建议零售价填写为 0
|
||||
- **THEN** 系统 SHALL 拒绝保存
|
||||
- **THEN** 系统 SHALL 提示普通可售套餐不允许显式 0
|
||||
|
||||
#### Scenario: 普通可售套餐编辑时改成 0 被拒绝
|
||||
- **WHEN** 管理员编辑一个普通可售套餐,并将原建议零售价修改为 0
|
||||
- **THEN** 系统 SHALL 拒绝保存
|
||||
- **THEN** 系统 SHALL 保持原有价格状态不变
|
||||
|
||||
#### Scenario: 详情页不回填生效价
|
||||
- **WHEN** 管理员查看套餐详情
|
||||
- **THEN** 系统 SHALL 返回原始价格字段,而不是把回退后的生效价写回原始字段
|
||||
@@ -0,0 +1,57 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 价格配置状态与原始值分离
|
||||
|
||||
系统 MUST 同时保存套餐价格的原始配置值和价格配置状态,且 MUST 区分以下三种语义:未配置、赠送 0、已配置为非 0。普通可售套餐 MUST NOT 显式配置 0 作为售卖价,0 仅可用于赠送语义。配置页与编辑页 SHALL 展示原始值,不能用生效价覆盖原始值。
|
||||
|
||||
#### Scenario: 未配置价格与 0 价必须可区分
|
||||
- **WHEN** 管理员创建两个套餐,一个建议零售价未配置,一个已被标记为赠送且建议零售价显式配置为 0
|
||||
- **THEN** 系统 MUST 为两者保存不同的价格配置状态
|
||||
- **THEN** 系统 MUST NOT 仅依赖数值 0 判断价格是否已配置
|
||||
|
||||
#### Scenario: 普通可售套餐显式 0 被拒绝
|
||||
- **WHEN** 管理员创建或编辑一个普通可售套餐,并将建议零售价填写为 0
|
||||
- **THEN** 系统 MUST 拒绝保存
|
||||
- **THEN** 系统 MUST 返回普通可售套餐不允许显式 0 的错误
|
||||
|
||||
#### Scenario: 配置页展示原始值
|
||||
- **WHEN** 管理员进入套餐详情或编辑页
|
||||
- **THEN** 页面 SHALL 展示原始建议成本价和原始建议零售价
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 普通可售套餐的生效售价回退
|
||||
|
||||
系统 SHALL 对普通可售套餐计算生效售价。若建议零售价未配置,生效售价 MUST 回退为成本价。该规则 MUST 适用于后台采购、C 端展示与下单、自动购包以及所有依赖套餐成交价的链路。
|
||||
|
||||
#### Scenario: 建议零售价未配置时回退到成本价
|
||||
- **WHEN** 普通可售套餐的建议零售价未配置,成本价为 8000 分
|
||||
- **THEN** 系统返回的生效售价 MUST 为 8000 分
|
||||
|
||||
#### Scenario: 建议零售价已配置时保持原值
|
||||
- **WHEN** 普通可售套餐的建议零售价已配置为 12000 分,成本价为 8000 分
|
||||
- **THEN** 系统返回的生效售价 MUST 为 12000 分
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 历史数据回填与迁移
|
||||
|
||||
系统 MUST 对存量套餐、分配记录和订单相关价格快照执行迁移回填,补齐价格配置状态和生效价字段。迁移后,只有能被正向识别为赠送 0 的记录才保留为赠送 0,其余历史 0 数据 MUST 转为未配置并进入人工复核清单。
|
||||
|
||||
#### Scenario: 存量未配置价格被回填为未配置状态
|
||||
- **WHEN** 迁移执行前某套餐历史上从未配置过建议零售价
|
||||
- **THEN** 迁移后该套餐 MUST 保持未配置状态
|
||||
- **THEN** 其生效售价 MUST 按成本价回填
|
||||
|
||||
#### Scenario: 正向识别的赠送 0 被保留
|
||||
- **WHEN** 迁移执行前某套餐的建议零售价为 0,且历史记录能明确证明其为赠送语义
|
||||
- **THEN** 迁移后系统 MUST 保留赠送 0 状态
|
||||
|
||||
#### Scenario: 模糊旧 0 转为未配置并进入人工复核
|
||||
- **WHEN** 迁移执行前某套餐的建议零售价为 0,但历史记录无法明确证明其为赠送语义
|
||||
- **THEN** 迁移后系统 MUST 将其转为未配置状态
|
||||
- **THEN** 系统 MUST 将该记录加入人工复核清单
|
||||
|
||||
#### Scenario: 历史快照按生效价展示
|
||||
- **WHEN** 用户查询历史订单或历史分配记录
|
||||
- **THEN** 系统 SHALL 使用迁移后的生效价字段进行展示
|
||||
@@ -0,0 +1,48 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 获取购买价格
|
||||
|
||||
系统 MUST 根据买家身份返回正确的购买价格,并且 MUST 使用生效售价,不得直接把未配置的建议零售价当成交价返回。个人客户、平台自营和自动购包路径 SHALL 以套餐生效售价为准;代理为店铺购买时 SHALL 以分配记录上的生效售价为准。
|
||||
|
||||
#### Scenario: 个人客户购买
|
||||
- **WHEN** 个人客户购买一个普通可售套餐,且建议零售价未配置
|
||||
- **THEN** 系统 SHALL 返回回退后的生效售价,金额等于成本价
|
||||
|
||||
#### Scenario: 代理为店铺购买
|
||||
- **WHEN** 代理为自己店铺购买套餐
|
||||
- **THEN** 系统 SHALL 返回该分配记录对应的生效售价
|
||||
|
||||
---
|
||||
|
||||
### Requirement: validatePackages() 价格累加与展示校验
|
||||
|
||||
系统 MUST 在 `validatePackages()` 中按渠道来源使用一致的生效售价进行累加计算,并在代理渠道保持价格展示可见性校验。未配置建议零售价时,累计金额 MUST 使用回退后的生效售价,而不是原始空值。
|
||||
|
||||
#### Scenario: 代理渠道累加使用生效售价
|
||||
- **WHEN** `validatePackages()` 处理代理渠道的多套餐下单
|
||||
- **THEN** 总价累加 MUST 基于各套餐的生效售价
|
||||
|
||||
#### Scenario: 平台渠道累加使用生效售价
|
||||
- **WHEN** `validatePackages()` 处理平台渠道的多套餐下单
|
||||
- **THEN** 总价累加 MUST 基于各套餐的生效售价
|
||||
|
||||
#### Scenario: 未配置价格不再触发错误展示
|
||||
- **WHEN** 某个普通可售套餐的建议零售价未配置
|
||||
- **THEN** 系统 SHALL 使用回退后的生效售价继续校验
|
||||
- **THEN** 系统 MUST NOT 因原始建议零售价为空而把该套餐当成异常价格直接拦截
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 赠送套餐禁止进入自购下单
|
||||
|
||||
系统 SHALL 拒绝任何用户通过自购路径购买赠送套餐。赠送加油包只能通过后台发放进入资产,不能通过 C 端或后台自助下单购买。
|
||||
|
||||
赠送场景 SHALL 仅允许使用显式配置的建议零售价 0,但该 0 价仅表示赠送语义,不能作为普通可售套餐的正常销售路径。
|
||||
|
||||
#### Scenario: C 端自购赠送套餐被拦截
|
||||
- **WHEN** 个人客户尝试购买一个启用赠送语义的套餐
|
||||
- **THEN** 系统返回不可购买错误,订单不创建
|
||||
|
||||
#### Scenario: 后台自助下单赠送套餐被拦截
|
||||
- **WHEN** 后台操作者尝试通过普通购买接口创建赠送套餐订单
|
||||
- **THEN** 系统 MUST 拒绝该订单
|
||||
@@ -0,0 +1,33 @@
|
||||
## 1. 验证准备
|
||||
|
||||
- [x] 1.1 梳理现有价格读取、赠送发放和分配链路,整理一份手工验证清单,覆盖后台配置、C 端列表、下单、自动购包和授权发放。
|
||||
- [x] 1.2 记录当前会受影响的接口与数据表,确认回滚点和历史数据来源,避免后续迁移时把未配置价格、赠送 0 价和旧的模糊 0 数据混在一起。
|
||||
- [x] 1.3 对涉及的现有实现文件先跑一次 `lsp_diagnostics`,保留基线结果,作为改动前对比。
|
||||
|
||||
## 2. 数据模型与迁移
|
||||
|
||||
- [x] 2.1 为套餐及相关价格快照补充价格状态、赠送语义和平台专属标记的迁移设计,保持原始价格字段不变,并明确普通可售套餐不能显式配置 0。
|
||||
- [x] 2.2 编写 golang-migrate 迁移文件,先加字段,再回填存量数据,确保历史记录能区分未配置价格、可明确识别的赠送 0 价和需要人工复核的旧 0 数据。
|
||||
- [x] 2.3 补齐历史回填逻辑和回滚脚本,完成后用数据库核验确认字段状态、默认值、人工复核清单和历史记录数量一致。
|
||||
- [x] 2.4 对迁移相关文件和模型定义运行 `lsp_diagnostics`,确认没有语法或引用错误。
|
||||
|
||||
## 3. 价格回退与展示链路
|
||||
|
||||
- [x] 3.1 提炼统一的生效价解析方法,确保普通可售套餐在建议零售价未配置时回退到成本价,同时保留原始值供配置页使用。
|
||||
- [x] 3.2 更新后台套餐配置、编辑和详情返回值,只展示原始价格与价格状态,不把生效价写回配置字段。
|
||||
- [x] 3.3 更新 C 端可购套餐列表、订单创建和自动购包链路,统一使用生效价累加、展示和落单。
|
||||
- [ ] 3.4 对价格回退链路做接口回放和手工验收,确认未配置价格回退正确、普通可售套餐显式 0 被拒绝、赠送 0 保持赠送语义。
|
||||
- [x] 3.5 对改动后的价格相关服务、处理器和任务文件运行 `lsp_diagnostics`。
|
||||
|
||||
## 4. 赠送套餐政策与授权发放
|
||||
|
||||
- [x] 4.1 在套餐授权与分配链路加入赠送语义校验,禁止代理拿到、转发或分配赠送套餐。
|
||||
- [x] 4.2 在 C 端可购列表和购买校验里隐藏并拦截赠送套餐,确保用户无法通过自购路径获取。
|
||||
- [x] 4.3 保留后台发放入口的赠送套餐授予能力,确保赠送加油包只能通过后台 grant 进入资产,同时在后台创建、编辑时拒绝非赠送套餐的显式 0。
|
||||
- [ ] 4.4 对赠送政策相关的服务、接口和任务文件运行 `lsp_diagnostics`,并用正反例手工核验平台专属与不可自购规则。
|
||||
|
||||
## 5. 文档与最终验收
|
||||
|
||||
- [x] 5.1 更新接口文档、字段说明和变更说明,明确原始值、生效值、未配置价格、赠送 0 价和普通套餐显式 0 被拒绝的区别。
|
||||
- [x] 5.2 重新整理一份最终验收清单,覆盖价格回退、赠送拦截、平台发放和历史迁移四类核心场景。
|
||||
- [ ] 5.3 对所有改动文件做最终 `lsp_diagnostics`,再执行一次完整手工回归,确认没有遗漏的旧口径入口。
|
||||
@@ -0,0 +1,98 @@
|
||||
# 实施基线与手工验证清单
|
||||
|
||||
## 一、当前受影响接口与链路
|
||||
|
||||
### 1. 后台套餐管理
|
||||
- `GET /api/admin/packages`
|
||||
- `GET /api/admin/packages/:id`
|
||||
- `POST /api/admin/packages`
|
||||
- `PUT /api/admin/packages/:id`
|
||||
- `PUT /api/admin/packages/:id/shelf-status`
|
||||
- `PUT /api/admin/packages/:id/retail-price`
|
||||
|
||||
### 2. C 端可购套餐与下单
|
||||
- `GET /api/c/v1/asset/packages?identifier=xxx`
|
||||
- C 端创建订单链路(`internal/service/client_order/service.go`)
|
||||
- 充值后自动购包链路(`internal/task/auto_purchase.go`)
|
||||
|
||||
### 3. 后台订单与发放链路
|
||||
- 后台创建订单(`internal/service/order/service.go`)
|
||||
- 套餐激活链路(`internal/service/order/service.go`、`internal/service/package/activation_service.go`)
|
||||
|
||||
### 4. 代理授权与分配
|
||||
- 系列授权创建/维护(`internal/service/shop_series_grant/service.go`)
|
||||
- 批量分配(`internal/service/shop_package_batch_allocation/service.go`)
|
||||
|
||||
## 二、当前受影响数据表
|
||||
|
||||
- `tb_package`:套餐主数据,当前仅有 `cost_price`、`suggested_retail_price`
|
||||
- `tb_shop_package_allocation`:代理分配记录,当前仅有 `cost_price`、`retail_price`
|
||||
- `tb_order`:订单主表
|
||||
- `tb_order_item`:订单价格快照
|
||||
- `tb_package_usage`:套餐使用记录快照
|
||||
- `tb_shop_package_allocation_price_history`:当前仅记录成本价变更历史
|
||||
|
||||
## 三、回滚点与历史数据来源
|
||||
|
||||
### 1. 回滚点
|
||||
- 迁移文件回滚:撤销新增字段、清理人工复核清单表
|
||||
- Service 层回滚:恢复到直接读取 `suggested_retail_price` / `retail_price` 的旧逻辑
|
||||
- 发放策略回滚:撤销后台赠送订单专属分支,恢复统一后台订单逻辑
|
||||
|
||||
### 2. 历史数据判定来源
|
||||
- `tb_package.suggested_retail_price`
|
||||
- `tb_shop_package_allocation.retail_price`
|
||||
- `tb_order_item.unit_price`
|
||||
- `tb_order.total_amount` / `tb_order.actual_paid_amount`
|
||||
- `tb_package_usage.paid_amount`
|
||||
- 后台创建订单记录(用于识别“发放=后台创建订单”的赠送语义)
|
||||
|
||||
## 四、改动前诊断基线
|
||||
|
||||
### 1. 无阻塞诊断错误的核心文件
|
||||
- `internal/model/package.go`
|
||||
- `internal/service/purchase_validation/service.go`
|
||||
- `internal/service/client_order/service.go`
|
||||
- `internal/task/auto_purchase.go`
|
||||
- `internal/service/shop_package_batch_allocation/service.go`
|
||||
- `internal/service/shop_series_grant/service.go`
|
||||
|
||||
### 2. 已知基线提示(非本次改动引入)
|
||||
- `internal/service/order/service.go` 存在若干 `QF1003` 提示、`unusedparams` 提示和既有 `nilness` warning,作为改动前基线保留
|
||||
- `internal/handler/app/client_asset.go` 存在 `unusedfunc` 基线提示
|
||||
|
||||
## 五、手工验证清单
|
||||
|
||||
### 1. 后台套餐配置
|
||||
- 创建普通可售套餐,建议售价留空,保存成功
|
||||
- 创建普通可售套餐,建议售价填 `0`,保存失败
|
||||
- 创建赠送套餐,建议售价填 `0`,保存成功
|
||||
- 编辑普通可售套餐将建议售价改成 `0`,保存失败
|
||||
- 详情页/编辑页能区分“未配置价格”和“赠送 0 价”
|
||||
|
||||
### 2. C 端可购列表
|
||||
- 平台渠道普通套餐未配置建议售价时,列表显示成本价
|
||||
- 代理渠道分配记录未配置零售价时,列表显示成本价
|
||||
- 赠送套餐不出现在可购列表
|
||||
- 无主套餐时,加油包仍然不可见
|
||||
|
||||
### 3. 下单与自动购包
|
||||
- C 端普通套餐下单按生效价落单
|
||||
- H5/后台普通购买接口无法购买赠送套餐
|
||||
- 自动购包命中赠送套餐时被拦截
|
||||
- 同一订单仍禁止正式套餐和加油包混买
|
||||
|
||||
### 4. 后台发放(走创建订单)
|
||||
- 平台后台可为目标资产创建赠送订单并激活套餐
|
||||
- 赠送订单无需支付凭证,不产生正常购买语义
|
||||
- 赠送加油包必须已有主套餐,否则发放失败
|
||||
|
||||
### 5. 代理授权与分配
|
||||
- 代理授权列表不展示赠送套餐
|
||||
- 系列授权新增赠送套餐失败
|
||||
- 批量分配不会把赠送套餐分配给代理
|
||||
|
||||
### 6. 迁移与历史数据
|
||||
- 迁移后能区分未配置价格、赠送 0 价、已配置非 0
|
||||
- 历史模糊 0 数据进入人工复核清单
|
||||
- 历史快照查询不再把普通未配置价格当成显式 0 售卖价
|
||||
@@ -80,6 +80,13 @@ const (
|
||||
PackageTypeAddon = "addon" // 附加套餐
|
||||
)
|
||||
|
||||
// 套餐价格配置状态
|
||||
const (
|
||||
PackagePriceConfigStatusUnconfigured = 0 // 未配置
|
||||
PackagePriceConfigStatusGiftZero = 1 // 赠送 0 价
|
||||
PackagePriceConfigStatusConfigured = 2 // 已配置非 0
|
||||
)
|
||||
|
||||
// 订单类型
|
||||
const (
|
||||
OrderTypePackage = 1 // 套餐订单
|
||||
|
||||
Reference in New Issue
Block a user