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