From dc7835c8a9258cbcbe0dbecd4e1c24039ee09e58 Mon Sep 17 00:00:00 2001
From: liujie <1793218484@qq.com>
Date: 星期四, 30 七月 2026 15:45:52 +0800
Subject: [PATCH] update
---
backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerResponse.java | 82 +++++
backend/src/main/java/com/tourguide/modules/finance/service/GuideElectronicLedgerService.java | 212 +++++++++++++++
backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerRequest.java | 76 +++++
backend/src/main/java/com/tourguide/modules/finance/mapper/GuideWithdrawalAccountMapper.java | 7
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRequestSupport.java | 62 ++++
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongUserWithdrawalProfile.java | 12
backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerStatusResponse.java | 25 +
backend/src/main/java/com/tourguide/modules/finance/entity/GuideWithdrawalAccount.java | 163 +++++++++++
backend/src/main/java/com/tourguide/common/huifu/README.md | 49 +++
backend/src/test/java/com/tourguide/modules/finance/service/GuideElectronicLedgerServiceTest.java | 120 ++++++++
10 files changed, 808 insertions(+), 0 deletions(-)
diff --git a/backend/src/main/java/com/tourguide/common/huifu/README.md b/backend/src/main/java/com/tourguide/common/huifu/README.md
new file mode 100644
index 0000000..fe506b0
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/README.md
@@ -0,0 +1,49 @@
+# 斗拱支付接入备注
+
+## 1. 配置接入
+
+目的:让斗拱商户配置从运行环境注入,避免源码硬编码商户号、私钥和公钥。
+操作:通过 `HuiFuProperties` 读取 `huifu.*`,配置项在 `application.yml` 中映射到 `HUIFU_PRODUCT_ID`、`HUIFU_SYS_ID`、`HUIFU_RSA_PRIVATE_KEY`、`HUIFU_RSA_PUBLIC_KEY`、`HUIFU_ID`、`HUIFU_UPPER_HUIFU_ID`、`HUIFU_CALLBACK_PATH`。
+影响范围:影响所有斗拱接口调用,包括支付创建、退款、提现、分账、进件和回调验签。
+验证:执行 `mvn -DskipTests compile`,确认配置类能正常编译;运行环境需要确认这些环境变量非空。
+注意:生产密钥必须通过环境变量或安全配置中心注入,不能提交到 Git。
+
+## 2. 支付创建
+
+目的:后端生成斗拱微信小程序支付或支付宝支付请求,避免前端直接拼接支付参数。
+操作:`DouGongPayUtil` 统一生成 `req_seq_id`、`req_date`、`notify_url`、`trans_amt`,再按斗拱要求生成 RSA 签名并请求 `/v4/trade/payment/create`。
+影响范围:影响后续小程序订单从 `PENDING_PAYMENT` 进入真实支付流程,但当前还没有替换 `MiniTourOrderCommandService.confirmPayment` 的本地确认逻辑。
+验证:调用 `wxPayOrder` 或 `aliPayOrder` 后应拿到斗拱返回体和本地追加的 `req_seq_id`;微信支付响应会额外缓存到 Redis 五分钟。
+注意:创建支付订单不等于本地订单已支付,本地订单状态必须等斗拱回调验签成功后再更新。
+
+## 3. 回调验签
+
+目的:确保支付、退款、提现回调确实来自斗拱平台,防止伪造回调修改本地订单或资金状态。
+操作:`DouGongNotifyController` 接收 `/base/dougong/**`,校验 `sign` 和 `resp_data`,使用 `HUIFU_RSA_PUBLIC_KEY` 验签;验签失败返回 `FAIL`。
+影响范围:影响 `/base/dougong/pay-notify`、`/base/dougong/refund-logistics`、`/base/dougong/refund-crosscity`、`/base/dougong/encashment`。
+验证:安全配置已放行 `/base/dougong/**`;回调成功时日志会记录 `req_seq_id`、`org_req_seq_id`、`trans_stat`。
+注意:当前回调只记录日志,后续要按 `req_seq_id` 做幂等落库,更新 `tour_order.payment_order_no`、`tour_order.status`、`tour_order.payment_time` 等字段。
+
+## 4. 退款处理
+
+目的:通过斗拱发起原路退款,并保留退款回调用于最终确认。
+操作:`DouGongRefundBiz.refund` 组装 `req_seq_id`、`org_req_date`、`org_req_seq_id`、`ord_amt` 和退款回调地址。
+影响范围:影响订单取消、自动退款、后台退款等后续退款链路。
+验证:请求斗拱退款接口后应返回 `resp_code` 和 `resp_desc`;最终退款成功以退款回调 `trans_stat=S` 为准。
+注意:退款请求成功只代表斗拱受理,不能立即把本地退款状态当作最终成功。
+
+## 5. 提现处理
+
+目的:支持导游电子账户余额提现到绑定银行卡。
+操作:`DouGongWithdrawBiz.encashment` 组装提现金额、提现方汇付 ID、取现卡序列号、D1 到账类型和提现回调地址,并按完整报文签名。
+影响范围:影响导游余额、提现单审核、财务流水和提现回调处理。
+验证:斗拱返回体应包含提现受理结果;最终到账状态以 `/base/dougong/encashment` 回调为准。
+注意:当前工具只负责调用斗拱,导游余额扣减和提现单状态变更必须放到业务服务中做幂等处理。
+
+## 6. 后续落地
+
+目的:把斗拱工具真正接入本项目订单和资金模型。
+操作:下一步需要新增“创建斗拱支付订单”和“支付回调确认订单”服务,并在回调中按 `req_seq_id` 查本地订单。
+影响范围:涉及 `MiniTourOrderCommandService.confirmPayment`、`TourOrder` 支付字段、退款字段、导游收益和财务流水。
+验证:支付成功回调后订单只能从 `PENDING_PAYMENT` 流转到 `PENDING_ACCEPT`,重复回调不应重复入账或重复派单。
+注意:在回调落库完成前,不要把斗拱支付创建接口直接暴露为“支付成功”接口。
diff --git a/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRequestSupport.java b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRequestSupport.java
new file mode 100644
index 0000000..002815e
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRequestSupport.java
@@ -0,0 +1,62 @@
+package com.tourguide.common.huifu.biz;
+
+import com.huifu.bspay.sdk.opps.core.BasePay;
+import com.huifu.bspay.sdk.opps.core.config.MerConfig;
+import com.tourguide.common.huifu.properties.HuiFuProperties;
+
+import java.security.SecureRandom;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 斗拱请求公共能力。
+ *
+ * 备注:集中处理 SDK 初始化、日期和流水号生成,避免各业务类重复拼商户配置和随机串。
+ */
+final class DouGongRequestSupport {
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final DateTimeFormatter SEQ_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
+ private static final SecureRandom RANDOM = new SecureRandom();
+ private static final char[] ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();
+
+ private DouGongRequestSupport() {}
+
+ static void initBasePay(HuiFuProperties properties) throws Exception {
+ // 备注:斗拱 SDK 使用全局 BasePay 配置,所有接口调用前都要用当前环境配置初始化一次。
+ MerConfig merConfig = new MerConfig();
+ merConfig.setProcutId(properties.getProductId());
+ merConfig.setSysId(properties.getSysId());
+ merConfig.setRsaPrivateKey(properties.getRsaPrivateKey());
+ merConfig.setRsaPublicKey(properties.getRsaPublicKey());
+ BasePay.initWithMerConfig(merConfig);
+ }
+
+ static String todayForHuiFu() {
+ return LocalDate.now().format(DATE_FORMATTER);
+ }
+
+ static String generateForHuiFu() {
+ return LocalDateTime.now().format(SEQ_TIME_FORMATTER) + randomNumber(6);
+ }
+
+ static String generateForHuiFu(String prefix) {
+ return prefix + generateForHuiFu();
+ }
+
+ static String randomNumber(int length) {
+ StringBuilder builder = new StringBuilder(length);
+ for (int index = 0; index < length; index++) {
+ builder.append(RANDOM.nextInt(10));
+ }
+ return builder.toString();
+ }
+
+ static String randomString(int length) {
+ StringBuilder builder = new StringBuilder(length);
+ for (int index = 0; index < length; index++) {
+ builder.append(ALPHANUMERIC[RANDOM.nextInt(ALPHANUMERIC.length)]);
+ }
+ return builder.toString();
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongUserWithdrawalProfile.java b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongUserWithdrawalProfile.java
new file mode 100644
index 0000000..60229b7
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongUserWithdrawalProfile.java
@@ -0,0 +1,12 @@
+package com.tourguide.common.huifu.biz;
+
+public interface DouGongUserWithdrawalProfile {
+ String getPhone();
+ String getOpenAccountName();
+ String getCertificateCode();
+ String getCertificateExpireTime();
+ String getHuifuId();
+ String getBankCardCode();
+ String getBankCardProvinceCode();
+ String getBankCardCityCode();
+}
diff --git a/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerRequest.java b/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerRequest.java
new file mode 100644
index 0000000..fd7602e
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerRequest.java
@@ -0,0 +1,76 @@
+package com.tourguide.modules.finance.dto;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Pattern;
+import javax.validation.constraints.Size;
+
+public class GuideElectronicLedgerRequest {
+ @NotBlank(message = "证件姓名不能为空")
+ private String certificateName;
+ private Integer certificateType;
+ @NotBlank(message = "证件号码不能为空")
+ private String certificateCode;
+ @NotBlank(message = "证件有效期不能为空")
+ private String certificateExpireTime;
+ private String licenceIssuingAuthority;
+ @NotBlank(message = "银行名称不能为空")
+ private String bankName;
+ @NotBlank(message = "银行卡号不能为空")
+ @Size(max = 32, message = "银行卡号长度不能超过32位")
+ private String bankCardCode;
+ private String bankCode;
+ private String bankBranchCode;
+ @NotBlank(message = "开户名称不能为空")
+ private String openAccountName;
+ private String transactionAuthorizationCode;
+ @NotBlank(message = "手机号不能为空")
+ @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确")
+ private String phone;
+ @NotBlank(message = "身份证正面材料不能为空")
+ private String idCardFrontPicture;
+ @NotBlank(message = "身份证反面材料不能为空")
+ private String idCardReversePicture;
+ @NotBlank(message = "开户行省编码不能为空")
+ private String bankCardProvinceCode;
+ @NotBlank(message = "开户行市编码不能为空")
+ private String bankCardCityCode;
+ private String imgUrl;
+ private String imgUrlOne;
+
+ public String getCertificateName() { return certificateName; }
+ public void setCertificateName(String value) { this.certificateName = value; }
+ public Integer getCertificateType() { return certificateType; }
+ public void setCertificateType(Integer value) { this.certificateType = value; }
+ public String getCertificateCode() { return certificateCode; }
+ public void setCertificateCode(String value) { this.certificateCode = value; }
+ public String getCertificateExpireTime() { return certificateExpireTime; }
+ public void setCertificateExpireTime(String value) { this.certificateExpireTime = value; }
+ public String getLicenceIssuingAuthority() { return licenceIssuingAuthority; }
+ public void setLicenceIssuingAuthority(String value) { this.licenceIssuingAuthority = value; }
+ public String getBankName() { return bankName; }
+ public void setBankName(String value) { this.bankName = value; }
+ public String getBankCardCode() { return bankCardCode; }
+ public void setBankCardCode(String value) { this.bankCardCode = value; }
+ public String getBankCode() { return bankCode; }
+ public void setBankCode(String value) { this.bankCode = value; }
+ public String getBankBranchCode() { return bankBranchCode; }
+ public void setBankBranchCode(String value) { this.bankBranchCode = value; }
+ public String getOpenAccountName() { return openAccountName; }
+ public void setOpenAccountName(String value) { this.openAccountName = value; }
+ public String getTransactionAuthorizationCode() { return transactionAuthorizationCode; }
+ public void setTransactionAuthorizationCode(String value) { this.transactionAuthorizationCode = value; }
+ public String getPhone() { return phone; }
+ public void setPhone(String value) { this.phone = value; }
+ public String getIdCardFrontPicture() { return idCardFrontPicture; }
+ public void setIdCardFrontPicture(String value) { this.idCardFrontPicture = value; }
+ public String getIdCardReversePicture() { return idCardReversePicture; }
+ public void setIdCardReversePicture(String value) { this.idCardReversePicture = value; }
+ public String getBankCardProvinceCode() { return bankCardProvinceCode; }
+ public void setBankCardProvinceCode(String value) { this.bankCardProvinceCode = value; }
+ public String getBankCardCityCode() { return bankCardCityCode; }
+ public void setBankCardCityCode(String value) { this.bankCardCityCode = value; }
+ public String getImgUrl() { return imgUrl; }
+ public void setImgUrl(String value) { this.imgUrl = value; }
+ public String getImgUrlOne() { return imgUrlOne; }
+ public void setImgUrlOne(String value) { this.imgUrlOne = value; }
+}
diff --git a/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerResponse.java b/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerResponse.java
new file mode 100644
index 0000000..bdd5776
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerResponse.java
@@ -0,0 +1,82 @@
+package com.tourguide.modules.finance.dto;
+
+import com.tourguide.modules.finance.entity.GuideWithdrawalAccount;
+
+import java.time.LocalDateTime;
+
+public class GuideElectronicLedgerResponse {
+ private final Integer id;
+ private final String certificateName;
+ private final Integer certificateType;
+ private final String certificateCode;
+ private final String certificateExpireTime;
+ private final String licenceIssuingAuthority;
+ private final String bankName;
+ private final String bankCardCode;
+ private final String bankCode;
+ private final String bankBranchCode;
+ private final String openAccountName;
+ private final String phone;
+ private final String idCardFrontPicture;
+ private final String idCardReversePicture;
+ private final Integer auditStatus;
+ private final String applicationStatus;
+ private final String failureReason;
+ private final String huifuId;
+ private final String bankCardProvinceCode;
+ private final String bankCardCityCode;
+ private final LocalDateTime succeededAt;
+ private final LocalDateTime createTime;
+
+ private GuideElectronicLedgerResponse(GuideWithdrawalAccount account) {
+ this.id = account.getId();
+ this.certificateName = account.getCertificateName();
+ this.certificateType = account.getCertificateType();
+ this.certificateCode = account.getCertificateCode();
+ this.certificateExpireTime = account.getCertificateExpireTime();
+ this.licenceIssuingAuthority = account.getLicenceIssuingAuthority();
+ this.bankName = account.getBankName();
+ this.bankCardCode = account.getBankCardCode();
+ this.bankCode = account.getBankCode();
+ this.bankBranchCode = account.getBankBranchCode();
+ this.openAccountName = account.getOpenAccountName();
+ this.phone = account.getPhone();
+ this.idCardFrontPicture = account.getIdCardFrontPicture();
+ this.idCardReversePicture = account.getIdCardReversePicture();
+ this.auditStatus = account.getIsAudit();
+ this.applicationStatus = account.getApplicationStatus();
+ this.failureReason = account.getFailureMsgsReason();
+ this.huifuId = account.getHuifuId();
+ this.bankCardProvinceCode = account.getBankCardProvinceCode();
+ this.bankCardCityCode = account.getBankCardCityCode();
+ this.succeededAt = account.getSucceededAt();
+ this.createTime = account.getCreateTime();
+ }
+
+ public static GuideElectronicLedgerResponse from(GuideWithdrawalAccount account) {
+ return new GuideElectronicLedgerResponse(account);
+ }
+
+ public Integer getId() { return id; }
+ public String getCertificateName() { return certificateName; }
+ public Integer getCertificateType() { return certificateType; }
+ public String getCertificateCode() { return certificateCode; }
+ public String getCertificateExpireTime() { return certificateExpireTime; }
+ public String getLicenceIssuingAuthority() { return licenceIssuingAuthority; }
+ public String getBankName() { return bankName; }
+ public String getBankCardCode() { return bankCardCode; }
+ public String getBankCode() { return bankCode; }
+ public String getBankBranchCode() { return bankBranchCode; }
+ public String getOpenAccountName() { return openAccountName; }
+ public String getPhone() { return phone; }
+ public String getIdCardFrontPicture() { return idCardFrontPicture; }
+ public String getIdCardReversePicture() { return idCardReversePicture; }
+ public Integer getAuditStatus() { return auditStatus; }
+ public String getApplicationStatus() { return applicationStatus; }
+ public String getFailureReason() { return failureReason; }
+ public String getHuifuId() { return huifuId; }
+ public String getBankCardProvinceCode() { return bankCardProvinceCode; }
+ public String getBankCardCityCode() { return bankCardCityCode; }
+ public LocalDateTime getSucceededAt() { return succeededAt; }
+ public LocalDateTime getCreateTime() { return createTime; }
+}
diff --git a/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerStatusResponse.java b/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerStatusResponse.java
new file mode 100644
index 0000000..c2d4280
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/modules/finance/dto/GuideElectronicLedgerStatusResponse.java
@@ -0,0 +1,25 @@
+package com.tourguide.modules.finance.dto;
+
+import com.tourguide.modules.finance.entity.GuideWithdrawalAccount;
+
+public class GuideElectronicLedgerStatusResponse {
+ private final boolean exists;
+ private final boolean requiresOnboarding;
+ private final Integer auditStatus;
+ private final String applicationStatus;
+ private final String failureReason;
+
+ public GuideElectronicLedgerStatusResponse(GuideWithdrawalAccount account) {
+ this.exists = account != null;
+ this.requiresOnboarding = account == null;
+ this.auditStatus = account == null ? null : account.getIsAudit();
+ this.applicationStatus = account == null ? null : account.getApplicationStatus();
+ this.failureReason = account == null ? null : account.getFailureMsgsReason();
+ }
+
+ public boolean isExists() { return exists; }
+ public boolean isRequiresOnboarding() { return requiresOnboarding; }
+ public Integer getAuditStatus() { return auditStatus; }
+ public String getApplicationStatus() { return applicationStatus; }
+ public String getFailureReason() { return failureReason; }
+}
diff --git a/backend/src/main/java/com/tourguide/modules/finance/entity/GuideWithdrawalAccount.java b/backend/src/main/java/com/tourguide/modules/finance/entity/GuideWithdrawalAccount.java
new file mode 100644
index 0000000..2648c84
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/modules/finance/entity/GuideWithdrawalAccount.java
@@ -0,0 +1,163 @@
+package com.tourguide.modules.finance.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.tourguide.common.huifu.biz.DouGongUserWithdrawalProfile;
+
+import java.time.LocalDateTime;
+
+@TableName("t_user_withdrawal")
+public class GuideWithdrawalAccount implements DouGongUserWithdrawalProfile {
+ @TableId(type = IdType.AUTO)
+ private Integer id;
+ @TableField("userId")
+ private Integer userId;
+ @TableField("userType")
+ private Integer userType;
+ @TableField("certificateName")
+ private String certificateName;
+ @TableField("certificateType")
+ private Integer certificateType;
+ @TableField("certificateCode")
+ private String certificateCode;
+ @TableField("certificateExpireTime")
+ private String certificateExpireTime;
+ @TableField("licenceIssuingAuthority")
+ private String licenceIssuingAuthority;
+ @TableField("bankName")
+ private String bankName;
+ @TableField("bankCardCode")
+ private String bankCardCode;
+ @TableField("bankCode")
+ private String bankCode;
+ @TableField("bankBranchCode")
+ private String bankBranchCode;
+ @TableField("openAccountName")
+ private String openAccountName;
+ @TableField("transactionAuthorizationCode")
+ private String transactionAuthorizationCode;
+ private String phone;
+ @TableField("idCardFrontPicture")
+ private String idCardFrontPicture;
+ @TableField("idCardReversePicture")
+ private String idCardReversePicture;
+ @TableField("isAudit")
+ private Integer isAudit;
+ @TableField("applicationStatus")
+ private String applicationStatus;
+ @TableField("cusId")
+ private String cusId;
+ @TableField("succeededAt")
+ private LocalDateTime succeededAt;
+ @TableField("failureMsgsReason")
+ private String failureMsgsReason;
+ @TableField("relAcctNo")
+ private String relAcctNo;
+ @TableField("bindAcctName")
+ private String bindAcctName;
+ @TableField("balanceAcctId")
+ private String balanceAcctId;
+ @TableField("settleAcctId")
+ private String settleAcctId;
+ @TableField("outRequestNo")
+ private String outRequestNo;
+ @TableField("cusApplicationId")
+ private String cusApplicationId;
+ private Integer status;
+ @TableField("createTime")
+ private LocalDateTime createTime;
+ @TableField("imgUrl")
+ private String imgUrl;
+ @TableField("imgUrlOne")
+ private String imgUrlOne;
+ @TableField("bankCardPrivonceCode")
+ private String bankCardPrivonceCode;
+ @TableField("bankCardCityCode")
+ private String bankCardCityCode;
+ private Integer type;
+ @TableField("huifuId")
+ private String huifuId;
+ @TableField("bankCardProvinceCode")
+ private String bankCardProvinceCode;
+ @TableField("tokenNo")
+ private String tokenNo;
+
+ public Integer getId() { return id; }
+ public void setId(Integer id) { this.id = id; }
+ public Integer getUserId() { return userId; }
+ public void setUserId(Integer userId) { this.userId = userId; }
+ public Integer getUserType() { return userType; }
+ public void setUserType(Integer userType) { this.userType = userType; }
+ public String getCertificateName() { return certificateName; }
+ public void setCertificateName(String certificateName) { this.certificateName = certificateName; }
+ public Integer getCertificateType() { return certificateType; }
+ public void setCertificateType(Integer certificateType) { this.certificateType = certificateType; }
+ public String getCertificateCode() { return certificateCode; }
+ public void setCertificateCode(String certificateCode) { this.certificateCode = certificateCode; }
+ public String getCertificateExpireTime() { return certificateExpireTime; }
+ public void setCertificateExpireTime(String certificateExpireTime) { this.certificateExpireTime = certificateExpireTime; }
+ public String getLicenceIssuingAuthority() { return licenceIssuingAuthority; }
+ public void setLicenceIssuingAuthority(String licenceIssuingAuthority) { this.licenceIssuingAuthority = licenceIssuingAuthority; }
+ public String getBankName() { return bankName; }
+ public void setBankName(String bankName) { this.bankName = bankName; }
+ public String getBankCardCode() { return bankCardCode; }
+ public void setBankCardCode(String bankCardCode) { this.bankCardCode = bankCardCode; }
+ public String getBankCode() { return bankCode; }
+ public void setBankCode(String bankCode) { this.bankCode = bankCode; }
+ public String getBankBranchCode() { return bankBranchCode; }
+ public void setBankBranchCode(String bankBranchCode) { this.bankBranchCode = bankBranchCode; }
+ public String getOpenAccountName() { return openAccountName; }
+ public void setOpenAccountName(String openAccountName) { this.openAccountName = openAccountName; }
+ public String getTransactionAuthorizationCode() { return transactionAuthorizationCode; }
+ public void setTransactionAuthorizationCode(String transactionAuthorizationCode) { this.transactionAuthorizationCode = transactionAuthorizationCode; }
+ public String getPhone() { return phone; }
+ public void setPhone(String phone) { this.phone = phone; }
+ public String getIdCardFrontPicture() { return idCardFrontPicture; }
+ public void setIdCardFrontPicture(String idCardFrontPicture) { this.idCardFrontPicture = idCardFrontPicture; }
+ public String getIdCardReversePicture() { return idCardReversePicture; }
+ public void setIdCardReversePicture(String idCardReversePicture) { this.idCardReversePicture = idCardReversePicture; }
+ public Integer getIsAudit() { return isAudit; }
+ public void setIsAudit(Integer isAudit) { this.isAudit = isAudit; }
+ public String getApplicationStatus() { return applicationStatus; }
+ public void setApplicationStatus(String applicationStatus) { this.applicationStatus = applicationStatus; }
+ public String getCusId() { return cusId; }
+ public void setCusId(String cusId) { this.cusId = cusId; }
+ public LocalDateTime getSucceededAt() { return succeededAt; }
+ public void setSucceededAt(LocalDateTime succeededAt) { this.succeededAt = succeededAt; }
+ public String getFailureMsgsReason() { return failureMsgsReason; }
+ public void setFailureMsgsReason(String failureMsgsReason) { this.failureMsgsReason = failureMsgsReason; }
+ public String getRelAcctNo() { return relAcctNo; }
+ public void setRelAcctNo(String relAcctNo) { this.relAcctNo = relAcctNo; }
+ public String getBindAcctName() { return bindAcctName; }
+ public void setBindAcctName(String bindAcctName) { this.bindAcctName = bindAcctName; }
+ public String getBalanceAcctId() { return balanceAcctId; }
+ public void setBalanceAcctId(String balanceAcctId) { this.balanceAcctId = balanceAcctId; }
+ public String getSettleAcctId() { return settleAcctId; }
+ public void setSettleAcctId(String settleAcctId) { this.settleAcctId = settleAcctId; }
+ public String getOutRequestNo() { return outRequestNo; }
+ public void setOutRequestNo(String outRequestNo) { this.outRequestNo = outRequestNo; }
+ public String getCusApplicationId() { return cusApplicationId; }
+ public void setCusApplicationId(String cusApplicationId) { this.cusApplicationId = cusApplicationId; }
+ public Integer getStatus() { return status; }
+ public void setStatus(Integer status) { this.status = status; }
+ public LocalDateTime getCreateTime() { return createTime; }
+ public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
+ public String getImgUrl() { return imgUrl; }
+ public void setImgUrl(String imgUrl) { this.imgUrl = imgUrl; }
+ public String getImgUrlOne() { return imgUrlOne; }
+ public void setImgUrlOne(String imgUrlOne) { this.imgUrlOne = imgUrlOne; }
+ public String getBankCardPrivonceCode() { return bankCardPrivonceCode; }
+ public void setBankCardPrivonceCode(String bankCardPrivonceCode) { this.bankCardPrivonceCode = bankCardPrivonceCode; }
+ public String getBankCardCityCode() { return bankCardCityCode; }
+ public void setBankCardCityCode(String bankCardCityCode) { this.bankCardCityCode = bankCardCityCode; }
+ public Integer getType() { return type; }
+ public void setType(Integer type) { this.type = type; }
+ public String getHuifuId() { return huifuId; }
+ public void setHuifuId(String huifuId) { this.huifuId = huifuId; }
+ public String getBankCardProvinceCode() { return bankCardProvinceCode; }
+ public void setBankCardProvinceCode(String bankCardProvinceCode) { this.bankCardProvinceCode = bankCardProvinceCode; }
+ public String getTokenNo() { return tokenNo; }
+ public void setTokenNo(String tokenNo) { this.tokenNo = tokenNo; }
+}
diff --git a/backend/src/main/java/com/tourguide/modules/finance/mapper/GuideWithdrawalAccountMapper.java b/backend/src/main/java/com/tourguide/modules/finance/mapper/GuideWithdrawalAccountMapper.java
new file mode 100644
index 0000000..9e2a3dd
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/modules/finance/mapper/GuideWithdrawalAccountMapper.java
@@ -0,0 +1,7 @@
+package com.tourguide.modules.finance.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tourguide.modules.finance.entity.GuideWithdrawalAccount;
+
+public interface GuideWithdrawalAccountMapper extends BaseMapper<GuideWithdrawalAccount> {
+}
diff --git a/backend/src/main/java/com/tourguide/modules/finance/service/GuideElectronicLedgerService.java b/backend/src/main/java/com/tourguide/modules/finance/service/GuideElectronicLedgerService.java
new file mode 100644
index 0000000..b6f5d80
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/modules/finance/service/GuideElectronicLedgerService.java
@@ -0,0 +1,212 @@
+package com.tourguide.modules.finance.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.tourguide.common.exception.BusinessException;
+import com.tourguide.common.huifu.biz.DouGongBasicDataBiz;
+import com.tourguide.modules.finance.dto.GuideElectronicLedgerRequest;
+import com.tourguide.modules.finance.dto.GuideElectronicLedgerResponse;
+import com.tourguide.modules.finance.dto.GuideElectronicLedgerStatusResponse;
+import com.tourguide.modules.finance.entity.GuideWithdrawalAccount;
+import com.tourguide.modules.finance.mapper.GuideWithdrawalAccountMapper;
+import com.tourguide.modules.user.entity.GuideUser;
+import com.tourguide.modules.user.service.GuideUserService;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+@Service
+public class GuideElectronicLedgerService {
+ private static final int GUIDE_USER_TYPE = 2;
+ private static final int HUIFU_TYPE = 1;
+ private static final int ACTIVE_STATUS = 1;
+ private static final int PENDING_AUDIT = 1;
+ private static final String SUCCESS_CODE = "00000000";
+
+ private final GuideWithdrawalAccountMapper accountMapper;
+ private final GuideUserService guideUserService;
+ private final DouGongBasicDataBiz douGongBasicDataBiz;
+
+ public GuideElectronicLedgerService(GuideWithdrawalAccountMapper accountMapper,
+ GuideUserService guideUserService,
+ DouGongBasicDataBiz douGongBasicDataBiz) {
+ this.accountMapper = accountMapper;
+ this.guideUserService = guideUserService;
+ this.douGongBasicDataBiz = douGongBasicDataBiz;
+ }
+
+ public GuideElectronicLedgerResponse create(Long guideId, GuideElectronicLedgerRequest request) {
+ GuideUser guide = guideUserService.findEnabledById(guideId);
+ GuideWithdrawalAccount existing = findCurrent(guideId);
+ if (existing != null && StringUtils.hasText(existing.getHuifuId())) {
+ throw new BusinessException(409, "当前导游已完成斗拱进件,请使用修改接口");
+ }
+
+ GuideWithdrawalAccount account = existing == null ? new GuideWithdrawalAccount() : existing;
+ applyRequest(account, request);
+ account.setUserId(toTableUserId(guideId));
+ account.setUserType(GUIDE_USER_TYPE);
+ account.setType(HUIFU_TYPE);
+ account.setStatus(ACTIVE_STATUS);
+ account.setIsAudit(PENDING_AUDIT);
+ account.setApplicationStatus("processing");
+ account.setFailureMsgsReason(null);
+ account.setOutRequestNo(randomRequestNo());
+ account.setCreateTime(account.getCreateTime() == null ? LocalDateTime.now() : account.getCreateTime());
+ if (!StringUtils.hasText(account.getPhone())) account.setPhone(guide.getMobile());
+
+ save(account);
+ try {
+ Map<String, Object> basicResult = douGongBasicDataBiz.userBasicDataIndv(account);
+ requireSuccess(basicResult, "斗拱个人基本信息进件失败");
+ account.setHuifuId(stringValue(basicResult.get("huifu_id")));
+ account.setApplicationStatus("basic_succeeded");
+ save(account);
+
+ Map<String, Object> businessResult = douGongBasicDataBiz.userBusinessOpen(account);
+ requireSuccess(businessResult, "斗拱业务进件失败");
+ account.setTokenNo(stringValue(businessResult.get("token_no")));
+ account.setApplicationStatus("submitted");
+ account.setFailureMsgsReason(null);
+ save(account);
+ return GuideElectronicLedgerResponse.from(account);
+ } catch (BusinessException exception) {
+ markFailed(account, exception.getMessage());
+ throw exception;
+ } catch (Exception exception) {
+ markFailed(account, exception.getMessage());
+ throw new BusinessException(502, "斗拱进件服务暂不可用,请稍后重试");
+ }
+ }
+
+ public GuideElectronicLedgerResponse update(Long guideId, GuideElectronicLedgerRequest request) {
+ GuideWithdrawalAccount account = requireCurrent(guideId);
+ if (!StringUtils.hasText(account.getHuifuId())) {
+ throw new BusinessException(409, "斗拱基础进件尚未完成,请重新提交进件");
+ }
+ applyRequest(account, request);
+ account.setApplicationStatus("processing");
+ account.setFailureMsgsReason(null);
+ save(account);
+ try {
+ requireSuccess(douGongBasicDataBiz.userBasicDataIndvModify(account), "斗拱个人信息修改失败");
+ Map<String, Object> businessResult = douGongBasicDataBiz.userBusinessOpenModify(account);
+ requireSuccess(businessResult, "斗拱业务信息修改失败");
+ String tokenNo = stringValue(businessResult.get("token_no"));
+ if (StringUtils.hasText(tokenNo)) account.setTokenNo(tokenNo);
+ account.setApplicationStatus("submitted");
+ account.setFailureMsgsReason(null);
+ save(account);
+ return GuideElectronicLedgerResponse.from(account);
+ } catch (BusinessException exception) {
+ markFailed(account, exception.getMessage());
+ throw exception;
+ } catch (Exception exception) {
+ markFailed(account, exception.getMessage());
+ throw new BusinessException(502, "斗拱进件修改服务暂不可用,请稍后重试");
+ }
+ }
+
+ public GuideElectronicLedgerResponse detail(Long guideId) {
+ return GuideElectronicLedgerResponse.from(requireCurrent(guideId));
+ }
+
+ public GuideElectronicLedgerStatusResponse status(Long guideId) {
+ guideUserService.findEnabledById(guideId);
+ return new GuideElectronicLedgerStatusResponse(findCurrent(guideId));
+ }
+
+ public BigDecimal balance(Long guideId) {
+ GuideWithdrawalAccount account = requireCurrent(guideId);
+ if (!StringUtils.hasText(account.getHuifuId())) {
+ throw new BusinessException(409, "请先完成斗拱进件");
+ }
+ try {
+ return douGongBasicDataBiz.balanceQuery(account.getHuifuId());
+ } catch (Exception exception) {
+ throw new BusinessException(502, "斗拱账户余额查询失败");
+ }
+ }
+
+ private GuideWithdrawalAccount findCurrent(Long guideId) {
+ return accountMapper.selectOne(new LambdaQueryWrapper<GuideWithdrawalAccount>()
+ .eq(GuideWithdrawalAccount::getUserId, toTableUserId(guideId))
+ .eq(GuideWithdrawalAccount::getUserType, GUIDE_USER_TYPE)
+ .eq(GuideWithdrawalAccount::getType, HUIFU_TYPE)
+ .eq(GuideWithdrawalAccount::getStatus, ACTIVE_STATUS)
+ .orderByDesc(GuideWithdrawalAccount::getId)
+ .last("LIMIT 1"));
+ }
+
+ private GuideWithdrawalAccount requireCurrent(Long guideId) {
+ guideUserService.findEnabledById(guideId);
+ GuideWithdrawalAccount account = findCurrent(guideId);
+ if (account == null) throw new BusinessException(404, "当前导游尚未提交斗拱进件");
+ return account;
+ }
+
+ private void applyRequest(GuideWithdrawalAccount account, GuideElectronicLedgerRequest request) {
+ account.setCertificateName(trim(request.getCertificateName()));
+ account.setCertificateType(request.getCertificateType() == null ? 0 : request.getCertificateType());
+ account.setCertificateCode(trim(request.getCertificateCode()));
+ account.setCertificateExpireTime(trim(request.getCertificateExpireTime()));
+ account.setLicenceIssuingAuthority(trim(request.getLicenceIssuingAuthority()));
+ account.setBankName(normalizeBankName(request.getBankName()));
+ account.setBankCardCode(trim(request.getBankCardCode()));
+ account.setBankCode(trim(request.getBankCode()));
+ account.setBankBranchCode(trim(request.getBankBranchCode()));
+ account.setOpenAccountName(trim(request.getOpenAccountName()));
+ account.setTransactionAuthorizationCode(trim(request.getTransactionAuthorizationCode()));
+ account.setPhone(trim(request.getPhone()));
+ account.setIdCardFrontPicture(trim(request.getIdCardFrontPicture()));
+ account.setIdCardReversePicture(trim(request.getIdCardReversePicture()));
+ account.setBankCardProvinceCode(trim(request.getBankCardProvinceCode()));
+ account.setBankCardCityCode(trim(request.getBankCardCityCode()));
+ account.setImgUrl(trim(request.getImgUrl()));
+ account.setImgUrlOne(trim(request.getImgUrlOne()));
+ }
+
+ private void requireSuccess(Map<String, Object> result, String fallbackMessage) {
+ if (result != null && SUCCESS_CODE.equals(stringValue(result.get("resp_code")))) return;
+ String message = result == null ? null : stringValue(result.get("resp_desc"));
+ throw new BusinessException(502, StringUtils.hasText(message) ? message : fallbackMessage);
+ }
+
+ private void markFailed(GuideWithdrawalAccount account, String message) {
+ account.setApplicationStatus("failed");
+ account.setFailureMsgsReason(StringUtils.hasText(message) ? message : "斗拱进件失败");
+ save(account);
+ }
+
+ private void save(GuideWithdrawalAccount account) {
+ if (account.getId() == null) accountMapper.insert(account); else accountMapper.updateById(account);
+ }
+
+ private int toTableUserId(Long guideId) {
+ try {
+ return Math.toIntExact(guideId);
+ } catch (ArithmeticException exception) {
+ throw new BusinessException(400, "导游ID超出斗拱进件表支持范围");
+ }
+ }
+
+ private String randomRequestNo() {
+ return UUID.randomUUID().toString().replace("-", "");
+ }
+
+ private String normalizeBankName(String value) {
+ String bankName = trim(value);
+ return bankName == null ? null : bankName.replace("& #40;", "(").replace("& #41;", ")");
+ }
+
+ private String trim(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
+
+ private String stringValue(Object value) {
+ return value == null ? null : String.valueOf(value);
+ }
+}
diff --git a/backend/src/test/java/com/tourguide/modules/finance/service/GuideElectronicLedgerServiceTest.java b/backend/src/test/java/com/tourguide/modules/finance/service/GuideElectronicLedgerServiceTest.java
new file mode 100644
index 0000000..ecf9d30
--- /dev/null
+++ b/backend/src/test/java/com/tourguide/modules/finance/service/GuideElectronicLedgerServiceTest.java
@@ -0,0 +1,120 @@
+package com.tourguide.modules.finance.service;
+
+import com.tourguide.common.exception.BusinessException;
+import com.tourguide.common.huifu.biz.DouGongBasicDataBiz;
+import com.tourguide.modules.finance.dto.GuideElectronicLedgerRequest;
+import com.tourguide.modules.finance.dto.GuideElectronicLedgerResponse;
+import com.tourguide.modules.finance.dto.GuideElectronicLedgerStatusResponse;
+import com.tourguide.modules.finance.entity.GuideWithdrawalAccount;
+import com.tourguide.modules.finance.mapper.GuideWithdrawalAccountMapper;
+import com.tourguide.modules.user.entity.GuideUser;
+import com.tourguide.modules.user.service.GuideUserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class GuideElectronicLedgerServiceTest {
+ private GuideWithdrawalAccountMapper accountMapper;
+ private GuideUserService guideUserService;
+ private DouGongBasicDataBiz douGongBasicDataBiz;
+ private GuideElectronicLedgerService service;
+
+ @BeforeEach
+ void setUp() {
+ accountMapper = mock(GuideWithdrawalAccountMapper.class);
+ guideUserService = mock(GuideUserService.class);
+ douGongBasicDataBiz = mock(DouGongBasicDataBiz.class);
+ service = new GuideElectronicLedgerService(accountMapper, guideUserService, douGongBasicDataBiz);
+ }
+
+ @Test
+ void createShouldPersistSuccessfulDouGongOnboarding() throws Exception {
+ GuideUser guide = new GuideUser();
+ guide.setId(8L);
+ guide.setMobile("13800138000");
+ when(guideUserService.findEnabledById(8L)).thenReturn(guide);
+ when(accountMapper.selectOne(any())).thenReturn(null);
+ doAnswer(invocation -> {
+ GuideWithdrawalAccount account = invocation.getArgument(0);
+ account.setId(10);
+ return 1;
+ }).when(accountMapper).insert(any(GuideWithdrawalAccount.class));
+ when(douGongBasicDataBiz.userBasicDataIndv(any())).thenReturn(success("huifu_id", "HF100"));
+ when(douGongBasicDataBiz.userBusinessOpen(any())).thenReturn(success("token_no", "TOKEN100"));
+
+ GuideElectronicLedgerResponse response = service.create(8L, request());
+
+ assertThat(response.getHuifuId()).isEqualTo("HF100");
+ assertThat(response.getApplicationStatus()).isEqualTo("submitted");
+ verify(accountMapper).insert(any(GuideWithdrawalAccount.class));
+ verify(accountMapper, org.mockito.Mockito.atLeastOnce()).updateById(any(GuideWithdrawalAccount.class));
+ }
+
+ @Test
+ void statusShouldRequireOnboardingWhenAccountDoesNotExist() {
+ GuideUser guide = new GuideUser();
+ guide.setId(8L);
+ when(guideUserService.findEnabledById(8L)).thenReturn(guide);
+ when(accountMapper.selectOne(any())).thenReturn(null);
+
+ GuideElectronicLedgerStatusResponse response = service.status(8L);
+
+ assertThat(response.isExists()).isFalse();
+ assertThat(response.isRequiresOnboarding()).isTrue();
+ }
+
+ @Test
+ void createShouldPersistFailureReasonWhenDouGongRejectsRequest() throws Exception {
+ GuideUser guide = new GuideUser();
+ guide.setId(8L);
+ when(guideUserService.findEnabledById(8L)).thenReturn(guide);
+ when(accountMapper.selectOne(any())).thenReturn(null);
+ doAnswer(invocation -> {
+ GuideWithdrawalAccount account = invocation.getArgument(0);
+ account.setId(10);
+ return 1;
+ }).when(accountMapper).insert(any(GuideWithdrawalAccount.class));
+ Map<String, Object> rejected = new HashMap<String, Object>();
+ rejected.put("resp_code", "10000001");
+ rejected.put("resp_desc", "银行卡信息不正确");
+ when(douGongBasicDataBiz.userBasicDataIndv(any())).thenReturn(rejected);
+
+ assertThatThrownBy(() -> service.create(8L, request()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("银行卡信息不正确");
+ verify(accountMapper, org.mockito.Mockito.atLeastOnce()).updateById(any(GuideWithdrawalAccount.class));
+ }
+
+ private GuideElectronicLedgerRequest request() {
+ GuideElectronicLedgerRequest request = new GuideElectronicLedgerRequest();
+ request.setCertificateName("张三");
+ request.setCertificateCode("510000199001010000");
+ request.setCertificateExpireTime("2020-01-01,长期");
+ request.setBankName("中国银行");
+ request.setBankCardCode("6217000000000000000");
+ request.setOpenAccountName("张三");
+ request.setPhone("13800138000");
+ request.setIdCardFrontPicture("front-media-id");
+ request.setIdCardReversePicture("back-media-id");
+ request.setBankCardProvinceCode("320000");
+ request.setBankCardCityCode("320100");
+ return request;
+ }
+
+ private Map<String, Object> success(String key, String value) {
+ Map<String, Object> result = new HashMap<String, Object>();
+ result.put("resp_code", "00000000");
+ result.put(key, value);
+ return result;
+ }
+}
--
Gitblit v1.7.1