# 促销优惠价系统设计文档

## 1. 背景与目标

### 1.1 业务需求

为套餐规格提供可配置的优惠价格能力，支持多种促销场景：

- **新客优惠**：注册 N 天内的用户享受优惠价
- **回归用户**：N 个月未活跃的用户享受优惠价
- **活动促销**：指定时间段内所有用户享受优惠价
- **未来可扩展**：首充优惠、邀请用户专属价、指定地区优惠等

### 1.2 设计原则

1. **纯新增，不改老代码**：现有的 `new_user_only` + `discount.NewUserOnly` + 24h 窗口逻辑全部保留不动
2. **固定价格，非百分比**：运营直接设定优惠价（如 $5.99），不再需要反算折扣百分比
3. **后台可配置**：规则类型、参数、时间窗口、优先级均可在管理后台配置
4. **促销价不叠加批量折扣**：促销价命中时即为最终基础单价，跳过 `getDiscount()` 的百分比折扣

### 1.3 与现有体系的关系

```
现有体系（保留不动）：
  subscribe.NewUserOnly         → 套餐级新客限制
  discount[].NewUserOnly        → 折扣档位级新客限制
  newUserEligibility.go         → 24h 窗口 + 家庭组判定
  newUserDiscountEligibility.go → 新客折扣资格组装
  getDiscount()                 → 百分比折扣选择
  order.IsNew                   → 订单首购标记（统计/佣金用）

新增体系（本次设计）：
  promo_rule 表                 → 可配置的促销规则
  subscribe_promo 表            → 规格×规则 的优惠价
  promo_usage 表                → 使用记录（运营分析用）
  EvaluatePromo()               → 促销资格判定
```

**互斥规则**：促销价命中时，跳过老的百分比折扣逻辑（`getDiscount()`）。
两套体系不叠加 — 用户要么走促销价，要么走原价+百分比折扣，不会同时生效。

---

## 2. 数据模型

### 2.1 新增表：`promo_rule`（促销规则）

