refactor(merchant): 自查接口补冷却剩余秒数,refreshed 改为单点返回
两轴 code review 的后端修复,三处都是口径问题而不是功能缺失。
- merchantView 新增 retryAfterSeconds:冷却剩余秒数由服务端算好,客户端据此做倒计时,
不必自己解析数据库时间戳。它同时替掉了原先另查一次窗口是否放开的 select,
verifyAvailable 改由 retryAfterSeconds == 0 推出,冷却的读侧只剩 capabilityCooldownSeconds、
写侧只剩 acquireVerifyWindow,判据不再写两遍。
- merchantVerify 删掉通道校验关掉/汇付未启用那条早退:它不返回 refreshed,而 D9 要求
手动核实一定带这个字段,客户端把缺失当 false,会在根本没查通道时弹刚核实过。
现在 refreshed 由唯一返回点统一带上。
- stages_json 的证据键 merchantStatus 改名 merchantStat:前者与阶段状态同形,容易被读成
另一个阶段;后者与申请行的通道快照列同名同义。老记录没有这个键,回落到申请行的
merchant_stat(新增测试覆盖)。附带把 incompleteReason 里的全限定 Collectors.joining
换成 String.join。
测试:新增 1 项(回退路径),相关两类 13 项全绿;全量 319 项仅 docs/agents/testing.md
记录的 3 项基线失败。
| | |
| | | if (capabilities.containsKey("mer_stat")) { |
| | | stages.put("merchantNormal", "NORMAL".equals(text(capabilities.get("mer_stat")))); |
| | | // 原始值一起存下来,只是为了在完成判定里说清「状态异常(CLOSED)」这种话。 |
| | | // 它不属于六项阶段,不会进 stages 列表。 |
| | | stages.put("merchantStatus", text(capabilities.get("mer_stat"))); |
| | | // 与 realnameQr 一样,它是对应布尔项的**证据**,不是第七个阶段:stages 列表由 |
| | | // LABELS 生成,非 LABELS 的键不会进列表,完成判定也只认 LABELS 的六个键。 |
| | | stages.put("merchantStat", text(capabilities.get("mer_stat"))); |
| | | } |
| | | if ("S".equals(text(response.get("bind_card_status"))) && capabilities.containsKey("qry_cash_card_info_list") && capabilities.containsKey("qry_settle_config_list")) { |
| | | stages.put("bank", confirmOriginalCard(application,capabilities,owner)); |
| | |
| | | * 其余六项必须是字符串 {@code "confirmed"},加进去会让进件永远无法 approved。 |
| | | */ |
| | | private static List<Map<String,Object>> completionChecks(Map<String,Object> stages, Map<String,Object> application) { |
| | | String merchantStatus = text(stages.get("merchantStatus")); |
| | | // 证据优先取刷新那一刻存下来的通道原值;老记录里没有这个键时回落到申请行上的快照列。 |
| | | String merchantStatus = text(stages.get("merchantStat")); |
| | | if (merchantStatus.isBlank()) merchantStatus = text(application.get("merchantStat")); |
| | | List<Map<String,Object>> checks=new ArrayList<>(); |
| | | checks.add(completionCheck("bank","结算银行卡核实",stages.get("bank"), |
| | |
| | | private static String incompleteReason(List<Map<String,Object>> progress, List<Map<String,Object>> checks) { |
| | | boolean stagesAllConfirmed = progress.stream().allMatch(stage -> "confirmed".equals(stage.get("status"))); |
| | | if (!stagesAllConfirmed) return "请刷新进度核实各项能力,并按待办理项补齐缺失能力"; |
| | | String failed = checks.stream().filter(check -> !"confirmed".equals(check.get("status"))) |
| | | .map(check -> String.valueOf(check.get("label"))).collect(java.util.stream.Collectors.joining("、")); |
| | | return failed.isBlank() ? "请刷新进度核实各项能力,并按待办理项补齐缺失能力" |
| | | : "进件阶段已全部确认,但" + failed + "未通过核实,见下方核实结果"; |
| | | List<String> failed = checks.stream().filter(check -> !"confirmed".equals(check.get("status"))) |
| | | .map(check -> String.valueOf(check.get("label"))).toList(); |
| | | return failed.isEmpty() ? "请刷新进度核实各项能力,并按待办理项补齐缺失能力" |
| | | : "进件阶段已全部确认,但" + String.join("、", failed) + "未通过核实,见下方核实结果"; |
| | | } |
| | | |
| | | /** Called only after the existing HTTP callback boundary has verified Huifu's signature. */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 商户端自查:与下单闸门同源、同文案的判定结果,外加最近核实时间。 |
| | | * 商户端自查:与下单闸门同源、同文案的判定结果,外加最近核实时间与冷却状态。 |
| | | * |
| | | * <p>只读,不查通道:快照的新鲜度由既有的定时核实与 {@link #merchantVerify} 负责。只返回派生 |
| | | * 结果——通道原始值({@code merchant_stat} 等)不下发给商户端。 |
| | |
| | | public Map<String, Object> merchantView(Long merchantId, Long storeId) { |
| | | Application application = approvedApplication(merchantId, storeId); |
| | | Readiness readiness = readiness(merchantId, storeId); |
| | | // 冷却剩余时间由服务端算好:客户端不必自己解析数据库时间戳字符串,也能在冷却结束时恢复按钮。 |
| | | long retryAfterSeconds = application == null ? 0L : capabilityCooldownSeconds(application.id()); |
| | | Map<String, Object> view = new LinkedHashMap<>(); |
| | | view.put("ready", readiness.ready()); |
| | | view.put("reason", readiness.reason()); |
| | | view.put("verifiedAt", application == null ? "" : application.capabilityVerifiedAt()); |
| | | view.put("nextVerifyAt", application == null ? "" : application.capabilitySyncUntil()); |
| | | view.put("verifyAvailable", application != null && verifyWindowOpen(application.id())); |
| | | view.put("verifyAvailable", application != null && retryAfterSeconds == 0L); |
| | | view.put("retryAfterSeconds", retryAfterSeconds); |
| | | return view; |
| | | } |
| | | |
| | |
| | | * |
| | | * <p>刻意不加 {@code @Transactional}:冷却窗口必须在通道查询之前落库,否则查询进行中的并发点击 |
| | | * 会一起穿透;而通道查询也绝不能夹在一个持有该行锁的事务里。 |
| | | * |
| | | * <p>{@code refreshed} 在每条返回路径上都有:它回答的是「这次到底查没查通道」,客户端据此说话, |
| | | * 通道校验关掉时不能凭空宣称刚核实过。 |
| | | */ |
| | | public Map<String, Object> merchantVerify(Long merchantId, Long storeId) { |
| | | Application application = approvedApplication(merchantId, storeId); |
| | | if (application == null) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,暂时无法收款"); |
| | | if (application.huifuId().isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "商户进件缺少斗拱商户号,暂时无法收款"); |
| | | if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return merchantView(merchantId, storeId); |
| | | boolean refreshed = jdbc.update(""" |
| | | update merchant_onboarding_application |
| | | set capability_sync_until = date_add(now(), interval ? minute) |
| | | where id = ? and (capability_sync_until is null or capability_sync_until < now()) |
| | | """, MERCHANT_VERIFY_COOLDOWN_MINUTES, application.id()) > 0; |
| | | boolean refreshed = providerVerificationRequired() && huifuPaymentProperties.isEnabled() && acquireVerifyWindow(application.id()); |
| | | if (refreshed) queryProviderOutsideTransaction(application); |
| | | Map<String, Object> view = merchantView(merchantId, storeId); |
| | | view.put("refreshed", refreshed); |
| | | return view; |
| | | } |
| | | |
| | | /** 冷却窗口是否已放开:商户端据此决定「立即核实」能不能点。 */ |
| | | private boolean verifyWindowOpen(long applicationId) { |
| | | return !rows(""" |
| | | select id from merchant_onboarding_application |
| | | /** 抢冷却窗口:抢到才去查通道。写侧的判据只有这一处,读侧见 {@link #capabilityCooldownSeconds}。 */ |
| | | private boolean acquireVerifyWindow(long applicationId) { |
| | | return jdbc.update(""" |
| | | update merchant_onboarding_application |
| | | set capability_sync_until = date_add(now(), interval ? minute) |
| | | where id = ? and (capability_sync_until is null or capability_sync_until < now()) |
| | | """, applicationId).isEmpty(); |
| | | """, MERCHANT_VERIFY_COOLDOWN_MINUTES, applicationId) > 0; |
| | | } |
| | | |
| | | /** |
| | | * 冷却剩余秒数,0 表示现在就能核实。与写侧读的是同一列;{@code coalesce} 兜住「从没设过冷却」 |
| | | * 与「冷却已过期」两种情况——{@code timestampdiff} 遇上 NULL 会连 {@code greatest} 一起变 NULL。 |
| | | */ |
| | | private long capabilityCooldownSeconds(long applicationId) { |
| | | List<Map<String, Object>> rows = rows(""" |
| | | select coalesce(greatest(0, timestampdiff(second, now(), capability_sync_until)), 0) as remainingSeconds |
| | | from merchant_onboarding_application where id = ? |
| | | """, applicationId); |
| | | return rows.isEmpty() ? 0L : number(rows.get(0).get("remainingSeconds")); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | @Test |
| | | void explainsTheAbnormalChannelMerchantWhileEveryStageStaysConfirmed() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":false,\"merchantStatus\":\"CLOSED\"}")); |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":false,\"merchantStat\":\"CLOSED\"}")); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | |
| | | /** 结算银行卡没核实同样会让进件停在 pending,之前也看不见。 */ |
| | | @Test |
| | | void explainsTheUnverifiedSettlementCard() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":false,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}")); |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":false,\"merchantNormal\":true,\"merchantStat\":\"NORMAL\"}")); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | |
| | | /** 两项都核实通过、六项阶段也齐了,才是完成态。 */ |
| | | @Test |
| | | void completesOnlyAfterBothChecksAndEveryStageAreConfirmed() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}")); |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStat\":\"NORMAL\"}")); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | |
| | | assertThat(check(view, "merchantNormal")).containsEntry("status", "unknown"); |
| | | } |
| | | |
| | | /** |
| | | * 本次改动之前落下的进度记录里没有 {@code merchantStat} 这个证据键, |
| | | * 此时回落到申请行上的通道快照列,不能因此只说一句「状态异常」而给不出值。 |
| | | */ |
| | | @Test |
| | | void fallsBackToTheApplicationSnapshotForTheChannelMerchantValue() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":false}")); |
| | | jdbc.update("update merchant_onboarding_application set merchant_stat='FREEZEWITHDRAWAL' where id=?", applicationId); |
| | | |
| | | Map<String, Object> view = progressService.view(applicationId); |
| | | |
| | | assertThat(String.valueOf(check(view, "merchantNormal").get("reason"))).contains("FREEZEWITHDRAWAL"); |
| | | } |
| | | |
| | | /** 两项完成条件不能混进阶段列表:那是完成判定的键集,混进去进件永远无法 approved。 */ |
| | | @SuppressWarnings("unchecked") |
| | | @Test |
| | | void keepsTheStageListAtSixEntries() { |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}")); |
| | | stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStat\":\"NORMAL\"}")); |
| | | |
| | | List<Map<String, Object>> stages = (List<Map<String, Object>>) progressService.view(applicationId).get("stages"); |
| | | |
| | |
| | | int before = mockingDetails(capabilityGateway).getInvocations().size(); |
| | | Map<String, Object> view = readinessService.merchantView(merchantId, storeId); |
| | | |
| | | assertThat(view).containsEntry("ready", true).containsEntry("verifyAvailable", true); |
| | | assertThat(view).containsEntry("ready", true).containsEntry("verifyAvailable", true).containsEntry("retryAfterSeconds", 0L); |
| | | assertThat(String.valueOf(view.get("verifiedAt"))).isNotBlank(); |
| | | assertThat(mockingDetails(capabilityGateway).getInvocations().size()).isEqualTo(before); |
| | | } |
| | |
| | | .hasMessage(viewReason); |
| | | } |
| | | |
| | | /** 进件还没通过时也打开这个页面:给结论,不查通道。 */ |
| | | /** 进件还没通过时也打开这个页面:给结论,不查通道。此时不存在冷却,剩余秒数必须是 0。 */ |
| | | @Test |
| | | void merchantViewBlocksWithoutAnApprovedApplication() { |
| | | jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId); |
| | | |
| | | Map<String, Object> view = readinessService.merchantView(merchantId, storeId); |
| | | |
| | | assertThat(view).containsEntry("ready", false).containsEntry("verifyAvailable", false); |
| | | assertThat(view).containsEntry("ready", false).containsEntry("verifyAvailable", false).containsEntry("retryAfterSeconds", 0L); |
| | | assertThat(String.valueOf(view.get("reason"))).contains("进件"); |
| | | assertThat(String.valueOf(view.get("verifiedAt"))).isEmpty(); |
| | | verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | |
| | | |
| | | assertThat(verified).containsEntry("refreshed", true).containsEntry("ready", true).containsEntry("verifyAvailable", false); |
| | | assertThat(String.valueOf(verified.get("verifiedAt"))).isNotBlank(); |
| | | assertThat(remainingSeconds(verified)).isGreaterThan(0L); |
| | | verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | |
| | | Map<String, Object> throttled = readinessService.merchantVerify(merchantId, storeId); |
| | | |
| | | assertThat(throttled).containsEntry("refreshed", false).containsEntry("ready", true); |
| | | assertThat(remainingSeconds(throttled)).isGreaterThan(0L); |
| | | verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | | |
| | |
| | | |
| | | assertThat(failed).containsEntry("refreshed", true).containsEntry("ready", false); |
| | | assertThat(String.valueOf(failed.get("reason"))).contains("待确认"); |
| | | assertThat(remainingSeconds(failed)).isGreaterThan(0L); |
| | | assertThat(readinessService.merchantVerify(merchantId, storeId)).containsEntry("refreshed", false); |
| | | verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class)); |
| | | } |
| | |
| | | return prefix + suffix.substring(Math.max(0, suffix.length() - 8)); |
| | | } |
| | | |
| | | /** 冷却剩余秒数:客户端据此在冷却结束时自行恢复按钮,所以它必须是可用的数字。 */ |
| | | private long remainingSeconds(Map<String, Object> view) { |
| | | return ((Number) view.get("retryAfterSeconds")).longValue(); |
| | | } |
| | | |
| | | private void stubProvider(String merchantStatus, String settleStatus, String cashSwitch) { |
| | | when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class))) |
| | | .thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(true, "00000000", "成功", merchantStatus, settleStatus, "D1", cashSwitch, "DM")); |