yeg
2026-09-17 ab9b1f30b848d1f763fd0012e7ec9e1900f7d019
feat(payment): 补齐查询对等物、收口定时开关并接线后台按钮

凡状态推进都必须有一条不依赖回调的路径(ADR 0004),而系统里还剩四处只有回调能推进;
另有几处配置会静默失效、两个后台接口没有前端入口。

查询对等物:
- 进件通过后**新增**的结算卡:confirmOriginalCard 推广为「该门店所有待处理的卡」,挂在已有的人工
刷新路径与银行卡列表读路径上(只在确实有待处理卡时才查通道),不新增定时任务。
- 资金回退(reverting)与同一天的「确认中」:补偿任务原先只扫待确认/确认中,回退中的记录永远没人再看,
撤销核销会被永久卡住。现在 reverting 进入扫描范围(重发幂等的回退请求就是它的查询对等物),
当天认领的确认中改用一直零调用的 delaytrans.confirmquery 收敛,不必等次日。
- 补偿任务取行改为「每单只看最新一笔成功支付」,避免旧 pending 与新 confirmed 混在一起。

配置收口:
- 补上 app.payment.huifu.merchant-business-notify-url(绑卡回调地址原先靠 notify-url 推导,
配置里根本没有这个键);新增 HuifuCallbackUrlAudit 在启动时逐个念一遍五个回调地址,
地址为空时明确记录「回调未启用」。
- 对账间隔写进 yml;对账开关的注解默认值与配置一致(都是开),删掉配置行不再让后台兜底静默消失;
交易确认补偿的 app.payment.fund-confirmation-retry.enabled 真正实现(原先注释宣称有、代码从未读取)。

后台与开发环境:
- 退款列表接上「刷新退款」(POST /api/orders/refunds/{refundNo}/query)、进件详情接上「查询通道状态」,
原先两个接口都是零调用;角色权限矩阵补上对应动作。
- 订单列表新增「立即对账」入口,调用的就是定时任务那套真实对账逻辑(不是伪造资金事实的捷径);
本机开发可用 HUIFU_RECONCILIATION_FIXED_DELAY_MS 调短间隔。

顺带修掉一处环境相关失败:PaymentFundConfirmationTests 的补偿用例先把自己之外的相关行移出扫描窗口,
从「随测试库累积而必然失败」变成稳定通过;docs/agents/testing.md 对应章节已改写,
并记录了新的开发库 DDL 执行器 scripts/apply-sql-file.ps1。
2个文件已添加
14个文件已修改
563 ■■■■■ 已修改文件
docs/agents/testing.md 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantOnboardingDetailView.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/OrderView.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/RefundRecordView.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/RoleFormView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuCallbackUrlAudit.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationRetry.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/agents/testing.md
@@ -75,6 +75,12 @@
- 只补缺失的 DDL(`create table if not exists`、`add column`),**不要**删表重建。手工补 DDL 正是
  2026-09-17「点开收款能力就 500 / `Unknown column 'capability_sync_until'`」的解药。
- **有一条命令可用了**:`.\scripts\apply-sql-file.ps1 -File <某个 .sql> -Yes` 用项目已有的 JDBC 驱动
  逐条执行 SQL(默认连开发库,可 `-Database/-DatabaseHost/-Port` 覆盖;不带 `-Yes` 只打印不执行)。
  它认得 `DELIMITER`,所以 `db/migration/V*.sql` 也能原样重放。默认指向**开发库**,别拿它去改测试库
  (测试库由 Flyway 从 `db/migration` 重建,手工 DDL 会让迁移与 history 表对不上)。
  开发者首次拿到新列:2026-09-17 的 `V149`(支付流水两列)与 `V150`(手输核销码计数表)就是这样落地的,
  对应脚本 `scripts/dev-schema-v149-payment-attempt.sql`(写法可重复执行的那份在迁移里,两者等价)。
- 动结构之前先备份。本机**没有** mysql 客户端、也没有 Docker(README 里指向 `D:\MySQL\...` 和
  `docker exec talent-card-mysql-1` 的两条老做法都已失效),要备份就在 DB 主机上做,或照
  `scripts/reset-test-db.ps1` 的办法用项目已有的 JDBC 驱动写个单文件程序。
