yeg
2026-09-18 f5ede132185909c530a4a3f84b8a27d3935cba8a
fix(settlement): 修复结算记录页商户进件与银行卡申请的 12 处缺陷

「财务管理 / 结算记录」下的商户进件、银行卡申请两个页签,以及进件详情,逐条实测后修掉
12 处缺陷。下面按性质归类,只记结论。

查询静默失效:
- 日期区间点「清空」时 Element Plus 给的是 null(不是 []),查询里读 null[0] 抛 TypeError,
load() 整个中断且不弹任何提示,页面停在旧数据上,看起来像查询没生效。改用可选链——三个
页签共用这段代码,都会中招。仓库里 CarWashBenefitView 一直是这么写的。

数据合规:
- 银行卡申请列表下发的是解密后的完整卡号。card_no_encrypted 是可解密的密文,解密出来就是
完整 PAN;明文、密文和 card_hash 都不该出现在列表接口里。改为只下发后端已生成的 card_masked,
前端跟着改绑 cardMasked——同一页的「结算记录」页签本来就是这么显示的。

显示错误:
- 开户城市只认四川省:bankCardCityName 里写死 21 个市州,四川以外一律把 6 位行政区划代码原样
显示(实测 id 12/13 显示 441500,实为广东省汕尾市)。改为查 RegionCityCatalog,由商户端
同源的省市编码表(rencaika_merchant/config/area-codes.uts)离线展开出 369 条地市级映射,
不再按省单独维护。
- 商户进件列表「商家名称 / 联系方式」整列空白,而筛选框却能按商家名筛出记录:进件表和商户表
都有 merchant_name、contact_mobile,查询里带了 a.*,同名列存在两份,取到哪一个取决于驱动
返回同名列的顺序(实测「先出现的非空值赢」),于是列表显示申请时填的那份,筛选用的却是商户表
那份。列表现在显式取商户表;申请时填的名称另以 onboardingMerchantName 带出。进件详情仍取
进件表那份——它是进件当时的存档,两侧用途不同,别互相套用。
- 商家类型下拉的 8 项是前端手抄的:标签用了目录里只当别名的「住宿服务/购房服务/体检服务」
(库里存的是酒店服务/购房优惠/免费体检,所以这三项一个都筛不出来),后来新增的「文化服务/
数码服务」又选不到。改为由新接口 /merchant-settlement/merchant-types 直接读后端
MerchantTypeCatalog。

进件详情显示不全:
- 审核状态在有 progress 时被「开通尚未完成」顶掉,看不到这条进件到底审没审;驳回原因更是整个
页面都没渲染(18 条进件里 9 条是已驳回)。现在审核状态与开通状态分开显示,驳回原因单独提示。
- 结算银行卡只画了一个「卡号面」图片位,看不到这条进件绑的是哪张卡。详情接口按
initial_bank_card_id 左连结算卡、只取脱敏字段,页面补上卡号/开户银行/开户支行/开户城市/
绑定状态(该图片位本身从来没存过值,见下一个提交)。
- 补「进件信息」区块(进件编号、申请时间、审核时间、通道单号等),时间不再输出
2026-09-18T14:35:49 这种原始 ISO 串;门店照片区块原来只画在个人主体那一支,企业进件即使
传了也看不到,而微信业务开通与实名核对都要这张图,改为两个主体共用。

导航:
- 进件详情页左侧菜单跳到「商家管理 / 商家查询」并把财务管理折叠掉,与同一页的面包屑
(财务管理 / 商户进件 / 查看信息)自相矛盾。activeMenu 改归 /merchant-settlement。
- 页签切换不写回地址栏,刷新、浏览器返回、把链接发给别人都会落到别的页签(从进件详情按返回
会掉回「银行卡申请」就是这个原因)。changeKind 改为 router.replace 写回 query。

另:筛选项拉不到时不再弹「服务器内部错误」盖住页面(新增请求级 silent 标志,只降级不打扰)。

