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。
| | |
| | | |
| | | - 只补缺失的 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 驱动写个单文件程序。 |
| | |
| | | |
| | | 新会话不要为了证明它们"是既有的"而重建 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`(撤销核销 |
| | | 的回退在途)状态。以后新写这类「后台任务只处理了我这条」的用例,照它的做法先腾窗口,不要再依赖测试库的 |
| | | 累积状况。 |
| | | |
| | | ## 测试库的状态是共享的 |
| | | |
| | |
| | | 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 }) |
| | | } |
| | |
| | | <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' |
| | | |
| | |
| | | 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 } |
| | | } |
| | | // 只有「还没发起」或「已被通道驳回」才给发起入口:处理中、待本人办理、已确认都不能重复发起, |
| | | // 未决申请重复提交会给同一商户报出两份实名申请。 |
| | |
| | | <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> |
| | | |
| | |
| | | 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() |
| | |
| | | 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() { |
| | |
| | | |
| | | <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="暂无订单数据"> |
| | |
| | | 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' |
| | |
| | | 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' ? '团购名称' : '消费项目')) |
| | |
| | | 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) |
| | |
| | | </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> |
| | |
| | | { |
| | | name: '订单管理', |
| | | pages: [ |
| | | { code: 'orders', name: '订单列表', actions: ['查看', '下载'] }, |
| | | { code: 'refunds', name: '退款记录', actions: ['查看', '下载'] }, |
| | | { code: 'orders', name: '订单列表', actions: ['查看', '下载', '刷新'] }, |
| | | { code: 'refunds', name: '退款记录', actions: ['查看', '下载', '刷新'] }, |
| | | { code: 'hotel-free-verifications', name: '酒店免费核销记录', actions: ['查看', '下载'] }, |
| | | ], |
| | | }, |
| | |
| | | '审核': '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) |
| | |
| | | } |
| | | 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")); |
| | |
| | | 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; |
| | |
| | | @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); |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | /** |
| | | * 补偿仍未确认的已核销订单。默认 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); |
| | |
| | | |
| | | /** 返回本轮真正尝试过的订单数(供测试与运维使用)。 */ |
| | | 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))); |
| | |
| | | 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; |
| | |
| | | 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"); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 查一次「确认中」那笔确认的结果。 |
| | | * |
| | | * <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} 时才可以撤销核销。其余结果都 |
| | |
| | | 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-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 |
| | |
| | | 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} |
| | |
| | | } |
| | | |
| | | /** |
| | | * 进件通过后**新增**的结算卡也要能从人工刷新那条路收敛为已批准。 |
| | | * |
| | | * <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"); |
| | | } |
| | | |
| | | /** |
| | | * 分阶段模型下重复提交不新建进件,也就不会在通道上多出一家商户。唯一的例外是补齐个人主体的 |
| | | * 门店照片:那是开通微信支付与发起微信实名的硬材料,而商户端没有别的补图入口——真实开发库里 |
| | | * 已经有拿到汇付商户号的个人进件停在这个形态上(有商户号、门店照片为空、业务开通没做过)。 |
| | |
| | | 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"); |
| | |
| | | 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( |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |