- 还原 /v1/public/user/invite_sales 接口及 /invite/sales 别名(与 invite_records 并存) 逻辑/handler/types 与197fed7d删除前版本完全一致,按fefbd4f5新 routes 结构注册 - 新增迁移 02155_promo_schema_fix: 幂等修复 subscribe_promo / order 列类型与索引偏差 - 同步 etc/ppanel.yaml 数据库连接配置 - 补齐相关需求与设计文档
28 KiB
促销优惠价系统设计文档
1. 背景与目标
1.1 业务需求
为套餐规格提供可配置的优惠价格能力,支持多种促销场景:
- 新客优惠:注册 N 天内的用户享受优惠价
- 回归用户:N 个月未活跃的用户享受优惠价
- 活动促销:指定时间段内所有用户享受优惠价
- 未来可扩展:首充优惠、邀请用户专属价、指定地区优惠等
1.2 设计原则
- 纯新增,不改老代码:现有的
new_user_only+discount.NewUserOnly+ 24h 窗口逻辑全部保留不动 - 固定价格,非百分比:运营直接设定优惠价(如 $5.99),不再需要反算折扣百分比
- 后台可配置:规则类型、参数、时间窗口、优先级均可在管理后台配置
- 促销价不叠加批量折扣:促销价命中时即为最终基础单价,跳过
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_user 和 inactive_user,靠 priority 排序选择高优先级的那条。
3.3 campaign — 活动促销
含义:在指定时间段内,所有用户均可享受优惠价
params 结构:
{}
活动促销不需要额外参数,完全靠 promo_rule.start_time 和 end_time 控制。
判定逻辑:
eligible = start_time <= 当前时间 <= end_time
expires_at = end_time
3.4 扩展预留
未来新增规则类型只需:
- 定义新的
type字符串(如first_purchase、referral、region) - 定义对应的
params结构 - 在判定逻辑中增加一个
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.go 中 sub.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,
}
激活时写 usage(activateOrderLogic.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 统计报表
现有统计 SQL(order/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类型促销(不需要用户信息) - 用户已登录:展示所有命中的促销
- 未命中任何规则时,
promo为null
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必须是已支持的类型params按type做结构校验(如new_user必须有window_hours > 0)priority>= 0start_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 |
追加: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
-- 促销规则表
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_id 和 promo_discount,不依赖规则表 |
| 缓存与数据库不一致 | 管理后台修改时主动清缓存,判定逻辑以 DB 为准 |
| 新促销和老 NewUserOnly 折扣共存 | 互斥:促销命中时跳过 getDiscount() 的百分比折扣 |
| 活动到期后 5 分钟内仍可下单 | 缓存 TTL=300s 的延迟,可接受;下单时可选择实时查 DB 校验 |
| 退款后重新购买仍享促销 | 正常行为 — promo_usage 只做记录不做去重 |