diff --git a/README.md b/README.md index 438c38d..c780e12 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 7fd91a7..72f29e6 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -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: diff --git a/docs/package-price-fallback-and-platform-gift-policy/功能总结.md b/docs/package-price-fallback-and-platform-gift-policy/功能总结.md new file mode 100644 index 0000000..ef9df6b --- /dev/null +++ b/docs/package-price-fallback-and-platform-gift-policy/功能总结.md @@ -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 只读查询验证字段、默认值和回填结果 +- 后续手工回归按《最终验收清单》执行 diff --git a/docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md b/docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md new file mode 100644 index 0000000..8d93bde --- /dev/null +++ b/docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md @@ -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 ./...` 执行成功 diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index e7815d7..678b3f7 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -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 diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 20d7731..25d6671 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -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:"是否加油包"` diff --git a/internal/model/dto/package_dto.go b/internal/model/dto/package_dto.go index 0e77e01..f869492 100644 --- a/internal/model/dto/package_dto.go +++ b/internal/model/dto/package_dto.go @@ -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:"梯度返佣信息(仅代理用户可见)"` diff --git a/internal/model/order.go b/internal/model/order.go index 1498f85..951df6b 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -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 指定表名 diff --git a/internal/model/package.go b/internal/model/package.go index 92315d0..ccf5d4d 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -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"` diff --git a/internal/model/shop_package_allocation.go b/internal/model/shop_package_allocation.go index e393c40..81b0b60 100644 --- a/internal/model/shop_package_allocation.go +++ b/internal/model/shop_package_allocation.go @@ -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 指定表名 diff --git a/internal/service/order/service.go b/internal/service/order/service.go index b5129c9..21ad72d 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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" { diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 2fef7a6..92d8154 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -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 diff --git a/internal/service/packageprice/policy.go b/internal/service/packageprice/policy.go new file mode 100644 index 0000000..64a7218 --- /dev/null +++ b/internal/service/packageprice/policy.go @@ -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 +} diff --git a/internal/service/purchase_validation/service.go b/internal/service/purchase_validation/service.go index 81b39bd..3003c7d 100644 --- a/internal/service/purchase_validation/service.go +++ b/internal/service/purchase_validation/service.go @@ -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 获取代理渠道的套餐成本价 diff --git a/internal/service/shop_package_batch_allocation/service.go b/internal/service/shop_package_batch_allocation/service.go index fc9be71..1d3eff6 100644 --- a/internal/service/shop_package_batch_allocation/service.go +++ b/internal/service/shop_package_batch_allocation/service.go @@ -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 { diff --git a/internal/service/shop_series_grant/service.go b/internal/service/shop_series_grant/service.go index 1e9f05f..29fdbe4 100644 --- a/internal/service/shop_series_grant/service.go +++ b/internal/service/shop_series_grant/service.go @@ -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, diff --git a/internal/store/postgres/package_store.go b/internal/store/postgres/package_store.go index c8396b9..dd30ce5 100644 --- a/internal/store/postgres/package_store.go +++ b/internal/store/postgres/package_store.go @@ -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 != "" { diff --git a/internal/store/postgres/shop_package_allocation_store.go b/internal/store/postgres/shop_package_allocation_store.go index fe0c540..3779859 100644 --- a/internal/store/postgres/shop_package_allocation_store.go +++ b/internal/store/postgres/shop_package_allocation_store.go @@ -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 } diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index 080589c..47a5d28 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -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 { diff --git a/migrations/000139_add_package_price_status_and_gift_policy.down.sql b/migrations/000139_add_package_price_status_and_gift_policy.down.sql new file mode 100644 index 0000000..aa409ba --- /dev/null +++ b/migrations/000139_add_package_price_status_and_gift_policy.down.sql @@ -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; diff --git a/migrations/000139_add_package_price_status_and_gift_policy.up.sql b/migrations/000139_add_package_price_status_and_gift_policy.up.sql new file mode 100644 index 0000000..da6da57 --- /dev/null +++ b/migrations/000139_add_package_price_status_and_gift_policy.up.sql @@ -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; diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/.openspec.yaml b/openspec/changes/package-price-fallback-and-platform-gift-policy/.openspec.yaml new file mode 100644 index 0000000..0a064c1 --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-28 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/design.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/design.md new file mode 100644 index 0000000..a931edd --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/design.md @@ -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 数据,不回写为赠送语义。 +- 赠送套餐历史识别优先以后台发放记录为准,补录清单只作为迁移输入,不改变判定标准。 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/proposal.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/proposal.md new file mode 100644 index 0000000..2d5eb53 --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/proposal.md @@ -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 数据核验,不引入新的自动化测试要求。 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/agent-series-grant/spec.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/agent-series-grant/spec.md new file mode 100644 index 0000000..06674de --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/agent-series-grant/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: 赠送套餐不可纳入代理系列授权 + +系统 SHALL 将赠送套餐视为平台专属资产,禁止将其加入代理系列授权、套餐分配或任何下级可见的授权列表。赠送套餐的发放 MUST 走后台发放链路,而不是代理授权链路。 + +#### Scenario: 代理创建授权时包含赠送套餐 +- **WHEN** 代理在系列授权中尝试加入一个赠送套餐 +- **THEN** 系统返回错误并拒绝创建 + +#### Scenario: 平台后台发放赠送套餐 +- **WHEN** 平台管理员通过后台发放流程授予赠送套餐 +- **THEN** 系统允许创建发放记录,但不生成代理可分配权限 + +#### Scenario: 授权列表隐藏赠送套餐 +- **WHEN** 系统查询任意代理系列授权的可选套餐列表 +- **THEN** 赠送套餐 MUST 不出现在列表中 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/client-asset-info/spec.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/client-asset-info/spec.md new file mode 100644 index 0000000..6d7151f --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/client-asset-info/spec.md @@ -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 不返回该套餐 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/client-order-purchase/spec.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/client-order-purchase/spec.md new file mode 100644 index 0000000..270f2ff --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/client-order-purchase/spec.md @@ -0,0 +1,17 @@ +## MODIFIED Requirements + +### Requirement: 订单与自动购包价格规则 + +系统 SHALL 在既有订单创建和自动购包流程中统一使用生效售价。普通可售套餐若建议零售价未配置,则生效售价 MUST 回退到成本价;赠送套餐 MUST NOT 通过这些自购或自动购包路径创建。 + +#### Scenario: 普通可售套餐未配置建议零售价 +- **WHEN** 订单创建或自动购包处理一个普通可售套餐,且其建议零售价未配置 +- **THEN** 系统 SHALL 使用成本价作为生效售价 + +#### Scenario: 普通可售套餐已配置建议零售价 +- **WHEN** 订单创建或自动购包处理一个普通可售套餐,且其建议零售价已配置 +- **THEN** 系统 SHALL 使用已配置的建议零售价作为生效售价 + +#### Scenario: 赠送套餐进入购买路径 +- **WHEN** 任一自购或自动购包路径命中赠送套餐 +- **THEN** 系统 MUST 拒绝创建订单 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/gift-package-policy/spec.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/gift-package-policy/spec.md new file mode 100644 index 0000000..2d60836 --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/gift-package-policy/spec.md @@ -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 不返回任何赠送套餐 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-management/spec.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-management/spec.md new file mode 100644 index 0000000..cbe00dc --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-management/spec.md @@ -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 返回原始价格字段,而不是把回退后的生效价写回原始字段 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-price-policy/spec.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-price-policy/spec.md new file mode 100644 index 0000000..c315da9 --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-price-policy/spec.md @@ -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 使用迁移后的生效价字段进行展示 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-purchase-validation/spec.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-purchase-validation/spec.md new file mode 100644 index 0000000..3ced058 --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/specs/package-purchase-validation/spec.md @@ -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 拒绝该订单 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/tasks.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/tasks.md new file mode 100644 index 0000000..a5d295d --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/tasks.md @@ -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`,再执行一次完整手工回归,确认没有遗漏的旧口径入口。 diff --git a/openspec/changes/package-price-fallback-and-platform-gift-policy/实施基线与手工验证清单.md b/openspec/changes/package-price-fallback-and-platform-gift-policy/实施基线与手工验证清单.md new file mode 100644 index 0000000..758112b --- /dev/null +++ b/openspec/changes/package-price-fallback-and-platform-gift-policy/实施基线与手工验证清单.md @@ -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 售卖价 diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index e579ccc..8a49203 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -80,6 +80,13 @@ const ( PackageTypeAddon = "addon" // 附加套餐 ) +// 套餐价格配置状态 +const ( + PackagePriceConfigStatusUnconfigured = 0 // 未配置 + PackagePriceConfigStatusGiftZero = 1 // 赠送 0 价 + PackagePriceConfigStatusConfigured = 2 // 已配置非 0 +) + // 订单类型 const ( OrderTypePackage = 1 // 套餐订单