| | |
| | | <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> |
| New file |
| | |
| | | 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())); |
| | | } |
| | | } |
| New file |
| | |
| | | /** |
| | | * 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; |
| | | } |
| | | |
| | | } |
| New file |
| | |
| | | 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"; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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 { |
| | | } |
| New file |
| | |
| | | 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; } |
| | | } |
| | |
| | | "/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() |
| | |
| | | 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:} |