hi-server/doc/invite-purchase-flow.md
shanshanzhong 0ec0e2b9d2
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
fix(order): align invite gift ownership
2026-04-28 05:19:57 -07:00

22 KiB
Raw Blame History

邀请赠送与购买订阅逻辑说明

本文档说明当前代码中的购买订阅、订单激活、邀请佣金、邀请赠送时间、家庭组归属逻辑。重点覆盖每个主要分支,方便排查“重复订单/重复订阅/邀请未赠时/赠时落点错误”等问题。

涉及核心文件:

  • internal/logic/public/order/purchaseLogic.go
  • queue/logic/order/activateOrderLogic.go
  • internal/logic/common/familyEntitlement.go
  • internal/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 支付手续费与礼品余额抵扣

流程:

  1. 找支付方式。
  2. 如果金额大于 0计算手续费并加到订单金额。
  3. 如果用户 gift_amount > 0,继续抵扣订单金额。
  4. 抵扣金额记录到订单 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 事务内创建订单

事务内执行:

  1. 新购且套餐有 quota 时,再查一次 effective_user_id 名下订阅数量防并发。
  2. 新购且新用户限定时,再查一次新用户资格。
  3. 如有礼品余额抵扣,扣减余额并写日志。
  4. 新购且库存不是 -1 时扣库存。
  5. 插入订单。

订单核心字段:

字段
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

处理成功后:

  1. 执行订阅合并兜底 reconcilePostOrderSubscriptions
  2. 更新优惠券使用次数。
  3. 更新订单为 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 新购激活后的异步逻辑

订阅发放后:

  1. 后台触发用户分组重算。
  2. 后台异步处理邀请佣金和赠送时间。
  3. 清套餐缓存。

注意:邀请逻辑在 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

  1. 查询邀请人,也就是 userInfo.referer_id 对应用户。
  2. 佣金比例:
    • 邀请人自定义比例优先。
    • 否则用系统配置 Invite.ReferralPercentage
  3. 佣金金额:
(order.amount - order.fee_amount) * referral_percentage / 100
  1. 事务内幂等检查:
    • 如果已有同订单佣金日志,则跳过。
    • 否则增加邀请人的 commission
    • system_logs type=33 佣金日志。
  2. 更新邀请人缓存。
  3. 如果 order.is_new=true
    • 给被邀请人赠送订阅时间。

当前保持不变的行为:

  • 邀请人是家庭成员时,佣金仍然给实际邀请人成员本人。
  • 佣金不归并到家主。
  • 有佣金路径下,邀请人不额外赠送订阅时间。

7.4 不发佣金路径

如果 shouldProcessCommission=false

order.is_new 行为
true 被邀请人和邀请人双方赠送订阅时间
false 不赠送时间

双方赠时具体为:

  1. 被邀请人赠时:
    • 如果被邀请人是家庭成员,加到被邀请人家主套餐。
    • 否则加到被邀请人本人套餐。
  2. 邀请人赠时:
    • 如果邀请人是家庭成员,加到邀请人家主套餐。
    • 否则加到邀请人本人套餐。

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=34 gift increase 日志。

9.6 找到永久订阅

如果目标订阅 expire_time = FROM_UNIXTIME(0)

  • 不改变 expire_time,因为永久订阅没有可延长的到期时间。
  • 仍写 system_logs type=34 gift increase 日志,表示赠送逻辑已识别并处理。

9.7 赠时失败日志

发佣金路径和无佣金路径都会检查 grantGiftDays 返回错误。

如果出错,会写应用日志:

Grant invite gift days failed

附带字段:

  • stage
  • target_user_id
  • order_no
  • error

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. 部署注意事项

邀请配置存在两层状态:

  1. Redis 缓存:system:invite_config
  2. 服务进程内存: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