yeg
2026-09-17 1435780dbed9c0239459fad6f33014853da44e72
fix(merchant): 收款能力页冷却到点自动恢复,不再谎称刚核实过

复审发现三处:一是「冷却结束自动恢复可点」当时只在重新进入页面时才成立,二是核实回来的
提示在没查通道时也说刚核实过,三是进件未通过时把按钮写成稍后可再次核实(那根本没有
冷却,等多久都没用)。

- 新增 retryAfterSeconds 的客户端映射,页面用 setInterval 递减并把倒计时显示在按钮上
(4:05 后可再次核实);到点就地恢复并静默重拉一次(不切 loading,避免整屏闪),
onHide/onUnload 清定时器。
- refreshed=false 时改说未重新查询通道,显示最近一次结果。
- 两种不能点分开:冷却中提示刚核实过,请稍后再试,没有冷却却仍不可核实(例如进件
未通过)直接复用判定的 reason,按钮文案改为暂不可核实。
3个文件已修改
74 ■■■■■ 已修改文件
rencaika_merchant/docs/项目接口对接说明.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/payment-capability/payment-capability.uvue 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/settlement.uts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/项目接口对接说明.md
@@ -113,7 +113,7 @@
| 结算明细导出 | `/api/merchant/settlement-statistics/export` | GET | 状态、日期范围 | Excel 文件 | 已接入 |
| 商户进件状态 | `/api/merchant/onboarding/status` | GET | 当前门店会话 | `unsubmitted`、`pending`、`approved`、`rejected`、驳回原因、六项阶段、两项完成条件核实 | 已接入 |
| 提交商户进件 | `/api/merchant/onboarding` | POST | 主体类型、企业/个人资料、联系人、幂等键 | 进件单 ID、审核状态、提交时间 | 已接入 |
| 收款能力自查 | `/api/merchant/payment-readiness` | GET | 当前门店会话 | `ready`(能否收款)、`reason`(与下单被拒同一句文案)、`verifiedAt`(最近核实时间)、`nextVerifyAt`、`verifyAvailable` | 已接入 |
| 收款能力自查 | `/api/merchant/payment-readiness` | GET | 当前门店会话 | `ready`(能否收款)、`reason`(与下单被拒同一句文案)、`verifiedAt`(最近核实时间)、`nextVerifyAt`、`verifyAvailable`、`retryAfterSeconds`(冷却剩余秒数,客户端据此做倒计时) | 已接入 |
| 手动核实收款能力 | `/api/merchant/payment-readiness/verify` | POST | 当前门店会话 | 同上,另加 `refreshed`(本次是否真的查了通道);服务端有冷却,冷却期内直接返回当前快照 | 已接入 |
| 提现概览 | `/api/merchant/withdrawals/summary` | GET | 当前门店会话 | 可提现金额、冻结金额,单位分 | 已接入 |
| 提现记录 | `/api/merchant/withdrawals` | GET | 当前门店会话、分页 | 提现记录(状态只展示处理中/已到账/提现失败)、分页信息 | 已接入 |
rencaika_merchant/pages/payment-capability/payment-capability.uvue
@@ -6,7 +6,7 @@
        </view>
        <scroll-view scroll-y="true" class="result-content">
            <page-feedback v-if="loadState != 'success'" :state="loadState" loading-text="收款能力加载中" error-text="收款能力加载失败,点击重试" @retry="loadReadiness"></page-feedback>
            <page-feedback v-if="loadState != 'success'" :state="loadState" loading-text="收款能力加载中" error-text="收款能力加载失败,点击重试" @retry="retryLoad"></page-feedback>
            <view v-else>
                <view class="status-hero">
                    <view class="status-icon-wrap"><image class="status-icon" :src="statusIcon" mode="aspectFit"></image></view>
