22 KiB
邀请赠送与购买订阅逻辑说明
本文档说明当前代码中的购买订阅、订单激活、邀请佣金、邀请赠送时间、家庭组归属逻辑。重点覆盖每个主要分支,方便排查“重复订单/重复订阅/邀请未赠时/赠时落点错误”等问题。
涉及核心文件:
internal/logic/public/order/purchaseLogic.goqueue/logic/order/activateOrderLogic.gointernal/logic/common/familyEntitlement.gointernal/model/user/model.go
1. 核心概念
1.1 订单状态
| 状态 | 含义 |
|---|---|
1 |
pending,已创建未支付 |
2 |
paid,已支付待激活 |
3 |
close,已关闭 |
4 |
failed/claimed,代码里同时用于失败和 worker 临时领取 |
5 |
finished,激活完成 |
1.2 订单类型
| 类型 | 含义 |
|---|---|
1 |
新购套餐 |
2 |
续费/换套餐 |
3 |
重置流量 |
4 |
余额充值 |
5 |
兑换码激活 |
1.3 用户 ID 与订阅归属
订单有两个重要用户字段:
| 字段 | 含义 |
|---|---|
user_id |
发起订单/付款的用户 |
subscription_user_id |
订阅权益归属用户;0 表示同 user_id |
家庭组规则:
- 普通用户下单:
subscription_user_id = user_id。 - 家庭组成员下单:
subscription_user_id = 家主用户 ID。 - 家庭组主账号下单:
subscription_user_id = 家主用户 ID。
当前代码使用 ResolveEntitlementUser 判断家庭归属:
- 只有有效家庭组、有效成员关系、角色为 member 时,权益归家主。
- 家主本人不会被改写到别人名下。
2. 购买订阅下单逻辑
入口:Purchase(req *types.PurchaseOrderRequest)
这里只是创建订单和安排关闭任务,不直接发放订阅。真正发放订阅在订单支付后由队列激活处理。
2.1 登录用户检查
分支:
- 上下文没有当前用户:返回
InvalidAccess。 - 当前用户存在:继续。
2.2 解析订阅权益归属
调用 ResolveEntitlementUser:
| 场景 | effective_user_id |
结果 |
|---|---|---|
| 普通用户 | 本人 ID | 订阅归本人 |
| 家庭组主账号 | 本人 ID | 订阅归本人 |
| 家庭组成员 | 家主 ID | 订阅归家主 |
后续查询已有订阅、quota、创建订单里的 subscription_user_id 都会使用这个归属结果。
2.3 数量校验
分支:
quantity <= 0:自动改成1。quantity > MaxQuantity:返回参数错误。- 合法:继续。
2.4 单订阅模式路由
先默认:
order_type = 1
target_subscribe_id = req.subscribe_id
parent_order_id = 0
subscribe_token = ""
如果开启 Subscribe.SingleModel:
| 查询结果 | 行为 |
|---|---|
| 找到已有 anchor 订阅 | 下单路由为续费:order_type=2,保留新请求套餐 ID,设置 parent/order token |
| 没找到已有订阅 | 保持新购:order_type=1 |
| 查询异常 | 返回数据库错误 |
说明:即使是换套餐,只要单订阅模式已有订阅,也走续费语义,后续激活会更新套餐 ID 和流量配置。
2.5 非 SingleModel 的全局单订阅兜底
如果未开启 SingleModel,且当前还是新购 order_type=1:
- 查询
effective_user_id名下已有付费订阅:
user_id = effective_user_id
AND token != ''
AND (order_id > 0 OR token LIKE 'iap:%')
| 查询结果 | 行为 |
|---|---|
| 找到已有订阅 | 路由为续费:order_type=2,用已有订阅 token |
| 没找到 | 仍然新购 |
目的:避免同一个权益归属用户购买不同套餐后出现多条订阅权益。
2.6 pending 订单处理
当前只有一个分支会主动关闭旧 pending 单:
SingleModel = true
AND order_type = 1
AND 存在同 user_id + subscribe_id + status=1 的订单
行为:
- 关闭旧 pending 订单。
- 继续创建新订单。
注意:
- 如果订单已被路由为
order_type=2,这里不会关闭旧 pending。 - 非
SingleModel下也不会走这段 pending 关闭逻辑。
2.7 套餐校验
分支:
| 条件 | 行为 |
|---|---|
| 套餐不存在 | 返回数据库错误 |
sell=false |
返回套餐不可售 |
新购且库存为 0 |
返回库存不足 |
| 续费/换套餐 | 不检查库存为 0 的拦截分支 |
2.8 新用户优惠与新用户限定
调用 resolveNewUserDiscountEligibility:
| 分支 | 行为 |
|---|---|
| 解析失败 | 返回错误 |
| 有折扣且符合条件 | 按折扣计算金额 |
| 有折扣但不符合条件 | 按原价 |
| 新用户限定且不是新用户窗口 | 新购事务内再次校验,不通过则失败 |
2.9 优惠券逻辑
如果 req.coupon 为空:跳过。
如果不为空:
| 校验 | 不通过行为 |
|---|---|
| 优惠券存在 | 返回 CouponNotExist |
| 总使用次数未超限 | 返回使用次数不足 |
| 用户使用次数未超限 | 返回用户次数不足 |
| 套餐适用 | 返回不适用 |
通过后计算 coupon_discount,从订单金额中扣除。
2.10 支付手续费与礼品余额抵扣
流程:
- 找支付方式。
- 如果金额大于 0,计算手续费并加到订单金额。
- 如果用户
gift_amount > 0,继续抵扣订单金额。 - 抵扣金额记录到订单
gift_amount。
事务内如果有礼品余额抵扣:
- 扣减用户
gift_amount。 - 写
system_logs的 gift reduce 日志。
2.11 is_new 首单标记
创建订单前调用:
SELECT COUNT(*)
FROM `order`
WHERE user_id = 当前付款用户
AND status IN (2, 5)
| 结果 | is_new |
|---|---|
| count = 0 | true |
| count > 0 | false |
注意:
- 判断口径是付款用户
user_id,不是subscription_user_id。 - pending/closed 订单不影响
is_new。 - 续费订单也可能是
is_new=true,例如单订阅模式下首次购买被路由为续费。
2.12 事务内创建订单
事务内执行:
- 新购且套餐有 quota 时,再查一次
effective_user_id名下订阅数量防并发。 - 新购且新用户限定时,再查一次新用户资格。
- 如有礼品余额抵扣,扣减余额并写日志。
- 新购且库存不是
-1时扣库存。 - 插入订单。
订单核心字段:
| 字段 | 值 |
|---|---|
user_id |
当前付款用户 |
subscription_user_id |
权益归属用户 |
type |
新购 1 或续费 2 |
subscribe_id |
本次购买的套餐 ID |
subscribe_token |
续费时已有订阅 token |
is_new |
首单标记 |
status |
1 pending |
2.13 延迟关单任务
订单创建成功后,发送 DeferCloseOrder 任务:
- 延迟时间:15 分钟。
- 作用:未支付订单自动关闭。
3. 订单支付后的激活逻辑
入口:ActivateOrderLogic.ProcessTask
3.1 任务解析和订单领取
分支:
| 分支 | 行为 |
|---|---|
| payload 解析失败 | 记录错误,不重试 |
| 订单不存在 | 返回错误,允许重试 |
| 订单已 finished | 幂等跳过 |
| 订单不是 paid | 跳过 |
| paid 订单 | 原子更新为 claimed 状态后处理 |
3.2 按订单类型分发
| 订单类型 | 处理函数 |
|---|---|
新购 1 |
NewPurchase |
续费 2 |
Renewal |
重置流量 3 |
ResetTraffic |
充值 4 |
Recharge |
兑换码 5 |
RedemptionActivate |
处理成功后:
- 执行订阅合并兜底
reconcilePostOrderSubscriptions。 - 更新优惠券使用次数。
- 更新订单为
finished。
如果处理失败:
- 把订单从 claimed 释放回 paid。
- 返回错误给队列重试。
4. 新购订单激活与订阅发放
入口:NewPurchase
4.1 获取用户
分支:
| 订单 user_id | 行为 |
|---|---|
| 不为 0 | 查询已有用户 |
| 为 0 | 从 Redis 临时订单创建游客用户 |
游客订单创建用户时:
- 创建用户和 auth method。
- 生成 refer code。
- 把订单
user_id更新为新用户 ID。 - 如果临时订单有邀请码,绑定
referer_id。
4.2 新用户限定激活时复查
如果套餐是新用户限定,激活时再次校验:
- 不符合则激活失败,订单回到 paid 等待重试/处理。
- 符合继续。
4.3 SingleModel 下复用 anchor 订阅
如果 Subscribe.SingleModel=true:
| 分支 | 行为 |
|---|---|
| 找到 anchor 订阅 | 更新订单 parent_id;用续费逻辑延长/换套餐 |
| 找不到 | 继续后续分支 |
| 查询异常 | 记录错误,继续后续分支 |
家庭组场景下查 anchor 的用户 ID:
- 优先
subscription_user_id。 - 没有则用
user_id。
4.4 复用赠送订阅
如果还没有可复用订阅:
- 查找权益归属用户名下
order_id=0的赠送订阅。 - 找到后将它升级为付费订阅:
order_id改为当前订单 ID。- 延长到期时间。
- 状态改为 active。
- 如果套餐变更,更新套餐 ID、流量额度,并清空已用流量。
4.5 兜底复用已有订阅
如果仍未复用到订阅:
- 候选用户 ID:
order.user_id- 如果
subscription_user_id存在且不同,也加入候选。
- 查找这些用户名下
token != ''的订阅。
找到后:
- 如果订阅 owner 不是当前
subscription_user_id,先把user_id修正为权益归属用户。 - 用续费逻辑延长/换套餐。
目的:家庭组绑定前后 owner 变化时,也尽量复用旧记录,避免创建重复订阅。
4.6 创建新订阅
如果以上都没有复用成功,才创建新 user_subscribe:
| 字段 | 值 |
|---|---|
user_id |
subscription_user_id,没有则 order.user_id |
order_id |
当前订单 ID |
subscribe_id |
当前订单套餐 ID |
start_time |
当前时间 |
expire_time |
按套餐时间单位和数量计算 |
traffic |
套餐流量 |
token |
基于订单号生成 |
uuid |
新 UUID |
status |
1 active |
创建前如果套餐有 quota,会再按订阅 owner 统计数量。
4.7 新购激活后的异步逻辑
订阅发放后:
- 后台触发用户分组重算。
- 后台异步处理邀请佣金和赠送时间。
- 清套餐缓存。
注意:邀请逻辑在 goroutine 中执行,不阻塞订单激活。
5. 续费/换套餐激活逻辑
入口:Renewal
5.1 获取用户和订阅
- 查询订单
user_id对应用户。 - 通过
subscribe_token查订阅。 - 查询订单
subscribe_id对应套餐。
5.2 Apple IAP 与普通续费分支
| 分支 | 行为 |
|---|---|
iap_expire_at > 0 |
使用 IAP 到期时间兜底,但仍按累计加时语义 |
| 普通续费 | updateSubscriptionForRenewal |
5.3 普通续费/换套餐规则
updateSubscriptionForRenewal:
- 如果当前订阅已过期,先把基准时间改为现在。
- 如果套餐 ID 变化:
- 更新订阅套餐 ID。
- 更新流量额度。
- 清空已用流量。
- 如果套餐没变:
- 如果套餐设置 renewal reset,或今天是重置日,则清空已用流量。
- 清理
finished_at。 order_id改为当前订单 ID。- 按套餐时间单位和数量延长到期时间。
- 状态改为 active。
- 清空过期流量字段。
5.4 续费后的邀请逻辑
续费成功后也会调用 handleCommission:
- 是否发佣金由邀请配置和
order.is_new决定。 - 是否赠时同样由邀请配置和
order.is_new决定。
注意:如果 OnlyFirstPurchase=true,非首单续费通常不会发佣金,也不会赠首单时间。
6. 邀请关系绑定逻辑
6.1 注册/登录时的邀请码
新用户注册、游客订单创建用户时,如果带邀请码:
- 根据邀请码查邀请人。
- 设置新用户
referer_id = 邀请人 ID。
6.2 用户后绑邀请码
入口:BindInviteCode
分支:
| 分支 | 行为 |
|---|---|
| 当前用户不存在 | 返回无权限 |
当前用户已有 referer_id |
返回已绑定 |
| 邀请码不存在 | 返回邀请码错误 |
| 邀请码属于自己 | 返回不允许绑定自己 |
| 通过 | 更新当前用户 referer_id |
注意:
referer_id始终记录实际邀请码所有者。- 邀请人是家庭成员时,
referer_id仍然是该成员 ID,不自动改为家主 ID。
7. 邀请佣金与赠送时间逻辑
入口:handleCommission(userInfo, orderInfo)
这里的 userInfo 是订单付款用户,也就是被邀请人。
7.1 总入口分支
先调用 shouldProcessCommission(userInfo, orderInfo.IsNew)。
| 结果 | 行为 |
|---|---|
false |
不发佣金;如果 is_new=true,走双方赠时 |
true |
发佣金;如果 is_new=true,被邀请人赠时 |
7.2 什么时候发佣金
shouldProcessCommission 规则:
| 条件 | 结果 |
|---|---|
| 被邀请人为空 | 不发 |
被邀请人 referer_id=0 |
不发 |
| 查不到邀请人 | 不发 |
邀请人自定义 referral_percentage > 0,且只首购但不是首单 |
不发 |
邀请人自定义 referral_percentage > 0,且通过首购限制 |
发佣金 |
邀请人无自定义比例,系统 ReferralPercentage=0 |
不发 |
系统 OnlyFirstPurchase=true 且不是首单 |
不发 |
| 系统有比例且通过首购限制 | 发佣金 |
7.3 发佣金路径
如果 shouldProcessCommission=true:
- 查询邀请人,也就是
userInfo.referer_id对应用户。 - 佣金比例:
- 邀请人自定义比例优先。
- 否则用系统配置
Invite.ReferralPercentage。
- 佣金金额:
(order.amount - order.fee_amount) * referral_percentage / 100
- 事务内幂等检查:
- 如果已有同订单佣金日志,则跳过。
- 否则增加邀请人的
commission。 - 写
system_logs type=33佣金日志。
- 更新邀请人缓存。
- 如果
order.is_new=true:- 给被邀请人赠送订阅时间。
当前保持不变的行为:
- 邀请人是家庭成员时,佣金仍然给实际邀请人成员本人。
- 佣金不归并到家主。
- 有佣金路径下,邀请人不额外赠送订阅时间。
7.4 不发佣金路径
如果 shouldProcessCommission=false:
order.is_new |
行为 |
|---|---|
true |
被邀请人和邀请人双方赠送订阅时间 |
false |
不赠送时间 |
双方赠时具体为:
- 被邀请人赠时:
- 如果被邀请人是家庭成员,加到被邀请人家主套餐。
- 否则加到被邀请人本人套餐。
- 邀请人赠时:
- 如果邀请人是家庭成员,加到邀请人家主套餐。
- 否则加到邀请人本人套餐。
8. 赠送时间目标解析
入口:resolveGiftTargetUser(source, forcedOwnerID)
8.1 强制 owner 分支
如果 forcedOwnerID > 0:
- 赠送目标直接使用
forcedOwnerID。 - 典型场景:订单里已有
subscription_user_id。 - 这保证了家庭成员购买时,被邀请人的赠时落到家主。
8.2 自动家庭组解析分支
如果没有强制 owner:
- 调用
ResolveEntitlementUser(source.Id)。 - 如果 source 是有效家庭成员,目标改为家主。
- 否则目标为本人。
典型场景:
- 无佣金路径下,邀请人也赠时。
- 邀请人如果是家庭成员,赠时会加到邀请人家主套餐。
8.3 目标用户查询失败
如果解析出来的目标用户查不到:
- 记录错误日志。
- 回退为 source 本人。
9. 赠送时间落库逻辑
入口:grantGiftDays(u, days, orderNo, remark)
9.1 空值和配置分支
| 条件 | 行为 |
|---|---|
| 目标用户为空 | 直接返回,不写日志 |
days <= 0 |
直接返回,不写日志 |
9.2 幂等检查
按下面条件查 gift 日志:
type = 34
AND object_id = 目标用户 ID
AND content LIKE '%订单号%'
| 结果 | 行为 |
|---|---|
| 已存在 | 跳过,不重复赠时 |
| 不存在 | 继续 |
9.3 查目标用户活跃订阅
调用 FindActiveSubscribe。
当前活跃口径:
user_id = 目标用户 ID
AND status IN (0, 1)
AND (
expire_time > NOW()
OR expire_time = FROM_UNIXTIME(0)
)
说明:
expire_time > NOW()是普通未过期订阅。expire_time = FROM_UNIXTIME(0)是永久/不限时订阅。
9.4 没有活跃订阅
如果查不到活跃订阅:
- 不创建新订阅。
- 写一条
system_logs type=34日志。 - 日志 remark 为:
邀请赠送 skipped: no active subscription
这表示邀请赠时触发过,但目标用户当时没有可加时的套餐。
9.5 找到普通活跃订阅
如果目标订阅不是永久订阅:
expire_time += days * 24h- 更新订阅。
- 写
system_logs type=34gift increase 日志。
9.6 找到永久订阅
如果目标订阅 expire_time = FROM_UNIXTIME(0):
- 不改变
expire_time,因为永久订阅没有可延长的到期时间。 - 仍写
system_logs type=34gift increase 日志,表示赠送逻辑已识别并处理。
9.7 赠时失败日志
发佣金路径和无佣金路径都会检查 grantGiftDays 返回错误。
如果出错,会写应用日志:
Grant invite gift days failed
附带字段:
stagetarget_user_idorder_noerror
10. 家庭组下的完整分支示例
10.1 被邀请人是普通用户,邀请人普通用户,有佣金
条件:
- 被邀请人
referer_id != 0 - 系统或邀请人佣金比例大于 0
order.is_new=true
结果:
- 佣金给邀请人本人。
- 被邀请人本人套餐加赠送时间。
- 邀请人不加赠送时间。
10.2 被邀请人是家庭成员,邀请人普通用户,有佣金
结果:
- 佣金给邀请人本人。
- 被邀请人的赠送时间加到被邀请人家主套餐。
- 被邀请人成员本人不单独加订阅时间。
10.3 被邀请人普通用户,邀请人是家庭成员,有佣金
结果:
- 佣金给邀请人成员本人。
- 被邀请人本人套餐加赠送时间。
- 邀请人不加赠送时间。
- 邀请人家主不拿佣金,也不因该佣金路径加赠时。
10.4 被邀请人是家庭成员,邀请人也是家庭成员,有佣金
结果:
- 佣金给邀请人成员本人。
- 被邀请人的赠送时间加到被邀请人家主套餐。
- 邀请人不加赠送时间。
- 邀请人家主不拿佣金。
10.5 无佣金路径,被邀请人普通用户,邀请人普通用户
触发条件示例:
ReferralPercentage=0- 或因首购限制导致不发佣金
- 且
order.is_new=true
结果:
- 被邀请人本人套餐加赠送时间。
- 邀请人本人套餐加赠送时间。
10.6 无佣金路径,被邀请人是家庭成员
结果:
- 被邀请人的赠送时间加到被邀请人家主套餐。
- 邀请人的赠时按邀请人自己的家庭归属解析。
10.7 无佣金路径,邀请人是家庭成员
结果:
- 被邀请人的赠时按被邀请人的家庭归属解析。
- 邀请人的赠送时间加到邀请人家主套餐。
- 邀请人成员本人不单独加订阅时间。
10.8 被邀请人没有活跃订阅
结果:
- 不创建新订阅。
- 写 skipped gift 日志。
- 后续即使用户后来有订阅,也不会自动补赠,除非另行补偿。
10.9 被邀请人或目标家主是永久订阅
结果:
- 识别为活跃订阅。
- 不改变到期时间。
- 写 gift increase 日志。
11. 排查 SQL
11.1 查邀请配置
SELECT `key`, `value`, `updated_at`
FROM system
WHERE category = 'invite'
AND `key` IN ('GiftDays', 'OnlyFirstPurchase', 'ReferralPercentage');
11.2 查某邀请人的被邀请用户
SELECT id, referer_id, created_at
FROM `user`
WHERE referer_id = 23944
ORDER BY id DESC
LIMIT 100;
11.3 查被邀请人的订单和首单标记
SELECT u.id AS invited_user_id,
o.id AS order_id,
o.order_no,
o.type,
o.status,
o.amount,
o.is_new,
o.subscribe_id,
o.subscription_user_id,
o.created_at
FROM `user` u
LEFT JOIN `order` o
ON o.user_id = u.id
AND o.type IN (1, 2)
WHERE u.referer_id = 23944
ORDER BY u.id DESC, o.id ASC
LIMIT 200;
11.4 查某订单佣金和赠时日志
SELECT id, type, object_id, content, created_at
FROM system_logs
WHERE content LIKE '%202604281812556044982351822%'
ORDER BY id DESC;
11.5 查某用户订阅
SELECT id, user_id, order_id, subscribe_id, status,
expire_time, finished_at, token, created_at, updated_at
FROM user_subscribe
WHERE user_id = 24425
ORDER BY id DESC;
11.6 查首单但没有赠时日志的被邀请人
SELECT first_orders.user_id AS invited_user_id,
first_orders.order_no,
first_orders.is_new,
first_orders.status,
first_orders.subscription_user_id,
first_orders.created_at,
(
SELECT COUNT(*)
FROM system_logs sl
WHERE sl.type = 34
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')
) AS gift_log_count,
(
SELECT COUNT(*)
FROM system_logs sl
WHERE sl.type = 33
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')
) AS commission_log_count
FROM (
SELECT o.*
FROM `order` o
JOIN (
SELECT user_id, MIN(id) AS first_order_id
FROM `order`
WHERE type IN (1, 2)
AND status IN (2, 5)
GROUP BY user_id
) fo ON fo.first_order_id = o.id
) first_orders
JOIN `user` u ON u.id = first_orders.user_id
WHERE u.referer_id = 23944
ORDER BY first_orders.created_at DESC
LIMIT 100;
12. 部署注意事项
邀请配置存在两层状态:
- Redis 缓存:
system:invite_config - 服务进程内存:
svc.Config.Invite
如果直接修改数据库或 Redis,已经运行的 ppanel-server 进程不会自动刷新内存配置。订单激活和赠时发生在服务进程/队列 worker 内,所以修改邀请配置或部署赠时逻辑后,需要重启服务。
推荐步骤:
docker exec ppanel-redis redis-cli DEL system:invite_config system:global_config
docker restart ppanel-server
确认启动时间:
docker inspect --format '{{.Name}} {{.State.StartedAt}} {{.Config.Image}}' ppanel-server
docker ps --filter name=ppanel-server