diff --git a/docs/reviews/2026-09-22-order-realname-activation-misjudge/README.md b/docs/reviews/2026-09-22-order-realname-activation-misjudge/README.md new file mode 100644 index 00000000..b6f6c034 --- /dev/null +++ b/docs/reviews/2026-09-22-order-realname-activation-misjudge/README.md @@ -0,0 +1,109 @@ +# 后台代客下单误判为"C端已实名购买",套餐跳过实名门槛直接生效(Bug,已修复) + +> 2026-10-27 已修复:`activateMainPackage` 的 `isCEndPurchase` 增加 `order.Source == client` 条件,后台平台自营与赠送套餐线下订单不再跳过实名门槛(同时可正常进入沉默期)。 + +## 起因 + +排查设备 `60003081756`(换货前旧资产)与其换货后新资产 `60003080119` 的换货记录(换货单 `EXC20260922173715169105`)时发现:换货完成后,操作人蒋恒在后台为新设备(`tb_device.id=19452`)代客户下单购买套餐(订单 `ORD20260922175155311314`,`payment_status=paid`),套餐(`tb_package_usage.id=83144`)在下单瞬间(`2026-09-22 17:51:55`)就变成"生效中"(`status=1`),而此时这张卡(`tb_iot_card.id=132322`,iccid `89860624660030801193`)的 `real_name_status` 仍是 `0`(未实名)。本记录仅做事实归档,**未修改任何代码**,全部结论来自对生产库 `junhong_cmp_prod` 的只读查询(`root@116.162.101.91`,仅执行 `SELECT`)与仓库 Git 历史核对。 + +## 结论 + +- **Bug 真实存在,当前代码未修复,仍会触发。** +- 触发条件:后台管理员为**一台当时还没有分配店铺归属(`shop_id` 为空)的设备/卡**,用 `offline` 或 `wallet` 支付方式代客户下单购买套餐时,系统会把订单误判为"客户本人在 App 自助下单",从而跳过套餐本该有的"等实名认证后再激活"逻辑,导致套餐在客户尚未实名的情况下立即生效、开始计时。 +- 本次涉及的客户(personal customer_id=1008)事后约 18 分钟自行在运营商侧完成了实名(详见下方"实名完成时间核对"),影响较小;但历史上已有 3 起同类真实案例(非数据迁移脚本产生),不能排除客户未及时实名、白白损失部分套餐有效期的情况。 + +## 根因 + +### 1. 直接原因:`isCEndPurchase` 判断条件遗漏 `Source` 检查 + +- **代码位置**:`internal/service/order/service.go:2574-2596`,函数 `activateMainPackage`。 +- 决定"套餐是否要等实名才激活"的开关: + + ```go + // 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活) + isCEndPurchase := order.BuyerType == model.BuyerTypePersonal + + if !isCEndPurchase { + // 后台囤货路径:按 expiry_base 决定是否等实名 + if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase { + if !currentlyRealnamed { + status = constants.PackageUsageStatusPending + pendingRealnameActivation = true + } + } + } + // C 端购买已完成实名前置校验,直接激活。 + ``` + + 只要 `order.BuyerType == "personal"` 就认定"客户已在 App 里做过实名前置校验,可以直接激活",**完全没有检查订单来源(`order.Source`)**。 + +- 同一文件里另一处判断"是否真的是客户在 App 自助下单"时,写法是**严谨的**(`internal/service/order/service.go:2868`): + + ```go + if order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal { + ``` + + 两处对"C端购买"的判定标准不一致,`activateMainPackage` 里少了 `Source == client` 这一层。 + +### 2. 触发路径:`buyer_type=personal` 是怎么产生的 + +- **代码位置**:`internal/service/order/service.go:284-333`,函数 `CreateAdminOrder`(后台代客下单,线下支付分支)。 +- `resourceShopID = resolvedDevice.ShopID`(取设备当前的 `shop_id`): + - 若 `resourceShopID == nil || *resourceShopID == 0`(设备当时没有店铺归属),判定为"平台自营,直接卖给终端客户",**强制**把 `orderBuyerType` 设为 `model.BuyerTypePersonal`。 +- 本次案例中,设备 `19452` 在下单那一刻(`17:51:55`)确实还没有分配店铺——分配店铺的动作(`device.allocate`,`shop_id: null → 670`)发生在 `17:52:44`,**在下单之后**。于是订单被自动打上 `buyer_type=personal`,进而在 `activateMainPackage` 里命中上述 `isCEndPurchase=true` 分支。 + +### 3. 时间线为何这两段代码会"撞车" + +| 时间 | 提交 | 内容 | +|---|---|---| +| 2026-03-02 | `8efe7952` | 「修复平台自营资源(未分配代理)无法线下下单的问题」——引入"设备无店铺归属 → `buyer_type=personal`"这条路径 | +| 2026-03-28 | `7dd55630` | 「activateMainPackage 三维决策重构 REALNAME-02」——引入 `isCEndPurchase := order.BuyerType == Personal` 假设 | + +`7dd55630` 落地时,`8efe7952` 引入的"后台平台自营代客下单也会产出 `buyer_type=personal`"这条路径已经存在近一个月。`isCEndPurchase` 的假设从写下的那一刻起就没有覆盖到这条路径,二者从未真正对齐过。 + +## 实名完成时间核对(本案例,非普遍规律) + +``` +2026-09-22 17:51:55 订单创建,套餐立即激活(status=1,跳过实名等待) +2026-09-22 18:10:38 iot_card.worker_realname_sync(后台定时轮询任务,从运营商侧同步) + before: {"real_name_status": 0} + after: {"real_name_status": 1, "first_realname_at": "2026-09-22T10:10:37Z"(UTC,即北京时间 18:10:37)} +``` +客户是在运营商侧真实完成的实名认证,系统通过定时轮询同步过来,不是人工手动改的状态。套餐已提前激活约 18 分钟,这段时间里客户理论上是在为一个"计时中但用不了"(网络仍是停机状态)的套餐付费/计时。 + +## 数据验证:历史命中范围 + +用以下条件在生产库上识别同类命中(后台代客下单、`buyer_type=personal`、套餐配置为"需等实名激活"、但落库时直接是生效状态): + +```sql +-- 设备类套餐(usage_type='device') +SELECT count(*) FROM tb_package_usage pu +JOIN tb_order o ON o.id = pu.order_id +WHERE o.source = 'admin' AND o.buyer_type = 'personal' + AND pu.expiry_base_snapshot = 'from_activation' + AND pu.pending_realname_activation = false + AND pu.status = 1 AND pu.master_usage_id IS NULL + AND pu.usage_type = 'device' + AND NOT EXISTS ( + SELECT 1 FROM tb_device_sim_binding b + JOIN tb_iot_card c2 ON c2.id = b.iot_card_id + WHERE b.device_id = pu.device_id AND b.bind_status = 1 AND c2.real_name_status = 1 + ); +``` + +粗查命中 5210 条,但其中 **5207 条来自一次历史数据迁移脚本**(`order_no` 以 `MIG-` 开头,2026-09-03~09-04 一天多内由 2 个系统账号批量写入),属于把老系统里客户本来就已生效的历史套餐批量导入新系统,跳过实名门槛是迁移脚本的预期行为,**不算本 bug 命中**。 + +刨除迁移脚本后,**真正由人工后台操作触发的同类历史案例只有 3 起**,加上本次共 4 起: + +| 订单号 | 操作人 | 设备 | 时间 | +|---|---|---|---| +| ORD20260905144024517558 | 王鹏 | 18914 | 2026-09-05 14:40 | +| ORD20260905144241735458 | 王鹏 | 18913 | 2026-09-05 14:42 | +| ORD20260916112035085745 | 王鹏 | 19108 | 2026-09-16 11:20 | +| ORD20260922175155311314 | 蒋恒 | 19452 | 2026-09-22 17:51(本次) | + +## 后续整改(代码侧,尚未执行) + +1. **建议修复**:将 `internal/service/order/service.go:2575` 的 `isCEndPurchase` 判断改为与 `service.go:2868` 一致,同时检查 `order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal`,使"后台代客下单"不再被误判为"客户 App 自助下单"。 +2. **建议核实**:王鹏那 3 起历史案例(设备 18913、18914、19108)对应的卡后续是否及时完成了实名认证,是否存在客户实际损失套餐有效期天数的情况,需要跟客服/王鹏本人核对。 +3. 本记录暂未提交 OpenSpec Change,代码修复建议后续单独立项处理;此文档仅作为问题发现与数据取证的归档,供后续排期与客诉复盘引用。 diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 4e827e51..1790090d 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -2697,7 +2697,8 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m } // 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活) - isCEndPurchase := order.BuyerType == model.BuyerTypePersonal + // 后台平台自营 / 赠送订单的买家也记为 personal,但未做实名前置检查,须按来源区分 + isCEndPurchase := order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal if !isCEndPurchase { // 后台囤货路径:按 expiry_base 决定是否等实名