# 促销优惠价系统设计文档 ## 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` 只做记录不做去重 |