From fc767bf18e262b724c78b5f860b6d908d4993dcb Mon Sep 17 00:00:00 2001
From: liujie <1793218484@qq.com>
Date: 星期四, 30 七月 2026 15:45:48 +0800
Subject: [PATCH] update
---
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRefundBiz.java | 56 ++
backend/src/main/java/com/tourguide/common/huifu/properties/HuiFuProperties.java | 57 ++
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongPayUtil.java | 166 ++++++
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongWithdrawBiz.java | 131 +++++
backend/src/main/java/com/tourguide/config/SecurityConfig.java | 1
backend/src/main/java/com/tourguide/common/huifu/DouGongNotifyController.java | 75 +++
backend/src/main/java/com/tourguide/common/huifu/biz/FenZhangUtil.java | 84 +++
backend/src/main/java/com/tourguide/common/huifu/RequestSeqUtil.java | 217 ++++++++
backend/src/main/java/com/tourguide/common/huifu/biz/DouGongBasicDataBiz.java | 278 +++++++++++
backend/pom.xml | 12
backend/src/main/java/com/tourguide/common/huifu/DateUtil.java | 297 +++++++++++
backend/src/main/java/com/tourguide/common/huifu/config/HuiFuPropertiesConfiguration.java | 14
backend/src/main/resources/application.yml | 9
backend/src/main/java/com/tourguide/api/guide/ElectronicLedgerController.java | 67 ++
14 files changed, 1,464 insertions(+), 0 deletions(-)
diff --git a/backend/pom.xml b/backend/pom.xml
index c887039..2dd4a47 100644
--- a/backend/pom.xml
+++ b/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>
diff --git a/backend/src/main/java/com/tourguide/api/guide/ElectronicLedgerController.java b/backend/src/main/java/com/tourguide/api/guide/ElectronicLedgerController.java
new file mode 100644
index 0000000..564a682
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/api/guide/ElectronicLedgerController.java
@@ -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()));
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/DateUtil.java b/backend/src/main/java/com/tourguide/common/huifu/DateUtil.java
new file mode 100644
index 0000000..b1b2ab3
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/DateUtil.java
@@ -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;
+ }
+
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/DouGongNotifyController.java b/backend/src/main/java/com/tourguide/common/huifu/DouGongNotifyController.java
new file mode 100644
index 0000000..3c33f5e
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/DouGongNotifyController.java
@@ -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";
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/RequestSeqUtil.java b/backend/src/main/java/com/tourguide/common/huifu/RequestSeqUtil.java
new file mode 100644
index 0000000..b9d75ff
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/RequestSeqUtil.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongBasicDataBiz.java b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongBasicDataBiz.java
new file mode 100644
index 0000000..eb2df3d
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongBasicDataBiz.java
@@ -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;
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongPayUtil.java b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongPayUtil.java
new file mode 100644
index 0000000..f91ade2
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongPayUtil.java
@@ -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;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRefundBiz.java b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRefundBiz.java
new file mode 100644
index 0000000..c9853ea
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongRefundBiz.java
@@ -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;
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongWithdrawBiz.java b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongWithdrawBiz.java
new file mode 100644
index 0000000..4dfa902
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/biz/DouGongWithdrawBiz.java
@@ -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;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/biz/FenZhangUtil.java b/backend/src/main/java/com/tourguide/common/huifu/biz/FenZhangUtil.java
new file mode 100644
index 0000000..37fd194
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/biz/FenZhangUtil.java
@@ -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();
+ }
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/config/HuiFuPropertiesConfiguration.java b/backend/src/main/java/com/tourguide/common/huifu/config/HuiFuPropertiesConfiguration.java
new file mode 100644
index 0000000..c8104ea
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/config/HuiFuPropertiesConfiguration.java
@@ -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 {
+}
diff --git a/backend/src/main/java/com/tourguide/common/huifu/properties/HuiFuProperties.java b/backend/src/main/java/com/tourguide/common/huifu/properties/HuiFuProperties.java
new file mode 100644
index 0000000..5ccdb57
--- /dev/null
+++ b/backend/src/main/java/com/tourguide/common/huifu/properties/HuiFuProperties.java
@@ -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; }
+}
diff --git a/backend/src/main/java/com/tourguide/config/SecurityConfig.java b/backend/src/main/java/com/tourguide/config/SecurityConfig.java
index fda8a4d..3ec426f 100644
--- a/backend/src/main/java/com/tourguide/config/SecurityConfig.java
+++ b/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()
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 0fedf7f..3df72a8 100644
--- a/backend/src/main/resources/application.yml
+++ b/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:}
--
Gitblit v1.7.1