liujie
2026-07-30 fc767bf18e262b724c78b5f860b6d908d4993dcb
update
11个文件已添加
3个文件已修改
1464 ■■■■■ 已修改文件
backend/pom.xml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/api/guide/ElectronicLedgerController.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/DateUtil.java 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/DouGongNotifyController.java 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/RequestSeqUtil.java 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongBasicDataBiz.java 278 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongPayUtil.java 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRefundBiz.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongWithdrawBiz.java 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/biz/FenZhangUtil.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/config/HuiFuPropertiesConfiguration.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/common/huifu/properties/HuiFuProperties.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tourguide/config/SecurityConfig.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/application.yml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/pom.xml
@@ -17,6 +17,18 @@
        <jjwt.version>0.11.5</jjwt.version>
    </properties>
    <dependencies>
        <!--斗拱sdk-->
        <dependency>
            <groupId>com.huifu.bspay.sdk</groupId>
            <artifactId>dg-java-sdk</artifactId>
            <version>3.0.31</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
            <scope>provided</scope>
        </dependency>
        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
backend/src/main/java/com/tourguide/api/guide/ElectronicLedgerController.java
New file
@@ -0,0 +1,67 @@
package com.tourguide.api.guide;
import com.tourguide.common.api.ApiResponse;
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.service.GuideElectronicLedgerService;
import com.tourguide.security.AppUserPrincipal;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.math.BigDecimal;
/**
 * 导游电子账本接口。
 *
 * 备注:接口只暴露导游电子账本资料提交、修改、查询和余额查询;斗拱进件、银行卡、提现状态落库由
 * GuideElectronicLedgerService 统一处理,避免控制器直接依赖旧系统模型。
 */
