fix: 后台平台自营与赠送套餐订单不再按 C 端购买跳过实名门槛
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m29s

isCEndPurchase 只看 buyer_type=personal,后台平台自营资产与赠送套餐线下订单
买家也记为 personal,导致未实名资产的实名即生效套餐下单即生效、不进沉默期。
增加订单来源为 client 的条件,与同文件其他 C 端判定保持一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-27 17:30:53 +09:00
parent 94b6cfb47a
commit 69ec98aa5f
2 changed files with 111 additions and 1 deletions

View File

@@ -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,代码修复建议后续单独立项处理;此文档仅作为问题发现与数据取证的归档,供后续排期与客诉复盘引用。

View File

@@ -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 决定是否等实名