shinnosuke
2026-09-21 7844f5af457ff3b7d3f8eeac8799e8d281e635a1
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -5,63 +5,222 @@
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.HuifuMerchantWithdrawalService;
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;
/**
 * 后台结算管理。
 *
 * <p>出款由斗拱按结算周期自动完成(核销确认后的钱进商户基本户,次日自动结算到结算卡),
 * 平台不再产生提现记录。后台保留进件、银行卡查询、收款能力快照刷新,以及向通道查询
 * 真实结算记录用于监控每天结了多少到银行卡。
 */
@Service
public class MerchantSettlementAdminService {
    private final JdbcTemplate jdbc;
    private final HuifuMerchantWithdrawalService withdrawalService;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    public MerchantSettlementAdminService(JdbcTemplate jdbc, HuifuMerchantWithdrawalService withdrawalService, SensitiveDataCrypto sensitiveDataCrypto) { this.jdbc = jdbc; this.withdrawalService = withdrawalService; this.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<String,Object> onboardingProgress(Long id) { return progressService.view(id); }
    /** 后台刷新与商户端走同一条路径:先推进已满足前置条件的开通步骤,再查询回报结果。 */
    public Map<String,Object> refreshOnboarding(Long id, boolean retry) { return settlementService.refreshProgress(id, retry); }
    /** 微信实名认证:平台运营可在后台代为发起;未决申请不会重复提交。 */
    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();
    }
    /**
     * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。
     *
     * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。
     * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。
     *
     * <p>只查「通道侧收款能力已核实且正常」的商户({@link MerchantPaymentReadinessService#channelCapabilityNormal}):
     * 没开通好、或在汇付被关停的商户,查过去也会被通道拒(实测被关闭的子商户回「客户与产品关系已关闭」),
     * 只会让页面每次打开都多一条失败提示。判定共用收款能力那套取值,不在这里另立一套。
     *
     * <p>筛选分两处,因为两类条件属于两边:商家、联系电话、商家类型是**本地商户字段**,先筛掉不匹配的
     * 商户再去问通道——既让查询生效,也顺带少打几次通道;状态是**通道流水的结算状态**({@code S}/{@code F}/{@code P}),
     * 只能等通道把流水给回来之后再过滤。{@code beginDate}/{@code endDate} 是结算日期的区间(不是商户申请时间)。
     *
     * <p>条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败,
     * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。
     */
    public Map<String, Object> 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<Object> 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<Map<String, Object>> 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());
        List<Map<String, Object>> merchants = new ArrayList<>();
        for (Map<String, Object> candidate : candidates) {
            if (readinessService.channelCapabilityNormal(((Number) candidate.get("applicationId")).longValue())) {
                merchants.add(candidate);
            }
        }
        List<Map<String, Object>> records = new ArrayList<>();
        List<String> failures = new ArrayList<>();
        int limit = Math.min(Math.max(size, 1), HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE);
        for (Map<String, Object> merchant : merchants) {
            String huifuId = String.valueOf(merchant.get("providerHuifuId"));
            try {
                HuifuSettlementRecordGateway.SettlementRecords response =
                        settlementRecordGateway.query(huifuId, beginDate, endDate, limit);
                if (!response.succeeded()) {
                    failures.add(String.valueOf(merchant.get("merchantName")) + ":" + response.message());
                    continue;
                }
                for (Map<String, Object> record : decode(response.rawRecords(), merchant)) {
                    if (matchesStatus(record, status)) records.add(record);
                }
            } catch (RuntimeException exception) {
                failures.add(String.valueOf(merchant.get("merchantName")) + ":查询失败");
            }
        }
        records.sort((left, right) -> String.valueOf(right.get("settleDate"))
                .compareTo(String.valueOf(left.get("settleDate"))));
        Map<String, Object> 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<String, Object> record, String status) {
        if (status == null || status.isBlank()) return true;
        return status.trim().equalsIgnoreCase(String.valueOf(record.get("status")));
    }
    /** 把通道返回的结算记录 JSON 转成后台要展示的字段;只读展示,不做金额换算以外的加工。 */
    private List<Map<String, Object>> decode(String rawRecords, Map<String, Object> merchant) {
        List<Map<String, Object>> decoded = new ArrayList<>();
        if (rawRecords == null || rawRecords.isBlank()) return decoded;
        List<Map<String, Object>> parsed;
        try {
            parsed = JSON.parseObject(rawRecords, new com.alibaba.fastjson.TypeReference<>() { });
        } catch (RuntimeException exception) {
            return decoded;
        }
        if (parsed == null) return decoded;
        for (Map<String, Object> record : parsed) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("merchantId", merchant.get("merchantId"));
            row.put("merchantName", merchant.get("merchantName"));
            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<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.*, 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);
        if ("individual".equalsIgnoreCase(String.valueOf(result.get("subjectType")))) {
            String encrypted = String.valueOf(result.get("subjectIdNoEncrypted"));
            if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted));
        }
        // 结算凭证图一列两义(对私=银行卡卡号面、对公=开户许可证),名字取决于主体是哪一个。
        // 判定复用进件那条业务规则,后台不另抄一份。
        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<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 -> row.put("cityName", bankCardCityName(row.get("cityCode"))));
        result.records().forEach(row -> {
            // 卡号在库里是密文,解密出来就是完整 PAN。列表只需要展示,所以密文、卡哈希和
            // 明文卡号都不出接口,前端改用后端已生成的 cardMasked。
            row.remove("cardNoEncrypted");
            row.remove("cardHash");
            row.put("cityName", bankCardCityName(row.get("cityCode")));
        });
        return result;
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> withdrawals(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
        String available = "greatest(0, coalesce((select sum(o.pay_amount) from benefit_order o where o.merchant_id = w.merchant_id and (o.store_id is null or o.store_id = w.store_id) and o.order_status = 1 and o.verify_status = 1 and o.deleted = 0), 0) - coalesce((select sum(w2.amount_cents) from merchant_withdrawal w2 where w2.merchant_id = w.merchant_id and w2.store_id = w.store_id and w2.status in ('pending', 'processing', 'paid')), 0))";
        String select = "select w.*, c.bank_name, c.card_masked, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name, " + available + " as available_amount_cents from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id";
        String count = "select count(*) from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id";
        return page(select, count, "w", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate);
    }
    /** Provider-confirmed settlement and encashment capability of one approved sub-merchant. */
    public Map<String, Object> paymentCapability(Long applicationId) { return readinessService.capability(applicationId); }
    @Transactional public Map<String, Object> reviewBankCard(Long id, String status, String reason, String tokenNo) { return reviewBankCardInternal(id, status, reason, tokenNo); }
    public Map<String, Object> reviewWithdrawal(Long id, String status, String reason) { if ("paid".equals(normalizeWithdrawalStatus(status))) return withdrawalService.dispatch(id); return update("merchant_withdrawal", id, "failed", reason, "withdrawal"); }
    private Map<String,Object> reviewBankCardInternal(Long id,String status,String reason,String tokenNo) { String value=reviewStatus(status); if("approved".equals(value) && (tokenNo==null || !tokenNo.trim().matches("[A-Za-z0-9]{1,32}"))) throw new BusinessException(ErrorCode.BAD_REQUEST,"Huifu withdrawal card token is required when approving a bank card"); int affected=jdbc.update("update merchant_bank_card set status=?, reject_reason=?, provider_token_no=?, reviewed_at=now() where id=? and status='pending'",value,reason==null?"":reason.trim(),"approved".equals(value)?tokenNo.trim():null,id); if(affected==0)throw new BusinessException(ErrorCode.CONFLICT,"Bank card is already handled or does not exist");return Map.of("id",id,"status",value); }
    private String reviewStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("approved","rejected").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid review status");return value; }
    private String normalizeWithdrawalStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("paid","failed").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid withdrawal status");return value; }
    /** Re-reads the provider configuration and stores a fresh capability snapshot. */
    public Map<String, Object> refreshPaymentCapability(Long applicationId) { return readinessService.refresh(applicationId); }
    private PageResult<Map<String, Object>> 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<Map<String, Object>> 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");
        StringBuilder where = new StringBuilder(" where 1 = 1" + extraWhere);
        List<Object> args = new ArrayList<>();
        addLike(where, args, "m.merchant_name", merchantName);
        addLike(where, args, "m.contact_mobile", contactMobile);
