# 需求05：套餐分配生效条件（ExpiryBase 覆盖）

---

## 背景

`Package.ExpiryBase` 已存在（`from_activation` / `from_purchase`），在套餐创建时设定，控制套餐何时开始计时。

需求：分配套餐给代理时，可以对单条分配记录二次覆盖这个值。

---

## 快照链设计

```
ShopPackageAllocation.expiry_base_override（分配时设置覆盖值）
        ↓ 订单创建时读取，取有效值（override 优先，否则用套餐默认）
PackageUsage.expiry_base_snapshot（快照进去）
        ↓ 激活时读取
activation_service 读 usage.ExpiryBaseSnapshot，不再直接读 pkg.ExpiryBase
```

遗留数据兜底：`ExpiryBaseSnapshot` 为空（旧数据）时，回退读 `pkg.ExpiryBase`，行为不变。

---

## 数据库变更

### 1. ShopPackageAllocation 新增覆盖字段

```sql
ALTER TABLE tb_shop_package_allocation
    ADD COLUMN expiry_base_override VARCHAR(30) DEFAULT NULL
        COMMENT '生效条件覆盖（NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效）';
```

### 2. PackageUsage 新增快照字段

```sql
ALTER TABLE tb_package_usage
    ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT ''
        COMMENT '生效条件快照（创建时从分配记录取有效值写入，空字符串=旧数据兜底读套餐原值）';
```

旧数据不回填，默认空字符串，激活时自动兜底。

---

## 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"`
```

---

## 业务逻辑变更

### 1. 订单创建时快照（`internal/service/order/service.go`）

订单创建已通过 `GetByShopAndPackage` 查询分配记录（现有逻辑），在此基础上追加：

```go
// 取生效条件有效值：分配覆盖 > 套餐默认
expiryBase := pkg.ExpiryBase
if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" {
    expiryBase = *allocation.ExpiryBaseOverride
}
// 创建 PackageUsage 时写入快照
usage.ExpiryBaseSnapshot = expiryBase
```

### 2. 激活时读快照（`internal/service/package/activation_service.go`）

```go
// 修改前：直接读套餐原值
expiryBase := pkg.ExpiryBase

// 修改后：优先读快照，旧数据兜底
expiryBase := pkg.ExpiryBase // 兜底（旧数据 ExpiryBaseSnapshot 为空）
if usage.ExpiryBaseSnapshot != "" {
    expiryBase = usage.ExpiryBaseSnapshot
}
```

同文件另一处（`activation_service.go:556`）同理修改。

`internal/service/order/service.go:2290` 中后台囤货路径的 `ExpiryBase` 判断也需同样改为读快照（兜底逻辑一致）。

---

## API 变更

### 1. 分配套餐接口（新增参数）

```
POST /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 /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 /admin/shop-package-allocations/{id}/expiry-base`。
