# 需求05:套餐分配生效条件(ExpiryBase 覆盖) > 状态:待评审 --- ## 背景 `Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。 需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。 --- ## 快照链设计 ```mermaid flowchart TD Package[套餐默认 ExpiryBase] --> Effective{分配记录是否覆盖?} Allocation[ShopPackageAllocation.expiry_base_override] --> Effective Effective -->|有覆盖| Override[使用分配覆盖值] Effective -->|无覆盖| Default[使用套餐默认值] Override --> Snapshot[订单创建时写入 PackageUsage.expiry_base_snapshot] Default --> Snapshot Snapshot --> Activation[套餐激活只读快照] Legacy[旧数据快照为空] --> Fallback[兜底读取套餐默认值] Fallback --> Activation ``` 遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。 --- ## 数据库变更 ### 1. ShopPackageAllocation 新增覆盖字段 ```sql ALTER TABLE tb_shop_package_allocation ADD COLUMN expiry_base_override VARCHAR(30); COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override IS '生效条件覆盖(NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)'; ``` ### 2. PackageUsage 新增快照字段 ```sql ALTER TABLE tb_package_usage ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '', ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '', ADD COLUMN duration_months_snapshot INT NOT NULL DEFAULT 0, ADD COLUMN duration_days_snapshot INT NOT NULL DEFAULT 0; COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot IS '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)'; COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot IS '周期类型快照(空字符串=旧数据兜底读套餐原值)'; COMMENT ON COLUMN tb_package_usage.duration_months_snapshot IS '月数快照(0=旧数据兜底读套餐原值)'; COMMENT ON COLUMN tb_package_usage.duration_days_snapshot IS '天数快照(0=旧数据兜底读套餐原值)'; ``` 旧数据不回填,默认空字符串,激活时自动兜底。 --- ## Model 变更 ### ShopPackageAllocation(`internal/model/shop_package_allocation.go`) ```go // ExpiryBaseOverride 生效条件覆盖 // NULL = 使用宿主套餐的 ExpiryBase;有值 = 分配时指定,不受套餐后续修改影响 ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"` ``` ### PackageUsage(`internal/model/package.go`) ```go // ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值) ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"` // 以下三个字段和 ExpiryBaseSnapshot 一起固化,供激活和排队最终到期时间计算使用。 CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:套餐周期类型快照" json:"calendar_type_snapshot"` DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;not null;default:0;comment:套餐月数快照" json:"duration_months_snapshot"` DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;not null;default:0;comment:套餐天数快照" json:"duration_days_snapshot"` ``` --- ## 业务逻辑变更 ### 1. 订单创建时快照(`internal/service/order/service.go`) 订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加: ```go // 取生效条件有效值:分配覆盖 > 套餐默认 expiryBase := pkg.ExpiryBase if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" { expiryBase = *allocation.ExpiryBaseOverride } // 创建 PackageUsage 时一次性写入计时快照 usage.ExpiryBaseSnapshot = expiryBase usage.CalendarTypeSnapshot = pkg.CalendarType usage.DurationMonthsSnapshot = pkg.DurationMonths usage.DurationDaysSnapshot = pkg.DurationDays ``` ### 2. 激活时读快照(`internal/service/package/activation_service.go`) ```go // 新订单只读购买快照;旧记录兼容回退套餐当前值。 expiryBase := usage.ExpiryBaseSnapshot if expiryBase == "" { expiryBase = pkg.ExpiryBase } calendarType := usage.CalendarTypeSnapshot if calendarType == "" { calendarType = pkg.CalendarType } durationMonths := usage.DurationMonthsSnapshot if durationMonths == 0 { durationMonths = pkg.DurationMonths } durationDays := usage.DurationDaysSnapshot if durationDays == 0 { durationDays = pkg.DurationDays } ``` 同文件所有激活和排队接续位置都使用同一快照解析函数,禁止某一处重新读取可修改的 `Package` 字段。 `internal/service/order/service.go` 中后台囤货路径的 `ExpiryBase` 判断也使用已创建的使用记录快照;需求06的“预计最后到期时间”同样只读这组快照,保证购买后套餐配置变更不会改写历史预测。 --- ## API 变更 ### 1. 分配套餐接口(新增参数) ``` POST /api/admin/shop-package-allocations ``` 请求 DTO 新增字段: ```go ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件覆盖(不传=使用套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` ``` ### 2. 修改已分配套餐的生效条件(新接口) ``` PATCH /api/admin/shop-package-allocations/{id}/expiry-base ``` 请求 DTO: ```go type UpdateAllocationExpiryBaseRequest struct { ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件(null=恢复套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` } ``` > 注意:修改已有分配记录的覆盖值,**不影响**已创建的 PackageUsage(快照已定),只影响后续新建的订单。 --- ## 前端对接 ### 套餐分配弹框 新增"生效条件"选择项: ``` 生效条件: ○ 跟随套餐默认(默认选中,不传 expiry_base_override) ○ 购买即生效(from_purchase) ○ 实名即生效(from_activation) ``` ### 已分配套餐列表 列表新增"生效条件"列: | 值 | 展示 | |----|------| | NULL | 套餐默认 | | `from_activation` | 实名即生效(已覆盖) | | `from_purchase` | 购买即生效(已覆盖) | 操作列增加"修改生效条件"按钮,调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。