@@ -72,49 +231,37 @@
        List<Object> rowArgs = new ArrayList<>(args);
        rowArgs.add(limit);
        rowArgs.add((current - 1) * limit);
        List<Map<String, Object>> values = rows(query + where + " order by " + tableAlias + ".id desc limit ? offset ?", rowArgs.toArray());
        List<Map<String, Object>> 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<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); } }
    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();
        };
    /**
     * 把「商家名称 / 联系方式」换成商户表的值。
     *
     * <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"));
    }
    private Map<String, Object> update(String table, Long id, String status, String reason, String label) {
        String value = status == null ? "" : status.trim().toLowerCase();
        if (!List.of("approved", "rejected", "paid", "failed").contains(value)) throw new BusinessException(ErrorCode.BAD_REQUEST, "审核状态无效");
        if ("merchant_onboarding_application".equals(table) && !List.of("approved", "rejected").contains(value)) throw new BusinessException(ErrorCode.BAD_REQUEST, "进件状态无效");
        if ("merchant_bank_card".equals(table) && !List.of("approved", "rejected").contains(value)) throw new BusinessException(ErrorCode.BAD_REQUEST, "银行卡状态无效");
        int affected;
        if ("merchant_withdrawal".equals(table)) affected = jdbc.update("update merchant_withdrawal set status = ?, failure_reason = ?, reviewed_at = now(), paid_at = case when ? = 'paid' then now() else paid_at end where id = ? and status in ('pending','processing')", value, reason == null ? "" : reason.trim(), value, id);
        else affected = jdbc.update("update " + table + " set status = ?, reject_reason = ?, reviewed_at = now() where id = ? and status = 'pending'", value, reason == null ? "" : reason.trim(), id);
        if (affected == 0) throw new BusinessException(ErrorCode.CONFLICT, "该" + label + "已处理或不存在");
        return Map.of("id", id, "status", value);
    /**
     * 开户城市代码转「省|市」。
     *
     * <p>未收录的代码原样返回:行政区划是外部数据,宁可把生僻值露出来让运营反馈,也不要猜一个。
     */
    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<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
}