From f5ede132185909c530a4a3f84b8a27d3935cba8a Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期五, 18 九月 2026 15:52:54 +0800
Subject: [PATCH] fix(settlement): 修复结算记录页商户进件与银行卡申请的 12 处缺陷

---
 talent-card-admin/src/views/MerchantOnboardingDetailView.vue                                                |   59 +++
 talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java   |   79 +++--
 talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java        |    4 
 talent-card-admin/src/api/admin.ts                                                                          |    2 
 talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminListTests.java |  200 ++++++++++++++
 talent-card-server/src/main/java/com/talentcard/server/business/merchant/RegionCityCatalog.java             |  393 ++++++++++++++++++++++++++++
 talent-card-admin/src/api/http.ts                                                                           |   14 
 talent-card-admin/src/layouts/AdminLayout.vue                                                               |    5 
 talent-card-admin/src/views/MerchantSettlementView.vue                                                      |   57 +++-
 9 files changed, 762 insertions(+), 51 deletions(-)

diff --git a/talent-card-admin/src/api/admin.ts b/talent-card-admin/src/api/admin.ts
index 8cf9759..2322361 100644
--- a/talent-card-admin/src/api/admin.ts
+++ b/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 })
diff --git a/talent-card-admin/src/api/http.ts b/talent-card-admin/src/api/http.ts
index 37ebd82..57d1dd7 100644
--- a/talent-card-admin/src/api/http.ts
+++ b/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)}`)
       }
     }
-    const message = error.response?.data?.message || error.message || '请求失败'
-    ElMessage.error(message)
+    // 可选请求只降级、不弹错:筛选条件少一项不该盖住页面,更不该看起来像主流程挂了。
+    if (!error.config?.silent) {
+      const message = error.response?.data?.message || error.message || '请求失败'
+      ElMessage.error(message)
+    }
     return Promise.reject(error)
   },
 )
diff --git a/talent-card-admin/src/layouts/AdminLayout.vue b/talent-card-admin/src/layouts/AdminLayout.vue
index a4b8aad..1e5c9b5 100644
--- a/talent-card-admin/src/layouts/AdminLayout.vue
+++ b/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'
diff --git a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue b/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
index 098f0d9..ae6fea1 100644
--- a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
+++ b/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; }
diff --git a/talent-card-admin/src/views/MerchantSettlementView.vue b/talent-card-admin/src/views/MerchantSettlementView.vue
index dfb98d3..4ba1a12 100644
--- a/talent-card-admin/src/views/MerchantSettlementView.vue
+++ b/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') {
-    // 旧链接(提现管理)现在指向结算记录。
-    kind.value = 'settlements'
-  }
+  if (value === 'onboardings' || value === 'bank-cards' || value === 'settlements') return value
+  // 旧链接(提现管理)现在指向结算记录。
+  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">
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
index d69efa4..6ae2a4f 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
+++ b/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();
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java b/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
index ba25fc8..c88c810 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
+++ b/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)); }
 }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/RegionCityCatalog.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/RegionCityCatalog.java
new file mode 100644
index 0000000..926f1e0
--- /dev/null
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/RegionCityCatalog.java
@@ -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(), "");
+    }
+}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminListTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminListTests.java
new file mode 100644
index 0000000..a265f68
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/admin/MerchantSettlementAdminListTests.java
@@ -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);
+    }
+}

--
Gitblit v1.7.1