| | |
| | | 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); |
| | |
| | | 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)); } |
| | | } |