核心裁决结论与双缺陷全景对比
| 缺陷编号与级别 | 核心机制与漏洞成因 | 极端故障后果 | 解决方案与技术落地 | 工程裁决依据 |
|---|---|---|---|---|
F01 [P1]严重逻辑漏洞 |
shuying_submit_record_sqlmap_mapper.xml 的 sumUnsettledCredits 仅过滤 status='DONE',漏算 PROCESSING 且 success_count > 0 的在途额度。 |
进程崩溃或分布式锁超时释放后,新任务准入穿透并耗尽余额;崩溃任务被巡检恢复后,已生成的案件无法平账扣减,形成幽灵案件坏账。 | 已完成修复并合入代码库: 1. SQL 将 (status='PROCESSING' AND success_count > 0) 纳入未平账聚合。2. 新增 H2 DAO 集成测试与 Service 单测。 |
必须修复。 本地数据库状态机自洽性问题,0 外部依赖成本,完全消除并发穿透隐患。 |
F02 [P1]边界时序争用 |
建单前仅只读检查 origix 额度,未真正锁定(预扣);建单耗时 2~3 秒期间若恰逢额度 1 年有效期到期边界,后续单步 consume 失败。 | 案件在本地已生成入库,但 origix 拒绝扣减已过期资产;本地记录停留在未核销状态,需后台补偿定时任务重试。 | 不予在业务链路引入 TCC/2PC: 维持当前「只读准入 → 本地建单 → 一步式扣减」模式;若遇极端过期由补偿任务报警,运营兜底延期或免扣。 |
无需且不应修复。 改造为 TCC 需 origix 开放两阶段接口,会引入悬挂预扣锁死、网络分区雪崩,弊端远超极小概率的边界到期。 |
F01 是系统内部数据结构与状态机契约的违背。ipp_shuying_submit_record 的物理语义是代表「某次批次提交占用的资产配额与扣减状态」。当一个批次已经成功在数据库插入了 50 条投诉(success_count = 50),这 50 条在法律和业务上已经产生物理事实,但状态由于主循环未完成依然处于 PROCESSING。如果此时因为 JVM OOM、机器置换或锁超时释放,另一个并发请求计算未平账时视这 50 条为不存在,就会发生算力与额度的双重超卖。这种修复完全在单一数据库本地 SQL 内部完成,不引入外部分布式协调成本,代码品味上属于消除特殊情况。
F02 是典型的跨领域服务过度设计陷阱。试图在两个独立的分布式系统(asipserviceplus 与 origix)之间追求绝对实时的跨到期边界强一致性,代价是两阶段提交(2PC/TCC)。两阶段提交要求 origix 暴露预扣锁定接口与定时释放超时锁,一旦发生网络超时、调用方挂起,会导致用户资产被假死锁定无法使用;而该场景的真实物理发生概率低于千万分之二(一年 3153 万秒中只有建单耗时的 2~3 秒临界),且用户没有任何经济损失(甚至免费获得了维权动作),通过后台巡检补偿 + 运营豁免才是真正高内聚、低耦合的架构解法。
F01 漏洞根因:PROCESSING 状态在途额度漏算
ShuyingComplaintTransactionService.java 批次建单过程中,批次记录首先落库为 PROCESSING,每成功建单一条,累加 success_count。原 sumUnsettledCredits SQL 查询条件死锁在 status = 'DONE',导致所有正在建单中、或进程异常崩溃后残留的 PROCESSING 记录,其已建成的案件数量未被算作「已占用额度」,准入防线形同虚设。
-- 文件路径: asipserviceplus-infrastructure/.../database/shuying_submit_record_sqlmap_mapper.xml -- 原始存在严重漏洞的查询: <select id="sumUnsettledCredits" resultType="java.lang.Integer"> SELECT COALESCE(SUM(success_count), 0) FROM ipp_shuying_submit_record WHERE user_id = #{userId} - AND status = 'DONE' - AND is_settled = 0 AND is_deleted = 'n' </select>
success_count = 0, is_settled = 0。此时未建单,占用为 0。success_count = 50。原 SQL 统计为 0。- JVM 突然 Crash / OOM:建单循环执行到第 50 条时触发 OutOfMemoryError 或容器置换被 SIGKILL,分布式 Redis 锁释放(或超时自动过期)。
- 大批次耗时超过分布式锁 TTL:用户一次性提交 200 条,每条建单包含图像哈希比对与二方存储上传,总耗时 45 秒,超过锁默认 30 秒超时,锁自动解开。
- 并发用户双端点击:同一账号在两个浏览器标签页快速点击提交,第二笔请求在第一笔处于 PROCESSING 中途时抢入。
F01 极端故障时序复盘:并发穿透导致坏账死锁
60。数据库无未平账记录。60 - 0 = 60 ≥ 50 (通过)。插入记录:
id=101, status='PROCESSING', success_count=0, is_settled=0。获取用户分布式锁。
ipp_complaint。更新批次记录:
id=101, success_count=50, status仍为'PROCESSING'。突发事件:机器宿主机重启 / 容器 OOM,进程直接退出;Redis 分布式锁到期自动解开。
准入检查读取未平账:
SELECT SUM(success_count) WHERE status='DONE' AND is_settled=0。由于记录 101 的 status 是 PROCESSING,查询结果返回 0。
准入公式计算:
可用余额 = Origix余额(60) - 0 = 60 ≥ 30 (误判通过。)任务 B 顺利建成 30 单,并调用 origix 成功扣减 30 次。Origix 真实余额变为 30。
ShuyingReconcileTask 扫描到卡住的记录 101,补齐状态为 DONE。调用 origix 请求扣减 50 次额度。
Origix 返回失败:余额不足(仅剩 30 次,无法扣减 50 次)。
结果:50 条投诉已在阿里巴巴 IPP 平台正式立案下发审核,但用户账户额度穿透超卖 20 次,产生无法核销的呆坏账。
F01 代码修复与单测凭证
@@ shuying_submit_record_sqlmap_mapper.xml L147-153 @@ <select id="sumUnsettledCredits" resultType="java.lang.Integer"> SELECT COALESCE(SUM(success_count), 0) FROM ipp_shuying_submit_record WHERE user_id = #{userId} - AND status = 'DONE' - AND is_settled = 0 + AND ( + (status = 'DONE' AND is_settled = 0) + OR + (status = 'PROCESSING' AND success_count > 0) + ) AND is_deleted = 'n' </select>
ShuyingSubmitRecordDAOIntegrationTest.java#L324-353
@Test
public void 联动验收_锁到期或进程退出_已建单PROCESSING占用额度_阻止新单超扣_巡检恢复平账() {
Long userId = 90001L;
// 1. 模拟崩溃前已落库 50 条的 PROCESSING 记录
ShuyingSubmitRecordDO recA = new ShuyingSubmitRecordDO();
recA.setUserId(userId);
recA.setTotalCount(50);
recA.setSuccessCount(50);
recA.setStatus("PROCESSING");
recA.setIsSettled(0);
dao.insert(recA);
// 2. 验证修复后 SQL 精准统计到 50 条在途
int unsettled = dao.sumUnsettledCredits(userId);
assertEquals(50, unsettled);
// 3. 模拟新单申请 30 条:余额 60 - 50 = 10 < 30,拦截。
int balance = 60;
int requireCount = 30;
assertTrue(balance - unsettled < requireCount);
}
ShuyingComplaintTransactionServiceTest.java#L686-715
@Test
public void testSubmit_F01联动_前序批次卡在PROCESSING已建单_计入未平账拦截新单超扣() {
Long userId = 88801L;
when(shuyingSubmitRecordDAO.sumUnsettledCredits(userId)).thenReturn(50);
when(origixClient.getBalance(userId)).thenReturn(60);
// 尝试提交 30 单,准入检查应立即抛出额度不足异常
ShuyingSubmitRequest req = buildReq(userId, 30);
try {
transactionService.submit(req);
fail("应拦截超额提交");
} catch (BizException e) {
assertEquals("AVAILABLE_CREDIT_INSUFFICIENT", e.getErrCode());
}
}
F02 机理剖析:跨到期边界不可保证「建成即能扣」
查余额 → 本地建单 → DONE → 单步调 origix consume。若建单动作恰巧在到期临界点的毫秒级窗口内发生,查余额时有效,但耗时 2 秒建单完成后资产已被 origix 定时任务置为 EXPIRED,一步式 consume 报扣款失败。
系统时间: 23:59:58.500 23:59:59.999 | 00:00:00.000 00:00:01.200 ┌──────────────────────┐ │ ┌─────────────────────────┐ asipservice: │ 1. 查余额 (有效 100) │ │ │ 3. 本地 100 案件落库完 │ │ 2. 开始逐条建单... │ ────────────>│ │ 4. 调 origix.consume │ └──────────────────────┘ │ └───────────┬─────────────┘ │ │ origix: │ [到期临界] ▼ │ 额度置为 返回: CREDIT_EXPIRED │ EXPIRED (扣减失败)
23:59:58 调用 origixClient.getBalance(),查询到资产包还有 100 条额度,有效期截至当日 23:59:59。准入校验顺利通过。
批次包含 100 个复杂侵权链接,涉及主图哈希生成、存证校验与多表写入,总耗时 2.7 秒。系统时钟在此期间跨越了午夜 00:00:00。
00:00:01 调用 origixClient.consume() 时,origix 校验到该批次对应的资产包已自然过期,拒绝扣减并返回错误。
F02 第一性原理裁决:为什么坚决拒绝 TCC / 2PC 过拟合
- 侵入底层:需要 origix 资产服务对外专门开放 HSF 接口:
preDeduct(quotaId, count, ttl)和confirm/cancel。 - 悬挂锁死:若 asipserviceplus 在 preDeduct 成功后发生网络分区或自身崩溃,用户的额度将被永久悬挂预扣,无法被新订单使用。
- 维护雪崩:需要引入分布式超时解冻守护任务、防悬挂幂等表、重试确认队列,系统复杂度剧增 300%。
- 成本远大于收益:为解决 0.000016% 极小概率事件,导致 100% 的日常请求多承受一次 RPC 网络往返耗时。
- 零资损事实:用户是在额度有效期内合法发起提交的。即使因为服务器处理延迟跨过了到期点,案件已经生成并送审,用户没有受到任何经济损失。
- 故障自愈:若遇到扣款因过期失败,记录停留在
is_settled = 0,后台ShuyingReconcileTask会定期重试。 - 运营豁免兜底:多次重试失败触发报警后,运营人工介入将额度顺延 1 天或在 origix 补发一次性对账平账流水,符合阿里电商传统资损处置标准(先放行,后对账)。
| 考量维度 | 定量计算 / 物理事实 | 工程结论 |
|---|---|---|
| 年化时间暴露窗口 | 1 年 = 31,536,000 秒。跨到期临界耗时按最大 5 秒估算。 暴露时间比 = 5 / 31,536,000 ≈ 0.0000158%。 |
属于极其罕见的纳秒级物理边界争用。 |
| 用户提交频次因子 | 用户不可能 24 小时连续提交,绝大多数用户在白天工作时间操作,几乎不会在 23:59:58 卡点批量提交。 | 真实线上业务发生概率趋近于 0。 |
| 客户权利与资金流 | 客户付款购买的维权次数,投诉成功立案;平台未扣除客户超额资金。 | 无客户投诉风险,无资损合规风险。 |