07 — 核销码:轮换放宽与数字码核销

What to build: 商户扫码核销时,不再因为刚好赶上核销码轮换而报「核销凭证已失效,请重新扫码」;相机不好用或屏幕反光时,可以手输顾客的 6 位数字码完成核销,且失败次数受限。

现状是 60 秒轮换、一生成新码就立刻作废旧码;同时接口下发一个 6 位数字码,但系统里**没有任何地方能输入它**(商户看到数字会尝试手输,而手输没有路)。判定见 ../spec.md 的 D21–D23。

要点

  • 轮换放宽 + 新旧码短暂并存:不再一生成新码就立刻作废旧码,让上一张码在一个重叠期内仍然有效。轮换的安全目的是防截图转卖,保留一个重叠窗口不会明显削弱它,但能消掉「扫到时刚好翻转」这类失败。
  • 新增按 6 位数字码核销的入口:与动态码**同有效期**、有**失败次数限制与锁定**、**不返回「这个码属于哪笔订单」**。6 位只有 100 万空间,必须限次且不泄露归属。
  • 不引入第三套码:不做「静态券码 + 动态二维码」双轨——数字码做完后本身就承担了静态码的定位作用(客服定位、撤销核销)。
  • 涉及**两个独立的小程序工程**:人才端负责显示码,商户端负责扫码与输码。
  • 既有行为不能退化:重复核销仍被拒(幂等不变),已核销订单的撤销与退款规则不变。

Blocked by: None — can start immediately

Status: 已验收 —— 2026-09-17 用户人工验收通过(真机走查;本轮新增回归见 Comments)

  • [x] 轮换瞬间新旧码都能核销成功:rotatingTheCodeKeepsThePreviousOneUsableDuringTheOverlap(连发两张码后,旧码仍然是 status = 0 且能被扫到)。
  • [x] 旧码在重叠期之后失效:失效判定就是同一套 expires_at,anExpiredNumericCodeIsRefusedLikeAnyOtherInvalidCode 覆盖过期即失效。
  • [x] 手输有效的 6 位数字码能完成核销,且与动态码同有效期:theSixDigitCodeCanBeTypedInAndConfirmedWithoutDisclosingWhichOrderItBelongsTo(核销记录只写一条,与扫码同一条实现)。
  • [x] 连续输错触发限制与锁定;锁定期间不再校验:consecutiveWrongCodesLockTheNumericEntryAndLockedMeansItIsNotEvenChecked(锁定后连正确的码也被挡在门外,且不产生扫码尝试)、aWrongCodeThatIsFollowedByTheRightOneClearsTheFailureCount。
  • [x] 核销接口不返回码的归属信息(不泄露「属于哪笔订单」):同一用例断言扫码答复里没有 orderNo;撞码与猜错用同一句话回答(aWrongGuessAndACollidingCodeAnswerExactlyTheSameWay)。
  • [x] 重复核销仍被拒(既有幂等不变):既有核销闸门用例全绿(MerchantVerificationOrderCodeTests、MerchantVerificationServiceTests、OrderFundBoundaryTests)。
  • [x] 已支付未核销、已核销、退款中等既有核销闸门全部不退化:全量 348 项仅那 3 项已知基线失败。
  • [x] 两端真机人工验收 + 编译检查(人才端与商户端两个工程):2026-09-17 通过(同上)。人才端本票无需改动(已经开始下发 displayCode 与 refreshAfterSeconds);商户端新增 pages/verification-code-input/ 与扫码页「手输 6 位核销码」入口,**HBuilderX 编译未跑**(本机没有 HBuilderX)。

验收

真机:把码刷到即将轮换的那一秒连扫两次,两次都成功;用上一轮的旧码扫,在重叠期内成功、重叠期后失败;在商户端手输 6 位码完成一次核销;连续输错到锁定,确认锁定期间不再校验且提示明确。

Comments

  • 2026-09-17 发布。这一票与已核销团购券的撤销窗口(保持 3 小时)无关,那个窗口本次**不动**。
  • 2026-09-17 实现完成,状态转 ready-for-human。落地要点:issueCode 不再作废旧码,有效期改为「刷新间隔 60 秒 + 重叠 30 秒」;新增 merchant_numeric_code_attempt(V150,create table if not exists,可直接用 scripts/apply-sql-file.ps1 落到开发库,已实跑)与 MerchantNumericCodeAttemptGuard(连续 5 次输错锁 5 分钟,计数走 REQUIRES_NEW 独立事务,否则判定失败抛出时会连计数一起回滚);6 位数字输入复用现有 POST /api/merchant/verification/scan,确认核销新增 POST /api/merchant/verification/numeric/confirm(服务端从扫码尝试反查订单,因此客户端不需要订单号)。
  • 一处**实现选择**请在评审时确认:数字码撞码(同商户同时有两张活码同号)与猜错返回同一句话并同样计入失败次数——否则「撞码」这种答复本身就是「这个号有主」的探针。