@@ -40,9 +40,12 @@
    import { hasActiveSession } from '../../services/session.uts'
    import { loadPaymentReadiness, verifyPaymentReadiness, MerchantPaymentReadiness } from '../../services/settlement.uts'
    const readiness = ref<MerchantPaymentReadiness>({ ready: false, reason: '', verifiedAt: '', nextVerifyAt: '', verifyAvailable: false, refreshed: false })
    const readiness = ref<MerchantPaymentReadiness>({ ready: false, reason: '', verifiedAt: '', nextVerifyAt: '', verifyAvailable: false, retryAfterSeconds: 0, refreshed: false })
    const loadState = ref('loading')
    const syncing = ref(false)
    // 冷却剩余秒数由服务端给,客户端只做倒计时——不自己解析数据库时间戳字符串。
    const cooldownLeft = ref(0)
    let cooldownTimer : number | null = null
    /** 通道回的是数据库时间戳(形如 2026-09-17 12:34:56.0),去掉毫秒再展示。 */
    const displayTime = (value: string): string => {
@@ -54,45 +57,84 @@
    const verifiedText = computed((): string => readiness.value.verifiedAt.length > 0 ? displayTime(readiness.value.verifiedAt) : '尚未核实')
    // 冷却中才显示「下次可核实」:能点的时候显示这行只会让人以为还有别的限制。
    const nextVerifyText = computed((): string => readiness.value.verifyAvailable ? '' : displayTime(readiness.value.nextVerifyAt))
    const countdownText = computed((): string => {
        const total = cooldownLeft.value
        const minutes = Math.floor(total / 60)
        const seconds = total % 60
        const padded = seconds < 10 ? '0' + seconds : '' + seconds
        return minutes > 0 ? '' + minutes + ':' + padded : padded + ' 秒'
    })
    const verifyDisabled = computed((): boolean => syncing.value || !readiness.value.verifyAvailable)
    const verifyButtonLabel = computed((): string => {
        if (syncing.value) return '核实中...'
        return readiness.value.verifyAvailable ? '立即核实' : '稍后可再次核实'
        if (readiness.value.verifyAvailable) return '立即核实'
        // 两种「不能点」要分开说:冷却中会自己恢复,进件没通过则不是等一会儿就有用。
        return cooldownLeft.value > 0 ? countdownText.value + '后可再次核实' : '暂不可核实'
    })
    // 还没通过进件、或通道商户状态异常时用「等待核实」的图标,而不是打一个红叉——
    // 这两种情况都不是商户做错了什么。
    const statusIcon = computed((): string => readiness.value.ready ? '/static/icons/lucide/badge-check-orange.svg' : '/static/icons/lucide/clock-3.svg')
    const statusTitle = computed((): string => readiness.value.ready ? '现在可以收款' : '暂时无法收款')
    const stopCooldownTimer = () => {
        if (cooldownTimer != null) {
            clearInterval(cooldownTimer)
            cooldownTimer = null
        }
    }
    // 冷却结束就地恢复按钮,不必让商户退出页面再进来;到点顺手重新拉一次,拿到的就是最新判定。
    const startCooldownTimer = () => {
        stopCooldownTimer()
        if (cooldownLeft.value <= 0) return
        cooldownTimer = setInterval(() => {
            if (cooldownLeft.value <= 1) {
                cooldownLeft.value = 0
                stopCooldownTimer()
                loadReadiness(false)
                return
            }
            cooldownLeft.value = cooldownLeft.value - 1
        }, 1000)
    }
    const applyReadiness = (item: MerchantPaymentReadiness) => {
        readiness.value = item
        cooldownLeft.value = item.retryAfterSeconds
        startCooldownTimer()
        loadState.value = 'success'
    }
    const loadReadiness = () => {
    // 冷却到点的自动刷新走 showLoading=false:内容已经是对的,切一次 loading 只会让整屏闪一下。
    const loadReadiness = (showLoading: boolean) => {
        if (!hasActiveSession()) {
            loadState.value = 'error'
            return
        }
        loadState.value = 'loading'
        if (showLoading) loadState.value = 'loading'
        loadPaymentReadiness((item: MerchantPaymentReadiness) => {
            applyReadiness(item)
        }, (error: any) => {
            if (!showLoading) return
            loadState.value = 'error'
            uni.showToast({ title: error.message != null ? error.message : '收款能力加载失败', icon: 'none' })
        })
    }
    const retryLoad = () => loadReadiness(true)
    const verifyNow = () => {
        if (verifyDisabled.value) {
            if (!readiness.value.verifyAvailable) uni.showToast({ title: '刚核实过,请稍后再试', icon: 'none' })
        if (syncing.value) return
        if (!readiness.value.verifyAvailable) {
            // 冷却中说「刚核实过」;没有冷却却仍不可核实,那原因只能来自判定本身(例如进件未通过)。
            const title = cooldownLeft.value > 0 ? '刚核实过,请稍后再试'
                : readiness.value.reason.length > 0 ? readiness.value.reason : '当前无法核实收款能力'
            uni.showToast({ title: title, icon: 'none' })
            return
        }
        syncing.value = true
        verifyPaymentReadiness((item: MerchantPaymentReadiness) => {
            syncing.value = false
            applyReadiness(item)
            uni.showToast({ title: item.refreshed ? '已重新核实通道状态' : '刚核实过,这是最近一次的结果', icon: 'none' })
            uni.showToast({ title: item.refreshed ? '已重新核实通道状态' : '未重新查询通道,显示最近一次结果', icon: 'none' })
        }, (error: any) => {
            syncing.value = false
            uni.showToast({ title: error.message != null ? error.message : '核实失败,请稍后重试', icon: 'none' })
@@ -104,7 +146,15 @@
    }
    onShow(() => {
        loadReadiness()
        loadReadiness(true)
    })
    onHide(() => {
        stopCooldownTimer()
    })
    onUnload(() => {
        stopCooldownTimer()
    })
</script>
rencaika_merchant/services/settlement.uts
@@ -8,13 +8,13 @@
export type SettlementBankCard = { id: string, bankName: string, branchName: string, cardMasked: string, status: string, reason: string, createdAt: string }
export type SettlementPage<T> = { records: Array<T>, page: number, size: number, total: number }
/** 收款能力自查:判定与下单闸门同源同文案,只返回派生结果。 */
export type MerchantPaymentReadiness = { ready: boolean, reason: string, verifiedAt: string, nextVerifyAt: string, verifyAvailable: boolean, refreshed: boolean }
export type MerchantPaymentReadiness = { ready: boolean, reason: string, verifiedAt: string, nextVerifyAt: string, verifyAvailable: boolean, retryAfterSeconds: number, refreshed: boolean }
const stringValue = (value: any): string => value == null ? '' : '' + value
const numberValue = (value: any): number => value == null ? 0 : Number(value)
/** retryable:通道侧配置已明确失败,商户可以重新提交;缺省按 false,宁可保守。 */
const onboarding = (value: any): SettlementOnboarding => ({ id: stringValue(value != null ? value.id : ''), applicationNo: stringValue(value != null ? value.applicationNo : ''), status: stringValue(value != null ? value.status : 'unsubmitted'), reason: stringValue(value != null ? value.reason : ''), retryable: value != null && value.retryable == true, submittedAt: stringValue(value != null ? value.submittedAt : ''), refreshable: value != null && value.refreshable == true, lastSyncedAt: stringValue(value != null ? value.lastSyncedAt : ''), syncError: stringValue(value != null ? value.syncError : ''), stages: (value != null && value.stages instanceof Array ? value.stages : []).map((stage: any): OnboardingStage => ({ key: stringValue(stage.key), label: stringValue(stage.label), status: stringValue(stage.status), reason: stringValue(stage.reason), qrCode: stringValue(stage.qrCode) })), completionChecks: (value != null && value.completionChecks instanceof Array ? value.completionChecks : []).map((check: any): OnboardingCheck => ({ key: stringValue(check.key), label: stringValue(check.label), status: stringValue(check.status), reason: stringValue(check.reason) })) })
const paymentReadiness = (value: any): MerchantPaymentReadiness => ({ ready: value != null && value.ready == true, reason: stringValue(value != null ? value.reason : ''), verifiedAt: stringValue(value != null ? value.verifiedAt : ''), nextVerifyAt: stringValue(value != null ? value.nextVerifyAt : ''), verifyAvailable: value != null && value.verifyAvailable == true, refreshed: value != null && value.refreshed == true })
const paymentReadiness = (value: any): MerchantPaymentReadiness => ({ ready: value != null && value.ready == true, reason: stringValue(value != null ? value.reason : ''), verifiedAt: stringValue(value != null ? value.verifiedAt : ''), nextVerifyAt: stringValue(value != null ? value.nextVerifyAt : ''), verifyAvailable: value != null && value.verifyAvailable == true, retryAfterSeconds: value != null ? numberValue(value.retryAfterSeconds) : 0, refreshed: value != null && value.refreshed == true })
export const refreshOnboardingProgress = (retry: boolean, success: (value: SettlementOnboarding) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: '/api/merchant/onboarding/refresh?retry=' + (retry ? 'true' : 'false'), method: 'POST' }, (data: any) => success(onboarding(data)), error)
// 微信实名认证:业务开通确认生效后由商户主动推进的下一步。未决申请不会重复提交,
// retry 只对「通道已明确驳回」有意义。