refactor(payment): 退役平台自动提现,改由斗拱自动结算并新增结算记录页
未核销的钱现在由延迟户兜住(官方定义「不可结算、不可取现」),核销确认后才分账进商户
基本户,再由斗拱按 D1 结算周期自动结算到结算卡。平台侧那套「凌晨汇总 → 提交主动取现 →
结果收敛」因此整体退役:它原本就是为「未核销的钱被结算走」而设计的替代方案,现在成了
重复建设,还会被取现权限与额度卡住。
- 删除 AutoWithdrawal 汇总/结果/调度/属性四个类、斗拱取现网关与取现结果解析
- 删除下单、回调、后台的提现入口:取现回调端点、withdrawals 列表、补跑汇总接口、
商户端提现页与其服务;订单不再有「已进入提现记录」状态
- 新增 HuifuSettlementRecordGateway 与后台「结算记录」页:直接查斗拱结算流水监控每天
自动结算了多少到银行卡,单个商户查询失败不影响整页
- 迁移 V140 删除 merchant_withdrawal、merchant_withdrawal_order 与 benefit_order.withdrawal_status
- 同步更新 CONTEXT.md、斗拱集成说明与商户端文档
15个文件已删除
5个文件已添加
30个文件已修改
| New file |
| | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmation; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; |
| | | |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | |
| | | /** |
| | | * 真实调用一次交易确认,验证延迟户的钱能分账进商户基本户。 |
| | | * |
| | | * <p>这是会真实分配资金的调用,只用于人工验证一笔小额。 |
| | | * |
| | | * <p>用法:java -cp "target/classes;<deps>;<tools>" ConfirmProbe <huifu_id> <payment_no> <req_date> |
| | | */ |
| | | public class ConfirmProbe { |
| | | |
| | | public static void main(String[] args) throws Exception { |
| | | String huifuId = args.length > 0 ? args[0] : "6666000226048687"; |
| | | String paymentNo = args.length > 1 ? args[1] : ""; |
| | | String reqDate = args.length > 2 ? args[2] : ""; |
| | | String providerNo = args.length > 3 ? args[3] : ""; |
| | | |
| | | JsonNode huifu = new YAMLMapper().readTree(Files.newInputStream( |
| | | Path.of("src", "main", "resources", "application.yml"))) |
| | | .path("app").path("payment").path("huifu"); |
| | | |
| | | HuifuPaymentProperties properties = new HuifuPaymentProperties(); |
| | | properties.setEnabled(true); |
| | | properties.setEnvironment(text(huifu, "environment", "prod")); |
| | | properties.setSystemId(text(huifu, "system-id", "")); |
| | | properties.setProductId(text(huifu, "product-id", "")); |
| | | properties.setPrivateKey(text(huifu, "private-key", "")); |
| | | properties.setPlatformPublicKey(text(huifu, "platform-public-key", "")); |
| | | |
| | | HuifuDelayAccountGateway gateway = new HuifuDelayAccountGateway(properties); |
| | | String confirmNo = "DCP" + System.currentTimeMillis(); |
| | | |
| | | System.out.println("== 交易确认请求 =="); |
| | | System.out.println(" confirm_no = " + confirmNo); |
| | | System.out.println(" huifu_id = " + huifuId); |
| | | System.out.println(" payment_no = " + paymentNo); |
| | | System.out.println(" org_hf_seq_id= " + (providerNo.isBlank() ? "(空,按 payment_no 定位)" : providerNo)); |
| | | System.out.println(); |
| | | |
| | | try { |
| | | HuifuDelayConfirmation result = gateway.confirm(new HuifuDelayConfirmCommand( |
| | | confirmNo, huifuId, paymentNo, providerNo, reqDate, "人工验证:核销后确认资金")); |
| | | System.out.println("================ 交易确认回执 ================"); |
| | | System.out.println(" outcome = " + result.outcome()); |
| | | System.out.println(" code = " + result.code()); |
| | | System.out.println(" message = " + result.describe()); |
| | | System.out.println(" unconfirm_amt = " + result.unconfirmedAmount()); |
| | | System.out.println(" confirmed_amt = " + result.confirmedAmount()); |
| | | System.out.println(" provider_trade_no = " + result.providerTransactionNo()); |
| | | System.out.println("============================================="); |
| | | } catch (Exception exception) { |
| | | System.out.println("!! 调用失败: " + exception.getClass().getSimpleName() + " : " + exception.getMessage()); |
| | | } |
| | | } |
| | | |
| | | private static String text(JsonNode node, String field, String fallback) { |
| | | String value = node.path(field).asText(""); |
| | | if (value.isBlank()) return fallback; |
| | | if (value.startsWith("${") && value.endsWith("}")) { |
| | | String inner = value.substring(2, value.length() - 1); |
| | | int separator = inner.indexOf(':'); |
| | | value = separator >= 0 ? inner.substring(separator + 1) : ""; |
| | | } |
| | | return value.isBlank() ? fallback : value; |
| | | } |
| | | } |
| New file |
| | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; |
| | | import com.huifu.bspay.sdk.opps.client.BasePayClient; |
| | | import com.huifu.bspay.sdk.opps.core.BasePay; |
| | | import com.huifu.bspay.sdk.opps.core.config.MerConfig; |
| | | |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.time.LocalDate; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 读取斗拱商户的结算与取现配置(只读)。 |
| | | * |
| | | * <p>用「商户详细信息查询」而不是能力查询:后者对这个商户会返回 20002 数据权限认证失败, |
| | | * 而详细信息查询能一次拿到 qry_settle_config_list 与 qry_cash_config_list。 |
| | | * |
| | | * <p>用法:java -cp "target/classes;<deps>;<tools>" SettlementProbe <huifu_id> |
| | | */ |
| | | public class SettlementProbe { |
| | | |
| | | public static void main(String[] args) throws Exception { |
| | | String huifuId = args.length > 0 ? args[0] : "6666000226048687"; |
| | | JsonNode huifu = new YAMLMapper().readTree(Files.newInputStream( |
| | | Path.of("src", "main", "resources", "application.yml"))) |
| | | .path("app").path("payment").path("huifu"); |
| | | |
| | | String environment = text(huifu, "environment", "prod"); |
| | | String systemId = text(huifu, "system-id", ""); |
| | | String productId = text(huifu, "product-id", ""); |
| | | String privateKey = text(huifu, "private-key", ""); |
| | | String platformPublicKey = text(huifu, "platform-public-key", ""); |
| | | |
| | | BasePay.debug = false; |
| | | BasePay.prodMode = "prod".equalsIgnoreCase(environment) ? BasePay.MODE_PROD : BasePay.MODE_TEST; |
| | | MerConfig config = new MerConfig(); |
| | | config.setSysId(systemId.trim()); |
| | | config.setProductId(productId.trim()); |
| | | config.setRsaPrivateKey(privateKey.trim()); |
| | | config.setRsaPublicKey(platformPublicKey.trim()); |
| | | BasePay.initWithMerConfig(config); |
| | | |
| | | Map<String, Object> request = new LinkedHashMap<>(); |
| | | request.put("req_seq_id", "PS" + System.currentTimeMillis()); |
| | | request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); |
| | | request.put("huifu_id", huifuId.trim()); |
| | | |
| | | Map<String, Object> response = BasePayClient.request(request, "v2.merchant.basicdata.query", false); |
| | | Map<String, Object> data = response.get("data") instanceof Map<?, ?> map |
| | | ? (Map<String, Object>) map : response; |
| | | |
| | | System.out.println("================ 结算与取现配置 ================"); |
| | | print(data, "mer_stat", "delay_flag", "forced_delay_flag", "online_refund", "platform_refund"); |
| | | System.out.println(" qry_settle_config_list = " + data.get("qry_settle_config_list")); |
| | | System.out.println(" qry_cash_config_list = " + data.get("qry_cash_config_list")); |
| | | System.out.println(" qry_cash_card_info_list默认卡 = " + defaultCard(data)); |
| | | System.out.println("=============================================="); |
| | | } |
| | | |
| | | /** 结算卡信息含卡号与证件号,只打印是否默认卡与绑卡序列号。 */ |
| | | @SuppressWarnings("unchecked") |
| | | private static String defaultCard(Map<String, Object> data) { |
| | | Object raw = data.get("qry_cash_card_info_list"); |
| | | if (!(raw instanceof String json) || json.isBlank()) return String.valueOf(raw); |
| | | try { |
| | | Object parsed = JSON.parse(json); |
| | | if (parsed instanceof java.util.List<?> list) { |
| | | for (Object item : list) { |
| | | if (item instanceof Map<?, ?> card |
| | | && "Y".equals(String.valueOf(card.get("is_settle_default")))) { |
| | | return "token_no=" + card.get("token_no") + ", status=" + card.get("status") |
| | | + ", bank=" + card.get("bank_name"); |
| | | } |
| | | } |
| | | } |
| | | } catch (Exception ignored) { |
| | | return "解析失败"; |
| | | } |
| | | return "未找到默认结算卡"; |
| | | } |
| | | |
| | | private static void print(Map<String, Object> data, String... fields) { |
| | | for (String field : fields) { |
| | | if (data.containsKey(field)) System.out.println(" " + field + " = " + data.get(field)); |
| | | } |
| | | } |
| | | |
| | | private static String text(JsonNode node, String field, String fallback) { |
| | | String value = node.path(field).asText(""); |
| | | if (value.isBlank()) return fallback; |
| | | if (value.startsWith("${") && value.endsWith("}")) { |
| | | String inner = value.substring(2, value.length() - 1); |
| | | int separator = inner.indexOf(':'); |
| | | value = separator >= 0 ? inner.substring(separator + 1) : ""; |
| | | } |
| | | return value.isBlank() ? fallback : value; |
| | | } |
| | | } |
| New file |
| | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; |
| | | import com.huifu.bspay.sdk.opps.client.BasePayClient; |
| | | import com.huifu.bspay.sdk.opps.core.BasePay; |
| | | import com.huifu.bspay.sdk.opps.core.config.MerConfig; |
| | | import com.huifu.bspay.sdk.opps.core.request.V2MerchantBasicdataSettlementQueryRequest; |
| | | |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.time.LocalDate; |
| | | import java.time.format.DateTimeFormatter; |
| | | |
| | | /** |
| | | * 查询斗拱的结算记录(只读),用于后台「结算记录」页。 |
| | | * |
| | | * <p>用法:java -cp "target/classes;<deps>;<tools>" SettlementRecordProbe <huifu_id> [beginDate] [endDate] |
| | | */ |
| | | public class SettlementRecordProbe { |
| | | |
| | | public static void main(String[] args) throws Exception { |
| | | String huifuId = args.length > 0 ? args[0] : "6666000226048687"; |
| | | String end = args.length > 1 ? args[1] : LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); |
| | | String begin = args.length > 2 ? args[2] |
| | | : LocalDate.now().minusDays(30).format(DateTimeFormatter.BASIC_ISO_DATE); |
| | | |
| | | JsonNode huifu = new YAMLMapper().readTree(Files.newInputStream( |
| | | Path.of("src", "main", "resources", "application.yml"))) |
| | | .path("app").path("payment").path("huifu"); |
| | | |
| | | BasePay.debug = false; |
| | | BasePay.prodMode = "prod".equalsIgnoreCase(text(huifu, "environment", "prod")) |
| | | ? BasePay.MODE_PROD : BasePay.MODE_TEST; |
| | | MerConfig config = new MerConfig(); |
| | | config.setSysId(text(huifu, "system-id", "").trim()); |
| | | config.setProductId(text(huifu, "product-id", "").trim()); |
| | | config.setRsaPrivateKey(text(huifu, "private-key", "").trim()); |
| | | config.setRsaPublicKey(text(huifu, "platform-public-key", "").trim()); |
| | | BasePay.initWithMerConfig(config); |
| | | |
| | | V2MerchantBasicdataSettlementQueryRequest request = new V2MerchantBasicdataSettlementQueryRequest(); |
| | | request.setReqDate(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); |
| | | request.setReqSeqId("SQ" + System.currentTimeMillis()); |
| | | request.setHuifuId(huifuId); |
| | | request.setBeginDate(begin); |
| | | request.setEndDate(end); |
| | | request.setPageSize("20"); |
| | | |
| | | System.out.println("== 查询区间 " + begin + " ~ " + end + " =="); |
| | | System.out.println("================ 结算记录原始回执 ================"); |
| | | try { |
| | | System.out.println(JSON.toJSONString(BasePayClient.request(request, false), true)); |
| | | } catch (Exception exception) { |
| | | System.out.println("!! 调用失败: " + exception.getClass().getSimpleName() + " : " + exception.getMessage()); |
| | | } |
| | | System.out.println("================================================"); |
| | | } |
| | | |
| | | private static String text(JsonNode node, String field, String fallback) { |
| | | String value = node.path(field).asText(""); |
| | | if (value.isBlank()) return fallback; |
| | | if (value.startsWith("${") && value.endsWith("}")) { |
| | | String inner = value.substring(2, value.length() - 1); |
| | | int separator = inner.indexOf(':'); |
| | | value = separator >= 0 ? inner.substring(separator + 1) : ""; |
| | | } |
| | | return value.isBlank() ? fallback : value; |
| | | } |
| | | } |
| | |
| | | # 项目领域上下文 |
| | | |
| | | ## 自动提现 |
| | | ## 资金流 |
| | | |
| | | 系统在每日凌晨按商户汇总前一自然日已核销订单金额,并调用斗拱主动取现接口。自动提现记录复用现有提现记录页面,状态为“处理中”“已到账”或“提现失败”。 |
| | | 支付成功时钱不会直接进商户账户:商户开通了延迟入账,先付钱后核销的订单资金进入斗拱的**延迟户** |
| | | (官方定义:不可结算、不可取现),核销成功后才调用**交易确认**把资金分账到商户**基本户**, |
| | | 再由斗拱按 **D1 结算周期自动结算到商户绑定的结算银行卡**。 |
| | | |
| | | ## 提现关联 |
| | | 支付即核销的到店消费订单不走延迟,支付成功即分账给商户。 |
| | | |
| | | 系统内部记录一笔自动提现对应的已核销订单,用于防止重复提现、失败顺延和退款判断。该关联不向商户端或后台增加批次详情、订单明细或独立查询入口。 |
| | | ## 延迟入账与交易确认 |
| | | |
| | | 商户进件时即配好延迟入账能力(`delay_flag=Y`、`forced_delay_flag=N`)。是否延迟由下单参数 |
| | | `delay_acct_flag` 按订单类型决定:只有 `STORE_CONSUME` 不延迟,其它订单(含以后新增的类型) |
| | | 一律延迟,这样未核销的钱默认被扣在通道。 |
| | | |
| | | 核销成功时触发交易确认。资金确认状态记在 `payment_transaction.funds_confirmation_status`: |
| | | `NULL` 表示未走延迟入账,`pending` 表示钱还在延迟户,`confirmed` 表示已分账到商户基本户。 |
| | | |
| | | ## 结算 |
| | | |
| | | 出款由斗拱按结算周期自动完成,平台**不生成提现记录**,也不再发起主动取现。商户进件时通过 |
| | | `settle_config` 打开自动结算(批次结算、不设起结金额与留存金额),商户和平台都不需要手工出款。 |
| | | |
| | | 后台的「结算记录」页直接查询斗拱的结算流水,用于监控每天自动结算了多少到银行卡。 |
| | | |
| | | ## 结算银行卡 |
| | | |
| | | 商户绑定的银行卡是自动提现的到账目标。商户可以查看和更换结算银行卡,但不能发起提现申请或选择提现金额。 |
| | | |
| | | ## 可提现订单 |
| | | |
| | | 已成功核销、未退款、未进入其他提现记录的订单金额。低于最低提现金额或不满足手续费缓冲条件时顺延到下一次任务。 |
| | | 商户绑定的银行卡是自动结算的到账目标。商户可以查看和更换结算银行卡,但不能发起提现或选择金额。 |
| | |
| | | if (name == 'merchantOnboarding') return '/api/merchant/onboarding' |
| | | if (name == 'merchantBankCards') return '/api/merchant/bank-cards' |
| | | if (name == 'merchantBankBranches') return '/api/merchant/bank-branches' |
| | | if (name == 'merchantWithdrawals') return '/api/merchant/withdrawals' |
| | | if (name == 'merchantWithdrawalSummary') return '/api/merchant/withdrawals/summary' |
| | | return '' |
| | | } |
| | |
| | | | 10 | 待支付与已完成订单详情 | `/pages/order-detail/order-detail?id={orderId}` | 视觉与静态详情已完成 | |
| | | | 11 | 待核销订单详情 | `/pages/verification-detail/verification-detail?id={orderId}` | 视觉与静态核销操作已完成 | |
| | | | 12 | 相机扫码 | `/pages/scan/scan` | 原生相机扫码首版已完成 | |
| | | | 13 | 商户提现 | `/pages/withdrawal/withdrawal` | 只读的自动提现记录,三种状态 | |
| | | | 16 | 填写银行卡 | `/pages/bank-card-add/bank-card-add` | 银行卡表单、校验与待审核回显首版已完成 | |
| | | | 17 | 选择开户行 | `/pages/bank-branch-select/bank-branch-select?city={city}` | 开户行搜索、空状态与回填首版已完成 | |
| | | | 18 | 商户进件 | `/pages/merchant-onboarding/merchant-onboarding` | 企业/个人动态表单、校验和提交首版已完成 | |
| | |
| | | |
| | | 面向人才卡合作商户及其工作人员的微信小程序。已完成登录与会话、经营数据总览、订单分页和详情、扫码解析与核销、商户资料和经营配置、商品管理、协议权益,以及商户进件、银行卡申请、提现记录和结算管理等页面。页面通过统一请求层访问后端,开发环境的演示数据开关独立于真实接口。 |
| | | |
| | | 提现由系统在每日凌晨自动汇总前一日已核销金额并通过斗拱主动取现接口打款到结算银行卡,提现记录只展示“处理中 / 已到账 / 提现失败”,没有商户申请或后台审核;商户只能查看和更换结算银行卡。真实到账结果以斗拱的异步通知或主动查询为准。 |
| | | 提现由系统在每日凌晨自动汇总前一日已核销金额并通过斗拱主动取现接口打款到结算银行卡,提现记录只展示“处理中 / 已到账 / 提现失败”,没有商户申请或后台审核;商户只能查看和更换结算银行卡。真实到账结果以斗拱的异步通知或主动查询为准。(已下线:出款改为斗拱按 D1 结算周期自动完成,商户端不再有提现页;核销确认后资金进商户基本户,次日自动结算到结算卡。) |
| | | |
| | | ## 文档导航 |
| | | |
| | |
| | | │ ├─ scan/scan.uvue # 相机扫码核销 |
| | | │ ├─ verification-failure/verification-failure.uvue # 核销失败结果 |
| | | │ ├─ merchant/merchant.uvue # 商户中心 |
| | | │ ├─ withdrawal/withdrawal.uvue # 商户提现(只读:自动提现记录) |
| | | │ ├─ settlement-management/settlement-management.uvue # 结算管理 |
| | | │ ├─ merchant-onboarding/merchant-onboarding.uvue # 商户进件 |
| | | │ ├─ merchant-onboarding-pending/merchant-onboarding-pending.uvue # 进件待审核 |
| | |
| | | │ │ │ └─ 待支付/已完成订单详情 /pages/order-detail/order-detail |
| | | │ │ └─ 免费入住订单(已完成无详情入口) |
| | | │ └─ 商户中心 /pages/merchant/merchant |
| | | │ ├─ 商户提现 /pages/withdrawal/withdrawal |
| | | │ │ ├─ 只读:待提现金额与自动提现记录(没有提现申请入口) |
| | | │ │ └─ 更换结算银行卡 /pages/bank-card-add/bank-card-add |
| | | │ │ └─ 选择开户行 /pages/bank-branch-select/bank-branch-select |
| | | │ ├─ 商品管理 /pages/products/products |
| | |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/withdrawal/withdrawal", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | | "backgroundColor": "#F4F6F9" |
| | | } |
| | | }, |
| | | { |
| | | "path": "pages/settlement-management/settlement-management", |
| | | "style": { |
| | | "navigationStyle": "custom", |
| | |
| | | <script setup lang="uts"> |
| | | import { loadBankBranches } from '../../services/settlement.uts' |
| | | |
| | | const selectedBranchStorageKey = 'merchant-withdrawal-selected-branch' |
| | | const selectedBranchStorageKey = 'merchant-settlement-selected-branch' |
| | | const cityName = ref('攀枝花市') |
| | | const cityCode = ref('') |
| | | const searchKeyword = ref('') |
| | |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="security-tip"><text>银行卡仅用于商户提现,请填写本商户主体名下银行卡</text></view> |
| | | <view class="security-tip"><text>银行卡用于接收结算款,请填写本商户主体名下银行卡</text></view> |
| | | <view class="submit-button" @tap="submitBankCard"><text>添加银行卡</text></view> |
| | | <view class="safe-space"></view> |
| | | </scroll-view> |
| | |
| | | import { areaProvinceNames, areaProvinceCodes, areaCityNames, areaCityCodes } from '../../config/area-codes.uts' |
| | | import { addBankCard as addBankCardRemote } from '../../services/settlement.uts' |
| | | |
| | | const selectedBranchStorageKey = 'merchant-withdrawal-selected-branch' |
| | | const selectedBranchStorageKey = 'merchant-settlement-selected-branch' |
| | | const submitting = ref(false) |
| | | const cardNumber = ref('') |
| | | const selectedProvince = ref('') |
| | |
| | | uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' }) |
| | | } |
| | | }, (_error: any) => {}) |
| | | const stored = uni.getStorageSync('merchant-withdrawal-selected-branch') |
| | | const stored = uni.getStorageSync('merchant-settlement-selected-branch') |
| | | if (stored == null || stored == '') return |
| | | try { |
| | | const branch = JSON.parse(stored as string) |
| | |
| | | bankName.value = branch.bankName as string |
| | | bankBranchCode.value = branch.branchCode as string |
| | | } catch (_error) {} |
| | | uni.removeStorageSync('merchant-withdrawal-selected-branch') |
| | | uni.removeStorageSync('merchant-settlement-selected-branch') |
| | | }) |
| | | </script> |
| | | |
| | |
| | | |
| | | export type SettlementOnboarding = { id: string, applicationNo: string, status: string, reason: string, submittedAt: string } |
| | | export type SettlementBankCard = { id: string, bankName: string, branchName: string, cardMasked: string, status: string, reason: string, createdAt: string } |
| | | export type SettlementWithdrawal = { id: string, withdrawalNo: string, amountCents: number, status: string, reason: string, bankName: string, cardMasked: string, createdAt: string, reviewedAt: string, paidAt: string } |
| | | export type SettlementPage<T> = { records: Array<T>, page: number, size: number, total: number } |
| | | |
| | | const stringValue = (value: any): string => value == null ? '' : '' + value |
| | | const numberValue = (value: any): number => value == null ? 0 : Number(value) |
| | | const onboarding = (value: any): SettlementOnboarding => ({ id: stringValue(value != null ? value.id : ''), applicationNo: stringValue(value != null ? value.applicationNo : ''), status: stringValue(value != null ? value.status : 'unsubmitted'), reason: stringValue(value != null ? value.reason : ''), submittedAt: stringValue(value != null ? value.submittedAt : '') }) |
| | | const bankCard = (value: any): SettlementBankCard => ({ id: stringValue(value != null ? value.id : ''), bankName: stringValue(value != null ? value.bankName : ''), branchName: stringValue(value != null ? value.branchName : ''), cardMasked: stringValue(value != null ? value.cardMasked : ''), status: stringValue(value != null ? value.status : 'pending'), reason: stringValue(value != null ? value.reason : ''), createdAt: stringValue(value != null ? value.createdAt : '') }) |
| | | const withdrawal = (value: any): SettlementWithdrawal => ({ id: stringValue(value != null ? value.id : ''), withdrawalNo: stringValue(value != null ? value.withdrawalNo : ''), amountCents: numberValue(value != null ? value.amountCents : 0), status: stringValue(value != null ? value.status : 'pending'), reason: stringValue(value != null ? value.reason : ''), bankName: stringValue(value != null ? value.bankName : ''), cardMasked: stringValue(value != null ? value.cardMasked : ''), createdAt: stringValue(value != null ? value.createdAt : ''), reviewedAt: stringValue(value != null ? value.reviewedAt : ''), paidAt: stringValue(value != null ? value.paidAt : '') }) |
| | | |
| | | export const loadOnboardingStatus = (success: (value: SettlementOnboarding) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantOnboardingStatus') }, (data: any) => success(onboarding(data)), error) |
| | | export const submitOnboarding = (payload: any, key: string, success: (value: SettlementOnboarding) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantOnboarding'), method: 'POST', data: payload, idempotencyKey: key }, (data: any) => success(onboarding(data)), error) |
| | | export const loadBankCards = (success: (value: Array<SettlementBankCard>) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantBankCards') }, (data: any) => success((data instanceof Array ? data : []).map((item: any) => bankCard(item))), error) |
| | | export const addBankCard = (payload: any, key: string, success: (value: SettlementBankCard) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantBankCards'), method: 'POST', data: payload, idempotencyKey: key }, (data: any) => success(bankCard(data)), error) |
| | | export const loadWithdrawalSummary = (success: (data: any) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantWithdrawalSummary') }, success, error) |
| | | export const loadWithdrawals = (page: number, size: number, success: (value: SettlementPage<SettlementWithdrawal>) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantWithdrawals'), data: { page: page, size: size } }, (data: any) => success({ records: (data != null && data.records instanceof Array ? data.records : []).map((item: any) => withdrawal(item)), page: numberValue(data != null ? data.page : page), size: numberValue(data != null ? data.size : size), total: numberValue(data != null ? data.total : 0) }), error) |
| | | // 自动提现由系统每日汇总后提交给斗拱,商户不能发起提现申请,所以没有对应的写接口。 |
| | | export const loadBankBranches = (cityCode: string, cityName: string, keyword: string, success: (value: Array<any>) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantBankBranches'), data: { cityCode: cityCode, cityName: cityName, keyword: keyword, page: 1, size: 30 } }, (data: any) => success(data != null && data.records instanceof Array ? data.records : []), error) |
| | |
| | | const sessionStorageKey = 'merchant-card-session' |
| | | const onboardingStatusStorageKey = 'merchant-withdrawal-onboarding-status' |
| | | const onboardingStatusStorageKey = 'merchant-settlement-onboarding-status' |
| | | |
| | | const getStorageKeys = (): string[] => { |
| | | const info: any = uni.getStorageInfoSync() |
| | |
| | | startDate?: string |
| | | endDate?: string |
| | | } |
| | | export function getMerchantSettlement(kind: 'onboardings' | 'bank-cards' | 'withdrawals', params: MerchantSettlementQuery = {}) { return http.get<PageResult<any>, PageResult<any>>(`/api/admin/management/merchant-settlement/${kind}`, { params }) } |
| | | 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 getMerchantSettlements(params: { beginDate?: string; endDate?: string; size?: number } = {}) { |
| | | 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 }) |
| | | } |
| | | export function getMerchantOnboardingDetail(id: number | string) { return http.get<Record<string, any>, Record<string, any>>(`/api/admin/management/merchant-settlement/onboardings/${id}`) } |
| | | |
| | | export function getAdminNews(params: NewsArticleQuery = {}) { |
| | |
| | | <script setup lang="ts"> |
| | | <script setup lang="ts"> |
| | | import { computed, nextTick, onMounted, ref, watch } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { ElMessageBox } from 'element-plus' |
| | |
| | | ] |
| | | |
| | | const financeMenus: MenuItem[] = [ |
| | | { path: '/merchant-settlement', label: '提现管理' }, |
| | | { path: '/merchant-settlement', label: '结算记录' }, |
| | | ] |
| | | |
| | | const orderMenus: MenuItem[] = [ |
| | |
| | | import { createRouter, createWebHistory } from 'vue-router' |
| | | import { createRouter, createWebHistory } from 'vue-router' |
| | | import { useAppStore } from '../stores/app' |
| | | import AdminLayout from '../layouts/AdminLayout.vue' |
| | | import { hasAdminPermission, requiredRoutePermission } from '../utils/permissions' |
| | |
| | | path: 'merchant-settlement', |
| | | name: 'merchant-settlement', |
| | | component: () => import('../views/MerchantSettlementView.vue'), |
| | | meta: { title: '提现管理' }, |
| | | meta: { title: '结算记录' }, |
| | | }, |
| | | { |
| | | path: 'merchant-onboardings/:id', |
| | |
| | | import { computed, onMounted, reactive, ref, watch } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { Refresh, Search } from '@element-plus/icons-vue' |
| | | import { getMerchantSettlement } from '../api/admin' |
| | | import { getMerchantSettlement, getMerchantSettlements } from '../api/admin' |
| | | |
| | | type Kind = 'onboardings' | 'bank-cards' | 'withdrawals' |
| | | type Kind = 'onboardings' | 'bank-cards' | 'settlements' |
| | | |
| | | const kind = ref<Kind>('withdrawals') |
| | | const kind = ref<Kind>('settlements') |
| | | const router = useRouter() |
| | | const route = useRoute() |
| | | const loading = ref(false) |
| | | const rows = ref<Record<string, any>[]>([]) |
| | | const total = ref(0) |
| | | /** 结算记录逐商户查询,个别商户查不到时在这里提示,不影响整页。 */ |
| | | const failures = ref<string[]>([]) |
| | | const page = ref(1) |
| | | const pageSize = ref(20) |
| | | |
| | |
| | | }) |
| | | |
| | | const tabOptions: Array<{ value: Kind; label: string }> = [ |
| | | { value: 'withdrawals', label: '提现管理' }, |
| | | { value: 'settlements', label: '结算记录' }, |
| | | { value: 'onboardings', label: '商户进件' }, |
| | | { value: 'bank-cards', label: '银行卡申请' }, |
| | | ] |
| | | |
| | | const merchantTypes = ['餐饮服务', '住宿服务', '购房服务', '购车服务', '体检服务', '金融服务', '行政审批', '景区服务'] |
| | | // 提现记录只展示三种约定状态:状态由斗拱的异步通知或主动查询收敛,后台不能人工审核。 |
| | | const statusOptions = computed(() => kind.value === 'withdrawals' |
| | | ? [ |
| | | { value: 'processing', label: '处理中' }, |
| | | { value: 'paid', label: '已到账' }, |
| | | { value: 'failed', label: '提现失败' }, |
| | | ] |
| | | // 结算记录直接来自斗拱,没有本地状态可选;进件与银行卡才按审核状态筛选。 |
| | | const statusOptions = computed(() => kind.value === 'settlements' |
| | | ? [] |
| | | : [ |
| | | { value: 'pending', label: '待审核' }, |
| | | { value: 'approved', label: '已通过' }, |
| | |
| | | |
| | | function statusType(value: string) { |
| | | return ({ pending: 'warning', approved: 'success', paid: 'success', rejected: 'danger', failed: 'danger', processing: 'primary' } as Record<string, 'warning' | 'success' | 'danger' | 'primary'>)[value] || 'primary' |
| | | } |
| | | |
| | | /** 斗拱结算流水的交易状态:S 成功、F 失败、P 处理中。 */ |
| | | function settleStatusLabel(value: unknown) { |
| | | return ({ S: '已结算', F: '结算失败', P: '处理中' } as Record<string, string>)[String(value || '')] || String(value || '-') |
| | | } |
| | | |
| | | function settleStatusType(value: unknown) { |
| | | const normalized = String(value || '') |
| | | return normalized === 'S' ? 'success' : normalized === 'F' ? 'danger' : 'warning' |
| | | } |
| | | |
| | | /** 结算日期是 yyyyMMdd 字符串,展示成 2026-09-14。 */ |
| | | function formatSettleDate(value: unknown) { |
| | | const text = String(value || '') |
| | | return /^\d{8}$/.test(text) ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}` : text || '-' |
| | | } |
| | | |
| | | function subjectTypeLabel(value: unknown) { |
| | |
| | | async function load() { |
| | | loading.value = true |
| | | try { |
| | | if (kind.value === 'settlements') { |
| | | // 结算记录来自斗拱,按日期区间查询,没有分页与审核状态筛选。 |
| | | const result = await getMerchantSettlements({ |
| | | beginDate: normalizeDate(filters.dateRange[0]), |
| | | endDate: normalizeDate(filters.dateRange[1]), |
| | | size: 100, |
| | | }) |
| | | rows.value = result.records || [] |
| | | total.value = Number(result.total || 0) |
| | | failures.value = result.failures || [] |
| | | return |
| | | } |
| | | const result = await getMerchantSettlement(kind.value, { |
| | | page: page.value, |
| | | size: pageSize.value, |
| | |
| | | }) |
| | | rows.value = result.records || [] |
| | | total.value = Number(result.total || 0) |
| | | failures.value = [] |
| | | } finally { |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | /** 日期选择器给的是 yyyy-MM-dd,斗拱要 yyyyMMdd。 */ |
| | | function normalizeDate(value: string | undefined) { |
| | | return value ? value.replace(/-/g, '') : undefined |
| | | } |
| | | |
| | | function search() { |
| | |
| | | |
| | | function applyRouteKind() { |
| | | const value = String(route.query.kind || '') |
| | | if (value === 'onboardings' || value === 'bank-cards' || value === 'withdrawals') { |
| | | if (value === 'onboardings' || value === 'bank-cards' || value === 'settlements') { |
| | | kind.value = value |
| | | } else if (value === 'withdrawals') { |
| | | // 旧链接(提现管理)现在指向结算记录。 |
| | | kind.value = 'settlements' |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | <section class="table-panel"> |
| | | <el-table v-loading="loading" :data="rows" border class="settlement-table" empty-text="暂无数据"> |
| | | <template v-if="kind === 'withdrawals'"> |
| | | <el-table-column prop="withdrawalNo" label="提现id" min-width="156" /> |
| | | <el-table-column prop="merchantName" label="商家名称" min-width="130" show-overflow-tooltip /> |
| | | <el-table-column prop="contactMobile" label="联系方式" min-width="118" /> |
| | | <el-table-column prop="merchantType" label="商家类型" min-width="110" /> |
| | | <el-table-column label="可提现金额" min-width="116" align="right"> |
| | | <template #default="{ row }">{{ formatAmount(row.availableAmountCents) }}</template> |
| | | <template v-if="kind === 'settlements'"> |
| | | <el-table-column prop="providerTransactionNo" label="结算流水号" min-width="190" show-overflow-tooltip /> |
| | | <el-table-column prop="merchantName" label="商家名称" min-width="180" show-overflow-tooltip /> |
| | | <el-table-column label="结算日期" min-width="118"><template #default="{ row }">{{ formatSettleDate(row.settleDate) }}</template></el-table-column> |
| | | <el-table-column label="结算金额" min-width="110" align="right"><template #default="{ row }">{{ formatAmount(row.settleAmountCents) }}</template></el-table-column> |
| | | <el-table-column label="手续费" min-width="96" align="right"><template #default="{ row }">{{ formatAmount(row.feeAmountCents) }}</template></el-table-column> |
| | | <el-table-column prop="settleCycle" label="结算周期" min-width="96" /> |
| | | <el-table-column label="状态" min-width="96"> |
| | | <template #default="{ row }"><el-tag :type="settleStatusType(row.status)">{{ settleStatusLabel(row.status) }}</el-tag></template> |
| | | </el-table-column> |
| | | <el-table-column label="提现金额" min-width="104" align="right"> |
| | | <template #default="{ row }">{{ formatAmount(row.amountCents) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="bankName" label="提现银行" min-width="130" show-overflow-tooltip /> |
| | | <el-table-column prop="cardMasked" label="提现卡号" min-width="150" /> |
| | | <el-table-column prop="cardMasked" label="到账卡号" min-width="160" /> |
| | | <el-table-column prop="cardHolder" label="持卡人" min-width="180" show-overflow-tooltip /> |
| | | </template> |
| | | <template v-else-if="kind === 'onboardings'"> |
| | | <el-table-column prop="applicationNo" label="进件id" min-width="156" /> |
| | |
| | | <el-table-column prop="cityName" label="开户城市" min-width="120" show-overflow-tooltip /> |
| | | <el-table-column prop="cardNo" label="银行卡号" min-width="180" /> |
| | | </template> |
| | | <el-table-column label="申请时间" min-width="165"> |
| | | <template #default="{ row }">{{ formatDate(row.createdAt) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="状态" min-width="100" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <template v-if="kind !== 'settlements'"> |
| | | <el-table-column label="申请时间" min-width="165"> |
| | | <template #default="{ row }">{{ formatDate(row.createdAt) }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="状态" min-width="100" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | </template> |
| | | <el-table-column v-if="kind === 'onboardings'" label="操作" width="110" fixed="right" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button link type="primary" @click="openReview(row)">查看</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="pagination-bar"> |
| | | <el-alert |
| | | v-if="kind === 'settlements' && failures.length > 0" |
| | | class="settlement-failures" |
| | | type="warning" |
| | | :closable="false" |
| | | :title="`部分商户结算记录查询失败:${failures.join(';')}`" |
| | | /> |
| | | <div v-if="kind !== 'settlements'" class="pagination-bar"> |
| | | <el-pagination |
| | | v-model:current-page="page" |
| | | v-model:page-size="pageSize" |
| | |
| | | |
| | | <style scoped> |
| | | .withdrawal-page { min-width: 1040px; } |
| | | .settlement-tabs { margin: 0 0 16px; } |
| | | .settlement-failures { margin-top: 12px; }.settlement-tabs { margin: 0 0 16px; } |
| | | .settlement-tabs :deep(.el-radio-button__inner) { min-width: 96px; } |
| | | .query-panel, .table-panel { background: #fff; border: 1px solid #edf0f5; } |
| | | .query-panel { padding: 18px 20px 6px; } |
| | |
| | | { code: 'hotel-reservations', name: '酒店预约管理', actions: ['查看', '审核', '下载'] }, |
| | | ], |
| | | }, |
| | | // 提现记录只读:状态由斗拱的通知或查询收敛,后台没有人工审核动作。 |
| | | { name: '财务管理', pages: [{ code: 'merchant-settlement', name: '提现管理', actions: ['查看'] }] }, |
| | | // 结算记录只读:数据直接来自斗拱,后台没有人工操作。 |
| | | { name: '财务管理', pages: [{ code: 'merchant-settlement', name: '结算记录', actions: ['查看'] }] }, |
| | | { |
| | | name: '运营管理', |
| | | pages: [ |
| | |
| | | duplicate historical verification records as revoked (keeping the earliest one) before creating the |
| | | unique key. |
| | | |
| | | ## Daily withdrawal aggregation |
| | | ## Automatic settlement |
| | | |
| | | `AutoWithdrawalScheduler` runs `AutoWithdrawalAggregationService` every day at 00:10 Beijing time |
| | | (`app.merchant.auto-withdrawal.cron`, zone `Asia/Shanghai`) for the previous calendar day. The service |
| | | turns one merchant store-day into one `merchant_withdrawal` row and immediately hands the new records |
| | | to the automatic encashment path described below. `AUTO_WITHDRAWAL_ENABLED` still defaults to `false`: |
| | | the flag is turned on only after the sandbox and controlled real-amount acceptance of ticket 06. |
| | | The platform no longer aggregates daily orders and no longer calls Huifu encashment. Delayed |
| | | account plus transaction confirmation already keep unverified money away from the merchant, and |
| | | payout is left to Huifu: |
| | | |
| | | | Rule | Implementation | |
| | | | --- | --- | |
| | | | Business day | Beijing time. The window is `verify_time < business day 24:00` and `>= business day - 30 days` | |
| | | | Order eligibility | `order_status = 1`, `verify_status = 1`, `refund_status = 0`, `withdrawal_status = 0`, not deleted, plus an active `verify_record` | |
| | | | Merchant store eligibility | Newest approved onboarding of the store, with a non-blank `provider_huifu_id` and an approved settlement card that carries a provider token | |
| | | | Settlement card | The approval-time card when it has a token, otherwise the store's newest approved token card; the recorded `bank_card_id` and the token always come from that one card | |
| | | | One record per merchant store-day | `merchant_withdrawal.withdrawal_date` (Beijing `yyyyMMdd`) plus unique key `uk_merchant_withdrawal_daily (merchant_id, store_key, withdrawal_date)`, where `store_key = ifnull(store_id, 0)` | |
| | | | Source order links | `merchant_withdrawal_order`, one row per order, unique key on `order_id` | |
| | | | Amount | Integer cents, the sum of `benefit_order.pay_amount` | |
| | | | Deferral | Skip the day when the total is below `min-amount-cents`, when `total - fee-buffer-cents` is still below it, or when the merchant store has no dispatchable settlement configuration | |
| | | | Idempotency | The day's record is looked up first; the unique keys on `(merchant_id, store_key, withdrawal_date)` and on `order_id` are the database-level guard | |
| | | | Locking | Every aggregated order goes through `OrderFundBoundaryService.lockIntoWithdrawal`, so it can no longer be revoked, refunded or aggregated again | |
| | | - onboarding opens automatic settlement for every merchant (settle_status=1, cycle D1, |
| | | batch settlement, no minimum and no retained amount), so a one-cent settlement is possible; |
| | | - after verification confirms the delayed transaction, the money sits in the merchant's basic |
| | | account and Huifu settles it to the bound settlement card on the next natural day; |
| | | - the platform stores no withdrawal rows. The admin **结算记录** page queries Huifu settlement |
| | | records (/v2/merchant/basicdata/settlement/query) to monitor how much was settled each day. |
| | | |
| | | The window's lower bound exists so a first deployment does not sweep years of history into one |
| | | withdrawal; anything older than 30 days is left alone for manual review. Inside the window, an order |
| | | that was skipped because its refund was still in flight, or because the merchant store had no usable |
| | | settlement card, is picked up by the next run instead of being stranded. Aggregation is per merchant store, matching the store-scoped withdrawal |
| | | pages and the store that collected the money. |
| | | |
| | | ```text |
| | | AUTO_WITHDRAWAL_ENABLED=false |
| | | AUTO_WITHDRAWAL_CRON=0 10 0 * * * |
| | | AUTO_WITHDRAWAL_MAX_ATTEMPTS=3 |
| | | AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES=5 |
| | | AUTO_WITHDRAWAL_RESULT_CHECK_ENABLED=true |
| | | AUTO_WITHDRAWAL_RESULT_CHECK_FIXED_DELAY_MS=300000 |
| | | ``` |
| | | |
| | | There is no minimum withdrawal amount and no fee buffer: Huifu's active encashment has no minimum, |
| | | and encashment itself is free (the fee is deducted when the payment is collected), so any verified |
| | | amount is withdrawn no matter how small. `AUTO_WITHDRAWAL_ENABLED` only gates the automatic nightly |
| | | aggregation; result convergence (submitting pending rows and reconciling processing ones) is gated by |
| | | `AUTO_WITHDRAWAL_RESULT_CHECK_ENABLED` alone, because it must keep working when aggregation is run |
| | | manually from the admin endpoint. |
| | | |
| | | `AutoWithdrawalAggregationTests` covers the Beijing date boundary, the eligibility filters (each |
| | | negative order carries an active verification record, so the filters themselves are exercised), the |
| | | source order links, the minimum amount and fee-buffer deferral, repeated runs, the database uniqueness |
| | | guard, the 30-day lookback, the approved settlement card, a merchant store with no dispatchable |
| | | configuration, and per-store isolation. |
| | | |
| | | `AutoWithdrawalEndToEndAcceptanceTests` is the chain-level acceptance for ticket 06: it drives the |
| | | real services from `submitOnboarding` through sub-merchant approval, the capability snapshot, order |
| | | creation, miniprogram payment, the signed callback, merchant verification, daily aggregation, the |
| | | encashment submission and the notification/query convergence, with every Huifu gateway stubbed. The |
| | | sandbox and controlled real-amount procedure, the whitelist and amount-cap rules, and the items that |
| | | still need provider confirmation are in `docs/automatic-withdrawal-acceptance.md`. |
| | | Migration V140 drops merchant_withdrawal, merchant_withdrawal_order and the order's |
| | | withdrawal_status. |
| | | |
| | | ## Reconciliation and exception handling |
| | | |
| | |
| | | ## Remaining provider-dependent work |
| | | |
| | | - Delayed-account confirmation must only be enabled after the corresponding Huifu product and confirmation API are provisioned and tested. |
| | | - Merchant onboarding, bank-card verification, merchant settlement/withdrawal callbacks, platform bill download, and accounting reconciliation still require the exact Huifu product contracts and callback specifications for the merchant account. |
| | | |
| | | ## Merchant withdrawal (encashment) |
| | | ## Settlement records |
| | | |
| | | Automatic withdrawal uses Huifu `POST /v2/trade/settlement/encashment` (主动取现), not the mini-program |
| | | payment product, and it is submitted only after the provider values are recorded: |
| | | HuifuSettlementRecordGateway queries Huifu settlement records for one sub-merchant over a date |
| | | range. The admin endpoint GET /api/admin/management/merchant-settlement/settlements asks every |
| | | approved onboarding for the range (default: last 30 days), merges the rows and returns them newest |
| | | first. A merchant whose query fails is reported in ailures instead of failing the whole page. |
| | | |
| | | - the approved merchant's `huifu_id` on the onboarding application; |
| | | - the approved settlement card's `token_no` from Huifu card binding. |
| | | |
| | | The admin settlement pages are read-only for withdrawals: there is no manual approval and no way to set |
| | | a withdrawal to paid or failed by hand, on either the merchant or the admin side. A withdrawal state |
| | | changes only when Huifu confirms it through the asynchronous notification or through the status query. |
| | | Merchants can still view and replace the settlement card, because that is the account the automatic |
| | | withdrawal pays into. |
| | | |
| | | ### Submission |
| | | |
| | | `AutoWithdrawalResultService.submitPending` claims each `pending` auto-withdrawal row with a conditional |
| | | update (`where status = 'pending'`), so a repeated task or a second instance can never submit the same |
| | | record twice, then calls the provider with the record's own `withdrawal_no` as the idempotent request |
| | | number (`req_seq_id`). The exact values the request used are stored on the row and reused for the later |
| | | query: `provider_request_date` is the same `yyyyMMdd` that was sent as `req_date` (computed once, not |
| | | read from the clock twice), and `provider_huifu_id` is the sub-merchant the request was submitted to. |
| | | The query locates a payout by `huifu_id` + `org_req_date` + `org_req_seq_id`, so re-deriving the |
| | | sub-merchant at query time would read a merchant that re-onboarded in between as "transaction does not |
| | | exist" (`23000001`) and wrongly release an accepted payout. Rows written before migration `V136` carry |
| | | no pinned merchant and fall back to the store's newest approved sub-merchant. |
| | | |
| | | | Provider answer | Local state | Meaning | |
| | | | --- | --- | --- | |
| | | | `trans_stat = S` and an accepted code | `paid` | The money left the sub-merchant account | |
| | | | `trans_stat = P`, an accepted code, or a payload that cannot be read | `processing` | Submitted, result not decided | |
| | | | `trans_stat = F`, or a non-accepted code with no state | `failed` | The provider rejected it; no money moved | |
| | | | Gateway exception after the request may have been sent | `processing` | Unknown result; the query has to converge it | |
| | | | Local failure before the request (`BusinessException`) | stays `pending` | Nothing was sent, the next run retries | |
| | | |
| | | The result mapping (`HuifuWithdrawalResult`) is intentionally biased towards `processing`: only a |
| | | statement whose transaction state *and* response code agree may reach a final state, a contradictory |
| | | statement is re-queried, and the duplicate-transaction code `20000000` is never read as a failure. |
| | | Wrongly declaring a request failed would release its orders and pay the same money twice. |
| | | |
| | | ### Confirmation |
| | | |
| | | A signature-verified notification and the scheduled query both go through the same idempotent entry |
| | | point (`AutoWithdrawalResultService.apply`), which locks the withdrawal row, refuses to touch a record |
| | | that is already `paid` or `failed`, and only then moves `pending`/`processing` to a final state. A |
| | | repeated notification therefore changes nothing, and a late opposite result cannot reopen a terminal |
| | | one. Huifu may send two notifications for one withdrawal (channel and accounting, `msg_type 01`/`02`); |
| | | both carry the same `req_seq_id` and both converge on the same state. |
| | | |
| | | `failed` has to be terminal: the failure already released the source orders, so reopening the record on |
| | | a late success would let the same money be withdrawn a second time. A statement that contradicts the |
| | | recorded terminal state is therefore *not* applied and is written to the server log with the withdrawal |
| | | number, state, outcome and response code, so an operator can reconcile it by hand. |
| | | |
| | | While a withdrawal is still `processing`, an accepted answer from the submission is evidence and is |
| | | kept: the first non-blank response code and message are never overwritten by the temporary answers of |
| | | later queries. |
| | | |
| | | The query uses `huifu_id` + `org_req_date` + `org_req_seq_id`, and waits |
| | | `app.merchant.auto-withdrawal.query-after-minutes` (default 5) after submission. Querying too early |
| | | would read the provider's "not found" (`23000001`) as a failure and wrongly release the orders; the |
| | | grace period is also what makes `23000001` trustworthy, because a request the provider never accepted |
| | | is exactly the case that has to be deferred again. |
| | | |
| | | ### Failure deferral |
| | | |
| | | When Huifu confirms a failure, the reason is stored in `merchant_withdrawal.failure_reason` and each |
| | | source order is released (`OrderFundBoundaryService.releaseFromWithdrawal`, association row marked with |
| | | `released_at`), so the next daily aggregation combines it with the new day's orders and submits a fresh |
| | | request. One order is retried at most `app.merchant.auto-withdrawal.max-attempts` times (default 3); |
| | | after that it stays locked inside the failed record, which keeps the amount visible for manual |
| | | handling instead of retrying forever. That stuck money is the one withdrawal state an operator still |
| | | has to act on, so the management console shows a "提现失败待处理" notice counting failed withdrawals |
| | | that still hold locked orders. |
| | | |
| | | The uniqueness rule of `merchant_withdrawal_order` is therefore "at most one *unreleased* association |
| | | per order": the generated column `active` is `1` while `released_at` is null and `NULL` afterwards, and |
| | | MySQL ignores `NULL` entries in the unique key `uk_withdrawal_order_active (order_id, active)`. |
| | | Migrations: `V133`/`V134` (aggregation, day-scoped uniqueness), `V135` (release support, submit |
| | | counter), `V136` (sub-merchant pinned on the withdrawal for the result query). |
| | | |
| | | ### Configuration |
| | | |
| | | ```text |
| | | HUIFU_WITHDRAWAL_PRODUCT_ID=... # Product that has encashment permission, may differ from payment product |
| | | HUIFU_WITHDRAWAL_NOTIFY_URL=https://api.example.com/api/payments/huifu/withdrawal-notify |
| | | HUIFU_WITHDRAWAL_INTO_ACCOUNT_DATE_TYPE=D0 |
| | | ``` |
| | | |
| | | `HUIFU_WITHDRAWAL_NOTIFY_URL` must be publicly reachable HTTPS and configured with Huifu. The callback |
| | | verifies `resp_data` with Huifu's public key before a withdrawal is touched. `status=paid` is the only |
| | | terminal success; `processing` never means funds arrived. |
| | | |
| | | ### Statuses exposed to users |
| | | |
| | | `merchant_withdrawal.status` keeps four internal values, but both the merchant and the admin API collapse |
| | | them to the three agreed states with `AutoWithdrawalResultService.displayStatus`: `pending` and |
| | | `processing` are shown as 处理中, `paid` as 已到账 and `failed` as 提现失败. |
| | | |
| | | ### Remaining work |
| | | |
| | | - Huifu's `re_exchange = Y` (已退汇, money returned after a payout) and `acct_status = B` (回账成功) are |
| | | not handled yet: a payout that is returned after being confirmed is not moved back to 提现失败 and the |
| | | amount is not deferred again. |
| | | - The `query-after-minutes` grace period is the only thing standing between "the channel has not booked |
| | | it yet" and "the channel never accepted it". The sandbox acceptance of ticket 06 has to confirm how |
| | | quickly an accepted encashment becomes queryable before the flag is turned on. |
| | | - The single-encashment amount limits are per merchant and per bank, so the sandbox acceptance of ticket |
| | | 06 has to confirm the effective minimum, the daily cap and the fee deduction before enabling the flag. |
| | |
| | | "select count(*) as pending_count, max(created_at) as latest_time from merchant_onboarding_application where status = 'pending'"); |
| | | addPendingNotice(notices, "merchant-bank-card", "银行卡申请", "您有 %d 条银行卡申请待审核", "/merchant-settlement?kind=bank-cards", |
| | | "select count(*) as pending_count, max(created_at) as latest_time from merchant_bank_card where status = 'pending'"); |
| | | // 自动提现没有人工审核环节:提现记录的状态由斗拱的通知或查询收敛,后台只读。 |
| | | // 但重试到上限的失败提现会把金额留在记录里不再自动顺延,需要人工处理,所以单独提醒。 |
| | | addPendingNotice(notices, "merchant-withdrawal-stuck", "提现失败待处理", "您有 %d 笔提现失败且金额已停止自动重试", "/merchant-settlement?kind=withdrawals", |
| | | "select count(*) as pending_count, max(w.created_at) as latest_time from merchant_withdrawal w where w.status = 'failed' and exists (select 1 from merchant_withdrawal_order a join benefit_order o on o.id = a.order_id where a.withdrawal_id = w.id and a.released_at is null and o.withdrawal_status = 1)"); |
| | | // 出款由斗拱按结算周期自动完成,平台不再生成提现记录,也就没有需要人工处理的提现。 |
| | | addPendingNotice(notices, "medical-reservation", "体检预约", "您有 %d 条体检预约待处理", "/medical-reservations", |
| | | "select count(*) as pending_count, max(submitted_at) as latest_time from service_application where deleted = 0 and application_type = 'MEDICAL_RESERVATION' and status = '待处理'"); |
| | | addPendingNotice(notices, "medical-application", "就医申请", "您有 %d 条就医申请待处理", "/medical-applications", |
| | |
| | | @GetMapping("/merchant-settlement/onboardings/{id}/payment-capability") public ApiResponse<Map<String,Object>> paymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.paymentCapability(id)); } |
| | | @PostMapping("/merchant-settlement/onboardings/{id}/payment-capability/refresh") public ApiResponse<Map<String,Object>> refreshPaymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.refreshPaymentCapability(id)); } |
| | | @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); } |
| | | @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.withdrawals(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); } |
| | | /** |
| | | * 按业务日补跑一次自动提现汇总(运维排查与验收用)。日期格式 yyyyMMdd,必须显式传。 |
| | | * 结算记录:向斗拱查询指定区间的真实结算流水,用于监控每天自动结算了多少到银行卡。 |
| | | * 日期格式 yyyyMMdd,默认最近 30 天。 |
| | | */ |
| | | @PostMapping("/merchant-settlement/auto-withdrawal/run") public ApiResponse<Map<String,Object>> runAutoWithdrawalAggregation(@RequestParam String businessDate) { return ApiResponse.success(settlementService.runAutoWithdrawalAggregation(parseBusinessDate(businessDate))); } |
| | | |
| | | private static java.time.LocalDate parseBusinessDate(String businessDate) { |
| | | if (businessDate == null || businessDate.isBlank()) throw new com.talentcard.server.common.BusinessException(com.talentcard.server.common.ErrorCode.BAD_REQUEST, "请指定业务日 businessDate(yyyyMMdd)"); |
| | | try { |
| | | return java.time.LocalDate.parse(businessDate.trim(), java.time.format.DateTimeFormatter.BASIC_ISO_DATE); |
| | | } catch (java.time.format.DateTimeParseException exception) { |
| | | throw new com.talentcard.server.common.BusinessException(com.talentcard.server.common.ErrorCode.BAD_REQUEST, "业务日格式应为 yyyyMMdd"); |
| | | } |
| | | @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "100") int size) { |
| | | 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(); |
| | | String begin = (beginDate == null || beginDate.isBlank()) ? today.minusDays(30).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : beginDate.trim(); |
| | | return ApiResponse.success(settlementService.settlements(begin, end, size)); |
| | | } |
| | | @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); } |
| | | @PatchMapping("/payment-reconciliation/issues/{id}") public ApiResponse<Map<String,Object>> closeReconciliationIssue(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(reconciliationService.close(id, body.get("status"), body.get("operator"))); } |
| | |
| | | package com.talentcard.server.business.admin; |
| | | |
| | | import java.time.LocalDate; |
| | | 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.merchant.AutoWithdrawalAggregationService; |
| | | import com.talentcard.server.business.merchant.AutoWithdrawalResultService; |
| | | import com.talentcard.server.business.merchant.AutoWithdrawalScheduler; |
| | | 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; |
| | |
| | | /** |
| | | * 后台结算管理。 |
| | | * |
| | | * <p>后台只能查看进件、银行卡和提现记录,不能修改提现状态:提现记录的状态只由斗拱的异步通知或主动 |
| | | * 查询收敛(见 {@code AutoWithdrawalResultService})。进件和银行卡的审核入口在这一版已经下线, |
| | | * 后台保留查询与能力快照刷新。 |
| | | * <p>出款由斗拱按结算周期自动完成(核销确认后的钱进商户基本户,次日自动结算到结算卡), |
| | | * 平台不再产生提现记录。后台保留进件、银行卡查询、收款能力快照刷新,以及向通道查询 |
| | | * 真实结算记录用于监控每天结了多少到银行卡。 |
| | | */ |
| | | @Service |
| | | public class MerchantSettlementAdminService { |
| | | private final JdbcTemplate jdbc; |
| | | private final SensitiveDataCrypto sensitiveDataCrypto; |
| | | private final MerchantPaymentReadinessService readinessService; |
| | | private final AutoWithdrawalScheduler autoWithdrawalScheduler; |
| | | public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, AutoWithdrawalScheduler autoWithdrawalScheduler) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.autoWithdrawalScheduler = autoWithdrawalScheduler; } |
| | | private final HuifuSettlementRecordGateway settlementRecordGateway; |
| | | public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, HuifuSettlementRecordGateway settlementRecordGateway) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.settlementRecordGateway = settlementRecordGateway; } |
| | | |
| | | /** |
| | | * 按业务日补跑一次日汇总并立刻提交、收敛结果。 |
| | | * 结算记录:钱按结算周期自动划到结算卡之后,「每天结了多少」只能问通道。 |
| | | * |
| | | * <p>定时任务汇总的是「前一自然日」,当天核销的订单要等到第二天凌晨才会出账;排查和验收都等不起, |
| | | * 所以这里提供一次显式补跑。它不接受「不传日期」:隐式取当天容易误刷历史,日期必须由调用方给定。 |
| | | * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。 |
| | | * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。 |
| | | */ |
| | | public Map<String, Object> runAutoWithdrawalAggregation(LocalDate businessDate) { |
| | | if (businessDate == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "请指定业务日 businessDate(yyyyMMdd)"); |
| | | AutoWithdrawalAggregationService.Result result = autoWithdrawalScheduler.runForBusinessDate(businessDate); |
| | | public Map<String, Object> settlements(String beginDate, String endDate, int size) { |
| | | List<Map<String, Object>> merchants = rows(""" |
| | | select 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 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) |
| | | order by a.merchant_id asc |
| | | """); |
| | | List<Map<String, Object>> records = new ArrayList<>(); |
| | | List<String> failures = new ArrayList<>(); |
| | | int limit = Math.min(Math.max(size, 1), 100); |
| | | 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; |
| | | } |
| | | records.addAll(decode(response.rawRecords(), merchant)); |
| | | } 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("businessDate", result.businessDate()); |
| | | view.put("merchantsAggregated", result.merchantsAggregated()); |
| | | view.put("merchantsSkipped", result.merchantsSkipped()); |
| | | view.put("merchantsFailed", result.merchantsFailed()); |
| | | view.put("ordersAggregated", result.ordersAggregated()); |
| | | view.put("amountCents", result.amountCents()); |
| | | 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; |
| | | } |
| | | |
| | | /** 把通道返回的结算记录 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) |
| | |
| | | }); |
| | | 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.refund_status = 0 and o.withdrawal_status = 0 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"; |
| | | PageResult<Map<String, Object>> result = page(select, count, "w", page, size, merchantName, contactMobile, |
| | | merchantType, null, startDate, endDate, withdrawalStatusCondition(status)); |
| | | result.records().forEach(row -> row.put("status", |
| | | AutoWithdrawalResultService.displayStatus(row.get("status") == null ? "" : String.valueOf(row.get("status"))))); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 对外三种状态到库里原始状态的映射条件,由 {@link AutoWithdrawalResultService#rawStatuses} 推导。 |
| | | * 没有筛选时不加条件;筛选取值不认识是「筛不出记录」,不是「忽略筛选」。 |
| | | */ |
| | | private String withdrawalStatusCondition(String status) { |
| | | if (status == null || status.isBlank()) return ""; |
| | | List<String> rawStatuses = AutoWithdrawalResultService.rawStatuses(status); |
| | | if (rawStatuses.isEmpty()) return " and 1 = 0"; |
| | | return " and w.status in (" + rawStatuses.stream().map(value -> "'" + value + "'") |
| | | .collect(java.util.stream.Collectors.joining(", ")) + ")"; |
| | | } |
| | | |
| | | /** Provider-confirmed settlement and encashment capability of one approved sub-merchant. */ |
| | | public Map<String, Object> paymentCapability(Long applicationId) { return readinessService.capability(applicationId); } |
| | | |
| | |
| | | return new PageResult<>(values, safePage, safeSize, total == null ? 0 : total); |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | | public Map<String, Object> withdrawalSummary(MerchantPrincipal principal) { |
| | | store(principal); |
| | | Long verified = jdbc.queryForObject("select coalesce(sum(pay_amount), 0) from benefit_order where merchant_id = ? and (store_id is null or store_id = ?) and order_status = 1 and verify_status = 1 and refund_status = 0 and withdrawal_status = 0 and deleted = 0", Long.class, principal.merchantId(), principal.storeId()); |
| | | Long held = jdbc.queryForObject("select coalesce(sum(amount_cents), 0) from merchant_withdrawal where merchant_id = ? and store_id = ? and status in ('pending','processing','paid')", Long.class, principal.merchantId(), principal.storeId()); |
| | | long available = Math.max(0, (verified == null ? 0 : verified) - (held == null ? 0 : held)); |
| | | return Map.of("availableAmountCents", available, "frozenAmountCents", held == null ? 0 : held); |
| | | } |
| | | |
| | | /** |
| | | * 商户端提现记录:只读,且只展示「处理中 / 已到账 / 提现失败」三种状态。自动提现由系统在每日凌晨 |
| | | * 生成并提交给斗拱,商户不能发起提现申请、选择提现金额,也不能修改提现状态(CONTEXT.md「自动提现」)。 |
| | | */ |
| | | @Transactional(readOnly = true) |
| | | public PageResult<Map<String, Object>> withdrawals(MerchantPrincipal principal, int page, int size) { |
| | | store(principal); int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 50); |
| | | List<Map<String, Object>> values = rows("select w.*, c.bank_name, c.card_masked from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id where w.merchant_id = ? and w.store_id = ? order by w.id desc limit ? offset ?", principal.merchantId(), principal.storeId(), safeSize, (safePage - 1) * safeSize).stream().map(this::withdrawal).toList(); |
| | | Long total = jdbc.queryForObject("select count(*) from merchant_withdrawal where merchant_id = ? and store_id = ?", Long.class, principal.merchantId(), principal.storeId()); |
| | | return new PageResult<>(values, safePage, safeSize, total == null ? 0 : total); |
| | | } |
| | | |
| | | private void requireApproved(MerchantPrincipal principal) { if (!"approved".equals(onboardingStatus(principal).get("status"))) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过"); } |
| | | private void submitBankCardToHuifu(MerchantPrincipal principal, Map<String, Object> card, String cardNumber, Map<String, Object> branch) { |
| | | List<Map<String, Object>> onboarding = rows("select * from merchant_onboarding_application where merchant_id=? and store_id=? and initial_bank_card_id is not null and status='approved' and provider_huifu_id is not null order by id desc limit 1", principal.merchantId(), principal.storeId()); |
| | |
| | | private void store(MerchantPrincipal principal) { if (rows("select id from merchant_store where id = ? and merchant_id = ? and status = 1 and deleted = 0", principal.storeId(), principal.merchantId()).isEmpty()) throw new BusinessException(ErrorCode.FORBIDDEN, "当前门店不可操作"); } |
| | | private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "submittedAt", String.valueOf(row.get("createdAt"))); } |
| | | private Map<String, Object> bankCard(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "bankName", row.get("bankName"), "branchName", row.get("branchName"), "cardMasked", row.get("cardMasked"), "status", row.get("status"), "reason", clean(row.get("rejectReason")), "createdAt", String.valueOf(row.get("createdAt"))); } |
| | | private Map<String, Object> withdrawal(Map<String, Object> row) { Map<String, Object> value = new LinkedHashMap<>(); value.put("id", String.valueOf(row.get("id"))); value.put("withdrawalNo", row.get("withdrawalNo")); value.put("amountCents", row.get("amountCents")); value.put("status", AutoWithdrawalResultService.displayStatus(row.get("status") == null ? "" : String.valueOf(row.get("status")))); value.put("reason", clean(row.get("failureReason"))); value.put("bankName", clean(row.get("bankName"))); value.put("cardMasked", clean(row.get("cardMasked"))); value.put("createdAt", String.valueOf(row.get("createdAt"))); value.put("reviewedAt", clean(row.get("reviewedAt"))); value.put("paidAt", clean(row.get("paidAt"))); return value; } |
| | | private Map<String, Object> branch(String id, String branchName, String bankName) { return Map.of("id", id, "branchName", branchName, "bankName", bankName); } |
| | | private Object[] appendPaging(List<Object> params, int size, int offset) { Object[] values = new Object[params.size() + 2]; for (int index = 0; index < params.size(); index++) values[index] = params.get(index); values[params.size()] = size; values[params.size() + 1] = offset; return values; } |
| | | private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } |
| | |
| | | @GetMapping("/bank-branches") |
| | | public ApiResponse<PageResult<Map<String, Object>>> bankBranches(@RequestParam(required = false) String cityCode, @RequestParam(required = false) String cityName, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(merchantSettlementService.bankBranches(cityCode, cityName, keyword, page, size)); } |
| | | |
| | | @GetMapping("/withdrawals/summary") |
| | | public ApiResponse<Map<String, Object>> withdrawalSummary(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal) { return ApiResponse.success(merchantSettlementService.withdrawalSummary(principal)); } |
| | | |
| | | @GetMapping("/withdrawals") |
| | | public ApiResponse<PageResult<Map<String, Object>>> withdrawals(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(merchantSettlementService.withdrawals(principal, page, size)); } |
| | | |
| | | @GetMapping("/dashboard/summary") |
| | | public ApiResponse<Map<String, Object>> dashboardSummary(@RequestAttribute(MerchantAuthService.PRINCIPAL_ATTRIBUTE) MerchantPrincipal principal) { |
| | | return ApiResponse.success(merchantSettlementStatisticsService.dashboardCards(principal.merchantId())); |
| | |
| | | o.merchant_id, m.merchant_name, m.merchant_rights_config, |
| | | o.product_id, o.product_name_snapshot, p.rights_config, |
| | | o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount, |
| | | o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.withdrawal_status, o.created_at, |
| | | o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, o.created_at, |
| | | vr.verify_time, vr.status as verify_record_status |
| | | from benefit_order o |
| | | left join talent_user tu on tu.id = o.talent_id |
| | |
| | | """, orderId, principal.merchantId(), principal.storeId()); |
| | | if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在"); |
| | | Map<String, Object> order = orders.get(0); |
| | | // 撤销核销与核销、退款、提现锁定共用同一把订单行锁;锁内读到的核销记录才是稳定的。 |
| | | orderFundBoundary.lock(orderId).requireRevocable(); |
| | | // 撤销核销与核销、退款共用同一把订单行锁;锁内读到的核销记录才是稳定的。 |
| | | orderFundBoundary.lock(orderId); |
| | | if (!"GROUP_BUY".equals(String.valueOf(order.get("orderType")))) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "当前业务类型不支持撤销核销"); |
| | | } |
| | |
| | | return result; |
| | | } |
| | | |
| | | private String revokeReason(String orderType, boolean withdrawalLocked) { |
| | | if (withdrawalLocked) return "订单已进入提现记录"; |
| | | private String revokeReason(String orderType) { |
| | | return "GROUP_BUY".equals(orderType) ? "已过可撤销时间" : "当前业务类型不支持撤销核销"; |
| | | } |
| | | |
| | |
| | | result.put("couponAmount", couponAmount); |
| | | // 免费入住没有 benefit_order 金额,补贴来自核销记录中的后台配置,不能按原价减实付覆盖为 0。 |
| | | result.put("subsidyAmount", "FREE_STAY".equals(orderType) ? configuredSubsidyAmount : totalDiscount); |
| | | // 已进入提现记录的订单不能再撤销核销,商户端不值得展示一个必然失败的按钮。 |
| | | Long withdrawalStatus = number(order.get("withdrawalStatus")); |
| | | boolean withdrawalLocked = withdrawalStatus != null && withdrawalStatus == 1; |
| | | boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1 && !withdrawalLocked; |
| | | boolean canRevoke = "GROUP_BUY".equals(orderType) && verifyTime != null && Duration.between(verifyTime, LocalDateTime.now()).toHours() < REVOCATION_HOURS && verifyStatus != null && verifyStatus == 1; |
| | | result.put("canRevoke", canRevoke); |
| | | result.put("revokeDeadline", verifyTime == null ? null : verifyTime.plusHours(REVOCATION_HOURS).toString()); |
| | | result.put("revokeReason", canRevoke ? "" : revokeReason(orderType, withdrawalLocked)); |
| | | result.put("revokeReason", canRevoke ? "" : revokeReason(orderType)); |
| | | return result; |
| | | } |
| | | |
| | |
| | | * <p>一个订单的核销、退款和提现锁定都会先锁定 {@code benefit_order} 的同一行,因此它们按订单串行化: |
| | | * 不会出现「退了款又核销」「核销后被退款」「已经进了提现记录又撤销核销」。 |
| | | * |
| | | * <p>{@code refund_status} 由本服务的退款三个方法维护;{@code withdrawal_status} 由本服务的 |
| | | * {@link #lockIntoWithdrawal(long)}(工单 04 的每日汇总把订单登记进提现记录时调用)和 |
| | | * {@link #releaseFromWithdrawal(long)}(工单 05 在斗拱确认取现失败时把订单放回可汇总范围)维护。 |
| | | * <p>{@code refund_status} 由本服务的退款三个方法维护。出款已改由斗拱按结算周期自动完成, |
| | | * 平台不再生成提现记录,因此订单上不再有「已进入提现记录」这一状态。 |
| | | * 核销、退款、提现锁定与解锁的业务规则都收敛在这里,调用方只负责在拿到 {@link LockedOrder} 后调用 |
| | | * 对应断言。 |
| | | * |
| | |
| | | /** 订单退款状态:退款成功(部分退款也算整单退出结算)。 */ |
| | | public static final long REFUNDED = 2; |
| | | |
| | | /** 提现锁定状态:未进入提现记录。 */ |
| | | public static final long WITHDRAWAL_NONE = 0; |
| | | /** 提现锁定状态:已进入提现记录,不能再撤销核销或退款。 */ |
| | | public static final long WITHDRAWAL_LOCKED = 1; |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | |
| | | public OrderFundBoundaryService(JdbcTemplate jdbc) { |
| | |
| | | @Transactional(propagation = Propagation.MANDATORY) |
| | | public LockedOrder lock(long orderId) { |
| | | List<Map<String, Object>> rows = jdbc.queryForList(""" |
| | | select id, order_status, verify_status, refund_status, withdrawal_status |
| | | select id, order_status, verify_status, refund_status |
| | | from benefit_order |
| | | where id = ? and deleted = 0 |
| | | limit 1 for update |
| | |
| | | """, orderId); |
| | | } |
| | | |
| | | /** |
| | | * 提现锁定:把已核销订单登记进某条提现记录后调用(工单 04 的每日汇总入口)。 |
| | | * |
| | | * <p>只有「已支付、已核销、未退款、未进入其他提现记录」的订单才能被锁定,因此同一条订单 |
| | | * 不会被两条提现记录重复汇总。锁定后订单不能再撤销核销或退款。 |
| | | */ |
| | | public void lockIntoWithdrawal(long orderId) { |
| | | int updated = jdbc.update(""" |
| | | update benefit_order |
| | | set withdrawal_status = 1 |
| | | where id = ? and deleted = 0 and withdrawal_status = 0 |
| | | and order_status = 1 and verify_status = 1 and refund_status = 0 |
| | | """, orderId); |
| | | if (updated != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单不满足进入提现记录的条件"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 提现顺延解锁:斗拱确认这次主动取现失败、没有资金变动时调用(工单 05 的失败顺延)。 |
| | | * |
| | | * <p>解锁后订单回到「未进入提现记录」,下一次每日汇总会把它连同新订单一起重新提交,这就是 |
| | | * 「失败顺延」。解锁只针对失败的提现记录,成功到账的订单不会经过这里;是否还能再次尝试由调用方 |
| | | * 按重试上限判断。 |
| | | */ |
| | | public void releaseFromWithdrawal(long orderId) { |
| | | jdbc.update(""" |
| | | update benefit_order |
| | | set withdrawal_status = 0 |
| | | where id = ? and deleted = 0 and withdrawal_status = 1 |
| | | """, orderId); |
| | | } |
| | | |
| | | /** 一条已被行锁固定的订单资金状态。 */ |
| | | public record LockedOrder(long orderId, long orderStatus, long verifyStatus, long refundStatus, long withdrawalStatus) { |
| | | public record LockedOrder(long orderId, long orderStatus, long verifyStatus, long refundStatus) { |
| | | |
| | | static LockedOrder fromRow(Map<String, Object> row) { |
| | | return new LockedOrder( |
| | | number(row.get("id")), |
| | | number(row.get("order_status")), |
| | | number(row.get("verify_status")), |
| | | number(row.get("refund_status")), |
| | | number(row.get("withdrawal_status"))); |
| | | number(row.get("refund_status"))); |
| | | } |
| | | |
| | | public boolean withdrawalLocked() { |
| | | return withdrawalStatus == WITHDRAWAL_LOCKED; |
| | | } |
| | | |
| | | /** 未支付、已核销、已退款或退款中、已进入提现记录的订单都不能再核销。 */ |
| | | /** 未支付、已核销、已退款或退款中的订单都不能再核销。 */ |
| | | public void requireVerifiable() { |
| | | if (orderStatus != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单未支付,不能核销"); |
| | |
| | | if (refundStatus != REFUND_NONE) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已退款或退款中,不能核销"); |
| | | } |
| | | if (withdrawalLocked()) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能核销"); |
| | | } |
| | | } |
| | | |
| | | /** 未支付、已退款或退款中、已进入提现记录的订单都不能再退款。 */ |
| | | /** 未支付、已退款或退款中的订单都不能再退款。 */ |
| | | public void requireRefundable() { |
| | | if (orderStatus != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单当前状态不可退款"); |
| | |
| | | } |
| | | if (refundStatus == REFUNDED) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已退款"); |
| | | } |
| | | if (withdrawalLocked()) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能退款"); |
| | | } |
| | | } |
| | | |
| | | /** 进入提现记录的订单不能再撤销核销。 */ |
| | | public void requireRevocable() { |
| | | if (withdrawalLocked()) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已进入提现记录,不能撤销核销"); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | import java.util.Map; |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.talentcard.server.business.merchant.AutoWithdrawalResultService; |
| | | import com.talentcard.server.business.merchant.MerchantSettlementService; |
| | | import com.talentcard.server.business.payment.huifu.HuifuSignatureService; |
| | | import com.talentcard.server.common.BusinessException; |
| | |
| | | public class PaymentCallbackController { |
| | | |
| | | private final HuifuPaymentCallbackService callbackService; |
| | | private final AutoWithdrawalResultService withdrawalService; |
| | | private final HuifuSignatureService signatureService; |
| | | private final MerchantSettlementService settlementService; |
| | | |
| | | public PaymentCallbackController(HuifuPaymentCallbackService callbackService, AutoWithdrawalResultService withdrawalService, HuifuSignatureService signatureService, MerchantSettlementService settlementService) { |
| | | public PaymentCallbackController(HuifuPaymentCallbackService callbackService, HuifuSignatureService signatureService, MerchantSettlementService settlementService) { |
| | | this.callbackService = callbackService; |
| | | this.withdrawalService = withdrawalService; |
| | | this.signatureService = signatureService; |
| | | this.settlementService = settlementService; |
| | | } |
| | |
| | | @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) |
| | | public ResponseEntity<String> notify(@RequestParam Map<String, String> form) { |
| | | return ResponseEntity.ok(callbackService.handle(form.get("resp_data"), form.get("sign"))); |
| | | } |
| | | |
| | | /** |
| | | * 主动取现的异步通知。验签只接受斗拱平台公钥的签名;请求号必须对得上一条本地提现记录, |
| | | * 否则视为未知通知。真正的状态收敛由 {@link AutoWithdrawalResultService} 幂等地完成, |
| | | * 因此重复通知、通道的两次通知(msg_type 01 通道 / 02 账务)都会得到同一个最终状态。 |
| | | */ |
| | | @PostMapping(value = "/withdrawal-notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) |
| | | public ResponseEntity<String> withdrawalNotify(@RequestParam Map<String, String> form) { |
| | | String payload = form.get("resp_data"), signature = form.get("sign"); |
| | | if (!signatureService.verifyCallbackPayload(payload, signature)) throw new BusinessException(ErrorCode.FORBIDDEN, "Invalid Huifu withdrawal callback signature"); |
| | | @SuppressWarnings("unchecked") Map<String, Object> data = JSON.parseObject(payload, Map.class); |
| | | String no = data.get("req_seq_id") == null ? "" : String.valueOf(data.get("req_seq_id")).trim(); |
| | | if (no.isBlank() || !withdrawalService.exists(no)) throw new BusinessException(ErrorCode.NOT_FOUND, "Unknown Huifu withdrawal callback"); |
| | | withdrawalService.handleNotification(no, data); |
| | | return ResponseEntity.ok("RECV_ORD_ID_" + no); |
| | | } |
| | | |
| | | @PostMapping(value = "/merchant-business-notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) |
| | |
| | | /** |
| | | * Shared readings of a Huifu payload. |
| | | * |
| | | * <p>A callback and a synchronous response carry the same business fields, so payment, refund and |
| | | * withdrawal results are all interpreted here instead of each caller keeping its own field names, |
| | | * money conversion and status rules. The provider reports money in yuan with two decimals while |
| | | * local amounts are integer cents. |
| | | * <p>A callback and a synchronous response carry the same business fields, so payment, delay |
| | | * confirmation and refund results are all interpreted here instead of each caller keeping its own |
| | | * field names, money conversion and status rules. The provider reports money in yuan with two |
| | | * decimals while local amounts are integer cents. |
| | | */ |
| | | public final class HuifuPayload { |
| | | |
| | |
| | | private String privateKey = ""; |
| | | private String platformPublicKey = ""; |
| | | private String notifyUrl = ""; |
| | | private String withdrawalProductId = ""; |
| | | private String withdrawalNotifyUrl = ""; |
| | | private String merchantBusinessNotifyUrl = ""; |
| | | private String merchantOnboardingNotifyUrl = ""; |
| | | private String merchantWechatConfigNotifyUrl = ""; |
| | |
| | | private String merchantOnlineBusinessType = "H5311L"; |
| | | private String merchantOnlineMediaName = "东区人才卡"; |
| | | private String merchantAgreementUrl = "https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm"; |
| | | private String withdrawalIntoAccountDateType = "DM"; |
| | | private boolean delayAccount; |
| | | private Boolean merchantCapabilityRequired; |
| | | |
| | | public boolean isEnabled() { return enabled; } |
| | |
| | | public void setPlatformPublicKey(String platformPublicKey) { this.platformPublicKey = platformPublicKey; } |
| | | public String getNotifyUrl() { return notifyUrl; } |
| | | public void setNotifyUrl(String notifyUrl) { this.notifyUrl = notifyUrl; } |
| | | public String getWithdrawalProductId() { return withdrawalProductId; } |
| | | public void setWithdrawalProductId(String withdrawalProductId) { this.withdrawalProductId = withdrawalProductId; } |
| | | public String getWithdrawalNotifyUrl() { return withdrawalNotifyUrl; } |
| | | public void setWithdrawalNotifyUrl(String withdrawalNotifyUrl) { this.withdrawalNotifyUrl = withdrawalNotifyUrl; } |
| | | public String getMerchantBusinessNotifyUrl() { |
| | | if (merchantBusinessNotifyUrl != null && !merchantBusinessNotifyUrl.isBlank()) return merchantBusinessNotifyUrl; |
| | | return withdrawalNotifyUrl == null ? "" : withdrawalNotifyUrl.replace("/withdrawal-notify", "/merchant-business-notify"); |
| | | return callbackUrl("merchant-business-notify"); |
| | | } |
| | | public void setMerchantBusinessNotifyUrl(String merchantBusinessNotifyUrl) { this.merchantBusinessNotifyUrl = merchantBusinessNotifyUrl; } |
| | | public String getMerchantOnboardingNotifyUrl() { |
| | | if (merchantOnboardingNotifyUrl != null && !merchantOnboardingNotifyUrl.isBlank()) return merchantOnboardingNotifyUrl; |
| | | return withdrawalNotifyUrl == null ? "" : withdrawalNotifyUrl.replace("/withdrawal-notify", "/merchant-onboarding-notify"); |
| | | return callbackUrl("merchant-onboarding-notify"); |
| | | } |
| | | public void setMerchantOnboardingNotifyUrl(String merchantOnboardingNotifyUrl) { this.merchantOnboardingNotifyUrl = merchantOnboardingNotifyUrl; } |
| | | public String getMerchantWechatConfigNotifyUrl() { |
| | |
| | | public void setMerchantOnlineMediaName(String merchantOnlineMediaName) { this.merchantOnlineMediaName = merchantOnlineMediaName; } |
| | | public String getMerchantAgreementUrl() { return merchantAgreementUrl; } |
| | | public void setMerchantAgreementUrl(String merchantAgreementUrl) { this.merchantAgreementUrl = merchantAgreementUrl; } |
| | | public String getWithdrawalIntoAccountDateType() { return withdrawalIntoAccountDateType; } |
| | | public void setWithdrawalIntoAccountDateType(String withdrawalIntoAccountDateType) { this.withdrawalIntoAccountDateType = withdrawalIntoAccountDateType; } |
| | | public boolean isDelayAccount() { return delayAccount; } |
| | | public void setDelayAccount(boolean delayAccount) { this.delayAccount = delayAccount; } |
| | | /** Provider-side capability confirmation is on whenever the provider integration is enabled. */ |
| | | public boolean isMerchantCapabilityRequired() { |
| | | return merchantCapabilityRequired != null ? merchantCapabilityRequired : enabled; |
| | | } |
| | | public void setMerchantCapabilityRequired(boolean merchantCapabilityRequired) { this.merchantCapabilityRequired = merchantCapabilityRequired; } |
| | | |
| | | /** 由支付回调地址推导同一 host 与路径下的其它回调地址。 */ |
| | | private String callbackUrl(String endpoint) { |
| | | if (withdrawalNotifyUrl == null || withdrawalNotifyUrl.isBlank()) return ""; |
| | | int slash = withdrawalNotifyUrl.lastIndexOf('/'); |
| | | return slash < 0 ? "" : withdrawalNotifyUrl.substring(0, slash + 1) + endpoint; |
| | | if (notifyUrl == null || notifyUrl.isBlank()) return ""; |
| | | int slash = notifyUrl.lastIndexOf('/'); |
| | | return slash < 0 ? "" : notifyUrl.substring(0, slash + 1) + endpoint; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import com.huifu.bspay.sdk.opps.client.BasePayClient; |
| | | import com.huifu.bspay.sdk.opps.core.BasePay; |
| | | import com.huifu.bspay.sdk.opps.core.config.MerConfig; |
| | | import com.huifu.bspay.sdk.opps.core.request.V2MerchantBasicdataSettlementQueryRequest; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.time.format.DateTimeFormatter; |
| | | |
| | | /** |
| | | * 斗拱结算记录查询(只读)。 |
| | | * |
| | | * <p>出款改由斗拱按结算周期自动完成之后,平台不再生成提现记录,「每天结了多少到银行卡」只能问通道。 |
| | | * 这个网关只做这一件事:按日期区间查一个子商户的结算记录,供后台监控使用。 |
| | | */ |
| | | @Service |
| | | public class HuifuSettlementRecordGateway { |
| | | |
| | | private static final Object SDK_INIT_LOCK = new Object(); |
| | | private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); |
| | | private static final Logger log = LoggerFactory.getLogger(HuifuSettlementRecordGateway.class); |
| | | |
| | | /** 单页上限:通道按页返回,后台一页最多展示这么多条。 */ |
| | | private static final int MAX_PAGE_SIZE = 100; |
| | | |
| | | private final HuifuPaymentProperties properties; |
| | | |
| | | public HuifuSettlementRecordGateway(HuifuPaymentProperties properties) { |
| | | this.properties = properties; |
| | | } |
| | | |
| | | /** |
| | | * 查询 {@code [beginDate, endDate]} 区间的结算记录,日期格式 {@code yyyyMMdd}。 |
| | | * |
| | | * <p>返回通道的原始回执:调用方只做展示,不做业务判断,因此这里不解析字段含义, |
| | | * 避免通道新增字段时又要改一次映射。 |
| | | */ |
| | | public SettlementRecords query(String huifuId, String beginDate, String endDate, int pageSize) { |
| | | ensureConfigured(); |
| | | if (huifuId == null || huifuId.isBlank()) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少斗拱子商户号,无法查询结算记录"); |
| | | } |
| | | initializeSdk(); |
| | | V2MerchantBasicdataSettlementQueryRequest request = new V2MerchantBasicdataSettlementQueryRequest(); |
| | | request.setReqDate(LocalDate.now().format(REQUEST_DATE)); |
| | | request.setReqSeqId("SQ" + System.currentTimeMillis()); |
| | | request.setHuifuId(huifuId.trim()); |
| | | request.setBeginDate(beginDate); |
| | | request.setEndDate(endDate); |
| | | request.setPageSize(String.valueOf(Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE))); |
| | | try { |
| | | return SettlementRecords.from(BasePayClient.request(request, false)); |
| | | } catch (Exception exception) { |
| | | log.warn("Huifu settlement record query failed: type={}, message={}", |
| | | exception.getClass().getSimpleName(), safe(exception.getMessage())); |
| | | throw new HuifuPaymentGatewayException("汇付结算记录查询失败", isOutcomeUnknown(exception), exception); |
| | | } |
| | | } |
| | | |
| | | private void ensureConfigured() { |
| | | if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "汇付支付尚未启用"); |
| | | if (blank(properties.getSystemId()) || blank(properties.getProductId()) |
| | | || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "汇付支付配置不完整"); |
| | | } |
| | | } |
| | | |
| | | private void initializeSdk() { |
| | | synchronized (SDK_INIT_LOCK) { |
| | | BasePay.debug = false; |
| | | BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) |
| | | ? BasePay.MODE_PROD : BasePay.MODE_TEST; |
| | | MerConfig config = new MerConfig(); |
| | | config.setSysId(properties.getSystemId().trim()); |
| | | config.setProductId(properties.getProductId().trim()); |
| | | config.setRsaPrivateKey(properties.getPrivateKey().trim()); |
| | | config.setRsaPublicKey(properties.getPlatformPublicKey().trim()); |
| | | try { |
| | | BasePay.initWithMerConfig(config); |
| | | } catch (Exception exception) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "汇付支付配置初始化失败"); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private boolean blank(String value) { |
| | | return value == null || value.isBlank(); |
| | | } |
| | | |
| | | private String safe(String message) { |
| | | if (message == null) return ""; |
| | | return message.length() > 300 ? message.substring(0, 300) : message; |
| | | } |
| | | |
| | | private boolean isOutcomeUnknown(Exception exception) { |
| | | String message = safe(exception.getMessage()).toLowerCase(); |
| | | return message.contains("timeout") || message.contains("no response") || message.contains("timed out"); |
| | | } |
| | | |
| | | /** 通道结算回执:原始记录列表加翻页信息。 */ |
| | | public record SettlementRecords(boolean succeeded, String code, String message, int total, |
| | | String rawRecords) { |
| | | |
| | | static SettlementRecords from(java.util.Map<String, Object> response) { |
| | | java.util.Map<String, Object> data = HuifuPayload.businessData(response); |
| | | String code = HuifuPayload.code(data); |
| | | String message = HuifuPayload.message(data); |
| | | boolean succeeded = HuifuPayload.successCode(code); |
| | | int total = 0; |
| | | Object count = data.get("result_count"); |
| | | if (count instanceof Number number) total = number.intValue(); |
| | | else if (count != null) { |
| | | try { |
| | | total = Integer.parseInt(String.valueOf(count).trim()); |
| | | } catch (NumberFormatException ignored) { |
| | | total = 0; |
| | | } |
| | | } |
| | | String records = HuifuPayload.value(data, "trans_log_result_list", "transLogResultList"); |
| | | return new SettlementRecords(succeeded, code, message, total, records); |
| | | } |
| | | } |
| | | } |
| | |
| | | private-key: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzPfipd3gxqHSr/ZiZG4hGXXulhXY9PCdf644SiE8IpH2Gf8pmvfTgs77FoyWVAPmYyF3JrQGQTlKjtHzkUGW20FETwopDwh7KP9zUvrHRHsAhCEi+kkD+4OO4BgP6I4sxJRANcVQM/G0woScxdHJB74gxVVnlxr3RpiR2aKyaEX6jCIsYiCXcuNJ7tpjX5H6pnFEldjWQpJgd1QnLrybgf66mEzQ/ym7SvaQ8oNWiILcpmUsAOUMwMgEr4J9MWTW/XH78++GtvrjEH311lmOk7+cM9qhGzvguXUFQkLnckseYnYAoEUaXmioBXu5qrqifMGZYCThLX9pzbJsFgswxAgMBAAECggEAbVdxmJert8dGnSDPdR8Ji7s9UWTQE1zdvDLq8T070U7e0CymoPRiLK0ZxGDY7UOx/1TDeeLTIF/XI8xXn8O3uMTtoLs9M0Z5F/HLWfCWcrZqh/wQvEhl49X2tGzfAjmqkKlGWJz4pA9c4Jx+Vzns9XIUx9uEQppxczIB7kW+fSEHGGIrilMbFPDWlqkjMuMBN7pDNUCMh9zEYCweLAzB1ZM0W3DVrRntCHJj1nLEFmBAsVDDy5J/m1Ew1SQ0252Ky8arYF6FjeOpnzw8WbCAXk6Cb3hjz83KWyoLXMzrHvkzEaR8eSaB5rmDP2Ukz5FIclqK91aMFVL8OQzq7Vh2fQKBgQDkWQIFK8s+YasyqcM+goXeYh+89anwbSbFB3yWrkIJrfh2aCjV0i/71tqh3aGKIZXokjoBR6ASWlHHtYJtAoms5wdvp6W/8phWlpM4clEsb8BHlQhWy+XCvH+KVVZAQrNMsAZPtUp/FhURnXJm7c7A9z3a0O10pSRu0ZuDcWc9OwKBgQDI8qS85p5Z5uy9WWcU676wS7dXyXK6Ev6Z3gMQpj2sGwCDOj0eZr3QZnLkFzLqV87pp5hsqX2xjphOrlVFKQDyWES5VF0hkX6jRMyodRw72FNEBAtEHXTaLY8QuBS6Sdxktywa50KZzbyJJ3RZgdFCpMwLxMakRG090nzhsu/1gwKBgQDA6pHSXJVxNoK0YsgY35zJrIyzz7iq7wx6GUEg7EfOdbQcFROq0dDIK1S6u8vmL0m/Gwr2WdTarIO6/ZYbErHN6cFWi4R1Dje1r7DhV3NM33zK/RX2fny2cRovfLlJwRBKEczDOp6dj8JtB1/HpdGvJTKU0z5J9ds55MxF2GEoUQKBgQCFp44W7rVISqYP0xUS/zIYBGRwCVc7mEKriNU1D71ZaaRPpD7ep6t8n4evWhbNc5X2JzjknV8y6As52g5xL7KnfjpHomtHVoSKh8owCaS70UigpqfvqD/+lergTe/LcDEc9BBr3IGZ1xOdjhZ/Ki9LSl1FPp54x+J5CjVx0Rh1jQKBgGfyr/mG9BMoXu6tHUoxxY5xw0Q8IA2N0kcSoilp233QQRBnxnO4zm7FITpnovaGvTj9LT6K+kDWxDTGG/Da+qzxtAMquJ5ZRpWv3fGEu6jzsJWcxTLMTiNroezq8oynNQbVqLlst1hl0LP6hX60b4jjEKHcQQKCerGav19Ay2Hj |
| | | platform-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhRV2nN2xnEGw4giX8O/K3j3UdD80QKeigZqkPaVPULBc8D/Ov9bzioz/eMsprbI1grU8M6LCcGC0eFgBpA5YHxKzL4bjiAxzYbbzK/l+LaqD1Xzg4/k5qYiPe7I8BUU5FcpR37sRgO+14dwUYtFyjuIDENcsiUcadEpt45xxfFDKm+xaKtEBGDYS4fRQoVd/QvXTf6dZwWyK15uB84gqyjQFwJF+r4FtmSwe11bLf83D9M5nHpfZ8xiRNPGUXWaoeX6TT/QMY94Ep5hdkqGiNYJsk8Fdvdqkpelqqk4EGeGNBoHcvEZ6pIorFNUTZ0L99BI8poOGia+71W82xd20vwIDAQAB |
| | | notify-url: ${HUIFU_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/notify} |
| | | withdrawal-product-id: ${HUIFU_WITHDRAWAL_PRODUCT_ID:KAZX} |
| | | withdrawal-notify-url: ${HUIFU_WITHDRAWAL_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/withdrawal-notify} |
| | | merchant-onboarding-notify-url: ${HUIFU_MERCHANT_ONBOARDING_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-onboarding-notify} |
| | | merchant-wechat-config-notify-url: ${HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-wechat-config-notify} |
| | | merchant-business-open-notify-url: ${HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-business-open-notify} |
| | |
| | | merchant-online-business-type: ${HUIFU_MERCHANT_ONLINE_BUSINESS_TYPE:H5311L} |
| | | merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:东区人才卡} |
| | | merchant-agreement-url: ${HUIFU_MERCHANT_AGREEMENT_URL:https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm} |
| | | delay-account: ${HUIFU_DELAY_ACCOUNT:false} |
| | | # A merchant may collect payments only after the provider confirms auto settlement is off and encashment is available. |
| | | # Set false only for a deployment that deliberately runs without provider-side capability confirmation. |
| | | # 收款能力只要求「斗拱子商户正常 + 取现可用」:未核销的钱由延迟户兜住, |
| | | # 自动结算开着不再影响收款。设 false 仅用于刻意跳过通道能力确认的部署。 |
| | | merchant-capability-required: ${HUIFU_MERCHANT_CAPABILITY_REQUIRED:true} |
| | | capability-refresh: |
| | | enabled: ${HUIFU_CAPABILITY_REFRESH_ENABLED:true} |
| | | stale-hours: ${HUIFU_CAPABILITY_STALE_HOURS:6} |
| | | fixed-delay-ms: ${HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS:1800000} |
| | | merchant: |
| | | auto-withdrawal: |
| | | # 每日凌晨按北京时间汇总前一个自然日已核销订单,生成一条商户(门店)日汇总提现记录,并调用斗拱 |
| | | # 主动取现接口。手动提现入口和后台审核已在工单 05 移除,这个开关不再有「同一批订单出两次账」的风险。 |
| | | # 它只管「自动汇总」;结果收敛(补提交 + 查询)由 result-check-enabled 单独控制。 |
| | | enabled: ${AUTO_WITHDRAWAL_ENABLED:false} |
| | | # 不设最低提现金额和手续费缓冲:斗拱主动取现没有最低金额,取现也不收手续费(手续费在支付时内扣), |
| | | # 当天有已核销订单就出账,金额再小也照提。 |
| | | cron: ${AUTO_WITHDRAWAL_CRON:0 10 0 * * *} |
| | | # 同一订单失败后最多重新提交几次;达到上限后金额留在失败记录里等人处理。 |
| | | max-attempts: ${AUTO_WITHDRAWAL_MAX_ATTEMPTS:3} |
| | | # 提交后至少等这么久才查询结果,避免通道还没落库时把「查不到」误判成失败。 |
| | | query-after-minutes: ${AUTO_WITHDRAWAL_QUERY_AFTER_MINUTES:5} |
| | | result-check-enabled: ${AUTO_WITHDRAWAL_RESULT_CHECK_ENABLED:true} |
| | | result-check-fixed-delay-ms: ${AUTO_WITHDRAWAL_RESULT_CHECK_FIXED_DELAY_MS:300000} |
| | | wechat: |
| | | mini-program: |
| | | # The app id must be the same mini-program that invokes uni.login and uni.requestPayment. |
| New file |
| | |
| | | -- 出款改为斗拱按结算周期自动完成后,平台不再生成提现记录。 |
| | | -- |
| | | -- 资金流变成:支付 → 延迟户(不可结算、不可取现)→ 核销确认 → 商户基本户 → |
| | | -- 按结算周期自动结算到结算卡。平台侧不再有「汇总 → 提交取现 → 结果收敛」这条链路, |
| | | -- 因此提现记录表、订单与提现记录的关联、以及订单上的提现锁定状态全部退役。 |
| | | -- |
| | | -- 后台的「结算记录」页改为直接查斗拱的结算流水,不再依赖本地表。 |
| | | -- |
| | | -- 用 exists 判断逐项删除,使脚本可以重复执行。 |
| | | DROP PROCEDURE IF EXISTS drop_withdrawal_objects; |
| | | DELIMITER // |
| | | CREATE PROCEDURE drop_withdrawal_objects() |
| | | BEGIN |
| | | IF EXISTS (SELECT 1 FROM information_schema.tables |
| | | WHERE table_schema = DATABASE() AND table_name = 'merchant_withdrawal_order') THEN |
| | | DROP TABLE merchant_withdrawal_order; |
| | | END IF; |
| | | IF EXISTS (SELECT 1 FROM information_schema.tables |
| | | WHERE table_schema = DATABASE() AND table_name = 'merchant_withdrawal') THEN |
| | | DROP TABLE merchant_withdrawal; |
| | | END IF; |
| | | IF EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'benefit_order' |
| | | AND column_name = 'withdrawal_status') THEN |
| | | ALTER TABLE benefit_order DROP COLUMN withdrawal_status; |
| | | END IF; |
| | | END // |
| | | DELIMITER ; |
| | | CALL drop_withdrawal_objects(); |
| | | DROP PROCEDURE drop_withdrawal_objects; |
| | |
| | | order_status tinyint not null, |
| | | verify_status tinyint not null, |
| | | refund_status tinyint not null default 0, |
| | | withdrawal_status tinyint not null default 0, |
| | | merchant_id bigint not null, |
| | | store_id bigint, |
| | | deleted tinyint not null default 0 |
| | |
| | | } |
| | | |
| | | @Test |
| | | void anUnpaidOrderCannotBeRefundedOrLockedForWithdrawal() { |
| | | void anUnpaidOrderCannotBeRefunded() { |
| | | String orderNo = insertOrder("GROUP_BUY", 0, 0); |
| | | Long orderId = orderId(orderNo); |
| | | |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "unpaid", "tester"))) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | |
| | | } |
| | | |
| | | @Test |
| | | void anExpiredOrderCannotBeVerifiedRefundedOrLockedForWithdrawal() { |
| | | void anExpiredOrderCannotBeVerifiedOrRefunded() { |
| | | // 超时未支付订单会被取消为 order_status = 2(与已退款共用同一个终态)。 |
| | | String orderNo = insertOrder("GROUP_BUY", 2, 0); |
| | | Long orderId = orderId(orderNo); |
| | |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "expired", "tester"))) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class); |
| | | |
| | | assertThat(activeVerifyRecords(orderId)).isZero(); |
| | | } |
| | | |
| | |
| | | // 全额退款同时把订单置为已退款状态,核销被拒;无论命中哪条规则,都不允许核销。 |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after callback"), "callback-succeeded-" + suffix)) |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class); |
| | | |
| | | } |
| | | |
| | | @Test |
| | |
| | | } |
| | | |
| | | @Test |
| | | void aPartialRefundStillExcludesTheWholeOrderFromVerificationAndWithdrawal() { |
| | | void aPartialRefundStillExcludesTheWholeOrderFromVerification() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | when(refundGateway.createRefund(any())).thenReturn(providerResponse(Map.of( |
| | |
| | | assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "after partial refund"), "partial-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("退款"); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | |
| | | } |
| | | |
| | | @Test |
| | |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "too late", "tester"))) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("refunded"); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderInAWithdrawalRecordCannotBeRevokedOrRefunded() { |
| | | String orderNo = insertPaidOrderWithSucceededPayment(); |
| | | Long orderId = orderId(orderNo); |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "verified"), "locked-" + suffix); |
| | | |
| | | orderFundBoundaryService.lockIntoWithdrawal(orderId); |
| | | |
| | | assertThat(jdbc.queryForObject("select withdrawal_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1); |
| | | assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal, |
| | | new MerchantRevokeVerificationRequest(String.valueOf(orderId), "too late"), "locked-revoke-" + suffix)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | assertThatThrownBy(() -> refundService.request(orderNo, new OrderRefundRequest(new BigDecimal("90.00"), "too late", "tester"))) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("提现记录"); |
| | | assertThatThrownBy(() -> orderFundBoundaryService.lockIntoWithdrawal(orderId)) |
| | | .as("同一订单不能被两条提现记录重复汇总") |
| | | .isInstanceOf(BusinessException.class); |
| | | assertThat(merchantWorkService.order(orderNo, merchantId, storeId)) |
| | | .containsEntry("canRevoke", false) |
| | | .containsEntry("revokeReason", "订单已进入提现记录"); |
| | | assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | |
| | | System.out.println("Huifu merchantId=" + runtimeProperties.getMerchantId()); |
| | | System.out.println("Huifu miniProgramAppId=" + runtimeProperties.getMiniProgramAppId()); |
| | | System.out.println("Huifu notifyUrl=" + runtimeProperties.getNotifyUrl()); |
| | | System.out.println("Huifu withdrawalProductId=" + runtimeProperties.getWithdrawalProductId()); |
| | | System.out.println("Huifu withdrawalNotifyUrl=" + runtimeProperties.getWithdrawalNotifyUrl()); |
| | | System.out.println("Huifu withdrawalIntoAccountDateType=" + runtimeProperties.getWithdrawalIntoAccountDateType()); |
| | | System.out.println("Huifu delayAccount=" + runtimeProperties.isDelayAccount()); |
| | | System.out.println("Huifu privateKey=" + runtimeProperties.getPrivateKey()); |
| | | System.out.println("Huifu platformPublicKey=" + runtimeProperties.getPlatformPublicKey()); |
| | | } |