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 通过。
| | |
| | | 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 }) |
| | |
| | | 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' |
| | | |
| | |
| | | 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) |
| | | }, |
| | | ) |
| | |
| | | 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' |
| | |
| | | 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) |
| | | } |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | .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; } |
| | |
| | | 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' |
| | | |
| | |
| | | { value: 'bank-cards', label: '银行卡申请' }, |
| | | ] |
| | | |
| | | const merchantTypes = ['餐饮服务', '住宿服务', '购房服务', '购车服务', '体检服务', '金融服务', '行政审批', '景区服务'] |
| | | // 商家类型选项来自后端目录。原先这里手写了一份 8 项清单,标签是目录里根本不用的别名 |
| | | // (住宿服务/购房服务/体检服务),后来又新增的类型也没跟上,才会出现下拉里有的筛不出数据、 |
| | | // 数据里有的选不到。现在只从接口取,取不到就留空。 |
| | | const merchantTypes = ref<string[]>([]) |
| | | // 结算记录没有本地审核状态:它的「状态」是通道流水的结算状态(S/F/P),与表格那一列同一套取值。 |
| | | const statusOptions = computed(() => kind.value === 'settlements' |
| | | ? [ |
| | |
| | | // 基础参数校验失败、整页查不出流水。商家、联系电话、商家类型送后端去筛商户(顺带少查几家), |
| | | // 状态是通道流水的结算状态,只能由后端在拿到流水后筛。 |
| | | 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, |
| | |
| | | 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 || [] |
| | |
| | | /** 日期选择器给的是 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() { |
| | |
| | | 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>) { |
| | |
| | | } |
| | | |
| | | watch(() => route.query.kind, () => { |
| | | applyRouteKind() |
| | | const next = routeKind() |
| | | // changeKind 自己写回地址栏也会走到这里,那一次已经查过了,别重复请求。 |
| | | if (!next || next === kind.value) return |
| | | kind.value = next |
| | | page.value = 1 |
| | | filters.status = '' |
| | | load() |
| | |
| | | onMounted(() => { |
| | | applyRouteKind() |
| | | load() |
| | | loadMerchantTypes() |
| | | }) |
| | | </script> |
| | | |
| | |
| | | <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"> |
| | |
| | | * 商家、联系电话、商家类型按本地商户字段先筛掉不匹配的商户(顺带少打几次通道), |
| | | * 状态是通道的流水结算状态(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(); |
| | |
| | | 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; |
| | | |
| | | /** |
| | | * 后台结算管理。 |
| | |
| | | /** 微信实名认证:平台运营可在后台代为发起;未决申请不会重复提交。 */ |
| | | 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(); |
| | | } |
| | | |
| | | /** |
| | | * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。 |
| | |
| | | |
| | | @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 或中文类型值; |
| | | // 只要已保存加密身份证号,后台详情均应解密后完整展示。 |
| | |
| | | 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; |
| | |
| | | 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)); } |
| | | } |
| New file |
| | |
| | | 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(), ""); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |