# 邀请赠送与购买订阅逻辑说明 本文档说明当前代码中的购买订阅、订单激活、邀请佣金、邀请赠送时间、家庭组归属逻辑。重点覆盖每个主要分支,方便排查“重复订单/重复订阅/邀请未赠时/赠时落点错误”等问题。 涉及核心文件: - `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 单订阅模式路由 先默认: ```text 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` 名下已有付费订阅: ```sql user_id = effective_user_id AND token != '' AND (order_id > 0 OR token LIKE 'iap:%') ``` | 查询结果 | 行为 | | --- | --- | | 找到已有订阅 | 路由为续费:`order_type=2`,用已有订阅 token | | 没找到 | 仍然新购 | 目的:避免同一个权益归属用户购买不同套餐后出现多条订阅权益。 ### 2.6 pending 订单处理 当前只有一个分支会主动关闭旧 pending 单: ```text 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` 首单标记 创建订单前调用: ```sql 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. 佣金金额: ```text (order.amount - order.fee_amount) * referral_percentage / 100 ``` 4. 事务内幂等检查: - 如果已有同订单佣金日志,则跳过。 - 否则增加邀请人的 `commission`。 - 写 `system_logs type=33` 佣金日志。 5. 更新邀请人缓存。 6. 如果 `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 日志: ```sql type = 34 AND object_id = 目标用户 ID AND content LIKE '%订单号%' ``` | 结果 | 行为 | | --- | --- | | 已存在 | 跳过,不重复赠时 | | 不存在 | 继续 | ### 9.3 查目标用户活跃订阅 调用 `FindActiveSubscribe`。 当前活跃口径: ```sql 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 为: ```text 邀请赠送 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` 返回错误。 如果出错,会写应用日志: ```text 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 查邀请配置 ```sql SELECT `key`, `value`, `updated_at` FROM system WHERE category = 'invite' AND `key` IN ('GiftDays', 'OnlyFirstPurchase', 'ReferralPercentage'); ``` ### 11.2 查某邀请人的被邀请用户 ```sql SELECT id, referer_id, created_at FROM `user` WHERE referer_id = 23944 ORDER BY id DESC LIMIT 100; ``` ### 11.3 查被邀请人的订单和首单标记 ```sql 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 查某订单佣金和赠时日志 ```sql SELECT id, type, object_id, content, created_at FROM system_logs WHERE content LIKE '%202604281812556044982351822%' ORDER BY id DESC; ``` ### 11.5 查某用户订阅 ```sql 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 查首单但没有赠时日志的被邀请人 ```sql 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 内,所以修改邀请配置或部署赠时逻辑后,需要重启服务。 推荐步骤: ```bash docker exec ppanel-redis redis-cli DEL system:invite_config system:global_config docker restart ppanel-server ``` 确认启动时间: ```bash docker inspect --format '{{.Name}} {{.State.StartedAt}} {{.Config.Image}}' ppanel-server docker ps --filter name=ppanel-server ```