回归:新增 MerchantSettlementAdminListTests 5 条用例,其中 4 条先在旧实现下确认变红、再修回绿;
后端完整套件 401 个用例,失败仅为文档记录的 3 个基线失败;前端 vue-tsc + vite build 通过。
2个文件已添加
7个文件已修改
805 ■■■■■ 已修改文件
talent-card-admin/src/api/admin.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/http.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantOnboardingDetailView.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/RegionCityCatalog.java 393 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminListTests.java 200 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts
@@ -111,6 +111,8 @@
  endDate?: string
}
export function getMerchantSettlement(kind: 'onboardings' | 'bank-cards', params: MerchantSettlementQuery = {}) { return http.get<PageResult<any>, PageResult<any>>(`/api/admin/management/merchant-settlement/${kind}`, { params }) }
/** 商家类型筛选项:取自后端目录,避免前端另抄一份而与库里的取值失配。拉不到就先留空,不打扰用户。 */
export function getMerchantSettlementMerchantTypes() { return http.get<string[], string[]>('/api/admin/management/merchant-settlement/merchant-types', { silent: true }) }
/** 结算记录直接来自斗拱:按结算日期区间返回真实结算流水,用于监控每天自动结算了多少到银行卡。 */
export function getMerchantSettlements(params: { beginDate?: string; endDate?: string; size?: number; merchantName?: string; contactMobile?: string; merchantType?: string; status?: string } = {}) {
  return http.get<{ records: Record<string, any>[]; total: number; failures: string[] }, { records: Record<string, any>[]; total: number; failures: string[] }>('/api/admin/management/merchant-settlement/settlements', { params })
talent-card-admin/src/api/http.ts
@@ -1,6 +1,13 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
declare module 'axios' {
  export interface AxiosRequestConfig {
    /** 可选请求:失败时不弹全局错误提示,由调用方自行降级(例如筛选项拉不到就先留空)。 */
    silent?: boolean
  }
}
const tokenKey = 'talent-card-admin-token'
const userKey = 'talent-card-admin-user'
@@ -33,8 +40,11 @@
        window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`)
      }
    }
    // 可选请求只降级、不弹错:筛选条件少一项不该盖住页面,更不该看起来像主流程挂了。
    if (!error.config?.silent) {
    const message = error.response?.data?.message || error.message || '请求失败'
    ElMessage.error(message)
    }
    return Promise.reject(error)
  },
)
talent-card-admin/src/layouts/AdminLayout.vue
@@ -164,7 +164,10 @@
    return '/merchants'
  }
  if (route.path.startsWith('/merchant-onboardings')) {
    return '/merchants'
    // 进件详情是从「财务管理 / 结算记录」的商户进件页签点「查看」进来的,归到结算记录,
    // 与面包屑(财务管理 / 商户进件 / 查看信息)一致。归到 /merchants 会让左侧跳到
    // 「商家管理 / 商家查询」,并把财务管理折叠掉。
    return '/merchant-settlement'
  }
  if (route.path === '/roles/new' || /^\/roles\/[^/]+(?:\/view)?$/.test(route.path)) {
    return '/roles'
talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -64,6 +64,12 @@
  return /^\d{8}$/.test(result) ? `${result.slice(0, 4)}-${result.slice(4, 6)}-${result.slice(6, 8)}` : result
}
/** 后端给的是 ISO 串(2026-09-18T14:35:49),去掉 T 和毫秒,和列表页的写法保持一致。 */
function dateTimeText(value: unknown) {
  if (value === undefined || value === null || value === '') return '-'
  return String(value).replace('T', ' ').replace(/\.\d+$/, '')
}
function statusLabel(value: unknown) {
  return ({ pending: '待审核', approved: '已通过', rejected: '已驳回' } as Record<string, string>)[String(value || '')] || text(value)
}
@@ -101,11 +107,37 @@
<template>
  <div v-loading="loading" class="onboarding-detail-page">
    <section v-if="detail" class="detail-panel">
      <h3>进件状态</h3>
      <div class="status-value">{{ detail.progress ? (detail.progress.status === 'approved' ? '开通已完成' : '开通尚未完成') : statusLabel(detail.status) }}</div>
      <h3>审核状态</h3>
      <!-- 审核状态原来在 progress 存在时会被「开通尚未完成」顶掉,运营看不到这条进件到底审没审。
           两者是不同的事:审核是这条申请的结论,开通是通道能力推进到哪一步。 -->
      <div class="status-value">{{ statusLabel(detail.status) }}</div>
      <el-alert
        v-if="detail.rejectReason"
        class="reject-reason"
        type="error"
        :closable="false"
        :title="`驳回原因:${detail.rejectReason}`"
      />
      <h3 class="onboarding-heading">进件信息</h3>
      <div class="detail-grid">
        <div class="detail-field"><span>进件编号</span><strong>{{ text(detail.applicationNo) }}</strong></div>
        <div class="detail-field"><span>申请时间</span><strong>{{ dateTimeText(detail.createdAt) }}</strong></div>
        <div class="detail-field"><span>商户编号</span><strong>{{ text(detail.merchantId) }}</strong></div>
        <div class="detail-field"><span>所属门店</span><strong>{{ text(detail.storeName) }}</strong></div>
        <div class="detail-field"><span>通道商户号</span><strong>{{ text(detail.providerHuifuId) }}</strong></div>
        <div class="detail-field"><span>通道申请单号</span><strong>{{ text(detail.providerApplyNo || detail.providerRequestNo) }}</strong></div>
        <div class="detail-field"><span>审核时间</span><strong>{{ dateTimeText(detail.reviewedAt) }}</strong></div>
        <div class="detail-field"><span>最后更新时间</span><strong>{{ dateTimeText(detail.updatedAt) }}</strong></div>
      </div>
      <template v-if="detail.progress">
        <h3 class="onboarding-heading">开通状态</h3>
        <div class="status-value">{{ detail.progress.status === 'approved' ? '开通已完成' : '开通尚未完成' }}</div>
      </template>
      <section v-if="detail.progress" class="progress-panel">
        <p>{{ detail.progress.reason }}</p>
        <p>最后成功同步:{{ text(detail.progress.lastSyncedAt) }}</p>
        <p>最后成功同步:{{ dateTimeText(detail.progress.lastSyncedAt) }}</p>
        <el-alert v-if="detail.progress.syncError" :title="detail.progress.syncError" type="warning" :closable="false" />
        <div v-for="stage in detail.progress.stages" :key="stage.key" class="progress-stage">
          <strong>{{ stage.label }} · {{ stageLabel(stage.status) }}</strong>
@@ -165,8 +197,26 @@
        <div class="detail-field"><span>邮箱</span><strong>{{ text(detail.administratorEmail) }}</strong></div>
        <div class="detail-field"><span>个人身份证人像面</span><strong><a v-if="detail.legalIdFrontUrl" :href="detail.legalIdFrontUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>个人身份证国徽面</span><strong><a v-if="detail.legalIdBackUrl" :href="detail.legalIdBackUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
      </div>
      <!-- 结算卡和门店照片对企业主体同样必要,原来只画在个人主体那一支:企业进件即使传了
           也看不到,而微信业务开通与微信实名认证都要这张店铺照片。 -->
      <h3 class="onboarding-heading">结算银行卡</h3>
      <div v-if="detail.initialBankCardId" class="detail-grid">
        <div class="detail-field"><span>卡号</span><strong>{{ text(detail.settlementCardMasked) }}</strong></div>
        <div class="detail-field"><span>开户银行</span><strong>{{ text(detail.settlementBankName) }}</strong></div>
        <div class="detail-field"><span>开户支行</span><strong>{{ text(detail.settlementBranchName) }}</strong></div>
        <div class="detail-field"><span>开户城市</span><strong>{{ text(detail.settlementCityName) }}</strong></div>
        <div class="detail-field"><span>绑定状态</span><strong>{{ statusLabel(detail.settlementCardStatus) }}</strong></div>
        <div class="detail-field"><span>结算银行卡卡号面</span><strong><a v-if="detail.settlementCardFrontUrl" :href="detail.settlementCardFrontUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <!-- 微信业务开通与微信实名认证都要这张店铺照片,运营核对开通失败原因时要看得到。 -->
        <div v-if="detail.settlementCardRejectReason" class="detail-field detail-field--auto-height"><span>绑定失败原因</span><strong>{{ detail.settlementCardRejectReason }}</strong></div>
      </div>
      <div v-else class="detail-grid">
        <div class="detail-field"><span>结算银行卡</span><strong>未绑定</strong></div>
      </div>
      <h3 class="onboarding-heading">门店照片</h3>
      <div class="detail-grid">
        <div class="detail-field"><span>店铺照片</span><strong><a v-if="detail.storeHeaderUrl" :href="detail.storeHeaderUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>店内环境照</span><strong><a v-if="detail.storeIndoorUrl" :href="detail.storeIndoorUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
      </div>
@@ -182,6 +232,7 @@
.detail-panel { min-height: 560px; padding: 18px 20px 54px; background: #fff; border: 1px solid #edf0f5; }
.detail-panel h3 { margin: 0; color: #1d2129; font-size: 15px; }
.status-value { margin-top: 16px; color: #1d2129; font-size: 13px; font-weight: 600; }
.reject-reason { margin-top: 12px; }
.onboarding-heading { margin-top: 24px !important; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 46px; margin-top: 22px; max-width: 1032px; }
.common-grid { margin-bottom: -2px; }
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Refresh, Search } from '@element-plus/icons-vue'
import { getMerchantSettlement, getMerchantSettlements } from '../api/admin'
import { getMerchantSettlement, getMerchantSettlementMerchantTypes, getMerchantSettlements } from '../api/admin'
type Kind = 'onboardings' | 'bank-cards' | 'settlements'
@@ -31,7 +31,10 @@
  { value: 'bank-cards', label: '银行卡申请' },
]
const merchantTypes = ['餐饮服务', '住宿服务', '购房服务', '购车服务', '体检服务', '金融服务', '行政审批', '景区服务']
// 商家类型选项来自后端目录。原先这里手写了一份 8 项清单,标签是目录里根本不用的别名
// (住宿服务/购房服务/体检服务),后来又新增的类型也没跟上,才会出现下拉里有的筛不出数据、
// 数据里有的选不到。现在只从接口取,取不到就留空。
const merchantTypes = ref<string[]>([])
// 结算记录没有本地审核状态:它的「状态」是通道流水的结算状态(S/F/P),与表格那一列同一套取值。
const statusOptions = computed(() => kind.value === 'settlements'
  ? [
@@ -103,8 +106,8 @@
      // 基础参数校验失败、整页查不出流水。商家、联系电话、商家类型送后端去筛商户(顺带少查几家),
      // 状态是通道流水的结算状态,只能由后端在拿到流水后筛。
      const result = await getMerchantSettlements({
        beginDate: normalizeDate(filters.dateRange[0]),
        endDate: normalizeDate(filters.dateRange[1]),
        beginDate: normalizeDate(filters.dateRange?.[0]),
        endDate: normalizeDate(filters.dateRange?.[1]),
        size: 50,
        merchantName: filters.merchantName.trim() || undefined,
        contactMobile: filters.contactMobile.trim() || undefined,
@@ -124,8 +127,10 @@
      contactMobile: filters.contactMobile.trim() || undefined,
      merchantType: filters.merchantType || undefined,
      status: filters.status || undefined,
      startDate: filters.dateRange[0],
      endDate: filters.dateRange[1],
      // 日期选择器点「清空」时给的是 null(不是 []),这里必须用可选链,否则查询会因为
      // 读 null[0] 抛异常而静默失败,页面停在旧数据上,看起来像查询没生效。
      startDate: filters.dateRange?.[0],
      endDate: filters.dateRange?.[1],
    })
    if (request !== latestRequest) return
    rows.value = result.records || []
@@ -140,6 +145,15 @@
/** 日期选择器给的是 yyyy-MM-dd,斗拱要 yyyyMMdd。 */
function normalizeDate(value: string | undefined) {
  return value ? value.replace(/-/g, '') : undefined
}
/** 商家类型筛选项取自后端目录;取不到就留空,不用一份可能过期的本地清单兜底。 */
async function loadMerchantTypes() {
  try {
    merchantTypes.value = (await getMerchantSettlementMerchantTypes()) || []
  } catch {
    merchantTypes.value = []
  }
}
function search() {
@@ -161,17 +175,25 @@
  kind.value = value as Kind
  page.value = 1
  filters.status = ''
  // 页签要写回地址栏。不写的话,刷新、浏览器返回、把链接发给别人,落地的都是地址栏里那个
  // 旧页签——从进件详情按返回会掉回银行卡申请就是这么来的。用 replace 而不是 push,
  // 切页签不该在历史里留一串记录。
  router.replace({ query: { ...route.query, kind: kind.value } })
  load()
}
function applyRouteKind() {
/** 地址栏里的页签;识别不了时返回 null,保留当前选择。 */
function routeKind(): Kind | null {
  const value = String(route.query.kind || '')
  if (value === 'onboardings' || value === 'bank-cards' || value === 'settlements') {
    kind.value = value
  } else if (value === 'withdrawals') {
  if (value === 'onboardings' || value === 'bank-cards' || value === 'settlements') return value
    // 旧链接(提现管理)现在指向结算记录。
    kind.value = 'settlements'
  if (value === 'withdrawals') return 'settlements'
  return null
  }
function applyRouteKind() {
  const next = routeKind()
  if (next) kind.value = next
}
function openReview(row: Record<string, any>) {
@@ -181,7 +203,10 @@
}
watch(() => route.query.kind, () => {
  applyRouteKind()
  const next = routeKind()
  // changeKind 自己写回地址栏也会走到这里,那一次已经查过了,别重复请求。
  if (!next || next === kind.value) return
  kind.value = next
  page.value = 1
  filters.status = ''
  load()
@@ -190,6 +215,7 @@
onMounted(() => {
  applyRouteKind()
  load()
  loadMerchantTypes()
})
</script>
@@ -272,7 +298,8 @@
          <el-table-column prop="bankName" label="开户银行" min-width="145" show-overflow-tooltip />
          <el-table-column prop="branchName" label="开户支行" min-width="180" show-overflow-tooltip />
          <el-table-column prop="cityName" label="开户城市" min-width="120" show-overflow-tooltip />
          <el-table-column prop="cardNo" label="银行卡号" min-width="180" />
          <!-- 卡号只展示后端生成的脱敏值:明文 PAN 不再从接口下发。 -->
          <el-table-column prop="cardMasked" label="银行卡号" min-width="180" />
        </template>
        <template v-if="kind !== 'settlements'">
          <el-table-column label="申请时间" min-width="165">
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -43,6 +43,10 @@
   * 商家、联系电话、商家类型按本地商户字段先筛掉不匹配的商户(顺带少打几次通道),
   * 状态是通道的流水结算状态(S 已结算 / F 失败 / P 处理中)。
   */
  /**
   * 商家类型筛选项。挂在结算页自己的前缀下,页面下拉直接拉这份,避免前端再维护一份会漂移的清单。
   */
  @GetMapping("/merchant-settlement/merchant-types") public ApiResponse<List<String>> merchantTypes() { return ApiResponse.success(settlementService.merchantTypes()); }
  @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "50") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status) {
    java.time.LocalDate today = java.time.LocalDate.now();
    String end = (endDate == null || endDate.isBlank()) ? today.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : endDate.trim();
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -16,6 +16,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.merchant.MerchantTypeCatalog;
import com.talentcard.server.business.merchant.RegionCityCatalog;
/**
 * 后台结算管理。
@@ -43,6 +45,17 @@
    /** 微信实名认证:平台运营可在后台代为发起;未决申请不会重复提交。 */
    public Map<String,Object> submitRealname(Long id, boolean retry) { return realnameService.submit(id, retry); }
    public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, HuifuSettlementRecordGateway settlementRecordGateway) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.settlementRecordGateway = settlementRecordGateway; }
    /**
     * 商家类型筛选项。
     *
     * <p>取值来自后端目录 {@link MerchantTypeCatalog}——前端原来自己抄了一份 8 项的清单,
     * 既有抄错的标签(住宿服务/购房服务/体检服务在目录里只是别名,库里存的是酒店服务/购房优惠/
     * 免费体检),也漏掉了后来新增的类型,导致下拉里有选项筛不出数据、数据里有的类型选不到。
     */
    public List<String> merchantTypes() {
        return MerchantTypeCatalog.all().stream().map(MerchantTypeCatalog.MerchantType::displayName).toList();
    }
    /**
     * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。
@@ -156,13 +169,21 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        return page("select a.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "select count(*) from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "a", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
        PageResult<Map<String, Object>> result = page("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_name as merchant_table_name, m.merchant_type, m.contact_mobile as merchant_table_contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "select count(*) from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "a", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
        result.records().forEach(this::useMerchantTableFields);
        return result;
    }
    @Transactional(readOnly = true)
    public Map<String, Object> onboarding(Long id) {
        List<Map<String, Object>> rows = rows("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id where a.id = ?", id);
        // 详情是进件当时的存档,所以名称和联系方式都取进件表那一份(a.* 里就有):这里不选
        // m.merchant_name / m.contact_mobile,同名撞车靠列顺序决定是不可靠的,不选它就没有歧义。
        // 列表的「联系电话」是商户表的筛选条件,两者用途不同,别把列表那套映射套过来。
        // 结算卡按 initial_bank_card_id 左连进来:运营要在这页看到这条进件绑的是哪张卡,
        // 但只取脱敏字段——明文卡号和 card_no_encrypted 都不出接口。
        List<Map<String, Object>> rows = rows("select a.*, a.merchant_name as onboarding_merchant_name, m.merchant_type, s.store_name, c.bank_name as settlement_bank_name, c.branch_name as settlement_branch_name, c.city_code as settlement_city_code, c.card_masked as settlement_card_masked, c.card_tail as settlement_card_tail, c.status as settlement_card_status, c.reject_reason as settlement_card_reject_reason from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id left join merchant_bank_card c on c.id = a.initial_bank_card_id where a.id = ?", id);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Map<String, Object> result = rows.get(0);
        result.put("settlementCityName", bankCardCityName(result.remove("settlementCityCode")));
        if (progressService.supports(result)) result.put("progress", progressService.view(result));
        // 个人主体的历史记录可能使用 individual、personal 或中文类型值;
        // 只要已保存加密身份证号,后台详情均应解密后完整展示。
@@ -174,9 +195,10 @@
    public PageResult<Map<String, Object>> bankCards(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        PageResult<Map<String, Object>> result = page("select c.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "select count(*) from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "c", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
        result.records().forEach(row -> {
            String encrypted = String.valueOf(row.remove("cardNoEncrypted"));
            if (!encrypted.isBlank() && !"null".equals(encrypted)) row.put("cardNo", sensitiveDataCrypto.decrypt(encrypted));
            else row.put("cardNo", row.get("cardMasked"));
            // 卡号在库里是密文,解密出来就是完整 PAN。列表只需要展示,所以密文、卡哈希和
            // 明文卡号都不出接口,前端改用后端已生成的 cardMasked。
            row.remove("cardNoEncrypted");
            row.remove("cardHash");
            row.put("cityName", bankCardCityName(row.get("cityCode")));
        });
        return result;
@@ -212,31 +234,30 @@
    private void addLike(StringBuilder where, List<Object> args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" like ?"); args.add("%" + value.trim() + "%"); } }
    private void addEqual(StringBuilder where, List<Object> args, String column, String value) { if (value != null && !value.trim().isEmpty()) { where.append(" and ").append(column).append(" = ?"); args.add(value.trim()); } }
    private void addDate(StringBuilder where, List<Object> args, String column, String value, boolean start) { if (value != null && value.matches("\\d{4}-\\d{2}-\\d{2}")) { where.append(" and ").append(column).append(start ? " >= ?" : " < date_add(?, interval 1 day)"); args.add(value); } }
    /**
     * 把「商家名称 / 联系方式」换成商户表的值。
     *
     * <p>进件表和商户表都有 merchant_name 和 contact_mobile。查询里又带了 {@code a.*},所以即使给
     * join 出来的列另起别名,同名列依然存在两份,最终取到哪一个取决于驱动返回同名列的顺序——
     * 实测是「先出现的非空值赢」,于是进件表里申请时填的商户名称会盖掉商户表的。而这两个字段的筛选
     * 条件({@link #page})用的都是商户表,显示必须跟着筛选走;申请时填的商户名称另以
     * onboardingMerchantName 带出,不丢。
     */
    private void useMerchantTableFields(Map<String, Object> row) {
        row.put("merchantName", row.remove("merchantTableName"));
        row.put("contactMobile", row.remove("merchantTableContactMobile"));
    }
    /**
     * 开户城市代码转「省|市」。
     *
     * <p>未收录的代码原样返回:行政区划是外部数据,宁可把生僻值露出来让运营反馈,也不要猜一个。
     */
    private String bankCardCityName(Object cityCode) {
        return switch (cityCode == null ? "" : String.valueOf(cityCode).trim()) {
            case "510100" -> "四川省|成都市";
            case "510300" -> "四川省|自贡市";
            case "510400" -> "四川省|攀枝花市";
            case "510500" -> "四川省|泸州市";
            case "510600" -> "四川省|德阳市";
            case "510700" -> "四川省|绵阳市";
            case "510800" -> "四川省|广元市";
            case "510900" -> "四川省|遂宁市";
            case "511000" -> "四川省|内江市";
            case "511100" -> "四川省|乐山市";
            case "511300" -> "四川省|南充市";
            case "511400" -> "四川省|眉山市";
            case "511500" -> "四川省|宜宾市";
            case "511600" -> "四川省|广安市";
            case "511700" -> "四川省|达州市";
            case "511800" -> "四川省|雅安市";
            case "511900" -> "四川省|巴中市";
            case "512000" -> "四川省|资阳市";
            case "513200" -> "四川省|阿坝藏族羌族自治州";
            case "513300" -> "四川省|甘孜藏族自治州";
            case "513400" -> "四川省|凉山彝族自治州";
            default -> cityCode == null ? "" : String.valueOf(cityCode).trim();
        };
        String code = cityCode == null ? "" : String.valueOf(cityCode).trim();
        if (code.isEmpty()) return "";
        String name = RegionCityCatalog.provinceCity(code);
        return name.isEmpty() ? code : name;
    }
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/RegionCityCatalog.java
New file
@@ -0,0 +1,393 @@
package com.talentcard.server.business.merchant;
import java.util.Map;
/**
 * 行政区划代码(地市级)到「省|市」名称的映射。
 *
 * <p>结算银行卡只存了 6 位开户城市代码,后台要按运营看得懂的写法展示,所以在这里一次性
 * 离线展开。数据与商户端同源({@code rencaika_merchant/config/area-codes.uts},原始来源为省市
 * 编码表),运营端不再自己维护一份只覆盖个别省份的映射。
 */
public final class RegionCityCatalog {
    private static final Map<String, String> CITY_NAMES = Map.ofEntries(
            Map.entry("110100", "北京市|北京市"),
            Map.entry("120100", "天津市|天津市"),
            Map.entry("130100", "河北省|石家庄市"),
            Map.entry("130200", "河北省|唐山市"),
            Map.entry("130300", "河北省|秦皇岛市"),
            Map.entry("130400", "河北省|邯郸市"),
            Map.entry("130500", "河北省|邢台市"),
            Map.entry("130600", "河北省|保定市"),
            Map.entry("130700", "河北省|张家口市"),
            Map.entry("130800", "河北省|承德市"),
            Map.entry("130900", "河北省|沧州市"),
            Map.entry("131000", "河北省|廊坊市"),
            Map.entry("131100", "河北省|衡水市"),
            Map.entry("140100", "山西省|太原市"),
            Map.entry("140200", "山西省|大同市"),
            Map.entry("140300", "山西省|阳泉市"),
            Map.entry("140400", "山西省|长治市"),
            Map.entry("140500", "山西省|晋城市"),
            Map.entry("140600", "山西省|朔州市"),
            Map.entry("140700", "山西省|晋中市"),
            Map.entry("140800", "山西省|运城市"),
            Map.entry("140900", "山西省|忻州市"),
            Map.entry("141000", "山西省|临汾市"),
            Map.entry("141100", "山西省|吕梁市"),
            Map.entry("150100", "内蒙古自治区|呼和浩特市"),
            Map.entry("150200", "内蒙古自治区|包头市"),
            Map.entry("150300", "内蒙古自治区|乌海市"),
            Map.entry("150400", "内蒙古自治区|赤峰市"),
            Map.entry("150500", "内蒙古自治区|通辽市"),
            Map.entry("150600", "内蒙古自治区|鄂尔多斯市"),
            Map.entry("150700", "内蒙古自治区|呼伦贝尔市"),
            Map.entry("150800", "内蒙古自治区|巴彦淖尔市"),
            Map.entry("150900", "内蒙古自治区|乌兰察布市"),
            Map.entry("152200", "内蒙古自治区|兴安盟"),
            Map.entry("152500", "内蒙古自治区|锡林郭勒盟"),
            Map.entry("152900", "内蒙古自治区|阿拉善盟"),
            Map.entry("210100", "辽宁省|沈阳市"),
            Map.entry("210200", "辽宁省|大连市"),
            Map.entry("210300", "辽宁省|鞍山市"),
            Map.entry("210400", "辽宁省|抚顺市"),
            Map.entry("210500", "辽宁省|本溪市"),
            Map.entry("210600", "辽宁省|丹东市"),
            Map.entry("210700", "辽宁省|锦州市"),
            Map.entry("210800", "辽宁省|营口市"),
            Map.entry("210900", "辽宁省|阜新市"),
            Map.entry("211000", "辽宁省|辽阳市"),
            Map.entry("211100", "辽宁省|盘锦市"),
            Map.entry("211200", "辽宁省|铁岭市"),
            Map.entry("211300", "辽宁省|朝阳市"),
            Map.entry("211400", "辽宁省|葫芦岛市"),
            Map.entry("220100", "吉林省|长春市"),
            Map.entry("220200", "吉林省|吉林市"),
            Map.entry("220300", "吉林省|四平市"),
            Map.entry("220400", "吉林省|辽源市"),
            Map.entry("220500", "吉林省|通化市"),
            Map.entry("220600", "吉林省|白山市"),
            Map.entry("220700", "吉林省|松原市"),
            Map.entry("220800", "吉林省|白城市"),
            Map.entry("222400", "吉林省|延边朝鲜族自治州"),
            Map.entry("230100", "黑龙江省|哈尔滨市"),
            Map.entry("230200", "黑龙江省|齐齐哈尔市"),
            Map.entry("230300", "黑龙江省|鸡西市"),
            Map.entry("230400", "黑龙江省|鹤岗市"),
            Map.entry("230500", "黑龙江省|双鸭山市"),
            Map.entry("230600", "黑龙江省|大庆市"),
            Map.entry("230700", "黑龙江省|伊春市"),
            Map.entry("230800", "黑龙江省|佳木斯市"),
            Map.entry("230900", "黑龙江省|七台河市"),
            Map.entry("231000", "黑龙江省|牡丹江市"),
            Map.entry("231100", "黑龙江省|黑河市"),
            Map.entry("231200", "黑龙江省|绥化市"),
            Map.entry("232700", "黑龙江省|大兴安岭地区"),
            Map.entry("310100", "上海市|上海市"),
            Map.entry("320100", "江苏省|南京市"),
            Map.entry("320200", "江苏省|无锡市"),
            Map.entry("320300", "江苏省|徐州市"),
            Map.entry("320400", "江苏省|常州市"),
            Map.entry("320500", "江苏省|苏州市"),
            Map.entry("320600", "江苏省|南通市"),
            Map.entry("320700", "江苏省|连云港市"),
            Map.entry("320800", "江苏省|淮安市"),
            Map.entry("320900", "江苏省|盐城市"),
            Map.entry("321000", "江苏省|扬州市"),
            Map.entry("321100", "江苏省|镇江市"),
            Map.entry("321200", "江苏省|泰州市"),
            Map.entry("321300", "江苏省|宿迁市"),
            Map.entry("330100", "浙江省|杭州市"),
            Map.entry("330200", "浙江省|宁波市"),
            Map.entry("330300", "浙江省|温州市"),
            Map.entry("330400", "浙江省|嘉兴市"),
            Map.entry("330500", "浙江省|湖州市"),
            Map.entry("330600", "浙江省|绍兴市"),
            Map.entry("330700", "浙江省|金华市"),
            Map.entry("330800", "浙江省|衢州市"),
            Map.entry("330900", "浙江省|舟山市"),
            Map.entry("331000", "浙江省|台州市"),
            Map.entry("331100", "浙江省|丽水市"),
            Map.entry("340100", "安徽省|合肥市"),
            Map.entry("340200", "安徽省|芜湖市"),
            Map.entry("340300", "安徽省|蚌埠市"),
            Map.entry("340400", "安徽省|淮南市"),
            Map.entry("340500", "安徽省|马鞍山市"),
            Map.entry("340600", "安徽省|淮北市"),
            Map.entry("340700", "安徽省|铜陵市"),
            Map.entry("340800", "安徽省|安庆市"),
            Map.entry("341000", "安徽省|黄山市"),
            Map.entry("341100", "安徽省|滁州市"),
            Map.entry("341200", "安徽省|阜阳市"),
            Map.entry("341300", "安徽省|宿州市"),
            Map.entry("341500", "安徽省|六安市"),
            Map.entry("341600", "安徽省|亳州市"),
            Map.entry("341700", "安徽省|池州市"),
            Map.entry("341800", "安徽省|宣城市"),
            Map.entry("350100", "福建省|福州市"),
            Map.entry("350200", "福建省|厦门市"),
            Map.entry("350300", "福建省|莆田市"),
            Map.entry("350400", "福建省|三明市"),
            Map.entry("350500", "福建省|泉州市"),
            Map.entry("350600", "福建省|漳州市"),
            Map.entry("350700", "福建省|南平市"),
            Map.entry("350800", "福建省|龙岩市"),
            Map.entry("350900", "福建省|宁德市"),
            Map.entry("360100", "江西省|南昌市"),
            Map.entry("360200", "江西省|景德镇市"),
            Map.entry("360300", "江西省|萍乡市"),
            Map.entry("360400", "江西省|九江市"),
            Map.entry("360500", "江西省|新余市"),
            Map.entry("360600", "江西省|鹰潭市"),
            Map.entry("360700", "江西省|赣州市"),
            Map.entry("360800", "江西省|吉安市"),
            Map.entry("360900", "江西省|宜春市"),
            Map.entry("361000", "江西省|抚州市"),
            Map.entry("361100", "江西省|上饶市"),
            Map.entry("370100", "山东省|济南市"),
            Map.entry("370200", "山东省|青岛市"),
            Map.entry("370300", "山东省|淄博市"),
            Map.entry("370400", "山东省|枣庄市"),
            Map.entry("370500", "山东省|东营市"),
            Map.entry("370600", "山东省|烟台市"),
            Map.entry("370700", "山东省|潍坊市"),
            Map.entry("370800", "山东省|济宁市"),
            Map.entry("370900", "山东省|泰安市"),
            Map.entry("371000", "山东省|威海市"),
            Map.entry("371100", "山东省|日照市"),
            Map.entry("371300", "山东省|临沂市"),
            Map.entry("371400", "山东省|德州市"),
            Map.entry("371500", "山东省|聊城市"),
            Map.entry("371600", "山东省|滨州市"),
            Map.entry("371700", "山东省|菏泽市"),
            Map.entry("410100", "河南省|郑州市"),
            Map.entry("410200", "河南省|开封市"),
            Map.entry("410300", "河南省|洛阳市"),
            Map.entry("410400", "河南省|平顶山市"),
            Map.entry("410500", "河南省|安阳市"),
            Map.entry("410600", "河南省|鹤壁市"),
            Map.entry("410700", "河南省|新乡市"),
            Map.entry("410800", "河南省|焦作市"),
            Map.entry("410900", "河南省|濮阳市"),
            Map.entry("411000", "河南省|许昌市"),
            Map.entry("411100", "河南省|漯河市"),
            Map.entry("411200", "河南省|三门峡市"),
            Map.entry("411300", "河南省|南阳市"),
            Map.entry("411400", "河南省|商丘市"),
            Map.entry("411500", "河南省|信阳市"),
            Map.entry("411600", "河南省|周口市"),
            Map.entry("411700", "河南省|驻马店市"),
            Map.entry("419001", "河南省|济源市"),
            Map.entry("420100", "湖北省|武汉市"),
            Map.entry("420200", "湖北省|黄石市"),
            Map.entry("420300", "湖北省|十堰市"),
            Map.entry("420500", "湖北省|宜昌市"),
            Map.entry("420600", "湖北省|襄阳市"),
            Map.entry("420700", "湖北省|鄂州市"),
            Map.entry("420800", "湖北省|荆门市"),
            Map.entry("420900", "湖北省|孝感市"),
            Map.entry("421000", "湖北省|荆州市"),
            Map.entry("421100", "湖北省|黄冈市"),
            Map.entry("421200", "湖北省|咸宁市"),
            Map.entry("421300", "湖北省|随州市"),
            Map.entry("422800", "湖北省|恩施土家族苗族自治州"),
            Map.entry("429004", "湖北省|仙桃市"),
            Map.entry("429005", "湖北省|潜江市"),
            Map.entry("429006", "湖北省|天门市"),
            Map.entry("429021", "湖北省|神农架林区"),
            Map.entry("430100", "湖南省|长沙市"),
            Map.entry("430200", "湖南省|株洲市"),
            Map.entry("430300", "湖南省|湘潭市"),
            Map.entry("430400", "湖南省|衡阳市"),
            Map.entry("430500", "湖南省|邵阳市"),
            Map.entry("430600", "湖南省|岳阳市"),
            Map.entry("430700", "湖南省|常德市"),
            Map.entry("430800", "湖南省|张家界市"),
            Map.entry("430900", "湖南省|益阳市"),
            Map.entry("431000", "湖南省|郴州市"),
            Map.entry("431100", "湖南省|永州市"),
            Map.entry("431200", "湖南省|怀化市"),
            Map.entry("431300", "湖南省|娄底市"),
            Map.entry("433100", "湖南省|湘西土家族苗族自治州"),
            Map.entry("440100", "广东省|广州市"),
            Map.entry("440200", "广东省|韶关市"),
            Map.entry("440300", "广东省|深圳市"),
            Map.entry("440400", "广东省|珠海市"),
            Map.entry("440500", "广东省|汕头市"),
            Map.entry("440600", "广东省|佛山市"),
            Map.entry("440700", "广东省|江门市"),
            Map.entry("440800", "广东省|湛江市"),
            Map.entry("440900", "广东省|茂名市"),
            Map.entry("441200", "广东省|肇庆市"),
            Map.entry("441300", "广东省|惠州市"),
            Map.entry("441400", "广东省|梅州市"),
            Map.entry("441500", "广东省|汕尾市"),
            Map.entry("441600", "广东省|河源市"),
            Map.entry("441700", "广东省|阳江市"),
            Map.entry("441800", "广东省|清远市"),
            Map.entry("441900", "广东省|东莞市"),
            Map.entry("442000", "广东省|中山市"),
            Map.entry("445100", "广东省|潮州市"),
            Map.entry("445200", "广东省|揭阳市"),
            Map.entry("445300", "广东省|云浮市"),
            Map.entry("450100", "广西壮族自治区|南宁市"),
            Map.entry("450200", "广西壮族自治区|柳州市"),
            Map.entry("450300", "广西壮族自治区|桂林市"),
            Map.entry("450400", "广西壮族自治区|梧州市"),
            Map.entry("450500", "广西壮族自治区|北海市"),
            Map.entry("450600", "广西壮族自治区|防城港市"),
            Map.entry("450700", "广西壮族自治区|钦州市"),
            Map.entry("450800", "广西壮族自治区|贵港市"),
            Map.entry("450900", "广西壮族自治区|玉林市"),
            Map.entry("451000", "广西壮族自治区|百色市"),
            Map.entry("451100", "广西壮族自治区|贺州市"),
            Map.entry("451200", "广西壮族自治区|河池市"),
            Map.entry("451300", "广西壮族自治区|来宾市"),
            Map.entry("451400", "广西壮族自治区|崇左市"),
            Map.entry("460100", "海南省|海口市"),
            Map.entry("460200", "海南省|三亚市"),
            Map.entry("460300", "海南省|三沙市"),
            Map.entry("460400", "海南省|儋州市"),
            Map.entry("469001", "海南省|五指山市"),
            Map.entry("469002", "海南省|琼海市"),
            Map.entry("469005", "海南省|文昌市"),
            Map.entry("469006", "海南省|万宁市"),
            Map.entry("469007", "海南省|东方市"),
            Map.entry("469021", "海南省|定安县"),
            Map.entry("469022", "海南省|屯昌县"),
            Map.entry("469023", "海南省|澄迈县"),
            Map.entry("469024", "海南省|临高县"),
            Map.entry("469025", "海南省|白沙黎族自治县"),
            Map.entry("469026", "海南省|昌江黎族自治县"),
            Map.entry("469027", "海南省|乐东黎族自治县"),
            Map.entry("469028", "海南省|陵水黎族自治县"),
            Map.entry("469029", "海南省|保亭黎族苗族自治县"),
            Map.entry("469030", "海南省|琼中黎族苗族自治县"),
            Map.entry("500100", "重庆市|重庆市辖区"),
            Map.entry("500200", "重庆市|重庆市"),
            Map.entry("510100", "四川省|成都市"),
            Map.entry("510300", "四川省|自贡市"),
            Map.entry("510400", "四川省|攀枝花市"),
            Map.entry("510500", "四川省|泸州市"),
            Map.entry("510600", "四川省|德阳市"),
            Map.entry("510700", "四川省|绵阳市"),
            Map.entry("510800", "四川省|广元市"),
            Map.entry("510900", "四川省|遂宁市"),
            Map.entry("511000", "四川省|内江市"),
            Map.entry("511100", "四川省|乐山市"),
            Map.entry("511300", "四川省|南充市"),
            Map.entry("511400", "四川省|眉山市"),
            Map.entry("511500", "四川省|宜宾市"),
            Map.entry("511600", "四川省|广安市"),
            Map.entry("511700", "四川省|达州市"),
            Map.entry("511800", "四川省|雅安市"),
            Map.entry("511900", "四川省|巴中市"),
            Map.entry("512000", "四川省|资阳市"),
            Map.entry("513200", "四川省|阿坝藏族羌族自治州"),
            Map.entry("513300", "四川省|甘孜藏族自治州"),
            Map.entry("513400", "四川省|凉山彝族自治州"),
            Map.entry("520100", "贵州省|贵阳市"),
            Map.entry("520200", "贵州省|六盘水市"),
            Map.entry("520300", "贵州省|遵义市"),
            Map.entry("520400", "贵州省|安顺市"),
            Map.entry("520500", "贵州省|毕节市"),
            Map.entry("520600", "贵州省|铜仁市"),
            Map.entry("522300", "贵州省|黔西南布依族苗族自治州"),
            Map.entry("522600", "贵州省|黔东南苗族侗族自治州"),
            Map.entry("522700", "贵州省|黔南布依族苗族自治州"),
            Map.entry("530100", "云南省|昆明市"),
            Map.entry("530300", "云南省|曲靖市"),
            Map.entry("530400", "云南省|玉溪市"),
            Map.entry("530500", "云南省|保山市"),
            Map.entry("530600", "云南省|昭通市"),
            Map.entry("530700", "云南省|丽江市"),
            Map.entry("530800", "云南省|普洱市"),
            Map.entry("530900", "云南省|临沧市"),
            Map.entry("532300", "云南省|楚雄彝族自治州"),
            Map.entry("532500", "云南省|红河哈尼族彝族自治州"),
            Map.entry("532600", "云南省|文山壮族苗族自治州"),
            Map.entry("532800", "云南省|西双版纳傣族自治州"),
            Map.entry("532900", "云南省|大理白族自治州"),
            Map.entry("533100", "云南省|德宏傣族景颇族自治州"),
            Map.entry("533300", "云南省|怒江傈僳族自治州"),
            Map.entry("533400", "云南省|迪庆藏族自治州"),
            Map.entry("540100", "西藏自治区|拉萨市"),
            Map.entry("540200", "西藏自治区|日喀则市"),
            Map.entry("540300", "西藏自治区|昌都市"),
            Map.entry("540400", "西藏自治区|林芝市"),
            Map.entry("540500", "西藏自治区|山南市"),
            Map.entry("540600", "西藏自治区|那曲市"),
            Map.entry("542500", "西藏自治区|阿里地区"),
            Map.entry("610100", "陕西省|西安市"),
            Map.entry("610200", "陕西省|铜川市"),
            Map.entry("610300", "陕西省|宝鸡市"),
            Map.entry("610400", "陕西省|咸阳市"),
            Map.entry("610500", "陕西省|渭南市"),
            Map.entry("610600", "陕西省|延安市"),
            Map.entry("610700", "陕西省|汉中市"),
            Map.entry("610800", "陕西省|榆林市"),
            Map.entry("610900", "陕西省|安康市"),
            Map.entry("611000", "陕西省|商洛市"),
            Map.entry("620100", "甘肃省|兰州市"),
            Map.entry("620200", "甘肃省|嘉峪关市"),
            Map.entry("620300", "甘肃省|金昌市"),
            Map.entry("620400", "甘肃省|白银市"),
            Map.entry("620500", "甘肃省|天水市"),
            Map.entry("620600", "甘肃省|武威市"),
            Map.entry("620700", "甘肃省|张掖市"),
            Map.entry("620800", "甘肃省|平凉市"),
            Map.entry("620900", "甘肃省|酒泉市"),
            Map.entry("621000", "甘肃省|庆阳市"),
            Map.entry("621100", "甘肃省|定西市"),
            Map.entry("621200", "甘肃省|陇南市"),
            Map.entry("622900", "甘肃省|临夏回族自治州"),
            Map.entry("623000", "甘肃省|甘南藏族自治州"),
            Map.entry("630100", "青海省|西宁市"),
            Map.entry("630200", "青海省|海东市"),
            Map.entry("632200", "青海省|海北藏族自治州"),
            Map.entry("632300", "青海省|黄南藏族自治州"),
            Map.entry("632500", "青海省|海南藏族自治州"),
            Map.entry("632600", "青海省|果洛藏族自治州"),
            Map.entry("632700", "青海省|玉树藏族自治州"),
            Map.entry("632800", "青海省|海西蒙古族藏族自治州"),
            Map.entry("640100", "宁夏回族自治区|银川市"),
            Map.entry("640200", "宁夏回族自治区|石嘴山市"),
            Map.entry("640300", "宁夏回族自治区|吴忠市"),
            Map.entry("640400", "宁夏回族自治区|固原市"),
            Map.entry("640500", "宁夏回族自治区|中卫市"),
            Map.entry("650100", "新疆维吾尔自治区|乌鲁木齐市"),
            Map.entry("650200", "新疆维吾尔自治区|克拉玛依市"),
            Map.entry("650400", "新疆维吾尔自治区|吐鲁番市"),
            Map.entry("650500", "新疆维吾尔自治区|哈密市"),
            Map.entry("652300", "新疆维吾尔自治区|昌吉回族自治州"),
            Map.entry("652700", "新疆维吾尔自治区|博尔塔拉蒙古自治州"),
            Map.entry("652800", "新疆维吾尔自治区|巴音郭楞蒙古自治州"),
            Map.entry("652900", "新疆维吾尔自治区|阿克苏地区"),
            Map.entry("653000", "新疆维吾尔自治区|克孜勒苏柯尔克孜自治州"),
            Map.entry("653100", "新疆维吾尔自治区|喀什地区"),
            Map.entry("653200", "新疆维吾尔自治区|和田地区"),
            Map.entry("654000", "新疆维吾尔自治区|伊犁哈萨克自治州"),
            Map.entry("654200", "新疆维吾尔自治区|塔城地区"),
            Map.entry("654300", "新疆维吾尔自治区|阿勒泰地区"),
            Map.entry("659001", "新疆维吾尔自治区|石河子市"),
            Map.entry("659002", "新疆维吾尔自治区|阿拉尔市"),
            Map.entry("659003", "新疆维吾尔自治区|图木舒克市"),
            Map.entry("659004", "新疆维吾尔自治区|五家渠市"),
            Map.entry("659005", "新疆维吾尔自治区|北屯市"),
            Map.entry("659006", "新疆维吾尔自治区|铁门关市"),
            Map.entry("659007", "新疆维吾尔自治区|双河市"),
            Map.entry("659008", "新疆维吾尔自治区|可克达拉市"),
            Map.entry("659009", "新疆维吾尔自治区|昆玉市"),
            Map.entry("659010", "新疆维吾尔自治区|胡杨河市"),
            Map.entry("659011", "新疆维吾尔自治区|新星市")
    );
    private RegionCityCatalog() {
    }
    /** 返回「省|市」写法;未收录该代码时返回空串。 */
    public static String provinceCity(String cityCode) {
        return cityCode == null ? "" : CITY_NAMES.getOrDefault(cityCode.trim(), "");
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminListTests.java
New file
@@ -0,0 +1,200 @@
package com.talentcard.server.business.admin;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import java.util.UUID;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.common.SensitiveDataCrypto;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
/**
 * 「商户进件」与「银行卡申请」两个列表、以及进件详情里几个「显示不对 / 显示不全」的回归。
 *
 * <p>这几处原来都靠同名列撞车后的偶然结果、或一份写死的映射兜着:改一行 SQL、换一个 switch
 * 分支就会悄悄退回去,而页面上只是少显示一个值,不会报错。所以断言都盯「运营最终看到的那一列
 * 是什么值」,而不是盯 SQL 或实现长什么样。
 */
@SpringBootTest
@ActiveProfiles("test")
class MerchantSettlementAdminListTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantSettlementAdminService settlementAdminService;
    @Autowired
    private SensitiveDataCrypto crypto;
    /**
     * 列表上的「商家名称 / 商家类型 / 联系方式」必须和筛选框用的是同一份值。
     *
     * <p>进件表和商户表都有 merchant_name、merchant_type、contact_mobile,而查询里带了 a.*,
     * 同名列会有两份。原来列表取到的是进件表那份(申请时填的,个人主体通常为空),筛选却走商户表,
     * 于是出现「输入商家名能筛出记录、商家名称列却整列空白」。申请时填的名称不该被丢掉,
     * 但要用另一个键名带出来。
     */
    @Test
    void theOnboardingListShowsTheMerchantTableValuesThatTheFiltersMatchOn() {
        String suffix = uniqueSuffix();
        String merchantName = "进件列表商户 " + suffix;
        String enteredName = "申请人填的商户名称 " + suffix;
        Long merchantId = insertMerchant(merchantName, "餐饮服务");
        Long storeId = insertStore(merchantId, "进件列表门店 " + suffix);
        insertOnboarding(merchantId, storeId, "MO-LIST-" + suffix, enteredName, null);
        PageResult<Map<String, Object>> page = settlementAdminService.onboardings(1, 20, merchantName, null, null, null, null, null);
        assertThat(page.records()).singleElement().satisfies(row -> assertThat(row)
                .containsEntry("merchantName", merchantName)
                .containsEntry("merchantType", "餐饮服务")
                .containsEntry("contactMobile", "13800000000")
                .containsEntry("onboardingMerchantName", enteredName));
    }
    /**
     * 银行卡列表只出脱敏卡号;明文 PAN、密文和卡哈希都不该出现在响应里。
     *
     * <p>库里的 card_no_encrypted 是可解密的,解密出来就是完整卡号——列表接口没有理由把它发出去,
     * 前端要展示的本来就是 card_masked。
     */
    @Test
    void theBankCardListKeepsTheNumberMaskedAndDropsTheEncryptedValue() {
        String suffix = uniqueSuffix();
        String merchantName = "银行卡列表商户 " + suffix;
        String fullCardNumber = "6217857000072971573";
        Long merchantId = insertMerchant(merchantName, "餐饮服务");
        Long storeId = insertStore(merchantId, "银行卡列表门店 " + suffix);
        insertBankCard(merchantId, storeId, fullCardNumber, "510100");
        PageResult<Map<String, Object>> page = settlementAdminService.bankCards(1, 20, merchantName, null, null, null, null, null);
        assertThat(page.records()).singleElement().satisfies(row -> {
            assertThat(row).containsEntry("cardMasked", masked(fullCardNumber));
            assertThat(row).doesNotContainKeys("cardNo", "cardNoEncrypted", "cardHash");
            assertThat(row.values()).noneMatch(value -> String.valueOf(value).contains(fullCardNumber));
        });
    }
    /**
     * 开户城市要按全国行政区划解析,不是只认四川那一张表。
     *
     * <p>原来的实现只列了四川省的市州,别的省一律把 6 位代码原样显示,运营看到的是「441500」。
     */
    @Test
    void theBankCardCityNameIsResolvedOutsideSichuan() {
        String suffix = uniqueSuffix();
        String merchantName = "开户城市商户 " + suffix;
        Long merchantId = insertMerchant(merchantName, "餐饮服务");
        Long storeId = insertStore(merchantId, "开户城市门店 " + suffix);
        insertBankCard(merchantId, storeId, "6217857000072971573", "441500");
        PageResult<Map<String, Object>> page = settlementAdminService.bankCards(1, 20, merchantName, null, null, null, null, null);
        assertThat(page.records()).singleElement().satisfies(row -> assertThat(row).containsEntry("cityName", "广东省|汕尾市"));
    }
    /**
     * 进件详情要能看到这条进件绑的结算卡(原来只有一个永远为空的卡号面图片位),
     * 同时同样不能带出明文卡号。
     *
     * <p>详情是进件当时的存档:名称和联系方式取的是申请时填的那一份,不是商户表的当前值
     * (列表的「商家名称 / 联系电话」是筛选条件,才必须跟商户表走)。
     */
    @Test
    void theOnboardingDetailCarriesTheBoundCardWithoutTheFullNumber() {
        String suffix = uniqueSuffix();
        String merchantName = "进件详情商户 " + suffix;
        String enteredName = "申请人填的商户名称 " + suffix;
        String fullCardNumber = "6217857000072971573";
        Long merchantId = insertMerchant(merchantName, "餐饮服务");
        Long storeId = insertStore(merchantId, "进件详情门店 " + suffix);
        Long cardId = insertBankCard(merchantId, storeId, fullCardNumber, "441500");
        Long onboardingId = insertOnboarding(merchantId, storeId, "MO-DETAIL-" + suffix, enteredName, cardId);
        Map<String, Object> detail = settlementAdminService.onboarding(onboardingId);
        assertThat(detail)
                .containsEntry("settlementBankName", "中国银行")
                .containsEntry("settlementBranchName", "中国银行股份有限公司汕尾海丰支行")
                .containsEntry("settlementCityName", "广东省|汕尾市")
                .containsEntry("settlementCardMasked", masked(fullCardNumber))
                .containsEntry("settlementCardStatus", "approved")
                .containsEntry("merchantName", enteredName)
                .containsEntry("onboardingMerchantName", enteredName)
                .containsEntry("contactMobile", "13900000000")
                .containsEntry("merchantType", "餐饮服务");
        assertThat(String.valueOf(detail.get("initialBankCardId"))).isEqualTo(String.valueOf(cardId));
        assertThat(detail).doesNotContainKeys("cardNo", "cardNoEncrypted", "cardHash");
        assertThat(detail.values()).noneMatch(value -> String.valueOf(value).contains(fullCardNumber));
    }
    /**
     * 商家类型筛选项直接来自后端目录。
     *
     * <p>前端原来自己抄了一份 8 项清单:标签写的是目录里只当别名的「住宿服务 / 购房服务 / 体检服务」
     * (库里存的是「酒店服务 / 购房优惠 / 免费体检」,所以这三个选项一个都筛不出来),
     * 后来又新增的「文化服务 / 数码服务 / 验证商户」则根本没得选。
     */
    @Test
    void theMerchantTypeOptionsComeFromTheBackendCatalog() {
        assertThat(settlementAdminService.merchantTypes())
                .contains("餐饮服务", "景区服务", "酒店服务", "购房优惠", "免费体检", "文化服务", "数码服务")
                .doesNotContain("住宿服务", "购房服务", "体检服务");
    }
    private String uniqueSuffix() {
        return String.valueOf(System.nanoTime());
    }
    private Long insertMerchant(String merchantName, String merchantType) {
        jdbc.update("insert into merchant(merchant_name, merchant_type, contact_mobile, status, deleted) values (?, ?, '13800000000', 1, 0)",
                merchantName, merchantType);
        return jdbc.queryForObject("select max(id) from merchant", Long.class);
    }
    private Long insertStore(Long merchantId, String storeName) {
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, storeName);
        return jdbc.queryForObject("select max(id) from merchant_store", Long.class);
    }
    /**
     * 进件表这一侧刻意填上与商户表不同的名称和手机号,用来验证列上显示的是商户表那份。
     * (进件表没有 merchant_type 列,类型只有商户表一个来源。)
     */
    private Long insertOnboarding(Long merchantId, Long storeId, String applicationNo, String onboardingMerchantName, Long bankCardId) {
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status,
                                                            merchant_name, initial_bank_card_id)
                values (?, ?, ?, 'individual', 'List owner', 'List owner', '13900000000', ?, 'pending', ?, ?)
                """, merchantId, storeId, applicationNo, "settlement-list-" + applicationNo, onboardingMerchantName, bankCardId);
        return jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
    }
    /** 卡号按生产路径落库:明文只以密文形式保存,另存一份脱敏串和卡哈希。 */
    private Long insertBankCard(Long merchantId, Long storeId, String fullCardNumber, String cityCode) {
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                               card_hash, card_no_encrypted, status, idempotency_key)
                values (?, ?, '中国银行', '中国银行股份有限公司汕尾海丰支行', ?, ?, ?, ?, ?, 'approved', ?)
                """, merchantId, storeId, cityCode, tail(fullCardNumber), masked(fullCardNumber),
                "0".repeat(64), crypto.encrypt(fullCardNumber), UUID.randomUUID().toString());
        return jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
    }
    private static String tail(String fullCardNumber) {
        return fullCardNumber.substring(fullCardNumber.length() - 4);
    }
    private static String masked(String fullCardNumber) {
        return "**** **** **** " + tail(fullCardNumber);
    }
}