@@ -100,17 +106,16 @@
新会话不要为了证明它们"是既有的"而重建 worktree:直接看这张表。修好其中任何一项时,请删掉对应行。
## 环境相关失败(不是基线,别当既有失败放过)
## 环境相关失败:已修(2026-09-17)
`PaymentFundConfirmationTests.aPendingConfirmationOfAVerifiedOrderIsRetriedWithoutAnotherVerification`
在测试库累计的「已核销 + 待确认」订单超过 50 条时会**必然**失败:补偿任务扫全库、`order by o.id asc
limit 50`(`PaymentFundConfirmationRetry.retry`),而用例新建的那单 id 最大、排在窗口之外,于是状态停在
`pending`。这是测试库长期不清理累积出来的,不是代码回归——2026-09-17 实测符合条件的行为 60 条,最早一条
09-14 就留下了,而且被处理过的行多数停在 `confirming`(仍在扫描条件内),所以不会自己排空。
原先会随测试库累积而**必然**失败:补偿任务扫全库、`order by o.id asc limit 50`,用例新建的那单 id 最大、
排在窗口之外。
现象、证据与建议改法见
[.scratch/payment-fund-confirmation-retry/issues/01](../../.scratch/payment-fund-confirmation-retry/issues/01-retry-limit-blocked-by-accumulated-leftovers.md)
(属支付链路范围,本轮只排查未修)。要么让用例自己把窗口腾出来,要么清理测试库遗留(后者只能缓解)。
2026-09-17 已按本文件的建议修好——该用例先把**自己之外**的相关行移出扫描条件,于是它从「环境相关失败」
变成稳定通过;同一天还把同一处扫描改为「每单只看最新一笔成功支付」,并把它扩展到 `reverting`(撤销核销
的回退在途)状态。以后新写这类「后台任务只处理了我这条」的用例,照它的做法先腾窗口,不要再依赖测试库的
累积状况。
## 测试库的状态是共享的
talent-card-admin/src/api/admin.ts
@@ -336,6 +336,26 @@
  return http.get<PageResult<RefundRecord>, PageResult<RefundRecord>>('/api/orders/refunds', { params })
}
// 刷新一笔退款的状态:退款通知和支付通知一样可能到不了,线上排查时不必干等定时任务或手写 SQL。
export function refreshRefundStatus(refundNo: string) {
  return http.post<RefundRecord, RefundRecord>(`/api/orders/refunds/${refundNo}/query`)
}
// 查询通道状态:重新读一次斗拱侧的收款能力快照。进件通过后新增的结算卡也靠这条路径收敛为已批准。
export function refreshMerchantPaymentCapability(id: number | string) {
  return http.post<Record<string, any>, Record<string, any>>(
    `/api/admin/management/merchant-settlement/onboardings/${id}/payment-capability/refresh`,
  )
}
// 立即对账:人工触发一次**真实**的对账(待支付流水向通道确认 + 在途退款收敛)。
// 给收不到回调的环境用:它走的是与定时任务完全相同的逻辑,不是「跳过真实通道」的捷径。
export function runPaymentReconciliation(pendingAfterMinutes = 5) {
  return http.post<Record<string, any>, Record<string, any>>('/api/admin/management/payment-reconciliation/run', null, {
    params: { pendingAfterMinutes },
  })
}
export function getVerificationRecords(params: VerificationQuery = {}) {
  return http.get<PageResult<VerificationRecord>, PageResult<VerificationRecord>>('/api/orders/verifications', { params })
}
talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getMerchantOnboardingDetail, refreshMerchantOnboarding, submitMerchantRealname } from '../api/admin'
import { getMerchantOnboardingDetail, refreshMerchantOnboarding, refreshMerchantPaymentCapability, submitMerchantRealname } from '../api/admin'
import { hasAdminPermission } from '../utils/permissions'
import { ElMessage } from 'element-plus'
@@ -22,6 +22,20 @@
function realnameStage() {
  const stages = detail.value?.progress?.stages
  return Array.isArray(stages) ? stages.find((stage: Record<string, any>) => stage.key === 'realname') : undefined
}
// 查询通道状态:重新读一次斗拱侧的收款能力快照(含结算卡绑定状态)。进件通过后**新增**的结算卡
// 只有绑定回调能推进,回调一丢就永远停在处理中——这条查询路径就是它的对等物。
async function refreshChannelStatus() {
  if (syncing.value) return
  syncing.value = true
  try {
    await refreshMerchantPaymentCapability(String(route.params.id))
    ElMessage.success('已按通道最新状态刷新')
    await load()
  } catch (error: any) {
    ElMessage.error(error?.message || '查询通道状态失败,请稍后重试')
  } finally { syncing.value = false }
}
// 只有「还没发起」或「已被通道驳回」才给发起入口:处理中、待本人办理、已确认都不能重复发起,
// 未决申请重复提交会给同一商户报出两份实名申请。
@@ -101,6 +115,7 @@
        <p>刷新会查询通道进度,并继续办理已满足条件的缺失能力。待本人实名或授权时,请办理后再次刷新。</p>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit')" type="primary" :loading="syncing" @click="refreshProgress()">刷新进度</el-button>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && detail.progress.retryable" :disabled="syncing" @click="refreshProgress(true)">重试失败步骤</el-button>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit')" :disabled="syncing" @click="refreshChannelStatus()">查询通道状态</el-button>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && realnameActionable()" :disabled="syncing" @click="startRealname()">{{ realnameStage()?.status === 'failed' ? '重新发起微信实名' : '发起微信实名' }}</el-button>
      </section>
talent-card-admin/src/views/OrderView.vue
@@ -3,12 +3,13 @@
import { useRouter } from 'vue-router'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getOrders } from '../api/admin'
import { getOrders, runPaymentReconciliation } from '../api/admin'
import type { Order } from '../types/admin'
import { TALENT_CARD_LEVELS } from '../utils/cardLevels'
import { downloadExcel } from '../utils/excel'
const loading = ref(false)
const reconciling = ref(false)
const activeTab = ref<'GROUP' | 'STORE'>('GROUP')
const orders = ref<Order[]>([])
const router = useRouter()
@@ -92,6 +93,20 @@
function search() {
  pagination.page = 1
  loadData()
}
// 立即对账:人工触发一次真实对账(待支付流水向通道确认 + 在途退款收敛)。
// 收不到回调的环境不必等下一个周期;它不是「跳过真实通道直接置为已支付」的捷径。
async function runReconciliation() {
  if (reconciling.value) return
  reconciling.value = true
  try {
    const result = await runPaymentReconciliation()
    ElMessage.success(`已对账 ${result?.paymentChecked ?? 0} 笔支付、${result?.refundChecked ?? 0} 笔退款`)
    await loadData()
  } finally {
    reconciling.value = false
  }
}
function resetFilters() {
@@ -206,6 +221,8 @@
      <div class="order-table-tools">
        <el-button v-permission="'admin.orders.export'" :icon="Download" @click="download">下载</el-button>
        <!-- 收不到回调的环境(本地、内网)用人工触发代替等待:跑的是与定时任务相同的真实对账逻辑。 -->
        <el-button v-permission="'admin.orders.edit'" :icon="Refresh" :loading="reconciling" @click="runReconciliation">立即对账</el-button>
      </div>
      <el-table v-loading="loading" :data="orders" border class="order-list-table" empty-text="暂无订单数据">
talent-card-admin/src/views/RefundRecordView.vue
@@ -3,7 +3,7 @@
import { useRouter } from 'vue-router'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getRefundRecords } from '../api/admin'
import { getRefundRecords, refreshRefundStatus } from '../api/admin'
import type { RefundRecord } from '../types/admin'
import { TALENT_CARD_LEVELS } from '../utils/cardLevels'
import { downloadExcel } from '../utils/excel'
@@ -12,6 +12,8 @@
const router = useRouter()
const loading = ref(false)
const rows = ref<RefundRecord[]>([])
/** 正在刷新的那笔退款号:同一时刻只允许一笔,避免连点打通道。 */
const refreshingRefundNo = ref('')
const cardLevels = TALENT_CARD_LEVELS
const itemNameLabel = computed(() => (activeTab.value === 'GROUP' ? '团购名称' : '消费项目'))
@@ -109,6 +111,19 @@
  router.push({ name: 'order-detail', params: { orderNo: row.orderNo } })
}
// 刷新退款:向通道问一次这笔退款的最终结果。退款通知会丢,线上排查不该只能等定时任务或手写 SQL。
async function refreshRefund(row: RefundRecord) {
  if (!row.refundNo || refreshingRefundNo.value) return
  refreshingRefundNo.value = row.refundNo
  try {
    const refreshed = await refreshRefundStatus(row.refundNo)
    ElMessage.success(refreshed?.refundStatus === 2 ? '通道已确认退款成功' : '已向通道刷新退款状态')
    await loadData()
  } finally {
    refreshingRefundNo.value = ''
  }
}
watch(activeTab, search)
onMounted(loadData)
@@ -199,9 +214,17 @@
        </el-table-column>
        <el-table-column label="退款时间" width="116" align="center"><template #default="{ row }">{{ formatDateTime(row.refundTime) }}</template></el-table-column>
        <el-table-column prop="operatorName" label="操作人" width="104" align="center" />
        <el-table-column label="操作" width="104" fixed="right" align="center">
        <el-table-column label="操作" width="168" fixed="right" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="viewOrder(row)">查看订单</el-button>
            <el-button
              v-permission="'admin.refunds.edit'"
              link
              type="primary"
              :loading="refreshingRefundNo === row.refundNo"
              :disabled="!row.refundNo || row.refundStatus !== 1"
              @click="refreshRefund(row)"
            >刷新退款</el-button>
          </template>
        </el-table-column>
      </el-table>
talent-card-admin/src/views/RoleFormView.vue
@@ -30,8 +30,8 @@
  {
    name: '订单管理',
    pages: [
      { code: 'orders', name: '订单列表', actions: ['查看', '下载'] },
      { code: 'refunds', name: '退款记录', actions: ['查看', '下载'] },
      { code: 'orders', name: '订单列表', actions: ['查看', '下载', '刷新'] },
      { code: 'refunds', name: '退款记录', actions: ['查看', '下载', '刷新'] },
      { code: 'hotel-free-verifications', name: '酒店免费核销记录', actions: ['查看', '下载'] },
    ],
  },
@@ -82,6 +82,7 @@
  '审核': 'audit',
  '批量导入': 'import',
  '下载': 'export',
  '刷新': 'edit',
} as Record<string, string>)[action] || 'view'}`
const pageCodes = (page: Page) => page.actions.map((action) => code(page.code, action))
const moduleCodes = (module: Module) => module.pages.flatMap(pageCodes)
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -74,6 +74,9 @@
                    }
                    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));
                        // 进件通过后新增的结算卡同样只有绑定回调能推进,而回调会丢(开发/内网环境更是收不到)。
                        // 把上面那条查询路径推广到该门店所有待处理的卡,挂在同一次人工刷新上——不新增定时任务。
                        confirmAddedCards(application,capabilities,owner);
                    }
                    if (capabilities.containsKey("qry_wx_conf_list")) {
                        List<Map<String,Object>> wechat = configurations(capabilities.get("qry_wx_conf_list"));
@@ -163,7 +166,35 @@
    private boolean confirmOriginalCard(Map<String,Object> application, Map<String,Object> capabilities, String owner) {
        List<Map<String,Object>> original=rows("select * from merchant_bank_card where id=? and merchant_id=?",application.get("initialBankCardId"),application.get("merchantId"));
        if (original.size()!=1) return false;
        Map<String,Object> card=original.get(0);
        return approveIfLinked(original.get(0),application,capabilities,owner);
    }
    /**
     * 进件通过后新增的结算卡:把 {@link #confirmOriginalCard} 的那条查询路径推广到该门店所有待处理的卡。
     *
     * <p>初次进件那张卡有查询对等物(只认 {@code initial_bank_card_id}),后加的卡却只有绑定回调能推进——
     * 回调一丢(内网或开发环境必然丢),卡就永远停在「处理中」。补法是在**已有的人工刷新**路径上多走一遍,
     * 不是新增定时任务(ADR 0001 / ADR 0002 的边界)。
     */
    private int confirmAddedCards(Map<String,Object> application, Map<String,Object> capabilities, String owner) {
        List<Map<String,Object>> added=rows("""
                select * from merchant_bank_card
                where merchant_id=? and status='pending' and (store_id=? or store_id is null) and id<>?
                """,application.get("merchantId"),application.get("storeId"),application.get("initialBankCardId"));
        int confirmed=0;
        for (Map<String,Object> card:added) {
            if (approveIfLinked(card,application,capabilities,owner)) confirmed++;
        }
        return confirmed;
    }
    /**
     * 通道的结算卡列表里有没有这张卡,且它已经挂到结算配置上——两件都成立才算绑定成功。
     *
     * <p>匹配用卡号哈希:通道回执里的卡号是明文,本地只存哈希与后四位。
     */
    private boolean approveIfLinked(Map<String,Object> card, Map<String,Object> application,
                                    Map<String,Object> capabilities, String owner) {
        for (Map<String,Object> remote:configurations(capabilities.get("qry_cash_card_info_list"))) {
            String number=text(remote.get("card_no")), token=text(remote.get("token_no"));
            if (!number.matches("[0-9]{8,32}") || token.isBlank() || !sha256(number).equals(text(card.get("cardHash")))) continue;
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -289,9 +289,33 @@
    @Transactional(readOnly = true)
    public List<Map<String, Object>> bankCards(MerchantPrincipal principal) {
        store(principal);
        convergePendingCards(principal);
        return rows("select * from merchant_bank_card where merchant_id = ? and store_id = ? order by id desc", principal.merchantId(), principal.storeId()).stream().map(this::bankCard).toList();
    }
    /**
     * 打开银行卡列表时顺手把仍在「处理中」的卡拉一次通道。
     *
     * <p>新增结算卡的状态原先只有绑定回调能推进,而回调会丢(开发与内网环境根本收不到),商户盯着
     * 这一页就是在等它变成已批准。这里只在**确实有待处理的卡**时才去查通道,所以它不是「每次读都打
     * 通道」的轮询;通道那边的查询结果与刷新进度走的是同一条路径(都不新增定时任务)。
     */
    private void convergePendingCards(MerchantPrincipal principal) {
        if (paymentReadinessService == null) return;
        Integer pending = jdbc.queryForObject("""
                select count(*) from merchant_bank_card
                where merchant_id = ? and status = 'pending' and (store_id = ? or store_id is null)
                """, Integer.class, principal.merchantId(), principal.storeId());
        if (pending == null || pending == 0) return;
        List<Map<String, Object>> applications = rows("""
                select id from merchant_onboarding_application
                where merchant_id = ? and store_id = ? and status = 'approved' and provider_huifu_id is not null
                order by id desc limit 1
                """, principal.merchantId(), principal.storeId());
        if (applications.isEmpty()) return;
        paymentReadinessService.refreshQuietly(((Number) applications.get(0).get("id")).longValue());
    }
    @Transactional
    public Map<String, Object> addBankCard(MerchantPrincipal principal, MerchantBankCardRequest request, String key) {
        requireKey(key); store(principal); requireApproved(principal);
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuCallbackUrlAudit.java
New file
@@ -0,0 +1,64 @@
package com.talentcard.server.business.payment;
import java.util.LinkedHashMap;
import java.util.Map;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
 * 启动时把五个回调地址念一遍:哪个配了、哪个没配。
 *
 * <p>为什么需要它:回调地址为空时系统原先什么都不说,于是很容易出现「以为回调在跑、其实指向一个不可达
 * 地址」或者「这个回调从来没在通道侧注册过」的错觉。把结论写进日志与报告,让缺失在启动那一刻就看得见,
 * 而不是等某笔支付卡住才发现。
 */
@Component
public class HuifuCallbackUrlAudit implements ApplicationRunner {
    private static final Logger log = LoggerFactory.getLogger(HuifuCallbackUrlAudit.class);
    /** 回调端点名(与 {@code PaymentCallbackController} 的路径一致)。 */
    static final String PAYMENT = "notify";
    static final String BANK_CARD = "merchant-business-notify";
    static final String ONBOARDING = "merchant-onboarding-notify";
    static final String WECHAT_CONFIG = "merchant-wechat-config-notify";
    static final String BUSINESS_OPEN = "merchant-business-open-notify";
    private final HuifuPaymentProperties properties;
    public HuifuCallbackUrlAudit(HuifuPaymentProperties properties) {
        this.properties = properties;
    }
    @Override
    public void run(ApplicationArguments args) {
        Map<String, String> report = report();
        for (Map.Entry<String, String> entry : report.entrySet()) {
            if (entry.getValue().isBlank()) {
                log.warn("Huifu callback {} is not enabled: the callback URL is empty,"
                        + " so nothing will be delivered there", entry.getKey());
            } else {
                log.info("Huifu callback {} is registered at {}", entry.getKey(), entry.getValue());
            }
        }
    }
    /** 每个回调端点的落地地址;空串表示「回调未启用」。 */
    public Map<String, String> report() {
        Map<String, String> report = new LinkedHashMap<>();
        report.put(PAYMENT, trimmed(properties.getNotifyUrl()));
        report.put(BANK_CARD, trimmed(properties.getMerchantBusinessNotifyUrl()));
        report.put(ONBOARDING, trimmed(properties.getMerchantOnboardingNotifyUrl()));
        report.put(WECHAT_CONFIG, trimmed(properties.getMerchantWechatConfigNotifyUrl()));
        report.put(BUSINESS_OPEN, trimmed(properties.getMerchantBusinessOpenNotifyUrl()));
        return report;
    }
    private String trimmed(String value) {
        return value == null ? "" : value.trim();
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationRetry.java
@@ -31,6 +31,10 @@
    private final JdbcTemplate jdbc;
    private final PaymentFundConfirmationService confirmationService;
    /** 交易确认补偿的开关:此前只写在注释里、配置键从未被读取,任务其实一直在跑。 */
    @org.springframework.beans.factory.annotation.Value("${app.payment.fund-confirmation-retry.enabled:true}")
    private boolean enabled = true;
    public PaymentFundConfirmationRetry(JdbcTemplate jdbc, PaymentFundConfirmationService confirmationService) {
        this.jdbc = jdbc;
        this.confirmationService = confirmationService;
@@ -39,9 +43,13 @@
    /**
     * 补偿仍未确认的已核销订单。默认 5 分钟一轮;用 {@code app.payment.fund-confirmation-retry.enabled}
     * 关闭,用 {@code ...fixed-delay-ms} 调整频率。
     *
     * <p>开关的默认值取「开」:它此前只写在注释里、配置键从未被读取,任务其实一直在跑;把默认值定成
     * 开是让代码与既有行为一致,也让 yml 里那一行能真正把它关掉。
     */
    @Scheduled(fixedDelayString = "${app.payment.fund-confirmation-retry.fixed-delay-ms:300000}")
    public void retryPendingConfirmations() {
        if (!enabled) return;
        try {
            int retried = retry(BATCH_SIZE);
            if (retried > 0) log.info("Retried fund confirmation for {} verified orders", retried);
@@ -52,13 +60,17 @@
    /** 返回本轮真正尝试过的订单数(供测试与运维使用)。 */
    public int retry(int limit) {
        // 每笔订单只取它**最新一笔**成功支付的状态:同一订单可能有多次支付尝试,
        // 旧的 pending 与新的 confirmed 混在一起会让「这笔钱到底确认了没有」有两个答案。
        // reverting(撤销核销的回退在途)也在扫描范围内:它原先没人再看,撤销核销会被永久卡住。
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select distinct o.id as order_id
                select o.id as order_id, p.funds_confirmation_status as funds_status
                from benefit_order o
                join payment_transaction p on p.order_id = o.id
                join payment_transaction p on p.order_id = o.id and p.status = 'SUCCEEDED'
                where o.deleted = 0 and o.order_status = 1 and o.verify_status = 1 and o.refund_status = 0
                  and p.status = 'SUCCEEDED'
                  and p.funds_confirmation_status in ('pending', 'confirming')
                  and p.funds_confirmation_status in ('pending', 'confirming', 'reverting')
                  and p.id = (select max(newer.id) from payment_transaction newer
                              where newer.order_id = o.id and newer.status = 'SUCCEEDED')
                order by o.id asc
                limit ?
                """, Math.max(1, limit)));
@@ -66,7 +78,11 @@
        for (Map<String, Object> row : rows) {
            Object orderId = row.get("orderId");
            if (!(orderId instanceof Number number)) continue;
            confirmationService.confirmForOrder(number.longValue());
            if ("reverting".equalsIgnoreCase(String.valueOf(row.get("fundsStatus")))) {
                confirmationService.retryRevertForOrder(number.longValue());
            } else {
                confirmationService.confirmForOrder(number.longValue());
            }
            retried++;
        }
        return retried;
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
@@ -145,7 +145,12 @@
        if (claimOutcome == ClaimOutcome.ORDER_NOT_VERIFIED) return NOT_VERIFIED;
        if (claimOutcome != ClaimOutcome.CLAIMED) {
            // 另一个请求已经在确认这笔资金,或者它刚被确认为终态。
            return isConfirmed(currentStatus(paymentId)) ? CONFIRMED : PROCESSING;
            if (isConfirmed(currentStatus(paymentId))) return CONFIRMED;
            // 当天认领出来的「确认中」不能重发确认(claim 只在次日再认领),但「那次确认到底怎么样了」
            // 可以查:确认查询接口从写下那天起就是死代码,这里把它接上,让同一天的确认中也能收敛,
            // 而不是整整等一天。
            String queried = queryInFlightConfirmation(paymentId);
            return queried != null ? queried : PROCESSING;
        }
        String confirmNo = BusinessIdGenerator.next("DC");
@@ -174,6 +179,91 @@
    }
    /**
     * 查一次「确认中」那笔确认的结果。
     *
     * <p>认领规则刻意不允许当天重发确认(避免重复分账),于是当天认领出来的确认中只能等到次日才有人再看
     * ——这期间商户的钱明明已经有了结论却没人知道。确认查询接口({@code delaytrans.confirmquery})
     * 从写下那天起就是死代码,这里把它接上:查得到就按结果落地(成功 → confirmed,失败/在途 → pending,
     * 下次还能重发),查不到就保持原样交给后续轮次。
     *
     * @return 应用了查询结果后的结论;拿不到定位键或查询失败时返回 {@code null},表示「没查出来」。
     */
    private String queryInFlightConfirmation(long paymentId) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select p.funds_confirm_no, p.provider_huifu_id, o.collect_huifu_id
                from payment_transaction p
                join benefit_order o on o.id = p.order_id
                where p.id = ?
                limit 1
                """, paymentId));
        if (rows.isEmpty()) return null;
        Map<String, Object> payment = rows.get(0);
        String confirmNo = text(payment.get("fundsConfirmNo"));
        String huifuId = firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId")));
        if (confirmNo.isBlank() || huifuId.isBlank()) return null;
        try {
            return applyResult(paymentId, confirmNo, gateway.queryConfirm(confirmNo, huifuId));
        } catch (HuifuPaymentGatewayException exception) {
            log.warn("Fund confirmation query for {} has no usable answer: {}", confirmNo, exception.getMessage());
            return null;
        } catch (RuntimeException exception) {
            log.warn("Fund confirmation query for {} failed locally: {}", confirmNo, exception.getMessage());
            return null;
        }
    }
    /**
     * 重发一次交易确认退款(撤销核销的回退)。
     *
     * <p>回退没有查询接口,而补偿任务原先只扫「待确认/确认中」——停在 {@code reverting} 的记录永远
     * 没人再看,撤销核销就会被永久卡住。回退以 {@code funds_revert_no} 为幂等键,通道对同一个请求号
     * 只会执行一次并回答它当前的状态,所以**幂等的重发就是它的查询对等物**:明确成功/失败就落地,
     * 仍然在途就继续停在 reverting 等下一轮。
     *
     * @return 与 {@link #revertForOrder(long)} 同一套结论常量。
     */
    public String retryRevertForOrder(long orderId) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select p.id, p.payment_no, p.funds_confirmation_status, p.funds_revert_no,
                       p.funds_revert_request_date, p.funds_confirmed_amount, p.funds_confirm_no,
                       p.funds_confirm_request_date, p.provider_huifu_id,
                       o.collect_huifu_id
                from payment_transaction p
                join benefit_order o on o.id = p.order_id
                where p.order_id = ?
                order by p.id desc
                limit 1
                """, orderId));
        if (rows.isEmpty()) return REVERT_UNKNOWN;
        Map<String, Object> payment = rows.get(0);
        if (!statusIs(text(payment.get("fundsConfirmationStatus")), "reverting")) return REVERT_NOT_NEEDED;
        String revertNo = text(payment.get("fundsRevertNo"));
        String requestDate = text(payment.get("fundsRevertRequestDate"));
        String confirmNo = text(payment.get("fundsConfirmNo"));
        String confirmRequestDate = text(payment.get("fundsConfirmRequestDate"));
        String huifuId = firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId")));
        if (revertNo.isBlank() || requestDate.isBlank() || confirmNo.isBlank() || confirmRequestDate.isBlank() || huifuId.isBlank()) {
            // 缺定位键就重发不了,钱的位置仍然未定:留在 reverting 等人工,绝不猜。
            log.warn("Payment {} has an in-flight revert with no usable reference, it needs a human", payment.get("paymentNo"));
            return REVERT_UNKNOWN;
        }
        long paymentId = number(payment.get("id"));
        HuifuDelayRevert result;
        try {
            result = gateway.refund(new HuifuDelayConfirmRefundCommand(
                    revertNo, huifuId, confirmNo, confirmRequestDate, "撤销核销退回延迟户(补偿重发)"));
        } catch (HuifuPaymentGatewayException exception) {
            recordRevertAttempt(paymentId, revertNo, "", exception.getMessage());
            return REVERT_IN_FLIGHT;
        } catch (RuntimeException exception) {
            log.warn("Fund revert retry for payment {} failed before reaching the provider: {}",
                    payment.get("paymentNo"), exception.getMessage());
            return REVERT_IN_FLIGHT;
        }
        return applyRevertResult(paymentId, revertNo, result, payment);
    }
    /**
     * 撤销核销:把这笔已经确认给商户的资金退回通道延迟户。
     *
     * <p>调用方只有在拿到 {@link #REVERTED} 或 {@link #REVERT_NOT_NEEDED} 时才可以撤销核销。其余结果都
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentReconciliationScheduler.java
@@ -10,7 +10,14 @@
public class PaymentReconciliationScheduler {
    private static final Logger log = LoggerFactory.getLogger(PaymentReconciliationScheduler.class);
    private final PaymentReconciliationService service;
    @Value("${app.payment.huifu.reconciliation.enabled:false}") private boolean enabled;
    /**
     * 定时对账的开关。
     *
     * <p>默认值取「开」,与 yml 里的 {@code app.payment.huifu.reconciliation.enabled: true} 一致:
     * 此前注解默认关、yml 打开,删掉 yml 里那一行就会让支付与退款唯一的后台兜底**静默消失**。
     * 现在两处默认值相同,删掉那一行也不会把任务关掉。
     */
    @Value("${app.payment.huifu.reconciliation.enabled:true}") private boolean enabled = true;
    @Value("${app.payment.huifu.reconciliation.pending-after-minutes:5}") private int pendingAfterMinutes;
    public PaymentReconciliationScheduler(PaymentReconciliationService service) { this.service = service; }
talent-card-server/src/main/resources/application.yml
@@ -70,8 +70,14 @@
    talent-id: 2
  payment:
    # 核销后把延迟入账的钱确认给商户;确认失败或超时时由补偿任务重试,避免钱永远冻在延迟户。
    # enabled 的默认值与代码里的默认值一致(都是开):删掉这一行不会让补偿静默消失。
    fund-confirmation-retry:
      enabled: ${FUND_CONFIRMATION_RETRY_ENABLED:true}
      fixed-delay-ms: ${FUND_CONFIRMATION_RETRY_FIXED_DELAY_MS:300000}
    # 悬账(订单已取消、通道却确认收了钱)的自动退款:退回付款人;通道拒绝则升级告警转人工。
    stranded-refund:
      enabled: ${STRANDED_REFUND_ENABLED:true}
      fixed-delay-ms: ${STRANDED_REFUND_FIXED_DELAY_MS:300000}
    huifu:
      # Keep disabled until the Huifu test/production product, merchant and keys are provisioned.
      enabled: true
@@ -83,12 +89,19 @@
      private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCSEpJD4Z62cb+g82jGY+d87noMrXkoHlw4CCCTtna1whTed5haEqGH3B6czSC7E2c9d/QdMP4Vt67uhS9OK0IgFdLG+r1uH6K4snV/CW4rMN7f9EZOJyzjfZ8gJeuwpGZHozfUeEoBwE6XTq9/2Us9moDQfRpEX3Q4MYF5dl/r35b8m0wdGndDq4aBugJf0+VXZ9bev8v6tQBm/VkGePpX9EeynQB7YxH7Bl3JW9LsHD+GJalO+Vmzo32o72YquryR9qmNmoC4mc9yFO4FSg6k6Bc2+CDBjwk4u//Y2jtccXHNNjJ/2JUA3EBNF4JpHl2lGt47DGXqgByASgHXF11NAgMBAAECggEAYpminauYhLK3lZ94pCIfyhb3z8LxO4Fy4meqBctuyjXd/GViM7rLyndtOo7Ef0MTt0xXPbUqbeJ1dI/2ZJ+SnTHd+26Zm9JC9sWPxlbJROJSL34mTu7mwBC17u9p7uyqFcU5FHS2EASNiFkW4gf9X/IA7brkli6QtrXEHgcnrIFVihAkLdI/MCrvD9RUa5QYHG0DqXT8RGwfVz4D/VHpjkqN3Tt8lEks56GRaZmNjWSZJmaeboKZC7jLDwUx07WSab7ZWAYU72X8NlY3t34NCBVPRAyrVW/UOIRGCHW/OibrMn4O7qOvHFgsNUWtd3ewSZkpZEt1oLc+cuXkt95bSQKBgQDKGHIxo3cllsCtrLedM5cAR75yy0MT+miL4TUvljTZ25JoUX+G1nmxeDrFhS0/pUJojjn+rSBqLkzsBOrisJ+7tmGKMWaUwikl0KOCHVXuKvzGWsPFdr7093Va9E/AgM2zFJf9S3VgF/FK4c0sBCra6NB5TXDjV92uXKjaDbRtqwKBgQC5CL8/l6hn2S+xMvf6NWmy1vGZXt0Dl8MfTwTBRFgiAy5nNdJLCbhypG/+d6sCe8nXddgr55LHLzf/gvA4SeGP4+WQvkVG3bCn/DyZOwgK0irKRcrUpHrFWssRYYQDEfRWPJYZE4yYjFT1pGhVF2ly3zwnHagjMUJoVwPY2uE45wKBgEJgVxlQnDB/XBdu0/vQ1vajmbywX/6uzymqNULE7eTkBik3I1CRHvUYDQ2U2TC/QZl/pTKtgCj/vgoC73a3Fi+cDltKMGwJfWRLK3jv06pe+hjX9NblUoC4yhWuiocqhuUbjXQHFa0TEBBNK4sab0zpDJfaxIoW8Nt02YyM1cbzAoGACykk6AAMz17BSq3pWTU5j+j5H6giKqJPyaapgcLOkhahCOatLYuDjZ6vhqjPLtIqpY1dIaDRMKR/ezD5rlt+eIBli2p+oTyj+C31tcRiwsxUemjlSBg3s4m7b+vy24CMvVrgLtkIBPeJ9FsbU+6alKw0Y6OdTaod+E8vUzg4l4cCgYEAsukZsL2AdMqjIZyeuitO0CPnvmRTCihuWi18M1Vun2RaCFSUavlLrmDt9i9v+eI5fRCGz8YgIMEyAp8T+xHX/tLTlXcWh9JS30RT+r3yQAOLnr4KG4LBBLgK64EOjhlo38gV6th7nvoi2R7rlPRBl3qW3bbgQyaAz2cZ0iHIoXo=
      platform-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiuRE4WR7SQqRs+3xNHpneFSU0YkftkBFRec7x1KQP9xCtOoySt/9pyjk+OyKypHnAHWGijpMRyd3AnJGzwgHS7PQ0Fj1yawbb94zYaNjsHsVg0py15vsvZexxLUttrY6s3F17hcMXHTqTutRR1zk9bsYehNkoooj09r9+5x6c5hIyWDXB7UQ+frnLBxhG8SQkeYnm1DqsRs4yB4qvDTb++h3X24bN7AaYcujL2ju3HI+eh4PrgWw9DZKtGpjNc+4imayE+of+nebkTQ4W2gtt+5gECmYEL6jcc06ZGzd8pi4MkASqER+PYt9zIFdavkHGCT8PDMRCl7AiwDkP6VVUQIDAQAB
      notify-url: ${HUIFU_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/notify}
      # 绑卡(业务开通)回调地址。这个键原来在配置里根本不存在:它靠 notify-url 推导出一个值,
      # 于是「通道侧到底注册了哪个地址」取决于另一个配置。显式写出来,缺一个都能一眼看见。
      merchant-business-notify-url: ${HUIFU_MERCHANT_BUSINESS_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-business-notify}
      # 回调到不了时(通道重发失败、本地或内网环境)靠查单把已经收下钱的订单捞回来:支付挂起超过
      # pending-after-minutes 就问一次通道,通道确认收款就落成本地已支付,对不上账的记一条问题记录。
      # 关掉它就只剩「有人点了取消/重申支付」这一个补救入口,回调一丢订单会一直停在待支付。
      # fixed-delay-ms 写进配置(注解里也有同样的默认值):间隔不该只靠注解默认值生效。
      # 本机开发要更快看到收敛时,用 HUIFU_RECONCILIATION_FIXED_DELAY_MS 把间隔调短(例如 30000),
      # 或者在后台订单列表点「立即对账」人工触发一次——它跑的是同一套真实对账逻辑,不是模拟支付。
      reconciliation:
        enabled: ${HUIFU_RECONCILIATION_ENABLED:true}
        pending-after-minutes: ${HUIFU_RECONCILIATION_PENDING_AFTER_MINUTES:5}
        fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:300000}
      merchant-onboarding-notify-url: ${HUIFU_MERCHANT_ONBOARDING_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-onboarding-notify}
      merchant-wechat-config-notify-url: ${HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-wechat-config-notify}
      merchant-business-open-notify-url: ${HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-business-open-notify}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
@@ -437,6 +437,36 @@
    }
    /**
     * 进件通过后**新增**的结算卡也要能从人工刷新那条路收敛为已批准。
     *
     * <p>它的状态原先只有绑定回调能推进(回调会丢,开发与内网环境根本收不到),而初次进件那张卡的
     * 查询对等物只认 {@code initial_bank_card_id}。补法是把同一条查询路径推广到该门店所有待处理的卡,
     * 不新增定时任务。
     */
    @Test
    void anAddedBankCardConvergesThroughTheSameManualRefresh() {
        Map<String,Object> fixture = fixture();
        MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
        String applicationNo = String.valueOf(fixture.get("applicationNo"));
        String addedCardKey = "T02ADD-" + applicationNo;
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail,
                                               card_masked, card_hash, status, idempotency_key)
                values (?, ?, 'Bank', 'Branch', '510100', '5678', '****5678', ?, 'pending', ?)
                """, principal.merchantId(), principal.storeId(), sha256("622200009999"), addedCardKey);
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N",
                "qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200009999","token_no","added-card-token")),
                "qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","added-card-token"))));
        service.refreshOnboarding(principal,false);
        assertThat(jdbc.queryForObject("select status from merchant_bank_card where idempotency_key=?",String.class,addedCardKey))
                .as("新增的结算卡也要能靠查询收敛,而不是永远停在处理中")
                .isEqualTo("approved");
    }
    /**
     * 分阶段模型下重复提交不新建进件,也就不会在通道上多出一家商户。唯一的例外是补齐个人主体的
     * 门店照片:那是开通微信支付与发起微信实名的硬材料,而商户端没有别的补图入口——真实开发库里
     * 已经有拿到汇付商户号的个人进件停在这个形态上(有商户号、门店照片为空、业务开通没做过)。
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
@@ -166,8 +166,18 @@
        long orderId = orderId(orderNo);
        jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId);
        when(delayGateway.confirm(any())).thenReturn(succeeded("1.00"));
        // 补偿任务扫的是整库、按订单号从小到大取一批,而测试库共享且不清理。先把自己之外的相关行移出
        // 扫描条件(那些用例已经跑完),本用例这条才一定落在窗口内——否则它会随测试库累积而必然失败
        // (docs/agents/testing.md 记的「环境相关失败」)。
        jdbc.update("""
                update benefit_order set refund_status = 1
                where id <> ? and deleted = 0 and order_status = 1 and verify_status = 1 and refund_status = 0
                  and exists (select 1 from payment_transaction p
                              where p.order_id = benefit_order.id and p.status = 'SUCCEEDED'
                                and p.funds_confirmation_status in ('pending', 'confirming', 'reverting'))
                """, orderId);
        assertThat(confirmationRetry.retry(50)).isPositive();
        assertThat(confirmationRetry.retry(50)).isEqualTo(1);
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction"
                + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed");
@@ -495,6 +505,68 @@
        verify(delayGateway, never()).confirm(any());
    }
    /**
     * 当天认领出来的「确认中」靠**确认查询**收敛,而不是整整等一天。
     *
     * <p>认领规则不允许当天重发确认(避免重复分账),于是同一天的确认中在旧实现里没人再看——而确认查询
     * 接口({@code delaytrans.confirmquery})从写下那天起就是死代码。这里把它接上。
     */
    @Test
    void aConfirmationClaimedTodayConvergesThroughTheConfirmationQuery() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPaidPayment(orderNo, 100L, "confirming");
        markVerified(orderId(orderNo));
        jdbc.update("""
                update payment_transaction
                set funds_confirm_request_date = ?, funds_confirm_no = ?
                where payment_no = ?
                """, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), "DC-CLAIMED-" + paymentNo, paymentNo);
        when(delayGateway.queryConfirm(any(), any())).thenReturn(succeeded("1.00"));
        assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
                PaymentFundConfirmationService.CONFIRMED);
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
                String.class, paymentNo)).isEqualTo("confirmed");
        verify(delayGateway, never()).confirm(any());
    }
    /**
     * 撤销核销的回退停在 {@code reverting} 时也要有人再看:回退没有查询接口,补偿任务原先只扫
     * 「待确认/确认中」,这类记录会永久卡住撤销核销。幂等的重发就是它的查询对等物。
     */
    @Test
    void anInFlightRevertConvergesThroughTheRetryTask() {
        String orderNo = createOrder("GROUP_BUY", 100L);
        String paymentNo = insertPaidPayment(orderNo, 100L, "reverting");
        long orderId = orderId(orderNo);
        markVerified(orderId);
        jdbc.update("""
                update payment_transaction
                set funds_confirm_no = ?, funds_confirm_request_date = ?, funds_revert_no = ?,
                    funds_revert_request_date = ?, funds_confirmed_amount = 100
                where payment_no = ?
                """, "DC-ORIGINAL-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE),
                "DR-INFLIGHT-" + paymentNo, LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo);
        // 补偿任务按订单号从小到大取一批,而测试库共享且不清理:先把别人的相关行移出扫描条件。
        jdbc.update("""
                update benefit_order set refund_status = 1
                where id <> ? and deleted = 0 and order_status = 1 and verify_status = 1 and refund_status = 0
                  and exists (select 1 from payment_transaction p
                              where p.order_id = benefit_order.id and p.status = 'SUCCEEDED'
                                and p.funds_confirmation_status in ('pending', 'confirming', 'reverting'))
                """, orderId);
        when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert(
                HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED, "00000000", "交易成功", "HF-REVERT-RETRY"));
        assertThat(confirmationRetry.retry(50)).isEqualTo(1);
        assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?",
                String.class, paymentNo))
                .as("回退成功了,钱重新回到延迟户(pending):撤销核销不再被永久卡住")
                .isEqualTo("pending");
    }
    /** 通道还在处理:状态保持 pending,供确认补偿任务下一轮再试。 */
    private HuifuDelayAccountGateway.HuifuDelayConfirmation processing() {
        return new HuifuDelayAccountGateway.HuifuDelayConfirmation(
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentSchedulingConfigurationTests.java
New file
@@ -0,0 +1,89 @@
package com.talentcard.server.business.payment;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.test.context.ActiveProfiles;
/**
 * 定时任务的开关与间隔必须能从配置读、且默认值一致。
 *
 * <p>为什么单独测这个:定时对账是支付与退款唯一的后台兜底,而它原先「注解默认关、配置里打开」——
 * 一次配置编辑删掉那一行,兜底就**静默消失**;交易确认补偿的开关更是只写在注释里、配置键从未被读取。
 * 这些都不会被任何业务用例发现,所以直接对着代码与配置的形状断言。
 */
@SpringBootTest
@ActiveProfiles("test")
class PaymentSchedulingConfigurationTests {
    @Test
    void everyPaymentBackgroundTaskCanBeReadFromTheConfigurationFile() throws Exception {
        String yml = configurationFile();
        assertThat(yml)
                .as("对账间隔要写进配置,不能只靠注解默认值")
                .contains("fixed-delay-ms: ${HUIFU_RECONCILIATION_FIXED_DELAY_MS:300000}");
        assertThat(yml)
                .as("交易确认补偿的开关要真的存在且可读")
                .contains("enabled: ${FUND_CONFIRMATION_RETRY_ENABLED:true}");
        assertThat(yml)
                .as("悬账自动退款的开关要真的存在且可读")
                .contains("enabled: ${STRANDED_REFUND_ENABLED:true}");
        assertThat(yml)
                .as("定时对账的开关默认与代码一致(删掉配置行不会让兜底静默消失)")
                .contains("enabled: ${HUIFU_RECONCILIATION_ENABLED:true}");
    }
    @Test
    void theReconciliationSwitchDefaultsToOnInTheCodeAsWell() throws Exception {
        assertThat(defaultExpression(PaymentReconciliationScheduler.class, "enabled"))
                .as("注解上的默认值必须与配置一致:默认关 + 配置打开,就等于一行配置编辑能关掉兜底")
                .isEqualTo("${app.payment.huifu.reconciliation.enabled:true}");
        assertThat(PaymentReconciliationScheduler.class.getMethod("reconcilePendingTransactions")
                .getAnnotation(Scheduled.class).fixedDelayString())
                .isEqualTo("${app.payment.huifu.reconciliation.fixed-delay-ms:300000}");
    }
    @Test
    void theFundConfirmationRetrySwitchIsImplementedAndDefaultsToOn() throws Exception {
        assertThat(defaultExpression(PaymentFundConfirmationRetry.class, "enabled"))
                .as("注释里宣称有这个开关,代码就必须真的读它")
                .isEqualTo("${app.payment.fund-confirmation-retry.enabled:true}");
    }
    @Test
    void everyHuifuCallbackUrlIsReportedAndBlankOnesAreCalledOut() {
        HuifuPaymentProperties properties = new HuifuPaymentProperties();
        properties.setNotifyUrl("https://example.com/api/payments/huifu/notify");
        HuifuCallbackUrlAudit audit = new HuifuCallbackUrlAudit(properties);
        assertThat(audit.report())
                .as("五个回调端点都要有结论(地址或「未启用」),而不是静默地什么都不说")
                .containsOnlyKeys("notify", "merchant-business-notify", "merchant-onboarding-notify",
                        "merchant-wechat-config-notify", "merchant-business-open-notify")
                .allSatisfy((endpoint, url) -> assertThat(url).isNotBlank());
        properties.setNotifyUrl("");
        assertThat(audit.report())
                .as("任何一个回调地址为空时都必须报「回调未启用」,而不是留一个不可达的假地址")
                .allSatisfy((endpoint, url) -> assertThat(url).isEmpty());
    }
    private String defaultExpression(Class<?> type, String fieldName) throws Exception {
        Field field = type.getDeclaredField(fieldName);
        return field.getAnnotation(Value.class).value();
    }
    private String configurationFile() throws Exception {
        try (var stream = getClass().getResourceAsStream("/application.yml")) {
            assertThat(stream).as("application.yml 必须在类路径上").isNotNull();
            return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
        }
    }
}