| | |
| | | } |
| | | |
| | | /** |
| | | * 通道侧收款能力是否已核实且正常:快照核实过、子商户状态正常、取现可用。 |
| | | * |
| | | * <p>它只回答「通道那边这家商户开通正常吗」,不含 {@link #readiness(Long, Long)} 额外要求的 |
| | | * 微信/实名与绑卡前置(那一条才回答「现在能不能收款」)。只读的监控页面用它决定「该不该去问 |
| | | * 通道」:通道侧没开通或被关停的商户问过去也会被拒(实测被关闭的子商户回「客户与产品关系已关闭」), |
| | | * 而本地缺进度记录、通道侧却正常且在结算的存量商户不该因此被摘出监控。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public boolean channelCapabilityNormal(Long applicationId) { |
| | | List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId); |
| | | if (rows.isEmpty()) return false; |
| | | Application application = application(rows.get(0)); |
| | | if (!"approved".equals(application.status()) || application.huifuId().isBlank()) return false; |
| | | // 快照没核实过就不算正常:那意味着「通道侧到底是什么状态」还没有答案。 |
| | | if (clean(application.capabilityVerifiedAt()).isBlank()) return false; |
| | | return isMerchantNormal(application.merchantStatus()) && isCashAvailable(application.cashSwitchStatus()); |
| | | } |
| | | |
| | | /** |
| | | * Returns the collecting sub-merchant an order must be bound to, or throws with the first unmet |
| | | * precondition. Callers persist the returned application, store and Huifu id on the order so the |
| | | * payment request cannot drift to another sub-merchant later. |
| | |
| | | Application application = approvedApplication(merchantId, storeId); |
| | | Readiness readiness = readiness(merchantId, storeId); |
| | | // 冷却剩余时间由服务端算好:客户端不必自己解析数据库时间戳字符串,也能在冷却结束时恢复按钮。 |
| | | long retryAfterSeconds = application == null ? 0L : capabilityCooldownSeconds(application.id()); |
| | | long retryAfterSeconds = cooldownSeconds(application == null ? null : application.capabilitySyncUntil()); |
| | | 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("verifiedAt", clean(application == null ? null : application.capabilityVerifiedAt())); |
| | | view.put("nextVerifyAt", clean(application == null ? null : application.capabilitySyncUntil())); |
| | | view.put("verifyAvailable", application != null && retryAfterSeconds == 0L); |
| | | view.put("retryAfterSeconds", retryAfterSeconds); |
| | | return view; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 冷却剩余秒数,0 表示现在就能核实。与写侧读的是同一列;{@code coalesce} 兜住「从没设过冷却」 |
| | | * 与「冷却已过期」两种情况——{@code timestampdiff} 遇上 NULL 会连 {@code greatest} 一起变 NULL。 |
| | | * 冷却剩余秒数,0 表示现在就能核实。用申请行上已经读到的那个时间戳算,不单发一条 select: |
| | | * 开发库(flyway 关闭)的列由启动时 best-effort 补,见 {@code MerchantOnboardingSchemaInitializer}; |
| | | * 点名 `capability_sync_until` 的查询在缺列时会整条报错、把商户页打成 500,而读已读到的行只会少一个字段。 |
| | | * |
| | | * <p>时区显式取 Asia/Shanghai:库里的 datetime 由 `serverTimezone=Asia/Shanghai` 解释成上海墙钟值, |
| | | * 拿它跟"本机默认时区"的 now() 相减,在非上海时区的机器上会整体偏掉(本仓库其它地方同样显式钉这个时区)。 |
| | | * |
| | | * <p>它只服务于按钮的倒计时:真正的闸门是 {@link #acquireVerifyWindow} 那条按数据库时钟判定的 UPDATE, |
| | | * 所以即便这里算早了,也只会让按钮早恢复、点了回来一个 {@code refreshed=false},不会多查通道。 |
| | | */ |
| | | 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")); |
| | | private static long cooldownSeconds(java.time.LocalDateTime syncUntil) { |
| | | if (syncUntil == null) return 0L; |
| | | java.time.LocalDateTime now = java.time.LocalDateTime.now(java.time.ZoneId.of("Asia/Shanghai")); |
| | | return Math.max(0L, java.time.Duration.between(now, syncUntil).getSeconds()); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | public Map<String, Object> capability(Long applicationId) { |
| | | Application application = requireApprovedApplication(applicationId); |
| | | if (application.capabilityVerifiedAt().isBlank()) { |
| | | if (clean(application.capabilityVerifiedAt()).isBlank()) { |
| | | queryProviderOutsideTransaction(application); |
| | | application = requireApprovedApplication(applicationId); |
| | | } |
| | |
| | | boolean unknown = application.autoSettleStatus().isBlank() |
| | | || application.cashSwitchStatus().isBlank() |
| | | || application.merchantStatus().isBlank() |
| | | || application.capabilityVerifiedAt().isBlank(); |
| | | || clean(application.capabilityVerifiedAt()).isBlank(); |
| | | if (application.huifuId().isBlank()) return Readiness.blocked("商户进件缺少斗拱商户号,暂时无法收款"); |
| | | if (unknown && !providerVerificationRequired()) return localOnly(application); |
| | | // The payment path never calls the provider itself: an unfilled snapshot is filled by the |
| | |
| | | view.put("autoSettleStatus", application.autoSettleStatus()); |
| | | view.put("cashAvailable", isCashAvailable(application.cashSwitchStatus())); |
| | | view.put("cashSwitchStatus", application.cashSwitchStatus()); |
| | | view.put("verifiedAt", application.capabilityVerifiedAt()); |
| | | view.put("verifiedAt", clean(application.capabilityVerifiedAt())); |
| | | return view; |
| | | } |
| | | |
| | |
| | | clean(row.get("merchantStat")), |
| | | clean(row.get("autoSettleStatus")), |
| | | clean(row.get("cashSwitchStatus")), |
| | | clean(row.get("capabilityVerifiedAt")), |
| | | clean(row.get("capabilitySyncUntil")) |
| | | // DatabaseRows 已把 datetime 归一成 LocalDateTime;缺列时这里是 null,不是异常。 |
| | | (java.time.LocalDateTime) row.get("capabilityVerifiedAt"), |
| | | (java.time.LocalDateTime) row.get("capabilitySyncUntil") |
| | | ); |
| | | } |
| | | |
| | |
| | | String merchantStatus, |
| | | String autoSettleStatus, |
| | | String cashSwitchStatus, |
| | | String capabilityVerifiedAt, |
| | | String capabilitySyncUntil |
| | | java.time.LocalDateTime capabilityVerifiedAt, |
| | | java.time.LocalDateTime capabilitySyncUntil |
| | | ) {} |
| | | |
| | | /** |