Files
hi-server/doc/promo-pricing-design-zh.md
T
shanshanzhong147 3e265bd837
Build docker and publish / build (20.15.1) (push) Failing after 13m47s
Build docker and publish / build (20.15.1) (pull_request) Failing after 15m7s
新功能: 还原 v1/public/user/invite_sales 接口 + promo schema 修复迁移
- 还原 /v1/public/user/invite_sales 接口及 /invite/sales 别名(与 invite_records 并存)
  逻辑/handler/types 与 197fed7d 删除前版本完全一致,按 fefbd4f5 新 routes 结构注册
- 新增迁移 02155_promo_schema_fix: 幂等修复 subscribe_promo / order 列类型与索引偏差
- 同步 etc/ppanel.yaml 数据库连接配置
- 补齐相关需求与设计文档
2026-05-28 20:46:37 -07:00

28 KiB
Raw Blame History

促销优惠价系统设计文档

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(促销规则)

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(规格优惠价)

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(促销使用记录)

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

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 表新增字段

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 结构

{
  "window_hours": 168
}

判定逻辑

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

与老逻辑的区别

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

3.2 inactive_user — 回归用户优惠

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

params 结构

{
  "inactive_months": 3
}

判定逻辑

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

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

注意:「从未购买过」的用户同时满足 new_userinactive_user,靠 priority 排序选择高优先级的那条。

3.3 campaign — 活动促销

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

params 结构

{}

活动促销不需要额外参数,完全靠 promo_rule.start_timeend_time 控制。

判定逻辑

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

3.4 扩展预留

未来新增规则类型只需:

  1. 定义新的 type 字符串(如 first_purchasereferralregion
  2. 定义对应的 params 结构
  3. 在判定逻辑中增加一个 case 分支

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


4. 核心逻辑

4.1 促销资格判定

新增文件:internal/logic/common/promoEligibility.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 各类型判定函数

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.gosub.UnitPrice * req.Quantity 之前,插入促销价判定:

// === 新增:促销价判定 ===
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 逻辑完全不动

订单创建时记录

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

激活时写 usageactivateOrderLogic.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):

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 统计报表

现有统计 SQLorder/model.go 中 8 处)按 is_new 拆分收入,不需要改动

未来如需促销维度报表,可通过 order.promo_rule_id 字段扩展:

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 字段。

{
  "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 类型促销(不需要用户信息)
  • 用户已登录:展示所有命中的促销
  • 未命中任何规则时,promonull

8.2 预算订单 API(改造)

接口POST /v1/public/order/pre

响应追加字段

{
  "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      删除规则(软删除)

创建/更新请求体

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

校验规则

  • type 必须是已支持的类型
  • paramstype 做结构校验(如 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          删除某条优惠价

批量设置请求体

{
  "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 追加:PromoRuleIDPromoDiscount 字段
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

-- 促销规则表
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

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_idpromo_discount,不依赖规则表
缓存与数据库不一致 管理后台修改时主动清缓存,判定逻辑以 DB 为准
新促销和老 NewUserOnly 折扣共存 互斥:促销命中时跳过 getDiscount() 的百分比折扣
活动到期后 5 分钟内仍可下单 缓存 TTL=300s 的延迟,可接受;下单时可选择实时查 DB 校验
退款后重新购买仍享促销 正常行为 — promo_usage 只做记录不做去重