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

797 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 促销优惠价系统设计文档
## 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` 只做记录不做去重 |