3e265bd837
- 还原 /v1/public/user/invite_sales 接口及 /invite/sales 别名(与 invite_records 并存) 逻辑/handler/types 与197fed7d删除前版本完全一致,按fefbd4f5新 routes 结构注册 - 新增迁移 02155_promo_schema_fix: 幂等修复 subscribe_promo / order 列类型与索引偏差 - 同步 etc/ppanel.yaml 数据库连接配置 - 补齐相关需求与设计文档
797 lines
28 KiB
Markdown
797 lines
28 KiB
Markdown
# 促销优惠价系统设计文档
|
||
|
||
## 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` 只做记录不做去重 |
|