```sql
CREATE TABLE `promo_rule` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name`          VARCHAR(100)    NOT NULL DEFAULT '' COMMENT '规则名称，如"新客7天优惠"',
  `type`          VARCHAR(32)     NOT NULL DEFAULT '' COMMENT '规则类型：new_user / inactive_user / campaign',
  `params`        JSON            NOT NULL             COMMENT '类型专属参数',
  `priority`      INT             NOT NULL DEFAULT 0   COMMENT '优先级，数值越大越优先匹配',
  `enabled`       TINYINT(1)      NOT NULL DEFAULT 1   COMMENT '是否启用',
  `start_time`    DATETIME        DEFAULT NULL          COMMENT '生效开始时间，NULL=立即生效',
  `end_time`      DATETIME        DEFAULT NULL          COMMENT '生效结束时间，NULL=永不过期',
  `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted_at`    DATETIME        DEFAULT NULL          COMMENT '软删除时间',
  PRIMARY KEY (`id`),
  KEY `idx_enabled_priority` (`enabled`, `priority` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';
```

### 2.2 新增表：`subscribe_promo`（规格优惠价）

```sql
CREATE TABLE `subscribe_promo` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `subscribe_id`  BIGINT UNSIGNED NOT NULL              COMMENT '套餐规格 ID',
  `promo_rule_id` BIGINT UNSIGNED NOT NULL              COMMENT '促销规则 ID',
  `promo_price`   BIGINT          NOT NULL DEFAULT 0    COMMENT '该规格在此规则下的优惠价（分）',
  `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_subscribe_rule` (`subscribe_id`, `promo_rule_id`),
  KEY `idx_promo_rule_id` (`promo_rule_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';
```

### 2.3 新增表：`promo_usage`（促销使用记录）

用于运营分析，不做强制去重约束。

```sql
CREATE TABLE `promo_usage` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`       BIGINT UNSIGNED NOT NULL              COMMENT '用户 ID',
  `promo_rule_id` BIGINT UNSIGNED NOT NULL              COMMENT '使用的规则 ID',
  `subscribe_id`  BIGINT UNSIGNED NOT NULL              COMMENT '购买的规格 ID',
  `order_no`      VARCHAR(255)    NOT NULL DEFAULT ''    COMMENT '关联订单号',
  `promo_price`   BIGINT          NOT NULL DEFAULT 0    COMMENT '使用时的促销单价（分）',
  `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_user_rule` (`user_id`, `promo_rule_id`),
  KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表';
```

### 2.4 `order` 表新增字段

```sql
ALTER TABLE `order`
  ADD COLUMN `promo_rule_id`  BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '促销规则ID, 0=未使用促销',
  ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT '促销优惠金额(分)';
```

**字段说明**：

| 订单字段 | 含义 | 促销命中时 | 未命中时 |
|---------|------|-----------|---------|
| `Price` | 原始总价 = `UnitPrice × Quantity` | 不变，始终记录原价 | 不变 |
| `promo_rule_id` | 使用的促销规则 | 规则 ID | 0 |
| `promo_discount` | 促销优惠金额 | `(UnitPrice - PromoPrice) × Quantity` | 0 |
| `Discount` | 百分比折扣金额 | **0**（不叠加） | 正常计算 |
| `Amount` | 最终支付金额 | 基于促销价计算 | 基于原价+折扣计算 |

**订单自证**：任何一笔订单都能独立还原其价格构成，不需要回查促销规则表：
```
Amount = Price - promo_discount - Discount - CouponDiscount + FeeAmount - GiftAmount
```

### 2.5 ER 关系

```
subscribe (1) ──── (*) subscribe_promo (*) ──── (1) promo_rule
                                                       │
                                                       │
user (1) ──────── (*) promo_usage (*) ─────────── (1) promo_rule
                         │
                    (*) order ← 新增 promo_rule_id, promo_discount
```

---

## 3. 规则类型定义

### 3.1 `new_user` — 新客优惠

**含义**：用户注册后 N 小时内可享受优惠价

**params 结构**：

```json
{
  "window_hours": 168
}
```

**判定逻辑**：

```
eligible = (当前时间 - 用户注册时间) < window_hours
expires_at = 用户注册时间 + window_hours
```

**与老逻辑的区别**：

| | 老逻辑 | 新逻辑 |
|--|--------|--------|
| 窗口期 | 硬编码 24h | 配置化，后台可改 |
| 判定基准 | 首台设备注册时间 + 家庭组 | 用户注册时间（`user.created_at`） |
| 价格方式 | 百分比折扣 | 固定价格 |
| 与折扣叠加 | 是（百分比折扣本身） | 否（替代原价，跳过折扣） |

### 3.2 `inactive_user` — 回归用户优惠

**含义**：最近 N 个月没有活跃订阅的用户可享受优惠价

**params 结构**：

```json
{
  "inactive_months": 3
}
```

**判定逻辑**：

```
last_active = 用户最后一个订阅的 expire_time
eligible = last_active 为空（从未购买过）
        OR (当前时间 - last_active) >= inactive_months 个月
expires_at = 规则的 end_time（如有），否则无过期
```

**查询依据**：`user_subscribe` 表中该用户最近一条记录的 `expire_time`

**注意**：「从未购买过」的用户同时满足 `new_user` 和 `inactive_user`，靠 `priority` 排序选择高优先级的那条。

### 3.3 `campaign` — 活动促销

**含义**：在指定时间段内，所有用户均可享受优惠价

**params 结构**：

```json
{}
```

活动促销不需要额外参数，完全靠 `promo_rule.start_time` 和 `end_time` 控制。

**判定逻辑**：

```
eligible = start_time <= 当前时间 <= end_time
expires_at = end_time
```

### 3.4 扩展预留

未来新增规则类型只需：
1. 定义新的 `type` 字符串（如 `first_purchase`、`referral`、`region`）
2. 定义对应的 `params` 结构
3. 在判定逻辑中增加一个 `case` 分支

不需要改表结构，不需要改 API 格式。

---

## 4. 核心逻辑

### 4.1 促销资格判定

新增文件：`internal/logic/common/promoEligibility.go`

```go
type PromoResult struct {
    Eligible      bool
    RuleID        int64
    RuleName      string
    RuleType      string
    PromoPrice    int64     // 促销单价（分）
    ExpiresAt     time.Time
}

func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) {
    // 1. 查询该规格关联的所有已启用规则，按 priority DESC
    // 2. 遍历规则，按类型判定
    // 3. 首条命中即返回
}
```

### 4.2 各类型判定函数

```go
func evaluateNewUser(user *User, params RuleParams) (bool, time.Time) {
    windowHours := params.WindowHours
    if windowHours <= 0 {
        return false, time.Time{}
    }
    expiresAt := user.CreatedAt.Add(time.Duration(windowHours) * time.Hour)
    eligible := time.Now().Before(expiresAt)
    return eligible, expiresAt
}

func evaluateInactiveUser(ctx context.Context, userID int64, rule PromoRule) (bool, time.Time) {
    inactiveMonths := rule.Params.InactiveMonths
    if inactiveMonths <= 0 {
        return false, time.Time{}
    }
    lastExpire := getLastSubscriptionExpireTime(ctx, userID)
    if lastExpire.IsZero() {
        return true, rule.GetExpiresAt()
    }
    threshold := time.Now().AddDate(0, -inactiveMonths, 0)
    eligible := lastExpire.Before(threshold)
    return eligible, rule.GetExpiresAt()
}
```

### 4.3 下单流程集成（不叠加方案）

在 `purchaseLogic.go` 中 `sub.UnitPrice * req.Quantity` 之前，插入促销价判定：

```go
// === 新增：促销价判定 ===
promoResult, promoErr := commonLogic.EvaluatePromo(l.ctx, l.svcCtx, u.Id, targetSubscribeID)
if promoErr != nil {
    return nil, promoErr
}

var promoDiscount int64
var promoRuleID int64

if promoResult.Eligible {
    // 促销命中 → 用促销价，跳过百分比折扣
    price = promoResult.PromoPrice * req.Quantity
    promoDiscount = (sub.UnitPrice * req.Quantity) - price
    promoRuleID = promoResult.RuleID
    discount = 1       // 不叠加批量折扣
    discountAmount = 0
} else {
    // 未命中 → 走原有逻辑（不动）
    price = sub.UnitPrice * req.Quantity
    discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
    discountAmount = price - int64(math.Round(float64(price)*discount))
}
// === 新增结束 ===

// 后续 coupon / fee / gift 逻辑完全不动
```

**订单创建时记录**：

```go
orderInfo := &order.Order{
    // ... 原有字段不动 ...
    Price:          sub.UnitPrice * req.Quantity, // 始终记录原价
    PromoRuleID:    promoRuleID,                  // 新增
    PromoDiscount:  promoDiscount,                // 新增
    Discount:       discountAmount,               // 促销命中时为 0
    Amount:         amount,
}
```

**激活时写 usage**（`activateOrderLogic.go` 追加）：

```go
if orderInfo.PromoRuleID > 0 {
    insertPromoUsage(ctx, orderInfo.UserId, orderInfo.PromoRuleID, orderInfo.SubscribeId, orderInfo.OrderNo, promoPrice)
}
```

### 4.4 价格计算完整流程

```
┌───────────────────────────────────────────────────┐
│ 1. 判定促销                                        │
│    EvaluatePromo(userId, subscribeId)              │
├──────────────┬────────────────────────────────────┤
│ 促销命中      │ 促销未命中                          │
├──────────────┼────────────────────────────────────┤
│ basePrice    │ basePrice                          │
│  = promoPrice│  = unitPrice                       │
│              │                                    │
│ discount = 0 │ discount = getDiscount(...)        │
│ （跳过折扣）  │ （百分比折扣正常生效）                │
├──────────────┴────────────────────────────────────┤
│ 2. price = basePrice × quantity                   │
│    amount = price - discountAmount                │
├───────────────────────────────────────────────────┤
│ 3. 优惠券（原有逻辑，不动）                         │
│    amount -= couponDiscount                       │
├───────────────────────────────────────────────────┤
│ 4. 手续费（原有逻辑，不动）                         │
│    amount += feeAmount                            │
├───────────────────────────────────────────────────┤
│ 5. 余额抵扣（原有逻辑，不动）                       │
│    amount -= giftAmount                           │
└───────────────────────────────────────────────────┘
```

---

## 5. 退款影响分析

### 5.1 结论：退款逻辑无需改动

当前退款流程（`refundOrderLogic.go`）基于**订单上已存储的字段**运作，不回查价格体系：

| 退款动作 | 数据来源 | 是否受促销影响 |
|---------|---------|--------------|
| 退款金额 | `order.Amount`（支付时已锁定） | 否 — Amount 已反映促销价 |
| 佣金回退 | `system_log` 表中的 commission 记录 | 否 — 佣金是基于 Amount 计算的 |
| 订阅终止 | `user_subscribe.status → 3` | 否 — 和价格无关 |
| 审计日志 | `buildRefundAuditLog()` 读订单快照 | 否 — 记录的就是实际值 |

**原因**：订单创建时所有金额字段（Price、Amount、Discount、PromoDiscount、FeeAmount 等）都已写入 `order` 表。退款只读这些已存储的值，不会重新计算价格。

### 5.2 退款后的促销资格

退款后用户的订阅被终止（`expire_time = now - 1s`）。如果用户再次购买：

| 场景 | 促销资格 | 说明 |
|------|---------|------|
| 新客退款后重新购买 | 如仍在窗口期内 → 仍然可以享受促销价 | 正常行为，`promo_usage` 只是记录不做去重 |
| 回归用户退款后重新购买 | 需重新判定 `inactive_months` | 退款后订阅 expire_time 被设为过去时间 |
| 活动促销退款后重新购买 | 如活动仍在进行 → 可以继续购买 | 活动促销不限次数 |

这些都是合理的业务行为，不需要额外处理。

### 5.3 佣金影响

佣金计算公式（`activateOrderLogic.go:1104`）：

```go
amount := l.calculateCommission(orderInfo.Amount - orderInfo.FeeAmount, referralPercentage)
```

- `Amount` 在促销命中时已反映促销价（更低的金额）
- 所以佣金会相应减少 — **这是正确的行为**
- 退款时佣金回退金额从 `system_log` 读取，回退的也是减少后的佣金

**无需任何改动**。

---

## 6. Apple IAP 影响分析

### 6.1 现状

- Apple IAP 价格在 App Store Connect 中配置，不支持后端动态定价
- 当前通过 `discount[].MapApple` 字段映射 Apple Product ID
- IAP 订单在 `appleIAPNotifyLogic.go` 中处理，走独立的价格逻辑

### 6.2 设计决策

**促销价不适用于 IAP 订单**。原因：
- IAP 价格由 Apple 控制，后端无法干预
- IAP 通知回调（`appleIAPNotifyLogic.go`）有独立的价格处理流程
- IAP 审计订单设 `IsNew: false`，不走常规购买逻辑

**实现方式**：`EvaluatePromo()` 不需要特殊处理 — IAP 订单根本不经过 `purchaseLogic.go`，自然不会触发促销判定。

---

## 7. 各购买场景适配

### 7.1 需要集成促销的场景

| 文件 | 场景 | 集成方式 |
|------|------|---------|
| `purchaseLogic.go` | 新购 | 完整促销判定 + 不叠加逻辑 |
| `preCreateOrderLogic.go` | 价格预览 | 同上（返回 promo_discount 字段） |

### 7.2 不需要改动的场景

| 文件 | 场景 | 原因 |
|------|------|------|
| `renewalLogic.go` | 续费 | 促销价仅限首购，续费走原价+折扣 |
| `rechargeLogic.go` | 余额充值 | 充值不涉及套餐价格 |
| `redeemCodeLogic.go` | 兑换码 | 兑换码有自己的固定逻辑 |
| `recoverOrderLogic.go` | 历史导入 | 导入的是已完成订单 |
| `appleIAPNotifyLogic.go` | IAP 续订 | Apple 控制价格 |
| `portal/purchaseLogic.go` | 游客购买 | 游客无 user_id，无法判定促销资格 |
| `refundOrderLogic.go` | 退款 | 读取订单已存储的金额，不重新计算 |
| `activateOrderLogic.go` | 订单激活 | 只追加 promo_usage 写入，价格不重算 |

### 7.3 统计报表

现有统计 SQL（`order/model.go` 中 8 处）按 `is_new` 拆分收入，**不需要改动**。

未来如需促销维度报表，可通过 `order.promo_rule_id` 字段扩展：
```sql
SUM(CASE WHEN promo_rule_id > 0 THEN amount ELSE 0 END) AS promo_order_amount,
SUM(CASE WHEN promo_rule_id = 0 THEN amount ELSE 0 END) AS normal_order_amount
```

---

## 8. API 设计

### 8.1 套餐列表 API（改造）

**接口**：`GET /v1/public/subscribe/list`

**响应变更**：在原有 `Subscribe` 结构体中追加 `promo` 字段。

```json
{
  "list": [
    {
      "id": 1,
      "name": "基础套餐",
      "unit_price": 288,
      "discount": [...],
      "promo": {
        "rule_name": "新客7天优惠",
        "rule_type": "new_user",
        "promo_price": 279,
        "expires_at": 1748870400
      }
    },
    {
      "id": 2,
      "name": "标准套餐",
      "unit_price": 688,
      "promo": null
    }
  ]
}
```

**`promo` 字段说明**：

| 字段 | 类型 | 说明 |
|------|------|------|
| `rule_name` | string | 规则名称，前端展示用 |
| `rule_type` | string | 规则类型，前端可据此展示不同样式 |
| `promo_price` | int64 | 优惠单价（分），注意是单价不是总价 |
| `expires_at` | int64 | 优惠过期时间戳（秒），0 = 无过期 |

- 用户未登录时：仅展示 `campaign` 类型促销（不需要用户信息）
- 用户已登录：展示所有命中的促销
- 未命中任何规则时，`promo` 为 `null`

### 8.2 预算订单 API（改造）

**接口**：`POST /v1/public/order/pre`

**响应追加字段**：

```json
{
  "price": 688,
  "amount": 499,
  "discount": 0,
  "promo_discount": 189,
  "coupon_discount": 0,
  "fee_amount": 0,
  "gift_amount": 0
}
```

| 字段 | 含义 |
|------|------|
| `price` | 原始总价 = `UnitPrice × Quantity` |
| `promo_discount` | 促销优惠 = `(UnitPrice - PromoPrice) × Quantity` |
| `discount` | 百分比折扣优惠（促销命中时为 0） |
| `amount` | 最终支付金额 |

前端可展示：~~原价 ¥6.88~~ → 促销价 ¥4.99

### 8.3 管理后台 API（新增）

#### 8.3.1 促销规则 CRUD

```
POST   /v1/admin/promo/rule          创建规则
GET    /v1/admin/promo/rule/list     规则列表
GET    /v1/admin/promo/rule/:id      规则详情
PUT    /v1/admin/promo/rule/:id      更新规则
DELETE /v1/admin/promo/rule/:id      删除规则（软删除）
```

**创建/更新请求体**：

```json
{
  "name": "新客7天优惠",
  "type": "new_user",
  "params": {
    "window_hours": 168
  },
  "priority": 10,
  "enabled": true,
  "start_time": null,
  "end_time": null
}
```

**校验规则**：
- `type` 必须是已支持的类型
- `params` 按 `type` 做结构校验（如 `new_user` 必须有 `window_hours > 0`）
- `priority` >= 0
- `start_time` < `end_time`（如果两者都提供）

#### 8.3.2 规格优惠价配置

```
POST   /v1/admin/promo/price             批量设置优惠价
GET    /v1/admin/promo/price/list         查询某规则下的所有优惠价
DELETE /v1/admin/promo/price/:id          删除某条优惠价
```

**批量设置请求体**：

```json
{
  "promo_rule_id": 1,
  "items": [
    {"subscribe_id": 1, "promo_price": 279},
    {"subscribe_id": 2, "promo_price": 599}
  ]
}
```

**校验**：`promo_price` 必须 < 对应规格的 `unit_price`（防止配置错误）。

#### 8.3.3 使用记录查询

```
GET /v1/admin/promo/usage/list?rule_id=1&page=1&size=20
```

---

## 9. 缓存策略

### 9.1 规则缓存

```
Key:   promo:rules:enabled
Value: JSON 数组（所有启用的规则，按 priority DESC）
TTL:   300 秒（5 分钟）
清除:  管理后台修改规则时主动删除
```

### 9.2 规格优惠价缓存

```
Key:   promo:subscribe:{subscribe_id}
Value: JSON 数组（该规格关联的所有 rule_id → promo_price）
TTL:   300 秒
清除:  管理后台修改优惠价时主动删除
```

### 9.3 注意事项

- 缓存 TTL 300 秒意味着活动 `end_time` 到期后最多 5 分钟延迟，可接受
- 管理后台操作后主动 DEL 缓存 key，确保配置变更及时生效
- `EvaluatePromo()` 缓存未命中时回查 DB

---

## 10. 确定的决策项

| 编号 | 问题 | 结论 | 原因 |
|------|------|------|------|
| D-01 | 促销价与批量折扣叠加 | **不叠加** | 促销价即最终单价，跳过 `getDiscount()` |
| D-02 | 未登录用户展示促销价 | 仅展示 `campaign` 类型 | `new_user`/`inactive_user` 需要用户信息 |
| D-03 | 续费订单适用促销价 | **仅首购** | 促销价用于拉新/回归，续费走原价 |
| D-04 | 回归用户判定方式 | 订阅过期时间 | `user_subscribe.expire_time`，数据最可靠 |
| D-05 | 多规则命中 | 按 `priority` DESC 取第一条 | 运营可控 |
| D-07 | Portal（游客）购买走促销 | **不走** | 游客无 user_id，无法判定资格 |

---

## 11. 新增文件清单

| 层级 | 新增文件 | 说明 |
|------|----------|------|
| **Model** | `internal/model/promo_rule/promo_rule.go` | 促销规则模型 |
| **Model** | `internal/model/subscribe_promo/subscribe_promo.go` | 规格优惠价模型 |
| **Model** | `internal/model/promo_usage/promo_usage.go` | 使用记录模型 |
| **Logic** | `internal/logic/common/promoEligibility.go` | 促销资格判定核心逻辑 |
| **Logic** | `internal/logic/admin/promo/` 目录（CRUD） | 管理后台逻辑 |
| **Handler** | `internal/handler/admin/promo/` 目录 | 管理后台 Handler |
| **Types** | `internal/types/types.go` 追加 | 新增结构体 |
| **Migration** | `initialize/migrate/database/02153_promo_rule.up.sql` | 建表 + order 加字段 |
| **Migration** | `initialize/migrate/database/02153_promo_rule.down.sql` | 回滚 |

### 需改动的已有文件（仅追加）

| 文件 | 改动方式 |
|------|----------|
| `internal/logic/public/subscribe/querySubscribeListLogic.go` | 追加：查促销信息，填充 `promo` |
| `internal/logic/public/order/purchaseLogic.go` | 追加：促销判定 + 不叠加分支 |
| `internal/logic/public/order/preCreateOrderLogic.go` | 追加：预算时考虑促销价 |
| `queue/logic/order/activateOrderLogic.go` | 追加：激活后写 `promo_usage` |
| `internal/model/order/order.go` | 追加：`PromoRuleID`、`PromoDiscount` 字段 |
| `internal/model/order/model.go` | 追加：`Details` 同步字段 |
| `internal/types/types.go` | 追加：新增结构体、响应字段 |
| `internal/svc/serviceContext.go` | 追加：注入新 Model |
| 路由配置 | 追加：管理后台路由 |

---

## 12. 运营配置示例

### 场景 1：新客 7 天优惠

```
promo_rule:
  name = "新客7天优惠"
  type = "new_user"
  params = {"window_hours": 168}
  priority = 10
  enabled = true
  start_time = NULL（永久生效）
  end_time = NULL

subscribe_promo:
  规格"7天"   → promo_price = 279
  规格"30天"  → promo_price = 599
  规格"90天"  → promo_price = 1299
  规格"365天" → promo_price = 4499
```

### 场景 2：回归用户优惠

```
promo_rule:
  name = "回归用户专属价"
  type = "inactive_user"
  params = {"inactive_months": 3}
  priority = 5
  enabled = true

subscribe_promo:
  规格"30天"  → promo_price = 499
  规格"90天"  → promo_price = 999
```

### 场景 3：双十一全站活动

```
promo_rule:
  name = "双十一特惠"
  type = "campaign"
  params = {}
  priority = 20（优先级高于新客和回归）
  enabled = true
  start_time = "2026-11-01 00:00:00"
  end_time = "2026-11-12 00:00:00"

subscribe_promo:
  规格"90天"  → promo_price = 999
  规格"365天" → promo_price = 3999
```

**优先级效果**：双十一期间（priority=20），即使用户是新客（priority=10），也走双十一价格。双十一结束后，新客仍可享受新客优惠。

---

## 13. 迁移脚本

### 02153_promo_system.up.sql

```sql
-- 促销规则表
CREATE TABLE IF NOT EXISTS `promo_rule` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name`          VARCHAR(100)    NOT NULL DEFAULT '',
  `type`          VARCHAR(32)     NOT NULL DEFAULT '',
  `params`        JSON            NOT NULL,
  `priority`      INT             NOT NULL DEFAULT 0,
  `enabled`       TINYINT(1)      NOT NULL DEFAULT 1,
  `start_time`    DATETIME        DEFAULT NULL,
  `end_time`      DATETIME        DEFAULT NULL,
  `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted_at`    DATETIME        DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_enabled_priority` (`enabled`, `priority` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';

-- 规格促销价表
CREATE TABLE IF NOT EXISTS `subscribe_promo` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `subscribe_id`  BIGINT UNSIGNED NOT NULL,
  `promo_rule_id` BIGINT UNSIGNED NOT NULL,
  `promo_price`   BIGINT          NOT NULL DEFAULT 0,
  `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_subscribe_rule` (`subscribe_id`, `promo_rule_id`),
  KEY `idx_promo_rule_id` (`promo_rule_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';

-- 促销使用记录表
CREATE TABLE IF NOT EXISTS `promo_usage` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`       BIGINT UNSIGNED NOT NULL,
  `promo_rule_id` BIGINT UNSIGNED NOT NULL,
  `subscribe_id`  BIGINT UNSIGNED NOT NULL,
  `order_no`      VARCHAR(255)    NOT NULL DEFAULT '',
  `promo_price`   BIGINT          NOT NULL DEFAULT 0,
  `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_user_rule` (`user_id`, `promo_rule_id`),
  KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表';

-- order 表新增促销字段
ALTER TABLE `order`
  ADD COLUMN `promo_rule_id`  BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '促销规则ID, 0=未使用促销',
  ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT '促销优惠金额(分)';
```

### 02153_promo_system.down.sql

```sql
ALTER TABLE `order`
  DROP COLUMN IF EXISTS `promo_discount`,
  DROP COLUMN IF EXISTS `promo_rule_id`;

DROP TABLE IF EXISTS `promo_usage`;
DROP TABLE IF EXISTS `subscribe_promo`;
DROP TABLE IF EXISTS `promo_rule`;
```

---

## 14. 风险与注意事项

| 风险 | 应对 |
|------|------|
| 促销价 > 原价（配置错误） | 管理后台校验：`promo_price` 必须 < `unit_price` |
| 规则删除后已有订单受影响 | 软删除（`deleted_at`），订单上已存储 `promo_rule_id` 和 `promo_discount`，不依赖规则表 |
| 缓存与数据库不一致 | 管理后台修改时主动清缓存，判定逻辑以 DB 为准 |
| 新促销和老 NewUserOnly 折扣共存 | **互斥**：促销命中时跳过 `getDiscount()` 的百分比折扣 |
| 活动到期后 5 分钟内仍可下单 | 缓存 TTL=300s 的延迟，可接受；下单时可选择实时查 DB 校验 |
| 退款后重新购买仍享促销 | 正常行为 — `promo_usage` 只做记录不做去重 |