@Validated
@RestController
@RequestMapping("/api/guide/electronic-ledger")
public class ElectronicLedgerController {
    private final GuideElectronicLedgerService electronicLedgerService;
    public ElectronicLedgerController(GuideElectronicLedgerService electronicLedgerService) {
        this.electronicLedgerService = electronicLedgerService;
    }
    @PostMapping
    public ApiResponse<GuideElectronicLedgerResponse> create(
            @AuthenticationPrincipal AppUserPrincipal principal,
            @Valid @RequestBody GuideElectronicLedgerRequest request) {
        return ApiResponse.success(electronicLedgerService.create(principal.getUserId(), request));
    }
    @PutMapping
    public ApiResponse<GuideElectronicLedgerResponse> update(
            @AuthenticationPrincipal AppUserPrincipal principal,
            @Valid @RequestBody GuideElectronicLedgerRequest request) {
        return ApiResponse.success(electronicLedgerService.update(principal.getUserId(), request));
    }
    @GetMapping
    public ApiResponse<GuideElectronicLedgerResponse> detail(
            @AuthenticationPrincipal AppUserPrincipal principal) {
        return ApiResponse.success(electronicLedgerService.detail(principal.getUserId()));
    }
    @GetMapping("/status")
    public ApiResponse<GuideElectronicLedgerStatusResponse> status(
            @AuthenticationPrincipal AppUserPrincipal principal) {
        return ApiResponse.success(electronicLedgerService.status(principal.getUserId()));
    }
    @GetMapping("/balance")
    public ApiResponse<BigDecimal> balance(@AuthenticationPrincipal AppUserPrincipal principal) {
        return ApiResponse.success(electronicLedgerService.balance(principal.getUserId()));
    }
}
backend/src/main/java/com/tourguide/common/huifu/DateUtil.java
New file
@@ -0,0 +1,297 @@
/**
 * Copyright (c) 2015-2016, Chill Zhuang 庄骞 (smallchill@163.com).
 * <p>
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * <p>
 * http://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.tourguide.common.huifu;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.apache.commons.lang3.time.DateUtils;
import java.sql.Timestamp;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class DateUtil {
    public static boolean compareTime(String s, String e) {
        if (parseTime(s) == null || parseTime(e) == null) {
            return false;
        }
        return parseTime(s).getTime() >= parseTime(e).getTime();
    }
    public static String plusDay(int num, String newDate) {
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        Date currdate = null;
        try {
            currdate = format.parse(newDate);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        Calendar ca = Calendar.getInstance();
        ca.setTime(currdate);
        ca.add(Calendar.DATE, num);// num为增加的天数,可以改变的
        currdate = ca.getTime();
        String enddate = format.format(currdate);
        return enddate;
    }
    public static long CalTime(String time1, String time2) {
        DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        long minutes = 0L;
        try {
            Date d1 = df.parse(time1);
            Date d2 = df.parse(time2);
            long diff = d1.getTime() - d2.getTime();// 这样得到的差值是微秒级别
            minutes = diff / (1000 * 60);
        } catch (ParseException e) {
            System.out.println("抱歉,时间日期解析出错。");
        }
        return minutes;
    }
    public static String getTimes(Date date) {
        return formatDate(date, "MM月dd日 HH:mm");
    }
    public static String getTimeMunite(Date date) {
        return formatDate(date, "yyyy-MM-dd HH:mm");
    }
    /**
     * 获取YYYY格式
     */
    public static String getYear() {
        return formatDate(new Date(), "yyyy");
    }
    /**
     * 获取YYYY格式
     */
    public static String getYear(Date date) {
        return formatDate(date, "yyyy");
    }
    /**
     * 获取YYYY-MM-DD格式
     */
    public static String getDay() {
        return formatDate(new Date(), "yyyy-MM-dd");
    }
    /**
     * 获取YYYY-MM-DD格式
     */
    public static String getDay(Date date) {
        return formatDate(date, "yyyy-MM-dd");
    }
    /**
     * 获取YYYYMMDD格式
     */
    public static String getDays() {
        return formatDate(new Date(), "yyyyMMdd");
    }
    /**
     * 获取YYYYMMDD格式
     */
    public static String getDays(Date date) {
        return formatDate(date, "yyyyMMdd");
    }
    /**
     * 获取YYYY-MM-DD HH:mm:ss格式
     */
    public static String getTime() {
        return formatDate(new Date(), "yyyy-MM-dd HH:mm:ss");
    }
    /**
     * 获取YYYY-MM-DD HH:mm:ss.SSS格式
     */
    public static String getMsTime() {
        return formatDate(new Date(), "yyyy-MM-dd HH:mm:ss.SSS");
    }
    /**
     * 获取YYYYMMDDHHmmss格式
     */
    public static String getAllTime() {
        return formatDate(new Date(), "yyyyMMddHHmmss");
    }
    /**
     * 获取YYYY-MM-DD HH:mm:ss格式
     */
    public static String getTime(Date date) {
        return formatDate(date, "yyyy-MM-dd HH:mm:ss");
    }
    public static String formatDate(Date date, String pattern) {
        String formatDate = null;
        if (StringUtils.isNotBlank(pattern)) {
            formatDate = DateFormatUtils.format(date, pattern);
        } else {
            formatDate = DateFormatUtils.format(date, "yyyy-MM-dd");
        }
        return formatDate;
    }
    /**
     * 日期比较,如果s>=e 返回true 否则返回false)
     *
     * @author luguosui
     */
    public static boolean compareDate(String s, String e) {
        if (parseDate(s) == null || parseDate(e) == null) {
            return false;
        }
        return parseDate(s).getTime() >= parseDate(e).getTime();
    }
    /**
     * 格式化日期
     */
    public static Date parseDate(String date) {
        return parse(date, "yyyy-MM-dd");
    }
    /**
     * 格式化日期
     */
    public static Date parseTimeMinutes(String date) {
        return parse(date, "yyyy-MM-dd HH:mm");
    }
    /**
     * 格式化日期
     */
    public static Date parseTime(String date) {
        return parse(date, "yyyy-MM-dd HH:mm:ss");
    }
    public static Date parseTime1(String date) {
        return parse(date, "yyyy-MM-dd HH:mm");
    }
    /**
     * 格式化日期
     */
    public static Date parse(String date, String pattern) {
        try {
            return DateUtils.parseDate(date, pattern);
        } catch (ParseException e) {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 格式化日期
     */
    public static String format(Date date, String pattern) {
        return DateFormatUtils.format(date, pattern);
    }
    /**
     * 把日期转换为Timestamp
     */
    public static Timestamp format(Date date) {
        return new Timestamp(date.getTime());
    }
    /**
     * 校验日期是否合法
     */
    public static boolean isValidDate(String s) {
        return parse(s, "yyyy-MM-dd HH:mm:ss") != null;
    }
    /**
     * 校验日期是否合法
     */
    public static boolean isValidDate(String s, String pattern) {
        return parse(s, pattern) != null;
    }
    public static int getDiffYear(String startTime, String endTime) {
        DateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
        try {
            int years = (int) (((fmt.parse(endTime).getTime() - fmt.parse(
                    startTime).getTime()) / (1000 * 60 * 60 * 24)) / 365);
            return years;
        } catch (Exception e) {
            // 如果throw java.text.ParseException或者NullPointerException,就说明格式不对
            return 0;
        }
    }
    /**
     * <li>功能描述:时间相减得到天数
     */
    public static long getDaySub(String beginDateStr, String endDateStr) {
        long day = 0;
        SimpleDateFormat format = new SimpleDateFormat(
                "yyyy-MM-dd");
        Date beginDate = null;
        Date endDate = null;
        try {
            beginDate = format.parse(beginDateStr);
            endDate = format.parse(endDateStr);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        day = (endDate.getTime() - beginDate.getTime()) / (24 * 60 * 60 * 1000);
        // System.out.println("相隔的天数="+day);
        return day;
    }
    /**
     * 得到n天之后的日期
     */
    public static String getAfterDayDate(String days) {
        int daysInt = Integer.parseInt(days);
        Calendar canlendar = Calendar.getInstance(); // java.util包
        canlendar.add(Calendar.DATE, daysInt); // 日期减 如果不够减会将月变动
        Date date = canlendar.getTime();
        SimpleDateFormat sdfd = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String dateStr = sdfd.format(date);
        return dateStr;
    }
    /**
     * 得到n天之后是周几
     */
    public static String getAfterDayWeek(String days) {
        int daysInt = Integer.parseInt(days);
        Calendar canlendar = Calendar.getInstance(); // java.util包
        canlendar.add(Calendar.DATE, daysInt); // 日期减 如果不够减会将月变动
        Date date = canlendar.getTime();
        SimpleDateFormat sdf = new SimpleDateFormat("E");
        String dateStr = sdf.format(date);
        return dateStr;
    }
}
backend/src/main/java/com/tourguide/common/huifu/DouGongNotifyController.java
New file
@@ -0,0 +1,75 @@
package com.tourguide.common.huifu;
import com.alibaba.fastjson.JSONObject;
import com.huifu.bspay.sdk.opps.core.utils.RsaUtils;
import com.tourguide.common.huifu.properties.HuiFuProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
 * 斗拱回调入口。
 *
 * 备注:该控制器只做参数校验、验签和日志记录,订单状态落库应在后续服务层按 req_seq_id 幂等处理。
 */
@RestController
@RequestMapping("/base/dougong")
public class DouGongNotifyController {
    private static final Logger log = LoggerFactory.getLogger(DouGongNotifyController.class);
    @Autowired
    private HuiFuProperties huiFuProperties;
    @PostMapping("/pay-notify")
    public String payNotify(HttpServletRequest request) {
        return handleNotify("dougong-pay", request);
    }
    @PostMapping("/refund-logistics")
    public String refundLogistics(HttpServletRequest request) {
        return handleNotify("dougong-refund-logistics", request);
    }
    @PostMapping("/refund-crosscity")
    public String refundCrossCity(HttpServletRequest request) {
        return handleNotify("dougong-refund-crosscity", request);
    }
    @PostMapping("/encashment")
    public String encashment(HttpServletRequest request) {
        return handleNotify("dougong-encashment", request);
    }
    private String handleNotify(String bizName, HttpServletRequest request) {
        String sign = request.getParameter("sign");
        String respData = request.getParameter("resp_data");
        if (!StringUtils.hasText(sign) || !StringUtils.hasText(respData)) {
            log.warn("{} missing params, sign={}, resp_data={}", bizName, sign, respData);
            return "FAIL";
        }
        // 备注:斗拱回调可能把括号转义为带空格的 HTML 实体,验签前必须恢复原文,否则签名会不一致。
        String normalizedRespData = respData.replaceAll("& #40;", "(").replaceAll("& #41;", ")");
        boolean verified = RsaUtils.verify(normalizedRespData, huiFuProperties.getRsaPublicKey(), sign);
        if (!verified) {
            log.warn("{} signature verify failed, resp_data={}", bizName, normalizedRespData);
            return "FAIL";
        }
        JSONObject body = JSONObject.parseObject(normalizedRespData);
        // 备注:当前阶段先记录回调关键流水,后续订单支付确认、退款确认、提现确认都应从这里分发到幂等服务。
        log.info("{} verified, req_seq_id={}, org_req_seq_id={}, trans_stat={}, body={}",
                bizName,
                body.getString("req_seq_id"),
                body.getString("org_req_seq_id"),
                body.getString("trans_stat"),
                body);
        return "200";
    }
}
backend/src/main/java/com/tourguide/common/huifu/RequestSeqUtil.java
New file
@@ -0,0 +1,217 @@
package com.tourguide.common.huifu;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicLong;
/**
 * 请求流水号生成工具类
 *
 * <p>用于生成32位唯一请求流水号,格式:[前缀2位][日期8位][时间6位][毫秒3位][序列号13位]</p>
 * <p>示例:rQ202511141540078404299875651</p>
 *
 * <p>特性:</p>
 * <ul>
 *   <li>线程安全:使用AtomicLong保证高并发环境下的唯一性</li>
 *   <li>高性能:避免重复创建对象,优化内存使用</li>
 *   <li>可配置:支持自定义前缀适应不同业务场景</li>
 *   <li>高可靠:时间戳+随机数+序列号三重保障唯一性</li>
 * </ul>
 *
 * @author harley
 * @since 2025-11-14
 */
public class RequestSeqUtil {
    /**
     * 默认前缀
     */
    private static final String DEFAULT_PREFIX = "RQ";
    /**
     * 序列号生成器,保证线程安全
     */
    private static final AtomicLong SEQUENCE_GENERATOR = new AtomicLong(0);
    /**
     * 安全随机数生成器
     */
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
    /**
     * 流水号总长度
     */
    private static final int TOTAL_LENGTH = 32;
    /**
     * 前缀长度
     */
    private static final int PREFIX_LENGTH = 2;
    /**
     * 日期长度 (yyyyMMdd)
     */
    private static final int DATE_LENGTH = 8;
    /**
     * 时间长度 (HHmmss)
     */
    private static final int TIME_LENGTH = 6;
    /**
     * 毫秒长度
     */
    private static final int MILLIS_LENGTH = 3;
    /**
     * 序列号长度
     */
    private static final int SEQUENCE_LENGTH = 13;
    /**
     * 私有构造函数,防止实例化
     */
    private RequestSeqUtil() {
        throw new UnsupportedOperationException("工具类不允许实例化");
    }
    /**
     * 生成默认前缀的32位请求流水号
     *
     * @return 32位请求流水号,格式:RQ + 时间信息 + 序列号
     */
    public static String generate() {
        return generate(DEFAULT_PREFIX);
    }
    /**
     * 生成指定前缀的32位请求流水号
     *
     * <p>流水号格式:[前缀2位][日期8位][时间6位][毫秒3位][序列号13位]</p>
     * <p>示例:rQ202511141540078404299875651</p>
     *
     * @param prefix 自定义前缀,建议2位字符
     * @return 32位请求流水号
     * @throws IllegalArgumentException 当前缀为空或长度不符合要求时抛出
     */
    public static String generate(String prefix) {
        // 参数校验
        if (prefix == null || prefix.isEmpty()) {
            throw new IllegalArgumentException("前缀不能为空");
        }
        if (prefix.length() != PREFIX_LENGTH) {
            throw new IllegalArgumentException("前缀长度必须为" + PREFIX_LENGTH + "位");
        }
        long currentTime = System.currentTimeMillis();
        // 构建流水号各个部分
        String datePart = DateUtil.format(new java.util.Date(currentTime), "yyyyMMdd");
        String timePart = DateUtil.format(new java.util.Date(currentTime), "HHmmss");
        String millisPart = String.format("%03d", currentTime % 1000);
        // 生成序列号(时间戳后7位 + 随机数6位)
        long sequence = SEQUENCE_GENERATOR.incrementAndGet();
        String sequencePart = String.format("%013d", Math.abs(sequence % 1000000000000L));
        // 组装完整的流水号
        StringBuilder seqBuilder = new StringBuilder(TOTAL_LENGTH);
        seqBuilder.append(prefix)
                  .append(datePart)
                  .append(timePart)
                  .append(millisPart)
                  .append(sequencePart);
        return seqBuilder.toString();
    }
    /**
     * 生成带业务标识的流水号
     *
     * <p>常用的业务前缀:</p>
     * <ul>
     *   <li>rQ - 汇付请求</li>
     *   <li>OR - 订单业务</li>
     *   <li>PY - 支付业务</li>
     *   <li>RF - 退款业务</li>
     * </ul>
     *
     * @param businessCode 业务代码前缀
     * @return 32位业务流水号
     */
    public static String generateForBusiness(String businessCode) {
        return generate(businessCode);
    }
    /**
     * 生成汇付相关的请求流水号
     *
     * @return 以"rQ"为前缀的32位流水号
     */
    public static String generateForHuiFu() {
        return generate("rQ");
    }
    /**
     * 验证流水号格式是否正确
     *
     * @param sequence 要验证的流水号
     * @return 验证结果
     */
    public static boolean isValid(String sequence) {
        if (sequence == null || sequence.length() != TOTAL_LENGTH) {
            return false;
        }
        // 验证时间部分格式(第3-16位应该是数字)
        String timePart = sequence.substring(2, 16);
        if (!timePart.matches("\\d+")) {
            return false;
        }
        // 验证最后13位序列号是否为数字
        String sequencePart = sequence.substring(19);
        if (!sequencePart.matches("\\d+")) {
            return false;
        }
        return true;
    }
    /**
     * 从流水号中提取前缀
     *
     * @param sequence 流水号
     * @return 流水号前缀
     */
    public static String extractPrefix(String sequence) {
        if (sequence == null || sequence.length() < PREFIX_LENGTH) {
            return null;
        }
        return sequence.substring(0, PREFIX_LENGTH);
    }
    /**
     * 从流水号中提取时间信息
     *
     * @param sequence 流水号
     * @return 格式化的时间字符串,格式:yyyy-MM-dd HH:mm:ss.SSS
     */
    public static String extractDateTime(String sequence) {
        if (!isValid(sequence)) {
            return null;
        }
        String dateStr = sequence.substring(2, 10);  // yyyyMMdd
        String timeStr = sequence.substring(10, 16); // HHmmss
        String millisStr = sequence.substring(16, 19); // 毫秒
        return dateStr.substring(0, 4) + "-" +
               dateStr.substring(4, 6) + "-" +
               dateStr.substring(6, 8) + " " +
               timeStr.substring(0, 2) + ":" +
               timeStr.substring(2, 4) + ":" +
               timeStr.substring(4, 6) + "." +
               millisStr;
    }
}
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongBasicDataBiz.java
New file
@@ -0,0 +1,278 @@
package com.tourguide.common.huifu.biz;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.huifu.bspay.sdk.opps.core.net.BasePayRequest;
import com.tourguide.common.huifu.properties.HuiFuProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
/**
 * 斗拱进件业务。
 *
 * 备注:该类只负责把本项目导游开户资料提交给斗拱;导游账号、银行卡和审核状态应由上层业务服务落库。
 */
@Component
public class DouGongBasicDataBiz {
    private static final Logger log = LoggerFactory.getLogger(DouGongBasicDataBiz.class);
    @Autowired
    private HuiFuProperties huiFuProperties;
    /**
     * 个人用户基本信息开户
     *
     * 备注:先创建斗拱个人基础资料,成功后才能继续调用业务入驻和结算卡配置。
     *
     * @return
     * @throws Exception
     */
    public Map<String, Object> userBasicDataIndv(DouGongUserWithdrawalProfile userWithdrawal) throws Exception {
        // 1. 数据初始化,填入对应的商户配置
        DouGongRequestSupport.initBasePay(huiFuProperties);
        // 2.组装请求参数
        Map<String, Object> paramsInfo = new HashMap<>();
        // 请求流水号(动态生成,确保唯一性)
        paramsInfo.put("req_seq_id", DouGongRequestSupport.generateForHuiFu());
        // 请求日期
        paramsInfo.put("req_date", DouGongRequestSupport.todayForHuiFu());
        // 个人证件类型
        paramsInfo.put("cert_type", "00");
        String[] certificateExpireDateArr = userWithdrawal.getCertificateExpireTime().split(",");
        String startDateStr = certificateExpireDateArr[0];
        // 个人证件有效期开始日期2023-08-02,2043-08-02
        paramsInfo.put("cert_begin_date", startDateStr.replace("-", ""));
        // 个人证件有效期截止日期
        String endDateStr = certificateExpireDateArr[1];
        if (!"长期".equals(endDateStr)) {
            String formatEndDate = endDateStr.replace("-", "");
            paramsInfo.put("cert_end_date", formatEndDate);
            // 个人证件有效期类型
            paramsInfo.put("cert_validity_type", "0");
        }else {
            // 个人证件有效期类型
            paramsInfo.put("cert_validity_type", "1");
        }
        // 手机号
        paramsInfo.put("mobile_no", userWithdrawal.getPhone());
        // 个人姓名
        paramsInfo.put("name", userWithdrawal.getOpenAccountName());
        // 个人证件号码
        paramsInfo.put("cert_no", userWithdrawal.getCertificateCode());
        // 管理员账号
//        paramsInfo.put("login_name", userWithdrawal.getPhone());
        // 是否发送短信标识
        paramsInfo.put("sms_send_flag", "Y");
        // 3. 发起API调用
        log.info("个人用户基本信息开户请求参数:{}", paramsInfo);
        Map<String, Object> response = BasePayRequest.requestBasePay("v2/user/basicdata/indv", paramsInfo, null, false);
        log.info("个人用户基本信息开户返回参数:{}", response);
        return response;
    }
    /**
     * 个人用户基本信息修改
     * @return
     * @throws Exception
     */
    public Map<String, Object> userBasicDataIndvModify(DouGongUserWithdrawalProfile userWithdrawal) throws Exception {
        // 1. 数据初始化,填入对应的商户配置
        DouGongRequestSupport.initBasePay(huiFuProperties);
        // 2.组装请求参数
        Map<String, Object> paramsInfo = new HashMap<>();
        // 请求流水号
        paramsInfo.put("req_seq_id", DouGongRequestSupport.generateForHuiFu());
        // 请求日期
        paramsInfo.put("req_date", DouGongRequestSupport.todayForHuiFu());
        // 汇付客户Id
        paramsInfo.put("huifu_id", userWithdrawal.getHuifuId());
        String[] certificateExpireDateArr = userWithdrawal.getCertificateExpireTime().split(",");
        String startDateStr = certificateExpireDateArr[0];
        // 个人证件有效期开始日期
        paramsInfo.put("cert_begin_date", startDateStr.replace("-", ""));
        // 个人证件有效期截止日期
        String endDateStr = certificateExpireDateArr[1];
        if (!"长期".equals(endDateStr)) {
            String formatEndDate = endDateStr.replace("-", "");
            paramsInfo.put("cert_end_date", formatEndDate);
            // 个人证件有效期类型
            paramsInfo.put("cert_validity_type", "0");
        }else {
            // 个人证件有效期类型
            paramsInfo.put("cert_validity_type", "1");
        }
        // 手机号
        paramsInfo.put("mobile_no", userWithdrawal.getPhone());
        log.info("个人用户基本信息修改返回参数:{}", paramsInfo);
        // 3. 发起API调用
        Map<String, Object> response = BasePayRequest.requestBasePay("v2/user/basicdata/indv/modify", paramsInfo, null, false);
        log.info("个人用户基本信息修改返回参数:{}", response);
        return response;
    }
    /**
     * 用户业务入驻
     *
     * 备注:业务入驻会绑定结算卡和提现规则,后续提现依赖这里返回的取现卡序列号。
     *
     * @return
     * @throws Exception
     */
    public Map<String, Object> userBusinessOpen(DouGongUserWithdrawalProfile userWithdrawal) throws Exception {
        // 1. 数据初始化,填入对应的商户配置
        DouGongRequestSupport.initBasePay(huiFuProperties);
        // 2.组装请求参数
        Map<String, Object> paramsInfo = new HashMap<>();
        // 汇付ID
        paramsInfo.put("huifu_id", userWithdrawal.getHuifuId());
        // 请求流水号
        paramsInfo.put("req_seq_id", DouGongRequestSupport.generateForHuiFu());
        // 请求日期
        paramsInfo.put("req_date", DouGongRequestSupport.todayForHuiFu());
        // 渠道商/商户汇付Id
        paramsInfo.put("upper_huifu_id", huiFuProperties.getUpperHuifuId());
        //// 结算信息配置
        //paramsInfo.put("settle_config", "{\"settle_cycle\":\"D1\",\"min_amt\":\"0.01\",\"remained_amt\":\"10.00\",\"settle_abstract\":\"结算摘要\",\"out_settle_flag\":\"2\",\"settle_batch_no\":\"300\",\"fixed_ratio\":\"2\"}");
        // 结算卡信息
        JSONObject cardInfo = new JSONObject();
        cardInfo.put("card_type", "1");
        cardInfo.put("card_name", userWithdrawal.getOpenAccountName());
        cardInfo.put("card_no", userWithdrawal.getBankCardCode());
        cardInfo.put("prov_id", userWithdrawal.getBankCardProvinceCode());
        cardInfo.put("area_id", userWithdrawal.getBankCardCityCode());
        cardInfo.put("cert_type", "00");
        cardInfo.put("cert_no", userWithdrawal.getCertificateCode());
        String[] certificateExpireDateArr = userWithdrawal.getCertificateExpireTime().split(",");
        String startDateStr = certificateExpireDateArr[0];
        // 个人证件有效期开始日期
        cardInfo.put("cert_begin_date", startDateStr.replace("-", ""));
        // 个人证件有效期截止日期
        String endDateStr = certificateExpireDateArr[1];
        if (!"长期".equals(endDateStr)) {
            String formatEndDate = endDateStr.replace("-", "");
            cardInfo.put("cert_end_date", formatEndDate);
            // 个人证件有效期类型
            cardInfo.put("cert_validity_type", "0");
        }else {
            // 个人证件有效期类型
            cardInfo.put("cert_validity_type", "1");
        }
        paramsInfo.put("card_info", cardInfo.toJSONString());
        // 结算卡信息
        JSONArray cashConfigArr = new JSONArray();
        JSONObject cashConfig = new JSONObject();
        /**
         * T1:下一工作日到银行账户;
         * D1:下一自然日到银行账户;
         * D0:当日到银行账户;默认D0;
         * DM:当日到账;到账资金不包括当天的交易资金;
         * 示例值:T1
         */
        cashConfig.put("cash_type", "D1");
        cashConfig.put("fix_amt", "0.00");
        cashConfig.put("weekday_fix_amt", "0.00");
        cashConfig.put("weekday_fee_rate", "0.00");
        cashConfigArr.add(cashConfig);
        paramsInfo.put("cash_config", cashConfigArr.toJSONString());
        log.info("用户业务入驻返回参数:{}", paramsInfo);
        // 3. 发起API调用
        Map<String, Object> response = BasePayRequest.requestBasePay("v2/user/busi/open", paramsInfo, null, false);;
        log.info("用户业务入驻返回参数:{}", response);
        return response;
    }
    /**
     * 用户业务入驻
     * @return
     * @throws Exception
     */
    public Map<String, Object> userBusinessOpenModify(DouGongUserWithdrawalProfile userWithdrawal) throws Exception {
        // 1. 数据初始化,填入对应的商户配置
        DouGongRequestSupport.initBasePay(huiFuProperties);
        // 2.组装请求参数
        Map<String, Object> paramsInfo = new HashMap<>();
        // 汇付ID
        paramsInfo.put("huifu_id", userWithdrawal.getHuifuId());
        // 请求流水号
        paramsInfo.put("req_seq_id", DouGongRequestSupport.generateForHuiFu());
        // 请求日期
        paramsInfo.put("req_date", DouGongRequestSupport.todayForHuiFu());
        // 结算卡信息
        JSONObject cardInfo = new JSONObject();
        cardInfo.put("card_type", "1");
        cardInfo.put("card_name", userWithdrawal.getOpenAccountName());
        cardInfo.put("card_no", userWithdrawal.getBankCardCode());
        cardInfo.put("prov_id", userWithdrawal.getBankCardProvinceCode());
        cardInfo.put("area_id", userWithdrawal.getBankCardCityCode());
        cardInfo.put("cert_type", "00");
        cardInfo.put("cert_no", userWithdrawal.getCertificateCode());
        String[] certificateExpireDateArr = userWithdrawal.getCertificateExpireTime().split(",");
        String startDateStr = certificateExpireDateArr[0];
        // 个人证件有效期开始日期
        cardInfo.put("cert_begin_date", startDateStr.replace("-", ""));
        // 个人证件有效期截止日期
        String endDateStr = certificateExpireDateArr[1];
        if (!"长期".equals(endDateStr)) {
            String formatEndDate = endDateStr.replace("-", "");
            cardInfo.put("cert_end_date", formatEndDate);
            // 个人证件有效期类型
            cardInfo.put("cert_validity_type", "0");
        }else {
            // 个人证件有效期类型
            cardInfo.put("cert_validity_type", "1");
        }
        paramsInfo.put("card_info", cardInfo.toJSONString());
        // 结算卡信息
        JSONArray cashConfigArr = new JSONArray();
        JSONObject cashConfig = new JSONObject();
        /**
         * T1:下一工作日到银行账户;
         * D1:下一自然日到银行账户;
         * D0:当日到银行账户;默认D0;
         * DM:当日到账;到账资金不包括当天的交易资金;
         * 示例值:T1
         */
        cashConfig.put("switch_state", "1");
        cashConfig.put("cash_type", "D1");
        cashConfig.put("fix_amt", "0.00");
        cashConfig.put("weekday_fix_amt", "0.00");
        cashConfig.put("weekday_fee_rate", "0.00");
        cashConfigArr.add(cashConfig);
        paramsInfo.put("cash_config", cashConfigArr.toJSONString());
        log.info("用户业务入驻修改返回参数:{}", paramsInfo);
        // 3. 发起API调用
        Map<String, Object> response = BasePayRequest.requestBasePay("v2/user/busi/modify", paramsInfo, null, false);;
        log.info("用户业务入驻修改返回参数:{}", response);
        return response;
    }
    public BigDecimal balanceQuery(String huifuId) throws Exception {
        // 1. 数据初始化,填入对应的商户配置
        DouGongRequestSupport.initBasePay(huiFuProperties);
        // 2.组装请求参数
        Map<String, Object> paramsInfo = new HashMap<>();
        // 汇付ID
        paramsInfo.put("huifu_id", huifuId);
        // 请求流水号
        paramsInfo.put("req_seq_id", DouGongRequestSupport.generateForHuiFu());
        // 请求日期
        paramsInfo.put("req_date", DouGongRequestSupport.todayForHuiFu());
        log.info("账户余额信息查询返回参数:{}", paramsInfo);
        // 3. 发起API调用
        Map<String, Object> response = BasePayRequest.requestBasePay("v2/trade/acctpayment/balance/query", paramsInfo, null, false);
        log.info("账户余额信息查询返回参数:{}", response);
        String string = response.get("acctInfo_list").toString();
        JSONArray accountList = JSONArray.parseArray(string);
        if (accountList != null && !accountList.isEmpty()) {
            JSONObject acctInfo = accountList.getJSONObject(0);
            log.info("账户余额信息查询返回余额:{}", acctInfo.getString("avail_bal"));
            return acctInfo.getBigDecimal("avail_bal");
        }
        return BigDecimal.ZERO;
    }
}
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongPayUtil.java
New file
@@ -0,0 +1,166 @@
package com.tourguide.common.huifu.biz;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.huifu.bspay.sdk.opps.core.net.BasePayRequest;
import com.tourguide.common.huifu.properties.HuiFuProperties;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
/**
 * 斗拱支付工具。
 *
 * 备注:这里只负责创建支付参数和调用斗拱,不能直接把本地订单改为已支付;订单状态必须以后端验签回调为准。
 */
@Component
public class DouGongPayUtil {
    private static final String PAYMENT_CREATE_URL = "https://api.huifu.com/v4/trade/payment/create";
    private static final Duration WX_PAY_CACHE_TTL = Duration.ofMinutes(5);
    private final HuiFuProperties huiFuProperties;
    private final StringRedisTemplate redisTemplate;
    private final RestTemplate restTemplate;
    public DouGongPayUtil(HuiFuProperties huiFuProperties,
                          StringRedisTemplate redisTemplate,
                          @Qualifier("wechatMiniProgramRestTemplate") RestTemplate restTemplate) {
        this.huiFuProperties = huiFuProperties;
        this.redisTemplate = redisTemplate;
        this.restTemplate = restTemplate;
    }
    public JSONObject wxPayOrder(String money, String openId, String goodsDesc, int type, String orderId, int callType) {
        JSONObject response = createPayment(money, goodsDesc, orderId, type, callType, "T_MINIAPP", methodExpand(openId), false);
        // 备注:微信小程序端可能需要短时间内二次读取斗拱响应,缓存只保留 5 分钟,避免支付参数长期滞留。
        String cacheKey = "dg_wx_pay:" + DouGongRequestSupport.randomString(18);
        redisTemplate.opsForValue().set(cacheKey, response.toJSONString(), WX_PAY_CACHE_TTL);
        response.put("dg_wx_pay", cacheKey);
        return response;
    }
    public JSONObject aliPayOrder(String money, String buyerId, String goodsDesc, int type, String orderId, int callType) {
        return createPayment(money, goodsDesc, orderId, type, callType, "A_NATIVE", null, true);
    }
    public Map<String, Object> payFromUser(String reqSeqId, String reqDate, String ordAmt, String receiverHuifuId) throws Exception {
        DouGongRequestSupport.initBasePay(huiFuProperties);
        BigDecimal amount = new BigDecimal(ordAmt).setScale(2, RoundingMode.HALF_UP);
        Map<String, Object> paramsInfo = new HashMap<>();
        paramsInfo.put("req_seq_id", reqSeqId);
        paramsInfo.put("req_date", reqDate);
        paramsInfo.put("out_huifu_id", huiFuProperties.getHuifuId());
        paramsInfo.put("ord_amt", amount.toString());
        paramsInfo.put("acct_split_bunch", acctSplitBunch(amount, receiverHuifuId));
        paramsInfo.put("risk_check_data", riskCheckData());
        paramsInfo.put("trans_fee_take_flag", "OUT");
        return BasePayRequest.requestBasePay("v2/trade/acctpayment/pay", paramsInfo, null, false);
    }
    private JSONObject createPayment(String money, String goodsDesc, String orderId, int type, int callType,
                                     String tradeType, String methodExpand, boolean alipay) {
        BigDecimal amount = new BigDecimal(money).setScale(2, RoundingMode.HALF_UP);
        String reqSeqId = orderId + "_" + type + "_" + System.currentTimeMillis() + DouGongRequestSupport.randomNumber(10);
        String reqDate = DouGongRequestSupport.todayForHuiFu();
        TreeMap<String, Object> data = new TreeMap<>();
        data.put("goods_desc", goodsDesc);
        data.put("huifu_id", huiFuProperties.getHuifuId());
        data.put("notify_url", huiFuProperties.getCallbackPath() + notifyPath(callType, alipay));
        data.put("remark", "TourGuide");
        data.put("req_date", reqDate);
        data.put("req_seq_id", reqSeqId);
        data.put("trade_type", tradeType);
        data.put("trans_amt", amount.toString());
        if (methodExpand != null) {
            data.put("method_expand", methodExpand);
        }
        // 备注:斗拱要求按业务 data 生成 RSA 签名,签名失败直接中断,避免发出不可追踪的支付请求。
        String sign = sign(JSON.toJSONString(data), huiFuProperties.getRsaPrivateKey());
        if (sign == null) {
            throw new IllegalStateException("DouGong payment sign failed");
        }
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("sys_id", huiFuProperties.getSysId());
        payload.put("product_id", huiFuProperties.getProductId());
        payload.put("sign", sign);
        payload.put("data", data);
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        ResponseEntity<String> response = restTemplate.postForEntity(PAYMENT_CREATE_URL, new HttpEntity<>(JSON.toJSONString(payload), headers), String.class);
        JSONObject result = JSONObject.parseObject(response.getBody());
        result.put("req_seq_id", reqSeqId);
        return result;
    }
    private String methodExpand(String openId) {
        JSONObject methodExpand = new JSONObject();
        methodExpand.put("sub_openid", openId);
        return methodExpand.toJSONString();
    }
    private String acctSplitBunch(BigDecimal amount, String huifuId) {
        JSONObject acctInfo = new JSONObject();
        acctInfo.put("div_amt", amount.toString());
        acctInfo.put("huifu_id", huifuId);
        JSONArray acctInfos = new JSONArray();
        acctInfos.add(acctInfo);
        JSONObject root = new JSONObject();
        root.put("acct_infos", acctInfos);
        return root.toJSONString();
    }
    private String riskCheckData() {
        JSONObject data = new JSONObject();
        data.put("sub_product", "1");
        data.put("transfer_type", "05");
        return data.toJSONString();
    }
    private String notifyPath(int callType, boolean alipay) {
        String channel = alipay ? "ALIPAY" : "WECHAT";
        // 备注:统一回调入口便于后续按 channel/callType 分发,安全配置已放行 /base/dougong/**。
        return "/base/dougong/pay-notify?channel=" + channel + "&callType=" + callType;
    }
    public static String sign(String data, String privateKeyBase64) {
        try {
            byte[] bytes = Base64.getDecoder().decode(privateKeyBase64);
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(bytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
            Signature signature = Signature.getInstance("SHA256WithRSA");
            signature.initSign(privateKey);
            signature.update(data.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(signature.sign());
        } catch (Exception e) {
            return null;
        }
    }
}
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRefundBiz.java
New file
@@ -0,0 +1,56 @@
package com.tourguide.common.huifu.biz;
import com.alibaba.fastjson.JSONObject;
import com.huifu.bspay.sdk.opps.core.net.BasePayRequest;
import com.tourguide.common.huifu.properties.HuiFuProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.Map;
/**
 * 斗拱退款业务。
 *
 * 备注:该类只发起斗拱退款请求,退款成功后的本地订单退款状态应由退款回调验签后幂等更新。
 */
@Component
public class DouGongRefundBiz {
    private static final Logger log = LoggerFactory.getLogger(DouGongRefundBiz.class);
    @Autowired
    private HuiFuProperties huiFuProperties;
    /**
     * 交易退款接口
     *
     * @param reqSeqId 请求流水号,同一huifu_id下当天唯一
     * @param ordAmt 申请退款金额,单位元
     * @param orgReqDate 原交易请求日期,格式yyyyMMdd
     * @param orgReqSeqId 原交易请求流水号(三选一)
     * @param type 类型 1:小件物流;2:跨城出行
     * @return 请求结果
     */
    public Map<String, Object> refund(String reqSeqId, BigDecimal ordAmt, String orgReqDate, String orgReqSeqId, Integer type) throws Exception {
        log.info("开始调用斗拱退款接口, 退款金额: {}",  ordAmt);
        DouGongRequestSupport.initBasePay(huiFuProperties);
        Map<String, Object> data = new HashMap<>();
        data.put("req_date", DouGongRequestSupport.todayForHuiFu());
        data.put("req_seq_id", reqSeqId);
        data.put("huifu_id", huiFuProperties.getSysId());
        data.put("ord_amt", ordAmt.setScale(2, RoundingMode.HALF_UP).toString());
        data.put("org_req_date", orgReqDate);
        // 备注:斗拱原交易定位支持三选一,这里统一使用本系统保存的原请求流水 org_req_seq_id。
        data.put("org_req_seq_id", orgReqSeqId);
        String notifyUrl = Integer.valueOf(1).equals(type) ? "/base/dougong/refund-logistics" : "/base/dougong/refund-crosscity";
        data.put("notify_url", huiFuProperties.getCallbackPath() + notifyUrl);
        Map<String, Object> response = BasePayRequest.requestBasePay("v4/trade/payment/scanpay/refund", data, null, false);
        log.info("斗拱退款接口响应内容: {}", JSONObject.toJSONString(response));
        return response;
    }
}
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongWithdrawBiz.java
New file
@@ -0,0 +1,131 @@
package com.tourguide.common.huifu.biz;
import com.alibaba.fastjson.JSONObject;
import com.tourguide.common.huifu.properties.HuiFuProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.text.SimpleDateFormat;
import java.util.Base64;
import java.util.Date;
/**
 * 斗拱主动提现业务。
 *
 * 备注:该类只向斗拱发起提现,导游余额扣减、提现单状态变更应在本项目业务服务中幂等完成。
 */
@Component
public class DouGongWithdrawBiz {
    private static final Logger log = LoggerFactory.getLogger(DouGongWithdrawBiz.class);
    @Autowired
    private HuiFuProperties huiFuProperties;
    @Autowired
    private RestTemplate restTemplate;
    private static final String ENCASHMENT_URL = "https://api.huifu.com/v2/trade/settlement/encashment";
    /**
     * 主动取现接口。
     *
     * @param cashAmt 取现金额,单位元
     * @param huifuId 取现方汇付 ID
     * @param tokenNo 绑定取现卡后返回的取现卡序列号
     * @return 斗拱响应结果
     */
    public JSONObject encashment(BigDecimal cashAmt, String huifuId, String tokenNo) {
        try {
            log.info("开始调用斗拱提现接口,取现方ID: {}, 取现金额: {}", huifuId, cashAmt);
            JSONObject data = new JSONObject();
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
            String reqDate = dateFormat.format(new Date());
            data.put("req_date", reqDate);
            data.put("req_seq_id", DouGongRequestSupport.generateForHuiFu("TX"));
            data.put("cash_amt", cashAmt.toString());
            data.put("huifu_id", huifuId);
            // 备注:提现到账周期先固定 D1,后续如果后台提供提现规则,再从配置或提现申请中读取。
            data.put("into_acct_date_type", "D1");
            data.put("token_no", tokenNo);
            data.put("notify_url", huiFuProperties.getCallbackPath() + "/base/dougong/encashment");
            String dataStr = data.toJSONString();
            log.info("业务参数data: {}", dataStr);
            JSONObject requestJson = new JSONObject();
            requestJson.put("sys_id", huiFuProperties.getSysId());
            requestJson.put("product_id", huiFuProperties.getProductId());
            requestJson.put("data", dataStr);
            // 备注:主动提现接口按完整报文签名,不复用支付创建接口只签 data 的逻辑。
            String signData = requestJson.toJSONString();
            log.info("待签名报文: {}", signData);
            String signature = sign(signData, huiFuProperties.getRsaPrivateKey());
            if (signature == null) {
                log.error("签名生成失败");
                return createErrorResult("签名生成失败");
            }
            requestJson.put("sign", signature);
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            HttpEntity<String> requestEntity = new HttpEntity<>(requestJson.toJSONString(), headers);
            log.info("发送斗拱提现请求到: {}", ENCASHMENT_URL);
            ResponseEntity<String> response = restTemplate.exchange(
                ENCASHMENT_URL,
                HttpMethod.POST,
                requestEntity,
                String.class
            );
            log.info("斗拱提现接口响应状态: {}, 响应内容: {}", response.getStatusCode(), response.getBody());
            // 解析响应
            JSONObject result = JSONObject.parseObject(response.getBody());
            return result;
        } catch (Exception e) {
            log.error("调用斗拱提现接口异常: {}", e.getMessage(), e);
            return createErrorResult("调用接口异常: " + e.getMessage());
        }
    }
    private JSONObject createErrorResult(String errorMsg) {
        JSONObject result = new JSONObject();
        result.put("code", "9999");
        result.put("msg", errorMsg);
        result.put("success", false);
        return result;
    }
    public static String sign(String data, String privateKeyBase64) {
        // Base64 --> Key
        try {
            byte[] bytes = Base64.getDecoder().decode(privateKeyBase64);
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(bytes);
            KeyFactory keyFactory;
            keyFactory = KeyFactory.getInstance("RSA");
            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
            // Sign
            Signature signature = Signature.getInstance("SHA256WithRSA");
            signature.initSign(privateKey);
            signature.update(data.getBytes("UTF-8"));
            return Base64.getEncoder().encodeToString(signature.sign());
        } catch (Exception e) {
            return null;
        }
    }
}
backend/src/main/java/com/tourguide/common/huifu/biz/FenZhangUtil.java
New file
@@ -0,0 +1,84 @@
package com.tourguide.common.huifu.biz;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.huifu.bspay.sdk.opps.core.net.BasePayRequest;
import com.tourguide.common.huifu.properties.HuiFuProperties;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.Map;
/**
 * DouGong account split helper.
 */
@Component
public class FenZhangUtil {
    private final HuiFuProperties huiFuProperties;
    public FenZhangUtil(HuiFuProperties huiFuProperties) {
        this.huiFuProperties = huiFuProperties;
    }
    public Map<String, Object> fenzhang(String reqSeqId, String reqDate, String ordAmt, String receiverHuifuId) throws Exception {
        DouGongRequestSupport.initBasePay(huiFuProperties);
        BigDecimal amount = new BigDecimal(ordAmt).setScale(2, RoundingMode.HALF_UP);
        Map<String, Object> paramsInfo = new HashMap<>();
        paramsInfo.put("req_seq_id", reqSeqId);
        paramsInfo.put("req_date", reqDate);
        paramsInfo.put("out_huifu_id", huiFuProperties.getHuifuId());
        paramsInfo.put("ord_amt", amount.toString());
        paramsInfo.put("acct_split_bunch", acctSplitBunch(amount, receiverHuifuId));
        paramsInfo.put("risk_check_data", riskCheckData());
        paramsInfo.put("trans_fee_take_flag", "OUT");
        return BasePayRequest.requestBasePay("v2/trade/acctpayment/pay", paramsInfo, null, false);
    }
    public Map<String, Object> queryFenzhang(String reqSeqId, String reqDate) throws Exception {
        DouGongRequestSupport.initBasePay(huiFuProperties);
        Map<String, Object> paramsInfo = new HashMap<>();
        paramsInfo.put("huifu_id", huiFuProperties.getHuifuId());
        paramsInfo.put("org_req_date", reqDate);
        paramsInfo.put("org_req_seq_id", reqSeqId);
        return BasePayRequest.requestBasePay("v2/trade/acctpayment/pay/query", paramsInfo, null, false);
    }
    public Map<String, Object> payFromUser(String reqSeqId, String reqDate, String ordAmt, String outHuifuId) throws Exception {
        DouGongRequestSupport.initBasePay(huiFuProperties);
        BigDecimal amount = new BigDecimal(ordAmt).setScale(2, RoundingMode.HALF_UP);
        Map<String, Object> paramsInfo = new HashMap<>();
        paramsInfo.put("req_seq_id", reqSeqId);
        paramsInfo.put("req_date", reqDate);
        paramsInfo.put("out_huifu_id", outHuifuId);
        paramsInfo.put("ord_amt", amount.toString());
        paramsInfo.put("acct_split_bunch", acctSplitBunch(amount, huiFuProperties.getHuifuId()));
        paramsInfo.put("risk_check_data", riskCheckData());
        paramsInfo.put("trans_fee_take_flag", "OUT");
        return BasePayRequest.requestBasePay("v2/trade/acctpayment/pay", paramsInfo, null, false);
    }
    private String acctSplitBunch(BigDecimal amount, String huifuId) {
        JSONObject acctInfo = new JSONObject();
        acctInfo.put("div_amt", amount.toString());
        acctInfo.put("huifu_id", huifuId);
        JSONArray acctInfos = new JSONArray();
        acctInfos.add(acctInfo);
        JSONObject root = new JSONObject();
        root.put("acct_infos", acctInfos);
        return root.toJSONString();
    }
    private String riskCheckData() {
        JSONObject data = new JSONObject();
        data.put("sub_product", "1");
        data.put("transfer_type", "05");
        return data.toJSONString();
    }
}
backend/src/main/java/com/tourguide/common/huifu/config/HuiFuPropertiesConfiguration.java
New file
@@ -0,0 +1,14 @@
package com.tourguide.common.huifu.config;
import com.tourguide.common.huifu.properties.HuiFuProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
 * @author mitao
 * @date 2025/11/14
 */
@Configuration
@EnableConfigurationProperties({HuiFuProperties.class})
public class HuiFuPropertiesConfiguration {
}
backend/src/main/java/com/tourguide/common/huifu/properties/HuiFuProperties.java
New file
@@ -0,0 +1,57 @@
package com.tourguide.common.huifu.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * 斗拱商户配置。
 *
 * 备注:所有敏感值都从环境变量或配置中心注入,禁止在源码中写真实商户号、私钥或公钥。
 */
@Component
@ConfigurationProperties(prefix = "huifu")
public class HuiFuProperties {
    /**
     * 汇付分配的产品号。
     */
    private String productId;
    /**
     * 汇付分配的系统号。
     */
    private String sysId;
    /**
     * 服务商私钥,用于调用斗拱接口签名。
     */
    private String rsaPrivateKey;
    /**
     * 汇付公钥,用于校验斗拱回调和响应签名。
     */
    private String rsaPublicKey;
    /**
     * 本平台商户汇付 ID。
     */
    private String huifuId;
    /**
     * 渠道商或上级商户汇付 ID。
     */
    private String upperHuifuId;
    /**
     * 对外可访问的回调根地址。
     */
    private String callbackPath;
    public String getProductId() { return productId; }
    public void setProductId(String productId) { this.productId = productId; }
    public String getSysId() { return sysId; }
    public void setSysId(String sysId) { this.sysId = sysId; }
    public String getRsaPrivateKey() { return rsaPrivateKey; }
    public void setRsaPrivateKey(String rsaPrivateKey) { this.rsaPrivateKey = rsaPrivateKey; }
    public String getRsaPublicKey() { return rsaPublicKey; }
    public void setRsaPublicKey(String rsaPublicKey) { this.rsaPublicKey = rsaPublicKey; }
    public String getHuifuId() { return huifuId; }
    public void setHuifuId(String huifuId) { this.huifuId = huifuId; }
    public String getUpperHuifuId() { return upperHuifuId; }
    public void setUpperHuifuId(String upperHuifuId) { this.upperHuifuId = upperHuifuId; }
    public String getCallbackPath() { return callbackPath; }
    public void setCallbackPath(String callbackPath) { this.callbackPath = callbackPath; }
}
backend/src/main/java/com/tourguide/config/SecurityConfig.java
@@ -51,6 +51,7 @@
                        "/api/guide/auth/login", "/api/guide/auth/password-login", "/api/guide/auth/sms-code",
                        "/api/health").permitAll()
                .antMatchers("/api/guide/registration/**").permitAll()
                .antMatchers("/base/dougong/**").permitAll()
                .antMatchers(HttpMethod.GET, "/uploads/avatars/**").permitAll()
                .antMatchers("/uploads/guide-applications/**").hasRole("ADMIN")
                .antMatchers(HttpMethod.GET, "/api/mini/scenic-spots", "/api/mini/scenic-spots/**").permitAll()
backend/src/main/resources/application.yml
@@ -54,3 +54,12 @@
    avatar-public-path: /uploads/avatars
    guide-application-directory: ${GUIDE_APPLICATION_STORAGE_DIR:./data/guide-applications}
    guide-application-public-path: /uploads/guide-applications
huifu:
  product-id: ${HUIFU_PRODUCT_ID:}
  sys-id: ${HUIFU_SYS_ID:}
  rsa-private-key: ${HUIFU_RSA_PRIVATE_KEY:}
  rsa-public-key: ${HUIFU_RSA_PUBLIC_KEY:}
  huifu-id: ${HUIFU_ID:}
  upper-huifu-id: ${HUIFU_UPPER_HUIFU_ID:}
  callback-path: ${HUIFU_CALLBACK_PATH:}