package com.talentcard.server.business.admin; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.alibaba.fastjson.JSON; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.PageResult; import com.talentcard.server.business.payment.huifu.HuifuSettlementRecordGateway; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.common.SensitiveDataCrypto; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.talentcard.server.business.merchant.MerchantPaymentReadinessService; import com.talentcard.server.business.merchant.MerchantSettlementService; import com.talentcard.server.business.merchant.MerchantTypeCatalog; import com.talentcard.server.business.merchant.RegionCityCatalog; /** * 后台结算管理。 * *

出款由斗拱按结算周期自动完成(核销确认后的钱进商户基本户,次日自动结算到结算卡), * 平台不再产生提现记录。后台保留进件、银行卡查询、收款能力快照刷新,以及向通道查询 * 真实结算记录用于监控每天结了多少到银行卡。 */ @Service public class MerchantSettlementAdminService { private final JdbcTemplate jdbc; private final SensitiveDataCrypto sensitiveDataCrypto; private final MerchantPaymentReadinessService readinessService; private final HuifuSettlementRecordGateway settlementRecordGateway; @org.springframework.beans.factory.annotation.Autowired private com.talentcard.server.business.merchant.MerchantOnboardingProgressService progressService; @org.springframework.beans.factory.annotation.Autowired private com.talentcard.server.business.merchant.MerchantRealnameService realnameService; @org.springframework.beans.factory.annotation.Autowired private com.talentcard.server.business.merchant.MerchantSettlementService settlementService; public Map onboardingProgress(Long id) { return progressService.view(id); } /** 后台刷新与商户端走同一条路径:先推进已满足前置条件的开通步骤,再查询回报结果。 */ public Map refreshOnboarding(Long id, boolean retry) { return settlementService.refreshProgress(id, retry); } /** 微信实名认证:平台运营可在后台代为发起;未决申请不会重复提交。 */ public Map 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; } /** * 商家类型筛选项。 * *

取值来自后端目录 {@link MerchantTypeCatalog}——前端原来自己抄了一份 8 项的清单, * 既有抄错的标签(住宿服务/购房服务/体检服务在目录里只是别名,库里存的是酒店服务/购房优惠/ * 免费体检),也漏掉了后来新增的类型,导致下拉里有选项筛不出数据、数据里有的类型选不到。 */ public List merchantTypes() { return MerchantTypeCatalog.all().stream().map(MerchantTypeCatalog.MerchantType::displayName).toList(); } /** * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。 * *

取各商家当前生效的斗拱子商户号,**按商户号去重后逐个查询**、合并成一条时间倒序的列表。 * 通道查询失败只影响这一个商户号,不会让整页失败——监控页最重要的是「看得到」。 * *

为什么按商户号而不是按商家去问:结算流水属于商户号(一个收款主体),不属于商家。 * 连锁各门店是各自独立的商家、共用同一个收款主体,通道里就是一条流水;按商家逐家去问会把同一条 * 流水按共用它的商家数重复印出来(7 家共用就是 7 行一模一样的流水号/金额/到账卡),而每家的查询 * 各自带 {@code limit},截断后又会把别的流水挤掉——重复与漏行是同一条错。共用时那一行的商家名 * 把所有共用的商家都列出来(见 {@link #sharedMerchantLabel}),因为流水确实不属于其中任何一家。 * *

只查「通道侧收款能力已核实且正常」的商户({@link MerchantPaymentReadinessService#channelCapabilityNormal}): * 没开通好、或在汇付被关停的商户,查过去也会被通道拒(实测被关闭的子商户回「客户与产品关系已关闭」), * 只会让页面每次打开都多一条失败提示。判定共用收款能力那套取值,不在这里另立一套。 * *

筛选分两处,因为两类条件属于两边:商家、联系电话、商家类型是**本地商户字段**,先筛掉不匹配的 * 商户再去问通道——既让查询生效,也顺带少打几次通道;状态是**通道流水的结算状态**({@code S}/{@code F}/{@code P}), * 只能等通道把流水给回来之后再过滤。{@code beginDate}/{@code endDate} 是结算日期的区间(不是商户申请时间)。 * *

条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败, * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。 */ public Map settlements(String beginDate, String endDate, int size, String merchantName, String contactMobile, String merchantType, String status) { StringBuilder where = new StringBuilder(""" where a.status = 'approved' and a.provider_huifu_id is not null and a.provider_huifu_id <> '' and a.id = (select max(newest.id) from merchant_onboarding_application newest where newest.merchant_id = a.merchant_id) """); List args = new ArrayList<>(); addLike(where, args, "m.merchant_name", merchantName); addLike(where, args, "m.contact_mobile", contactMobile); addEqual(where, args, "m.merchant_type", merchantType); List> candidates = rows(""" select a.id as application_id, a.merchant_id, a.provider_huifu_id, m.merchant_name, 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 + " order by a.merchant_id asc", args.toArray()); // 关键:按**商户号**聚合,而不是按商家。一个商户号就是一个收款主体,结算流水只挂在它上面。 Map>> merchantsByHuifuId = new LinkedHashMap<>(); for (Map candidate : candidates) { if (!readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) continue; merchantsByHuifuId.computeIfAbsent(String.valueOf(candidate.get("providerHuifuId")), huifuId -> new ArrayList<>()) .add(candidate); } List> records = new ArrayList<>(); List failures = new ArrayList<>(); int limit = Math.min(Math.max(size, 1), HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE); for (Map.Entry>> entry : merchantsByHuifuId.entrySet()) { String huifuId = entry.getKey(); String label = sharedMerchantLabel(entry.getValue()); try { HuifuSettlementRecordGateway.SettlementRecords response = settlementRecordGateway.query(huifuId, beginDate, endDate, limit); if (!response.succeeded()) { failures.add(label + ":" + response.message()); continue; } for (Map record : decode(response.rawRecords(), entry.getValue().get(0), label)) { if (matchesStatus(record, status)) records.add(record); } } catch (RuntimeException exception) { failures.add(label + ":查询失败"); } } records.sort((left, right) -> String.valueOf(right.get("settleDate")) .compareTo(String.valueOf(left.get("settleDate")))); Map view = new LinkedHashMap<>(); view.put("beginDate", beginDate); view.put("endDate", endDate); view.put("records", records.size() > limit ? records.subList(0, limit) : records); view.put("total", records.size()); view.put("failures", failures); return view; } /** 结算状态筛选:值是通道自己的流水状态({@code S} 已结算 / {@code F} 失败 / {@code P} 处理中),空则全要。 */ private boolean matchesStatus(Map record, String status) { if (status == null || status.isBlank()) return true; return status.trim().equalsIgnoreCase(String.valueOf(record.get("status"))); } /** * 这一行显示成哪家的。 * *

一个商户号可以被多家商家共用(连锁各门店各是一个独立商家,共用一个收款主体),而流水属于 * 商户号、不属于其中任何一家。只说第一家会误导,只留一个名字又像把别家漏了,所以共用时把商家名 * 都列出来并注明共用;只有一家时就照常显示那一家。 */ private static String sharedMerchantLabel(List> merchants) { List names = merchants.stream().map(merchant -> String.valueOf(merchant.get("merchantName"))) .distinct().toList(); return names.size() == 1 ? names.get(0) : String.join("、", names) + "(共用收款主体)"; } /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */ private List> decode(String rawRecords, Map merchant, String merchantLabel) { List> decoded = new ArrayList<>(); if (rawRecords == null || rawRecords.isBlank()) return decoded; List> parsed; try { parsed = JSON.parseObject(rawRecords, new com.alibaba.fastjson.TypeReference<>() { }); } catch (RuntimeException exception) { return decoded; } if (parsed == null) return decoded; for (Map record : parsed) { Map row = new LinkedHashMap<>(); row.put("merchantId", merchant.get("merchantId")); // 商家名取聚合出来的那个标签:几家共用同一个商户号时,这一条流水是所有共用商家一起的。 row.put("merchantName", merchantLabel); row.put("storeName", merchant.get("storeName")); row.put("providerTransactionNo", record.get("trans_id")); row.put("settleDate", record.get("trans_date")); row.put("settleAmountCents", cents(record.get("trans_amt"))); row.put("feeAmountCents", cents(record.get("fee_amt"))); row.put("settleCycle", record.get("settle_cycle")); row.put("status", record.get("trans_stat")); row.put("cardMasked", record.get("card_no")); row.put("cardHolder", record.get("card_name")); decoded.add(row); } return decoded; } private Long cents(Object yuan) { if (yuan == null) return null; return com.talentcard.server.business.payment.huifu.HuifuPayload.amountInCent(String.valueOf(yuan)); } @Transactional(readOnly = true) public PageResult> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { PageResult> 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 onboarding(Long id) { // 详情是进件当时的存档,所以名称和联系方式都取进件表那一份(a.* 里就有):这里不选 // m.merchant_name / m.contact_mobile,同名撞车靠列顺序决定是不可靠的,不选它就没有歧义。 // 列表的「联系电话」是商户表的筛选条件,两者用途不同,别把列表那套映射套过来。 // 结算卡按 initial_bank_card_id 左连进来:运营要在这页看到这条进件绑的是哪张卡, // 但只取脱敏字段——明文卡号和 card_no_encrypted 都不出接口。 List> 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 result = rows.get(0); // 结算凭证图一列两义(对私=银行卡卡号面、对公=开户许可证),名字取决于主体是哪一个。 // 判定复用进件那条业务规则,后台不另抄一份。 result.put("privateSettlement", MerchantSettlementService.isPrivateSettlement(result.get("subjectType"), result.get("companyType"))); result.put("settlementCityName", bankCardCityName(result.remove("settlementCityCode"))); if (progressService.supports(result)) result.put("progress", progressService.view(result)); // 个人主体的历史记录可能使用 individual、personal 或中文类型值; // 只要已保存加密身份证号,后台详情均应解密后完整展示。 String encrypted = String.valueOf(result.get("subjectIdNoEncrypted")); if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted)); return result; } @Transactional(readOnly = true) public PageResult> bankCards(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { PageResult> 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 -> { // 卡号在库里是密文,解密出来就是完整 PAN。列表只需要展示,所以密文、卡哈希和 // 明文卡号都不出接口,前端改用后端已生成的 cardMasked。 row.remove("cardNoEncrypted"); row.remove("cardHash"); row.put("cityName", bankCardCityName(row.get("cityCode"))); }); return result; } /** Provider-confirmed settlement and encashment capability of one approved sub-merchant. */ public Map paymentCapability(Long applicationId) { return readinessService.capability(applicationId); } /** Re-reads the provider configuration and stores a fresh capability snapshot. */ public Map refreshPaymentCapability(Long applicationId) { return readinessService.refresh(applicationId); } private PageResult> page(String query, String count, String tableAlias, int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { return page(query, count, tableAlias, page, size, merchantName, contactMobile, merchantType, status, startDate, endDate, ""); } private PageResult> page(String query, String count, String tableAlias, int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate, String extraWhere) { int current = Math.max(1, page); int limit = Math.min(Math.max(1, size), 100); StringBuilder where = new StringBuilder(" where 1 = 1" + extraWhere); List args = new ArrayList<>(); addLike(where, args, "m.merchant_name", merchantName); addLike(where, args, "m.contact_mobile", contactMobile); addEqual(where, args, "m.merchant_type", merchantType); addEqual(where, args, tableAlias + ".status", status); addDate(where, args, tableAlias + ".created_at", startDate, true); addDate(where, args, tableAlias + ".created_at", endDate, false); List rowArgs = new ArrayList<>(args); rowArgs.add(limit); rowArgs.add((current - 1) * limit); List> values = rows(query + where + " order by " + tableAlias + ".created_at desc, " + tableAlias + ".id desc limit ? offset ?", rowArgs.toArray()); Long total = jdbc.queryForObject(count + where, Long.class, args.toArray()); return new PageResult<>(values, current, limit, total == null ? 0 : total); } private void addLike(StringBuilder where, List 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 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 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); } } /** * 把「商家名称 / 联系方式」换成商户表的值。 * *

进件表和商户表都有 merchant_name 和 contact_mobile。查询里又带了 {@code a.*},所以即使给 * join 出来的列另起别名,同名列依然存在两份,最终取到哪一个取决于驱动返回同名列的顺序—— * 实测是「先出现的非空值赢」,于是进件表里申请时填的商户名称会盖掉商户表的。而这两个字段的筛选 * 条件({@link #page})用的都是商户表,显示必须跟着筛选走;申请时填的商户名称另以 * onboardingMerchantName 带出,不丢。 */ private void useMerchantTableFields(Map row) { row.put("merchantName", row.remove("merchantTableName")); row.put("contactMobile", row.remove("merchantTableContactMobile")); } /** * 开户城市代码转「省|市」。 * *

未收录的代码原样返回:行政区划是外部数据,宁可把生僻值露出来让运营反馈,也不要猜一个。 */ private String bankCardCityName(Object cityCode) { String code = cityCode == null ? "" : String.valueOf(cityCode).trim(); if (code.isEmpty()) return ""; String name = RegionCityCatalog.provinceCity(code); return name.isEmpty() ? code : name; } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } }