添加缴费相关功能

This commit is contained in:
mirage 2026-03-26 17:44:13 +08:00
parent 54f5a128cf
commit fff37d2935
30 changed files with 2093 additions and 401 deletions

View File

@ -33,11 +33,11 @@ spring:
matching-strategy: ant_path_matcher
# 数据源配置
datasource:
url: jdbc:mysql://localhost:3306/la?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&sql_mode=ANSI_QUOTES
url: jdbc:mysql://8.153.111.6:3306/la?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&sql_mode=ANSI_QUOTES
type: com.zaxxer.hikari.HikariDataSource # 数据源类型
driver-class-name: com.mysql.cj.jdbc.Driver # MySql的驱动
username: root # 数据库账号
password: 123456 # 数据库密码
password: 11111111 # 数据库密码
hikari:
connection-timeout: 30000 # 等待连接分配连接的最大时长(毫秒),超出时长还没可用连接则发送SQLException,默认30秒
minimum-idle: 5 # 最小连接数
@ -55,9 +55,9 @@ spring:
enabled: true
# Redis配置
redis:
host: localhost # Redis服务地址
host: 8.153.111.6 # Redis服务地址
port: 6379 # Redis端口
password: # Redis密码
password: 11111111 # Redis密码
database: 0 # 数据库索引
timeout: 5000 # 连接超时
lettuce:

View File

@ -95,4 +95,7 @@ public class StudentInfo implements Serializable {
@ApiModelProperty(value = "删除时间")
private Date deleteTime;
@ApiModelProperty(value = "绑定缴费订单IDla_enrollment_pay_order.id")
private Long payOrderId;
}

View File

@ -0,0 +1,66 @@
package com.mdd.common.entity.bank;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@ApiModel("开放银行缴费支付流水")
public class BankFeePayOrder implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
@ApiModelProperty("ID")
private Long id;
@ApiModelProperty("用户ID")
private Integer userId;
@ApiModelProperty("报名编号/业务单号application_number")
private String applicationNumber;
@ApiModelProperty("商户订单号out_trade_no")
private String outTradeNo;
@ApiModelProperty("支付方式: [2=微信, 3=支付宝]")
private Integer payWay;
@ApiModelProperty("支付金额(分)")
private Integer totalFeeFen;
@ApiModelProperty("支付状态: [0=待支付, 1=已支付, 2=已关闭]")
private Integer payStatus;
@ApiModelProperty("银行交易流水transaction_id / trade_no 等)")
private String bankTransactionId;
@ApiModelProperty("银行回调/查单返回的 trade_state如 SUCCESS/NOTPAY/CLOSED")
private String tradeState;
@ApiModelProperty("下单返回的 pay_info可选")
private String payInfo;
@ApiModelProperty("支付宝 H5 可能返回 pay_url可选")
private String payUrl;
@ApiModelProperty("支付时间(秒)")
private Long payTime;
@ApiModelProperty("最近一次原始回调/查单 JSON可选便于排障")
private String lastBankBody;
@ApiModelProperty("创建时间(秒)")
private Long createTime;
@ApiModelProperty("更新时间(秒)")
private Long updateTime;
@ApiModelProperty("删除时间(秒)")
private Long deleteTime;
}

View File

@ -0,0 +1,66 @@
package com.mdd.common.entity.bank;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@ApiModel("报名缴费开放银行订单流水")
public class EnrollmentPayOrder implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
@ApiModelProperty("ID")
private Long id;
@ApiModelProperty("下单用户ID")
private Integer userId;
@ApiModelProperty("学生ID来自 la_student_info.student_id")
private Long studentId;
@ApiModelProperty("商户订单号out_trade_no银行原样回传")
private String outTradeNo;
@ApiModelProperty("支付方式: 2=微信, 3=支付宝")
private Integer payWay;
@ApiModelProperty("支付金额(分)")
private Integer totalFeeFen;
@ApiModelProperty("支付状态: 0=待支付 1=已支付 2=已关闭")
private Integer payStatus;
@ApiModelProperty("银行交易流水transaction_id/trade_no 等)")
private String bankTransactionId;
@ApiModelProperty("银行交易状态SUCCESS/NOTPAY/CLOSED...")
private String tradeState;
@ApiModelProperty("下单返回 pay_info可选")
private String payInfo;
@ApiModelProperty("支付宝H5可能返回 pay_url可选")
private String payUrl;
@ApiModelProperty("支付时间(秒)")
private Long payTime;
@ApiModelProperty("最近一次回调/查单明文JSON可选")
private String lastBankBody;
@ApiModelProperty("创建时间(秒)")
private Long createTime;
@ApiModelProperty("更新时间(秒)")
private Long updateTime;
@ApiModelProperty("删除时间(秒)")
private Long deleteTime;
}

View File

@ -0,0 +1,10 @@
package com.mdd.common.mapper.bank;
import com.mdd.common.core.basics.IBaseMapper;
import com.mdd.common.entity.bank.BankFeePayOrder;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BankFeePayOrderMapper extends IBaseMapper<BankFeePayOrder> {
}

View File

@ -0,0 +1,10 @@
package com.mdd.common.mapper.bank;
import com.mdd.common.core.basics.IBaseMapper;
import com.mdd.common.entity.bank.EnrollmentPayOrder;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EnrollmentPayOrderMapper extends IBaseMapper<EnrollmentPayOrder> {
}

View File

@ -0,0 +1,94 @@
package com.mdd.front.config;
import com.cib.fintech.dfp.open.sdk.config.Configure;
import com.cib.fintech.dfp.open.sdk.config.KeyConfigure;
import com.cib.fintech.dfp.open.sdk.enums.KeySignTypeEnum;
import com.cib.fintech.dfp.open.sdk.enums.RespSignAlgorithmEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
/**
* 兴业数金开放银行 SDK 初始化配置
* <p>
* 注意私钥/加密密钥仅从服务端配置读取不允许下发到前端
*/
@Configuration
public class DfpOpenSdkInitConfig {
private static final Logger log = LoggerFactory.getLogger(DfpOpenSdkInitConfig.class);
@Value("${dfp-open.dev-env:true}")
private boolean devEnv;
@Value("${dfp-open.keyId:}")
private String keyId;
@Value("${dfp-open.priKey:}")
private String priKey;
@Value("${dfp-open.respPubKey:}")
private String respPubKey;
@Value("${dfp-open.reqParamEncryptKey:}")
private String reqParamEncryptKey;
@PostConstruct
public void init() {
if (isBlank(keyId) || isBlank(priKey) || isBlank(respPubKey) || isBlank(reqParamEncryptKey)) {
log.warn("dfp-open 配置未完整,跳过 SDK 初始化。keyId={}, devEnv={}", safeKeyId(keyId), devEnv);
return;
}
// 1) 设置环境测试/生产网关地址会随 devEnv 变化
Configure.setDevEnv(devEnv);
// 2) 配置默认 keyId确保 OpenSdk.gatewayWithKeyId 可传入正确的 keyId
Configure.setKeyId(keyId);
// 2) 配置应用密钥
KeyConfigure keyConfigure = new KeyConfigure();
keyConfigure.setKeyId(keyId);
keyConfigure.setPriKey(priKey);
keyConfigure.setRespPubKey(respPubKey);
keyConfigure.setReqParamEncryptKey(reqParamEncryptKey);
// 请求签名算法与响应验签算法
keyConfigure.setKeySignType(KeySignTypeEnum.SM3WITHSM2);
keyConfigure.setRespSignSwitch(true);
keyConfigure.setRespSignAlgorithm(RespSignAlgorithmEnum.SM3WITHSM2);
Map<String, KeyConfigure> keyConfigures = new HashMap<>(4);
keyConfigures.put(keyId, keyConfigure);
Configure.setKeyConfigures(keyConfigures);
log.info("dfp-open SDK 初始化完成。keyIdPrefix={}, devEnv={}", safeKeyIdPrefix(keyId), devEnv);
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
private static String safeKeyId(String s) {
if (isBlank(s)) {
return "";
}
s = s.trim();
if (s.length() <= 6) {
return "******";
}
return s.substring(0, 2) + "******" + s.substring(s.length() - 2);
}
private static String safeKeyIdPrefix(String s) {
if (isBlank(s)) {
return "";
}
s = s.trim();
return s.length() <= 4 ? s : s.substring(0, 4);
}
}

View File

@ -0,0 +1,175 @@
package com.mdd.front.controller;
import com.alibaba.fastjson2.JSONObject;
import com.cib.fintech.dfp.open.sdk.config.Configure;
import com.cib.fintech.dfp.open.sdk.config.KeyConfigure;
import com.cib.fintech.dfp.open.sdk.exception.SdkException;
import com.cib.fintech.dfp.open.sdk.util.CallbackUtil;
import com.mdd.common.aop.NotLogin;
import com.mdd.common.exception.OperateException;
import com.mdd.common.util.StringUtils;
import com.mdd.front.service.IBankFeeNotifyService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* 兴业数金开放银行异步通知验签 + SM4 解密 + 业务落库
* <p>
* 说明
* - 回调为银行服务器主动调用不需要登录
* - 验签失败建议返回 fail让银行重试验签成功但业务幂等已处理可直接 success
*/
@RestController
@RequestMapping("/api/bank-fee")
@Api(tags = "开放银行支付回调")
public class BankFeeNotifyController {
private static final Logger log = LoggerFactory.getLogger(BankFeeNotifyController.class);
@Resource
private IBankFeeNotifyService bankFeeNotifyService;
@PostMapping("/notify")
@NotLogin
@ApiOperation("开放银行异步通知(验签解密后更新缴费状态)")
public String notify(HttpServletRequest request, @RequestBody(required = false) String rawBody) {
String keyId = header(request, "Keyid");
String timestamp = header(request, "Timestamp");
String nonce = header(request, "Nonce");
String signature = header(request, "Signature");
String pwd = header(request, "Pwd"); // V2 可能存在
String ciphertext = extractCiphertext(rawBody);
if (StringUtils.isBlank(ciphertext)) {
log.warn("bank-fee notify missing ciphertext. keyIdPrefix={}, rawLen={}", safePrefix(keyId, 6),
rawBody == null ? 0 : rawBody.length());
return "fail";
}
KeyConfigure keyConfigure = resolveKeyConfigure(keyId);
if (keyConfigure == null) {
log.error("bank-fee notify no keyConfigure. keyIdPrefix={}", safePrefix(keyId, 6));
return "fail";
}
String decrypted;
try {
if (StringUtils.isNotBlank(pwd)) {
decrypted = CallbackUtil.decryptAndVerifyV2(
keyId,
timestamp,
nonce,
pwd,
signature,
ciphertext,
keyConfigure.getPriKey(),
keyConfigure.getRespPubKey()
).get("body");
} else {
decrypted = CallbackUtil.decryptAndVerify(
keyId,
timestamp,
nonce,
signature,
ciphertext,
keyConfigure.getReqParamEncryptKey(),
keyConfigure.getRespPubKey()
);
}
} catch (SdkException e) {
log.error("bank-fee notify verify/decrypt failed. code={}, msg={}, keyIdPrefix={}", e.getCode(),
e.getMsg(), safePrefix(keyId, 6));
return "fail";
} catch (Exception e) {
log.error("bank-fee notify verify/decrypt failed. keyIdPrefix={}, err={}", safePrefix(keyId, 6), e.toString());
return "fail";
}
JSONObject bodyJson;
try {
bodyJson = JSONObject.parseObject(decrypted);
} catch (Exception e) {
log.error("bank-fee notify decrypted not json. keyIdPrefix={}, decryptedPrefix={}",
safePrefix(keyId, 6), safePrefix(decrypted, 80));
return "fail";
}
try {
bankFeeNotifyService.handlePaidNotify(bodyJson);
} catch (OperateException oe) {
// 业务异常为避免银行无限重试这里仍返回 success但记录错误
log.error("bank-fee notify business error. msg={}, bodyKeys={}", oe.getMessage(), bodyJson.keySet());
return "success";
} catch (Exception e) {
log.error("bank-fee notify business error. err={}, bodyKeys={}", e.toString(), bodyJson.keySet());
// 未知异常可让银行重试你也可以改为 success 并自行补偿
return "fail";
}
return "success";
}
private static KeyConfigure resolveKeyConfigure(String keyIdHeader) {
if (StringUtils.isNotBlank(keyIdHeader)) {
KeyConfigure cfg = Configure.getKeyConfigures().get(keyIdHeader.trim());
if (cfg != null) {
return cfg;
}
}
// fallback: 取默认 keyId 的配置
try {
return Configure.getKeyConfigure();
} catch (Exception ignored) {
return null;
}
}
private static String extractCiphertext(String rawBody) {
if (rawBody == null) {
return null;
}
String s = rawBody.trim();
if (s.isEmpty()) {
return null;
}
if (s.startsWith("{") && s.endsWith("}")) {
try {
JSONObject json = JSONObject.parseObject(s);
String ciphertext = json.getString("ciphertext");
if (StringUtils.isNotBlank(ciphertext)) {
return ciphertext.trim();
}
} catch (Exception ignored) {
// fallthrough
}
}
// 有些环境可能直接把 ciphertext 作为 body 传入
return s;
}
private static String header(HttpServletRequest request, String name) {
String v = request.getHeader(name);
return v == null ? "" : v.trim();
}
private static String safePrefix(String s, int maxLen) {
if (s == null) {
return "";
}
s = s.trim();
if (s.length() <= maxLen) {
return s;
}
return s.substring(0, maxLen) + "...";
}
}

View File

@ -0,0 +1,43 @@
package com.mdd.front.controller;
import com.mdd.common.core.AjaxResult;
import com.mdd.front.LikeFrontThreadLocal;
import com.mdd.front.service.IDfpOpenPayService;
import com.mdd.front.validate.bankfee.DfpAlipayJspayValidate;
import com.mdd.front.validate.bankfee.DfpWechatJspayValidate;
import com.mdd.front.vo.bankfee.DfpPayPrepareVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 兴业数金开放银行正式下单入口需登录
*/
@RestController
@RequestMapping("/api/bank-fee/pay")
@Api(tags = "开放银行聚合支付")
public class BankFeePayController {
@Resource
private IDfpOpenPayService dfpOpenPayService;
@PostMapping("/wechat")
@ApiOperation("微信小程序 jspay 下单(服务端根据登录用户查询 sub_openid")
public AjaxResult<DfpPayPrepareVo> wechatMini(@Validated @RequestBody DfpWechatJspayValidate validate) {
DfpPayPrepareVo vo = dfpOpenPayService.prepareWechatMiniJspay(LikeFrontThreadLocal.getUserId(), validate);
return AjaxResult.success(vo);
}
@PostMapping("/alipay")
@ApiOperation("支付宝服务窗 jspay 下单")
public AjaxResult<DfpPayPrepareVo> alipay(@Validated @RequestBody DfpAlipayJspayValidate validate) {
DfpPayPrepareVo vo = dfpOpenPayService.prepareAlipayJspay(LikeFrontThreadLocal.getUserId(), validate);
return AjaxResult.success(vo);
}
}

View File

@ -0,0 +1,41 @@
package com.mdd.front.controller;
import com.mdd.common.core.AjaxResult;
import com.mdd.front.LikeFrontThreadLocal;
import com.mdd.front.service.IBankFeeTradeService;
import com.mdd.front.validate.bankfee.BankFeeTradeCloseValidate;
import com.mdd.front.validate.bankfee.BankFeeTradeQueryValidate;
import com.mdd.front.vo.bankfee.BankFeeTradeStatusVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
@RequestMapping("/api/bank-fee/trade")
@Api(tags = "开放银行-查单关单")
public class BankFeeTradeController {
@Resource
private IBankFeeTradeService bankFeeTradeService;
@PostMapping("/query")
@ApiOperation("开放银行查单(查到 SUCCESS 会自动落库更新缴费状态)")
public AjaxResult<BankFeeTradeStatusVo> query(@Validated @RequestBody BankFeeTradeQueryValidate validate) {
BankFeeTradeStatusVo vo = bankFeeTradeService.query(LikeFrontThreadLocal.getUserId(), validate);
return AjaxResult.success(vo);
}
@PostMapping("/close")
@ApiOperation("开放银行关单")
public AjaxResult<BankFeeTradeStatusVo> close(@Validated @RequestBody BankFeeTradeCloseValidate validate) {
BankFeeTradeStatusVo vo = bankFeeTradeService.close(LikeFrontThreadLocal.getUserId(), validate);
return AjaxResult.success(vo);
}
}

View File

@ -0,0 +1,164 @@
package com.mdd.front.controller;
import com.alibaba.fastjson2.JSONObject;
import com.cib.fintech.dfp.open.sdk.OpenSdk;
import com.cib.fintech.dfp.open.sdk.enums.ReqMethodEnum;
import com.mdd.common.aop.NotLogin;
import com.mdd.common.core.AjaxResult;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 兴业数金 dfp-open 临时测试接口仅用于联调/测试环境
* <p>
* 注意不要把私钥/加密密钥放到前端或返回给前端
*/
@RestController
@RequestMapping("/frontapi/bank-open/test")
@Api(tags = "银行SDK测试临时")
public class BankOpenSdkTestController {
private static final Logger log = LoggerFactory.getLogger(BankOpenSdkTestController.class);
@Value("${dfp-open.keyId:}")
private String keyId;
@Value("${dfp-open.mchId:}")
private String mchId;
@PostMapping("/terminalInfoInput")
@NotLogin
@ApiOperation("4.3 终端信息采集/入网(临时测试)")
public AjaxResult<Object> terminalInfoInput(@Validated @RequestBody TerminalInfoInputReq req) {
String outTermId = blankToNull(req.getOutTermId());
if (outTermId == null) {
return AjaxResult.success(0, "outTermId 参数必填", 0);
}
String termType = blankToNull(req.getTermType());
if (termType == null) {
termType = "01";
}
String operationFlag = blankToNull(req.getOperationFlag());
if (operationFlag == null) {
operationFlag = "0";
}
String version = blankToNull(req.getVersion());
if (version == null) {
version = "1.0.0";
}
String mchIdResolved = blankToNull(req.getMchId());
if (mchIdResolved == null) {
mchIdResolved = blankToNull(this.mchId);
}
if (mchIdResolved == null) {
return AjaxResult.success(0, "mchId 缺失:请在 yml 配置 dfp-open.mchId或在请求体传 mchId", 0);
}
if (blankToNull(keyId) == null) {
return AjaxResult.success(0, "keyId 缺失:请在 yml 配置 dfp-open.keyId", 0);
}
Map<String, String> bodyParams = new HashMap<>(8);
bodyParams.put("version", version);
bodyParams.put("mchId", mchIdResolved);
bodyParams.put("termType", termType);
bodyParams.put("operationFlag", operationFlag);
bodyParams.put("outTermId", outTermId);
try {
String response = OpenSdk.gatewayWithKeyId(
"/api/payment/wholeNetworkAcquiring/new/terminalInfoInput",
ReqMethodEnum.POST,
null,
null,
bodyParams,
keyId
);
String termId = null;
String termIdAlt = null;
boolean showRaw = Boolean.TRUE.equals(req.getShowRaw());
try {
JSONObject json = JSONObject.parseObject(response);
termId = json.getString("termId");
if (termId == null) {
termIdAlt = json.getString("term_id");
if (termIdAlt == null) {
termIdAlt = json.getString("terminal_id");
}
}
} catch (Exception parseEx) {
// 如果返回不是 JSONSDK 异常 toStringtermId 可能为空
log.warn("terminalInfoInput 返回非 JSON无法解析 termId。responsePrefix={}", safePrefix(response, 120));
}
Map<String, Object> data = new HashMap<>(3);
data.put("termId", termId != null ? termId : termIdAlt);
data.put("outTermId", outTermId);
// 如果 termId 解析不到自动返回原始响应方便定位错误原因
if (showRaw || (termId == null && termIdAlt == null)) {
data.put("response", response);
}
return AjaxResult.success(data);
} catch (Exception e) {
log.error("terminalInfoInput 调用银行网关失败。outTermId={}, err={}", outTermId, e.toString());
return AjaxResult.success(0, "调用银行网关失败:" + e.getMessage(), 0);
}
}
private static String blankToNull(String s) {
if (s == null) {
return null;
}
s = s.trim();
return s.isEmpty() ? null : s;
}
private static String safePrefix(String s, int maxLen) {
if (s == null) {
return "";
}
if (s.length() <= maxLen) {
return s;
}
return s.substring(0, maxLen) + "...";
}
@Setter
@Getter
public static class TerminalInfoInputReq {
// 商户侧终端编号必填
private String outTermId;
// 终端类型默认 01
private String termType;
// 操作标志默认 0
private String operationFlag;
// 版本默认 1.0.0
private String version;
// 商户号可从 yml dfp-open.mchId 默认
private String mchId;
// 是否返回银行原始 response临时联调用
private Boolean showRaw = false;
}
}

View File

@ -0,0 +1,17 @@
package com.mdd.front.service;
import com.alibaba.fastjson2.JSONObject;
/**
* 开放银行回调处理缴费/业务订单
*/
public interface IBankFeeNotifyService {
/**
* 处理开放银行支付成功回调明文 JSON
*
* @param notifyBody 已解密且已验签后的明文 JSON
*/
void handlePaidNotify(JSONObject notifyBody);
}

View File

@ -0,0 +1,13 @@
package com.mdd.front.service;
import com.mdd.front.validate.bankfee.BankFeeTradeCloseValidate;
import com.mdd.front.validate.bankfee.BankFeeTradeQueryValidate;
import com.mdd.front.vo.bankfee.BankFeeTradeStatusVo;
public interface IBankFeeTradeService {
BankFeeTradeStatusVo query(Integer userId, BankFeeTradeQueryValidate validate);
BankFeeTradeStatusVo close(Integer userId, BankFeeTradeCloseValidate validate);
}

View File

@ -0,0 +1,21 @@
package com.mdd.front.service;
import com.mdd.front.validate.bankfee.DfpAlipayJspayValidate;
import com.mdd.front.validate.bankfee.DfpWechatJspayValidate;
import com.mdd.front.vo.bankfee.DfpPayPrepareVo;
/**
* 兴业数金开放银行小程序微信 / 支付宝服务窗 下单jspay
*/
public interface IDfpOpenPayService {
/**
* 微信小程序 pay.weixin.jspay
*/
DfpPayPrepareVo prepareWechatMiniJspay(Integer userId, DfpWechatJspayValidate validate);
/**
* 支付宝服务窗 pay.alipay.jspay
*/
DfpPayPrepareVo prepareAlipayJspay(Integer userId, DfpAlipayJspayValidate validate);
}

View File

@ -0,0 +1,160 @@
package com.mdd.front.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.mdd.common.entity.StudentInfo;
import com.mdd.common.entity.bank.EnrollmentPayOrder;
import com.mdd.common.mapper.StudentInfoMapper;
import com.mdd.common.mapper.bank.EnrollmentPayOrderMapper;
import com.mdd.common.util.StringUtils;
import com.mdd.common.util.TimeUtils;
import com.mdd.front.service.IBankFeeNotifyService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class BankFeeNotifyServiceImpl implements IBankFeeNotifyService {
private static final Logger log = LoggerFactory.getLogger(BankFeeNotifyServiceImpl.class);
@Resource
private StudentInfoMapper studentInfoMapper;
@Resource
private EnrollmentPayOrderMapper enrollmentPayOrderMapper;
@Override
@Transactional
public void handlePaidNotify(JSONObject notifyBody) {
if (notifyBody == null) {
return;
}
String outTradeNo = firstNonBlank(
notifyBody.getString("out_trade_no"),
notifyBody.getString("outTradeNo"),
notifyBody.getString("application_number"),
notifyBody.getString("applicationNumber")
);
if (StringUtils.isBlank(outTradeNo)) {
log.warn("bank-fee notify missing out_trade_no. bodyKeys={}", notifyBody.keySet());
return;
}
// 成功判断尽量兼容不同回调字段
boolean success = isSuccess(notifyBody);
if (!success) {
log.info("bank-fee notify not success. outTradeNo={}, brief={}", outTradeNo, brief(notifyBody));
return;
}
long now = TimeUtils.timestamp();
// 先更新/补齐流水表幂等
EnrollmentPayOrder payOrder = enrollmentPayOrderMapper.selectOne(new QueryWrapper<EnrollmentPayOrder>()
.eq("out_trade_no", outTradeNo)
.isNull("delete_time")
.last("limit 1"));
if (payOrder == null) {
payOrder = new EnrollmentPayOrder();
payOrder.setStudentId(tryParseStudentId(outTradeNo));
payOrder.setOutTradeNo(outTradeNo);
payOrder.setPayStatus(0);
payOrder.setCreateTime(now);
payOrder.setUpdateTime(now);
enrollmentPayOrderMapper.insert(payOrder);
}
if (payOrder.getPayStatus() == null || payOrder.getPayStatus() != 1) {
payOrder.setPayStatus(1);
payOrder.setPayTime(now);
payOrder.setTradeState(firstNonBlank(notifyBody.getString("trade_state"), notifyBody.getString("tradeState")));
payOrder.setBankTransactionId(firstNonBlank(
notifyBody.getString("transaction_id"),
notifyBody.getString("transactionId"),
notifyBody.getString("trade_no"),
notifyBody.getString("tradeNo")
));
payOrder.setLastBankBody(storeRaw(notifyBody));
payOrder.setUpdateTime(now);
enrollmentPayOrderMapper.updateById(payOrder);
}
// 绑定到学生信息表幂等
if (payOrder.getStudentId() != null) {
StudentInfo studentInfo = studentInfoMapper.selectById(payOrder.getStudentId());
if (studentInfo != null && studentInfo.getPayOrderId() == null) {
studentInfo.setPayOrderId(payOrder.getId());
studentInfoMapper.updateById(studentInfo);
}
}
log.info("bank-fee notify marked paid. outTradeNo={}, payOrderId={}", outTradeNo, payOrder.getId());
}
private static boolean isSuccess(JSONObject body) {
// 常见status/result_code=0 trade_state=SUCCESS pay_result=0
String status = firstNonBlank(body.getString("status"), body.getString("Status"));
String resultCode = firstNonBlank(body.getString("result_code"), body.getString("resultCode"));
String tradeState = firstNonBlank(body.getString("trade_state"), body.getString("tradeState"));
String payResult = firstNonBlank(body.getString("pay_result"), body.getString("payResult"));
if ("SUCCESS".equalsIgnoreCase(tradeState)) {
return true;
}
if ("0".equals(status) && ("0".equals(resultCode) || StringUtils.isBlank(resultCode))) {
return true;
}
if ("0".equals(payResult)) {
return true;
}
// 兜底有时仅给 result_code
return "0".equals(resultCode);
}
private static String firstNonBlank(String... arr) {
if (arr == null) {
return null;
}
for (String s : arr) {
if (StringUtils.isNotBlank(s)) {
return s.trim();
}
}
return null;
}
private static String brief(JSONObject body) {
JSONObject o = new JSONObject();
o.put("status", body.getString("status"));
o.put("result_code", body.getString("result_code"));
o.put("trade_state", body.getString("trade_state"));
o.put("err_msg", body.getString("err_msg"));
o.put("message", body.getString("message"));
return o.toJSONString();
}
private static String storeRaw(JSONObject body) {
String raw = body == null ? null : body.toJSONString();
if (raw == null) {
return null;
}
return raw.length() > 20000 ? raw.substring(0, 20000) : raw;
}
private static Long tryParseStudentId(String outTradeNo) {
if (StringUtils.isBlank(outTradeNo)) {
return null;
}
try {
return Long.parseLong(outTradeNo.trim());
} catch (Exception ignored) {
return null;
}
}
}

View File

@ -0,0 +1,260 @@
package com.mdd.front.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.cib.fintech.dfp.open.sdk.OpenSdk;
import com.cib.fintech.dfp.open.sdk.enums.ReqMethodEnum;
import com.mdd.common.entity.StudentInfo;
import com.mdd.common.entity.bank.EnrollmentPayOrder;
import com.mdd.common.exception.OperateException;
import com.mdd.common.mapper.StudentInfoMapper;
import com.mdd.common.mapper.bank.EnrollmentPayOrderMapper;
import com.mdd.common.util.StringUtils;
import com.mdd.common.util.TimeUtils;
import com.mdd.front.service.IBankFeeTradeService;
import com.mdd.front.validate.bankfee.BankFeeTradeCloseValidate;
import com.mdd.front.validate.bankfee.BankFeeTradeQueryValidate;
import com.mdd.front.vo.bankfee.BankFeeTradeStatusVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
@Service
public class BankFeeTradeServiceImpl implements IBankFeeTradeService {
private static final Logger log = LoggerFactory.getLogger(BankFeeTradeServiceImpl.class);
private static final String PATH_UNIFIED_TRADE_QUERY = "/api/payment/wholeNetworkAcquiring/new/unifiedTradeQuery";
private static final String PATH_UNIFIED_TRADE_CLOSE = "/api/payment/wholeNetworkAcquiring/new/unifiedTradeClose";
@Value("${dfp-open.keyId:}")
private String keyId;
@Value("${dfp-open.mchId:}")
private String mchId;
@Resource
private EnrollmentPayOrderMapper enrollmentPayOrderMapper;
@Resource
private StudentInfoMapper studentInfoMapper;
@Override
@Transactional
public BankFeeTradeStatusVo query(Integer userId, BankFeeTradeQueryValidate validate) {
assertLogin(userId);
String outTradeNo = validate.getOutTradeNo().trim();
String raw = callBank(PATH_UNIFIED_TRADE_QUERY, buildQueryBody(outTradeNo));
BankFeeTradeStatusVo vo = toStatusVo(outTradeNo, raw, Boolean.TRUE.equals(validate.getIncludeRawResponse()));
// 如果查到成功收口幂等更新流水表与业务表
if (isSuccess(vo) && "SUCCESS".equalsIgnoreCase(vo.getTradeState())) {
markPaid(outTradeNo, vo, raw);
} else {
touchTradeState(outTradeNo, vo, raw);
}
return vo;
}
@Override
@Transactional
public BankFeeTradeStatusVo close(Integer userId, BankFeeTradeCloseValidate validate) {
assertLogin(userId);
String outTradeNo = validate.getOutTradeNo().trim();
String raw = callBank(PATH_UNIFIED_TRADE_CLOSE, buildCloseBody(outTradeNo));
BankFeeTradeStatusVo vo = toStatusVo(outTradeNo, raw, Boolean.TRUE.equals(validate.getIncludeRawResponse()));
if (isSuccess(vo)) {
markClosed(outTradeNo, vo, raw);
} else {
touchTradeState(outTradeNo, vo, raw);
}
return vo;
}
private void assertLogin(Integer userId) {
if (userId == null || userId <= 0) {
throw new OperateException("请先登录");
}
if (StringUtils.isBlank(keyId) || StringUtils.isBlank(mchId)) {
throw new OperateException("dfp-open.keyId / dfp-open.mchId 未配置");
}
}
private Map<String, String> buildQueryBody(String outTradeNo) {
Map<String, String> body = new HashMap<>(8);
body.put("version", "3.0");
body.put("service", "unified.trade.query");
body.put("charset", "UTF-8");
body.put("mch_id", mchId);
body.put("out_trade_no", outTradeNo);
return body;
}
private Map<String, String> buildCloseBody(String outTradeNo) {
Map<String, String> body = new HashMap<>(8);
body.put("version", "3.0");
body.put("service", "unified.trade.close");
body.put("charset", "UTF-8");
body.put("mch_id", mchId);
body.put("out_trade_no", outTradeNo);
return body;
}
private String callBank(String path, Map<String, String> bodyParams) {
try {
return OpenSdk.gatewayWithKeyId(path, ReqMethodEnum.POST, null, null, bodyParams, keyId);
} catch (Exception e) {
throw new OperateException("调用银行网关失败:" + e.getMessage());
}
}
private static BankFeeTradeStatusVo toStatusVo(String outTradeNo, String raw, boolean includeRaw) {
BankFeeTradeStatusVo vo = new BankFeeTradeStatusVo();
vo.setOutTradeNo(outTradeNo);
vo.setBankResponseRaw(includeRaw ? raw : null);
if (StringUtils.isBlank(raw)) {
vo.setErrMsg("银行返回为空");
return vo;
}
try {
JSONObject json = JSONObject.parseObject(raw);
vo.setStatus(json.getString("status"));
vo.setResultCode(json.getString("result_code"));
vo.setTradeState(firstNonBlank(json.getString("trade_state"), json.getString("tradeState")));
vo.setBankTransactionId(firstNonBlank(
json.getString("transaction_id"),
json.getString("transactionId"),
json.getString("trade_no"),
json.getString("tradeNo")
));
String totalFee = firstNonBlank(json.getString("total_fee"), json.getString("totalFee"));
if (StringUtils.isNotBlank(totalFee)) {
try {
vo.setTotalFeeFen(Integer.parseInt(totalFee.trim()));
} catch (Exception ignored) {
}
}
vo.setErrMsg(firstNonBlank(json.getString("err_msg"), json.getString("message")));
} catch (Exception e) {
vo.setErrMsg("银行响应非 JSON无法解析");
}
return vo;
}
private static boolean isSuccess(BankFeeTradeStatusVo vo) {
return "0".equals(vo.getStatus()) && "0".equals(vo.getResultCode());
}
private void markPaid(String outTradeNo, BankFeeTradeStatusVo vo, String raw) {
long now = TimeUtils.timestamp();
EnrollmentPayOrder payOrder = findOrCreate(outTradeNo, now);
if (payOrder.getPayStatus() != null && payOrder.getPayStatus() == 1) {
return;
}
payOrder.setPayStatus(1);
payOrder.setPayTime(now);
payOrder.setTradeState(vo.getTradeState());
payOrder.setBankTransactionId(vo.getBankTransactionId());
payOrder.setLastBankBody(safeStoreRaw(raw));
payOrder.setUpdateTime(now);
enrollmentPayOrderMapper.updateById(payOrder);
// 绑定到学生信息表幂等
if (payOrder.getStudentId() != null) {
StudentInfo studentInfo = studentInfoMapper.selectById(payOrder.getStudentId());
if (studentInfo != null && studentInfo.getPayOrderId() == null) {
studentInfo.setPayOrderId(payOrder.getId());
studentInfoMapper.updateById(studentInfo);
}
}
}
private void markClosed(String outTradeNo, BankFeeTradeStatusVo vo, String raw) {
long now = TimeUtils.timestamp();
EnrollmentPayOrder payOrder = findOrCreate(outTradeNo, now);
if (payOrder.getPayStatus() != null && payOrder.getPayStatus() == 1) {
return;
}
payOrder.setPayStatus(2);
payOrder.setTradeState(vo.getTradeState());
payOrder.setLastBankBody(safeStoreRaw(raw));
payOrder.setUpdateTime(now);
enrollmentPayOrderMapper.updateById(payOrder);
}
private void touchTradeState(String outTradeNo, BankFeeTradeStatusVo vo, String raw) {
long now = TimeUtils.timestamp();
EnrollmentPayOrder payOrder = findOrCreate(outTradeNo, now);
if (payOrder.getPayStatus() != null && payOrder.getPayStatus() == 1) {
return;
}
if (StringUtils.isNotBlank(vo.getTradeState())) {
payOrder.setTradeState(vo.getTradeState());
}
if (StringUtils.isNotBlank(vo.getBankTransactionId())) {
payOrder.setBankTransactionId(vo.getBankTransactionId());
}
payOrder.setLastBankBody(safeStoreRaw(raw));
payOrder.setUpdateTime(now);
enrollmentPayOrderMapper.updateById(payOrder);
}
private EnrollmentPayOrder findOrCreate(String outTradeNo, long now) {
EnrollmentPayOrder existing = enrollmentPayOrderMapper.selectOne(new QueryWrapper<EnrollmentPayOrder>()
.eq("out_trade_no", outTradeNo)
.isNull("delete_time")
.last("limit 1"));
if (existing != null) {
return existing;
}
EnrollmentPayOrder created = new EnrollmentPayOrder();
created.setStudentId(tryParseStudentId(outTradeNo));
created.setOutTradeNo(outTradeNo);
created.setPayStatus(0);
created.setCreateTime(now);
created.setUpdateTime(now);
enrollmentPayOrderMapper.insert(created);
return created;
}
private static String safeStoreRaw(String raw) {
if (raw == null) {
return null;
}
return raw.length() > 20000 ? raw.substring(0, 20000) : raw;
}
private static String firstNonBlank(String... arr) {
if (arr == null) {
return null;
}
for (String s : arr) {
if (StringUtils.isNotBlank(s)) {
return s.trim();
}
}
return null;
}
private static Long tryParseStudentId(String outTradeNo) {
if (StringUtils.isBlank(outTradeNo)) {
return null;
}
try {
return Long.parseLong(outTradeNo.trim());
} catch (Exception ignored) {
return null;
}
}
}

View File

@ -0,0 +1,320 @@
package com.mdd.front.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.cib.fintech.dfp.open.sdk.OpenSdk;
import com.cib.fintech.dfp.open.sdk.enums.ReqMethodEnum;
import com.mdd.common.entity.StudentInfo;
import com.mdd.common.entity.bank.EnrollmentPayOrder;
import com.mdd.common.entity.user.UserAuth;
import com.mdd.common.enums.ClientEnum;
import com.mdd.common.exception.OperateException;
import com.mdd.common.mapper.StudentInfoMapper;
import com.mdd.common.mapper.bank.EnrollmentPayOrderMapper;
import com.mdd.common.mapper.user.UserAuthMapper;
import com.mdd.common.util.ConfigUtils;
import com.mdd.common.util.RequestUtils;
import com.mdd.common.util.StringUtils;
import com.mdd.common.util.TimeUtils;
import com.mdd.front.service.IDfpOpenPayService;
import com.mdd.front.validate.bankfee.DfpAlipayJspayValidate;
import com.mdd.front.validate.bankfee.DfpWechatJspayValidate;
import com.mdd.front.vo.bankfee.DfpPayPrepareVo;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@Service
public class DfpOpenPayServiceImpl implements IDfpOpenPayService {
private static final String PATH_WECHAT_JSPAY = "/api/payment/wholeNetworkAcquiring/new/payWechatJspay";
private static final String PATH_ALIPAY_JSPAY = "/api/payment/wholeNetworkAcquiring/new/payAlipayJspay";
@Value("${dfp-open.keyId:}")
private String keyId;
@Value("${dfp-open.mchId:}")
private String mchId;
@Value("${dfp-open.termId:}")
private String termId;
/**
* 小程序 appId优先 yml否则读库表 mnp_setting.app_id
*/
@Value("${dfp-open.appId:}")
private String dfpMiniAppId;
@Value("${dfp-open.notify-url:}")
private String notifyUrlOverride;
@Resource
private UserAuthMapper userAuthMapper;
@Resource
private EnrollmentPayOrderMapper enrollmentPayOrderMapper;
@Resource
private StudentInfoMapper studentInfoMapper;
@Override
public DfpPayPrepareVo prepareWechatMiniJspay(Integer userId, DfpWechatJspayValidate validate) {
assertUser(userId);
String openId = resolveMiniOpenId(userId);
String subAppId = resolveMiniAppId();
String outTradeNo = createEnrollmentPayOrderAndReturnOutTradeNo(userId, validate.getStudentId(), 2, validate.getAmountFen());
String bodyText = StringUtils.isBlank(validate.getBody()) ? "缴费支付" : validate.getBody().trim();
String notifyUrl = resolveNotifyUrl();
Map<String, String> bodyParams = baseBodyParams(outTradeNo, validate.getAmountFen(), bodyText, notifyUrl);
bodyParams.put("service", "pay.weixin.jspay");
bodyParams.put("is_minipg", "1");
bodyParams.put("sub_appid", subAppId);
bodyParams.put("sub_openid", openId);
String raw = callBank(PATH_WECHAT_JSPAY, bodyParams);
DfpPayPrepareVo vo = toVo(outTradeNo, raw, Boolean.TRUE.equals(validate.getIncludeRawResponse()));
updatePayOrderAfterBankPlace(outTradeNo, vo, raw);
return vo;
}
@Override
public DfpPayPrepareVo prepareAlipayJspay(Integer userId, DfpAlipayJspayValidate validate) {
assertUser(userId);
String outTradeNo = createEnrollmentPayOrderAndReturnOutTradeNo(userId, validate.getStudentId(), 3, validate.getAmountFen());
String bodyText = StringUtils.isBlank(validate.getBody()) ? "缴费支付" : validate.getBody().trim();
String notifyUrl = resolveNotifyUrl();
Map<String, String> bodyParams = baseBodyParams(outTradeNo, validate.getAmountFen(), bodyText, notifyUrl);
bodyParams.put("service", "pay.alipay.jspay");
bodyParams.put("buyer_logon_id", validate.getBuyerLogonId() == null ? "" : validate.getBuyerLogonId().trim());
String raw = callBank(PATH_ALIPAY_JSPAY, bodyParams);
DfpPayPrepareVo vo = toVo(outTradeNo, raw, Boolean.TRUE.equals(validate.getIncludeRawResponse()));
updatePayOrderAfterBankPlace(outTradeNo, vo, raw);
return vo;
}
private void assertUser(Integer userId) {
if (userId == null || userId <= 0) {
throw new OperateException("请先登录");
}
}
private String resolveMiniOpenId(Integer userId) {
UserAuth primary = userAuthMapper.selectOne(new QueryWrapper<UserAuth>()
.select("openid")
.eq("user_id", userId)
.eq("terminal", ClientEnum.MNP.getCode())
.last("limit 1"));
if (primary != null && StringUtils.isNotBlank(primary.getOpenid())) {
return primary.getOpenid();
}
UserAuth fallback = userAuthMapper.selectOne(new QueryWrapper<UserAuth>()
.select("openid,terminal")
.eq("user_id", userId)
.orderByDesc("id")
.last("limit 1"));
if (fallback != null && StringUtils.isNotBlank(fallback.getOpenid())) {
return fallback.getOpenid();
}
throw new OperateException("未找到微信 openid请使用微信小程序一键登录或先绑定微信");
}
private String resolveMiniAppId() {
if (StringUtils.isNotBlank(dfpMiniAppId)) {
return dfpMiniAppId.trim();
}
String fromDb = ConfigUtils.get("mnp_setting", "app_id", "");
if (StringUtils.isNotBlank(fromDb)) {
return fromDb.trim();
}
throw new OperateException("未配置小程序 appId请在 yml 配置 dfp-open.appId 或后台 mnp_setting.app_id");
}
private String createEnrollmentPayOrderAndReturnOutTradeNo(Integer userId, Long studentId, int payWay, int amountFen) {
StudentInfo studentInfo = null;
// studentId 改为可选只有传入时才校验并尝试绑定
if (studentId != null) {
if (studentId <= 0) {
throw new OperateException("studentId 参数非法");
}
studentInfo = studentInfoMapper.selectById(studentId);
if (studentInfo == null || studentInfo.getDeleteTime() != null) {
throw new OperateException("studentId 不存在或已删除:" + studentId);
}
}
long now = TimeUtils.timestamp();
// 先插入一条临时 out_trade_no 获取自增 id
EnrollmentPayOrder created = new EnrollmentPayOrder();
created.setUserId(userId);
created.setStudentId(studentId);
created.setOutTradeNo("TMP-" + userId + "-" + System.currentTimeMillis());
created.setPayWay(payWay);
created.setTotalFeeFen(amountFen);
created.setPayStatus(0);
created.setCreateTime(now);
created.setUpdateTime(now);
enrollmentPayOrderMapper.insert(created);
if (created.getId() == null) {
throw new OperateException("创建缴费订单失败未返回ID");
}
// 正式规则QDKXJG-YBMJF-序号序号=自增ID
String outTradeNo = "QDKXJG-YBMJF-" + created.getId();
created.setOutTradeNo(outTradeNo);
enrollmentPayOrderMapper.updateById(created);
// 绑定学生信息
if (studentInfo != null && studentInfo.getPayOrderId() == null) {
studentInfo.setPayOrderId(created.getId());
studentInfoMapper.updateById(studentInfo);
}
return outTradeNo;
}
private void updatePayOrderAfterBankPlace(String outTradeNo, DfpPayPrepareVo vo, String raw) {
long now = TimeUtils.timestamp();
EnrollmentPayOrder order = enrollmentPayOrderMapper.selectOne(new QueryWrapper<EnrollmentPayOrder>()
.eq("out_trade_no", outTradeNo)
.isNull("delete_time")
.last("limit 1"));
if (order == null) {
return;
}
order.setPayInfo(vo == null ? null : vo.getPayInfo());
order.setPayUrl(vo == null ? null : vo.getPayUrl());
order.setLastBankBody(safeRaw(raw));
order.setUpdateTime(now);
enrollmentPayOrderMapper.updateById(order);
}
private static String safeRaw(String raw) {
if (raw == null) {
return null;
}
return raw.length() > 20000 ? raw.substring(0, 20000) : raw;
}
private String resolveNotifyUrl() {
if (StringUtils.isNotBlank(notifyUrlOverride)) {
String u = notifyUrlOverride.trim();
if (!u.startsWith("http://") && !u.startsWith("https://")) {
throw new OperateException("dfp-open.notify-url 必须是完整 http(s) 地址");
}
return u;
}
String base = RequestUtils.uri();
if (StringUtils.isBlank(base)) {
throw new OperateException("无法解析回调域名,请在 yml 配置 dfp-open.notify-url 为完整 URL");
}
return trimTrailingSlash(base) + "/api/bank-fee/notify";
}
private static String trimTrailingSlash(String base) {
if (base.endsWith("/")) {
return base.substring(0, base.length() - 1);
}
return base;
}
private Map<String, String> baseBodyParams(String outTradeNo, int amountFen, String body, String notifyUrl) {
if (StringUtils.isBlank(keyId)) {
throw new OperateException("dfp-open.keyId 未配置");
}
if (StringUtils.isBlank(mchId)) {
throw new OperateException("dfp-open.mchId 未配置");
}
if (StringUtils.isBlank(termId)) {
throw new OperateException("dfp-open.termId 未配置(请先完成终端入网 4.3");
}
Map<String, String> bodyParams = new HashMap<>(20);
bodyParams.put("version", "3.0");
bodyParams.put("charset", "UTF-8");
bodyParams.put("mch_id", mchId);
bodyParams.put("out_trade_no", outTradeNo);
bodyParams.put("total_fee", String.valueOf(amountFen));
bodyParams.put("body", StringUtils.abbreviate(body, 120));
bodyParams.put("mch_create_ip", resolveClientIp());
bodyParams.put("notify_url", notifyUrl);
bodyParams.put("terminal_info.terminal_type", "11");
bodyParams.put("terminal_info.terminal_id", termId);
bodyParams.put("terminal_info.app_version", "1.000000");
return bodyParams;
}
private String resolveClientIp() {
HttpServletRequest request = RequestUtils.handler();
if (request == null) {
return "127.0.0.1";
}
String xff = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(xff) && !"unknown".equalsIgnoreCase(xff)) {
int idx = xff.indexOf(',');
return (idx > 0 ? xff.substring(0, idx) : xff).trim();
}
return request.getRemoteAddr() == null ? "127.0.0.1" : request.getRemoteAddr();
}
private String callBank(String path, Map<String, String> bodyParams) {
try {
return OpenSdk.gatewayWithKeyId(path, ReqMethodEnum.POST, null, null, bodyParams, keyId);
} catch (Exception e) {
throw new OperateException("调用银行网关失败:" + e.getMessage());
}
}
private DfpPayPrepareVo toVo(String outTradeNo, String raw, boolean includeRaw) {
DfpPayPrepareVo vo = new DfpPayPrepareVo();
vo.setOutTradeNo(outTradeNo);
vo.setBankResponseRaw(includeRaw ? raw : null);
if (StringUtils.isBlank(raw)) {
throw new OperateException("银行返回为空");
}
if (raw.trim().startsWith("com.cib.fintech.dfp.open.sdk.exception.SdkException")
|| raw.contains("KEY_CONFIGURE_ERR")) {
throw new OperateException("开放银行 SDK 配置错误或未初始化:" + safeMsg(raw));
}
try {
JSONObject json = JSONObject.parseObject(raw);
vo.setStatus(json.getString("status"));
vo.setResultCode(json.getString("result_code"));
vo.setPayInfo(json.getString("pay_info"));
vo.setPayUrl(json.getString("pay_url"));
vo.setErrMsg(json.getString("err_msg"));
if (json.getString("message") != null && StringUtils.isBlank(vo.getErrMsg())) {
vo.setErrMsg(json.getString("message"));
}
} catch (Exception e) {
throw new OperateException("银行响应非 JSON无法解析" + safeMsg(raw));
}
if (!"0".equals(vo.getStatus()) || !"0".equals(vo.getResultCode())) {
String msg = StringUtils.isNotBlank(vo.getErrMsg()) ? vo.getErrMsg() : "银行下单失败";
throw new OperateException(msg);
}
if (StringUtils.isBlank(vo.getPayInfo()) && StringUtils.isBlank(vo.getPayUrl())) {
throw new OperateException("银行返回成功但未提供 pay_info / pay_url");
}
return vo;
}
private static String safeMsg(String raw) {
if (raw == null) {
return "";
}
return raw.length() > 200 ? raw.substring(0, 200) + "..." : raw;
}
}

View File

@ -14,6 +14,8 @@ import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Service
public class StudentServiceImpl extends ServiceImpl<StudentInfoMapper, StudentInfo> implements IStudentInfoService {
@ -63,6 +65,12 @@ public class StudentServiceImpl extends ServiceImpl<StudentInfoMapper, StudentIn
studentBaseInfo.setStudentId(studentInfo.getStudentId());
studentBaseInfoMapper.insert(studentBaseInfo);
}
return AjaxResult.success();
Long finalStudentId = studentInfo.getStudentId();
if (finalStudentId == null && baseInfo != null) {
finalStudentId = baseInfo.getStudentId();
}
Map<String, Object> data = new HashMap<>(1);
data.put("studentId", finalStudentId);
return AjaxResult.success(data);
}
}

View File

@ -0,0 +1,23 @@
package com.mdd.front.validate.bankfee;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
@Data
@ApiModel("开放银行-关单参数")
public class BankFeeTradeCloseValidate implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "outTradeNo不能为空")
@ApiModelProperty(value = "商户订单号(out_trade_no)", required = true)
private String outTradeNo;
@ApiModelProperty("是否返回银行原始响应(仅联调用)")
private Boolean includeRawResponse = false;
}

View File

@ -0,0 +1,23 @@
package com.mdd.front.validate.bankfee;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
@Data
@ApiModel("开放银行-查单参数")
public class BankFeeTradeQueryValidate implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "outTradeNo不能为空")
@ApiModelProperty(value = "商户订单号(out_trade_no)", required = true)
private String outTradeNo;
@ApiModelProperty("是否返回银行原始响应(仅联调用)")
private Boolean includeRawResponse = false;
}

View File

@ -0,0 +1,35 @@
package com.mdd.front.validate.bankfee;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
@Data
@ApiModel("开放银行-支付宝服务窗下单参数")
public class DfpAlipayJspayValidate implements Serializable {
private static final long serialVersionUID = 1L;
@NotNull(message = "amountFen不能为空")
@Min(value = 1, message = "金额至少1分")
@Max(value = 100000000, message = "金额超出限制")
@ApiModelProperty(value = "支付金额(分)", required = true, example = "1")
private Integer amountFen;
@ApiModelProperty("商品描述,默认:缴费支付")
private String body;
@ApiModelProperty(value = "学生IDla_student_info.student_id可选", required = false)
private Long studentId;
@ApiModelProperty("买家支付宝账号(可选)")
private String buyerLogonId;
@ApiModelProperty("是否把银行原始响应一并返回(仅联调建议 true")
private Boolean includeRawResponse = false;
}

View File

@ -0,0 +1,32 @@
package com.mdd.front.validate.bankfee;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
@Data
@ApiModel("开放银行-微信小程序下单参数")
public class DfpWechatJspayValidate implements Serializable {
private static final long serialVersionUID = 1L;
@NotNull(message = "amountFen不能为空")
@Min(value = 1, message = "金额至少1分")
@Max(value = 100000000, message = "金额超出限制")
@ApiModelProperty(value = "支付金额(分)", required = true, example = "1")
private Integer amountFen;
@ApiModelProperty("商品描述,默认:缴费支付")
private String body;
@ApiModelProperty(value = "学生IDla_student_info.student_id可选", required = false)
private Long studentId;
@ApiModelProperty("是否把银行原始响应一并返回(仅联调建议 true")
private Boolean includeRawResponse = false;
}

View File

@ -0,0 +1,39 @@
package com.mdd.front.vo.bankfee;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@ApiModel("开放银行-查单/关单返回")
public class BankFeeTradeStatusVo implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("商户订单号(out_trade_no)")
private String outTradeNo;
@ApiModelProperty("银行返回 status")
private String status;
@ApiModelProperty("银行返回 result_code")
private String resultCode;
@ApiModelProperty("交易状态(trade_state)")
private String tradeState;
@ApiModelProperty("银行交易流水(transaction_id/trade_no等)")
private String bankTransactionId;
@ApiModelProperty("支付金额(分),如果银行返回了该字段")
private Integer totalFeeFen;
@ApiModelProperty("错误信息")
private String errMsg;
@ApiModelProperty("银行完整响应(仅 includeRawResponse=true 时返回)")
private String bankResponseRaw;
}

View File

@ -0,0 +1,35 @@
package com.mdd.front.vo.bankfee;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@ApiModel("开放银行-拉起支付参数")
public class DfpPayPrepareVo implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("商户订单号(发往银行的 out_trade_no")
private String outTradeNo;
@ApiModelProperty("银行返回 status")
private String status;
@ApiModelProperty("银行返回 result_code")
private String resultCode;
@ApiModelProperty("银行返回 err_msg失败时有值")
private String errMsg;
@ApiModelProperty("微信/支付宝拉起支付所需原文(一般为 pay_info格式以银行为准")
private String payInfo;
@ApiModelProperty("支付宝 H5 等情况可能返回的 pay_url")
private String payUrl;
@ApiModelProperty("银行完整响应(仅 includeRawResponse=true 时有值)")
private String bankResponseRaw;
}

View File

@ -26,11 +26,11 @@ spring:
matching-strategy: ant_path_matcher
# 数据源配置
datasource:
url: jdbc:mysql://localhost:3306/la?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false
url: jdbc:mysql://8.153.111.6:3306/la?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false
type: com.zaxxer.hikari.HikariDataSource # 数据源类型
driver-class-name: com.mysql.cj.jdbc.Driver # MySql的驱动
username: root # 数据库账号
password: 123456 # 数据库密码
password: 11111111 # 数据库密码
hikari:
connection-timeout: 30000 # 等待连接分配连接的最大时长(毫秒),超出时长还没可用连接则发送SQLException,默认30秒
minimum-idle: 5 # 最小连接数
@ -48,9 +48,9 @@ spring:
enabled: true
# Redis配置
redis:
host: localhost # Redis服务地址
host: 8.153.111.6 # Redis服务地址
port: 6379 # Redis端口
password: # Redis密码
password: 11111111 # Redis密码
database: 0 # 数据库索引
timeout: 5000 # 连接超时
lettuce:
@ -90,3 +90,29 @@ knife4j:
openapi:
title: Knife4j前台页面文档
description: ""
# 对接银行参数
dfp-open:
# 是否测试用
dev-env: true
# 应用ID
keyId: KYVoTG4wsm7iKc8pn7zMyV88
# 应用签名私钥
priKey: FP3/5ciXVmEexoVJ4fWo4DU3BU9ssJYwLIjlA/vZzaQ=
# 平台验签公钥
respPubKey: BKD5RpG0XAsIyCowMQttyub3YrMQD4xFicOlNJgYAHpu/mSqiwJyyZwk52W2m6ARBfDrf31WdOeSnEqNS10wvbU=
# 字段加密密钥
reqParamEncryptKey: U9jnqp48wK5fqL9hdOPGLw==
# 商户ID
mchId: 999216001000BHK
# outTermId
outTermId: QDKXJG-TERM-01
# 终端ID
termId: P0003698
# 小程序appId
appId: wxaf99770eb7b49cb7
# 开放银行异步通知完整 URL公网可达银行服务器必须能访问
# 注意:如果不配置,会尝试自动拼接 RequestUtils.uri() + /api/bank-fee/notify在反向代理/HTTPS 场景可能不准确)
# notify-url: https://你的域名/api/bank-fee/notify
# 异步通知完整 URL公网可达银行服务器必须能访问。不填则使用 RequestUtils.uri() + /api/bank-fee/notify
# notify-url: https://你的域名/api/bank-fee/notify

View File

@ -15,57 +15,75 @@ export function getPayResult(data: any) {
return request.get({ url: '/pay/payStatus', data }, { isAuth: true })
}
// 开放银行支付宝服务窗支付
export function alipayJspay(data: any) {
return request.post({
url: '/api/payment/wholeNetworkAcquiring/new/payAlipayJspay',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}, { isAuth: true })
/** 开放银行支付宝服务窗:走后端 like-front /api/bank-fee/pay/alipayJSON */
export function alipayJspay(data: {
amountFen: number
body?: string
studentId: number
buyerLogonId?: string
includeRawResponse?: boolean
}) {
return request.post({ url: '/bank-fee/pay/alipay', data }, { isAuth: true })
}
// 开放银行微信支付JSPay
export function wechatJspay(data: any) {
return request.post({
url: '/api/payment/wholeNetworkAcquiring/new/payWechatJspay',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}, { isAuth: true })
/** 开放银行微信小程序 jspay走后端 like-front /api/bank-fee/pay/wechat服务端补 openid */
export function wechatJspay(data: {
amountFen: number
body?: string
studentId: number
includeRawResponse?: boolean
}) {
return request.post({ url: '/bank-fee/pay/wechat', data }, { isAuth: true })
}
/** 开放银行查单:走后端 /api/bank-fee/trade/queryJSON */
export function bankFeeTradeQuery(data: { outTradeNo: string; includeRawResponse?: boolean }) {
return request.post({ url: '/bank-fee/trade/query', data }, { isAuth: true })
}
/** 开放银行关单:走后端 /api/bank-fee/trade/closeJSON */
export function bankFeeTradeClose(data: { outTradeNo: string; includeRawResponse?: boolean }) {
return request.post({ url: '/bank-fee/trade/close', data }, { isAuth: true })
}
// 开放银行订单查询
export function unifiedTradeQuery(data: any) {
return request.post({
url: '/api/payment/wholeNetworkAcquiring/new/unifiedTradeQuery',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}, { isAuth: true })
return request.post(
{
url: '/api/payment/wholeNetworkAcquiring/new/unifiedTradeQuery',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
},
{ isAuth: true }
)
}
// 开放银行订单关闭
export function unifiedTradeClose(data: any) {
return request.post({
url: '/api/payment/wholeNetworkAcquiring/new/unifiedTradeClose',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}, { isAuth: true })
return request.post(
{
url: '/api/payment/wholeNetworkAcquiring/new/unifiedTradeClose',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
},
{ isAuth: true }
)
}
// 开放银行订单撤销
export function unifiedMicropayReverse(data: any) {
return request.post({
url: '/api/payment/wholeNetworkAcquiring/new/unifiedMicropayReverse',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}, { isAuth: true })
return request.post(
{
url: '/api/payment/wholeNetworkAcquiring/new/unifiedMicropayReverse',
data,
header: {
'Content-Type': 'application/x-www-form-urlencoded'
}
},
{ isAuth: true }
)
}

View File

@ -16,17 +16,29 @@
</view>
<view class="w-full mt-[140rpx] pb-[60rpx]">
<block v-if="!phoneLogin">
<!-- #ifdef MP-WEIXIN || H5 -->
<!-- <view v-if="isOpenOtherAuth && isWeixin && inWxAuth">
<!-- #ifdef MP-WEIXIN -->
<view v-if="isOpenOtherAuth" class="mt-[40rpx]">
<u-button
type="primary"
@click="wxLogin"
:customStyle="{ height: '100rpx' }"
hover-class="none"
>
用户一键登录
微信小程序一键登录
</u-button>
</view> -->
</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view v-if="isOpenOtherAuth && isWeixin && inWxAuth" class="mt-[40rpx]">
<u-button
type="primary"
@click="wxLogin"
:customStyle="{ height: '100rpx' }"
hover-class="none"
>
微信公众号一键登录
</u-button>
</view>
<!-- #endif -->
<view class="mt-[40rpx]">
@ -205,7 +217,7 @@
@cancel="showModel = false"
>
<view class="text-center px-[70rpx] py-[60rpx]">
<view> 请先阅读并同意 </view>
<view> 请先阅读并同意</view>
<view class="flex justify-center">
<navigator data-theme="" url="/pages/agreement/agreement?type=service">
<view class="text-primary">服务协议</view>

View File

@ -77,11 +77,7 @@
<text class="currency">¥</text>
<text class="amount">{{ amount || '0.00' }}</text>
</view>
<view
class="submit-btn"
:class="{ disabled: !canSubmit }"
@click="handleSubmit"
>
<view class="submit-btn" :class="{ disabled: !canSubmit }" @click="handleSubmit">
立即缴费
</view>
</view>
@ -97,9 +93,7 @@
v-model="password"
@finish="onPasswordFinish"
/>
<view class="popup-cancel" @click="showPasswordPopup = false">
取消
</view>
<view class="popup-cancel" @click="showPasswordPopup = false"> 取消</view>
</view>
</u-popup>
</view>
@ -107,9 +101,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getPayWay, prepay, getPayResult, alipayJspay, wechatJspay, unifiedTradeQuery } from '@/api/pay'
import { getPayWay, getPayResult, alipayJspay, wechatJspay, bankFeeTradeQuery } from '@/api/pay'
import { useUserStore } from '@/stores/user'
import config from '@/config'
import { onLoad } from '@dcloudio/uni-app'
declare const WeixinJSBridge: any
declare const AlipayJSBridge: any
const userStore = useUserStore()
@ -123,12 +121,13 @@ const payWayList = ref<any[]>([])
const selectedPayWay = ref('')
const showPasswordPopup = ref(false)
const password = ref('')
const orderId = ref('')
const studentId = ref<number | null>(null)
const outTradeNo = ref('')
//
const canSubmit = computed(() => {
const numAmount = parseFloat(amount.value)
return numAmount > 0 && selectedPayWay.value
return numAmount > 0 && selectedPayWay.value && !!studentId.value
})
// - 使
@ -155,9 +154,9 @@ const selectPayWay = (payWay: string) => {
//
const getPayWayIcon = (payWay: string) => {
const iconMap: Record<string, string> = {
'wechat': 'weixin-fill',
'alipay': 'zhifubao-circle-fill',
'balance': 'rmb-circle-fill'
wechat: 'weixin-fill',
alipay: 'zhifubao-circle-fill',
balance: 'rmb-circle-fill'
}
return iconMap[payWay] || 'rmb-circle-fill'
}
@ -165,9 +164,9 @@ const getPayWayIcon = (payWay: string) => {
//
const getPayWayColor = (payWay: string) => {
const colorMap: Record<string, string> = {
'wechat': '#07C160',
'alipay': '#1677FF',
'balance': '#FF6B00'
wechat: '#07C160',
alipay: '#1677FF',
balance: '#FF6B00'
}
return colorMap[payWay] || '#999'
}
@ -201,37 +200,17 @@ const onPasswordFinish = () => {
const doPay = async () => {
try {
uni.showLoading({ title: '支付中...' })
const params: any = {
pay_way: selectedPayWay.value,
amount: parseFloat(amount.value),
remark: remark.value
if (!studentId.value) {
throw new Error('缺少学生ID请从预报名详情进入缴费')
}
if (selectedPayWay.value === 'balance') {
params.password = password.value
}
const res = await prepay(params)
if (res && res.order_id) {
orderId.value = res.order_id
// -
if (selectedPayWay.value === 'wechat' && res.pay_params) {
await wechatJspayPay(res.pay_params)
}
// -
else if (selectedPayWay.value === 'alipay' && res.pay_params) {
await alipayJspayPay(res.pay_params)
}
//
else {
uni.hideLoading()
goToResult(true)
}
// out_trade_noQDKXJG-YBMJF-
if (selectedPayWay.value === 'wechat') {
await wechatJspayPay()
} else if (selectedPayWay.value === 'alipay') {
await alipayJspayPay()
} else {
throw new Error('支付参数错误')
throw new Error('暂不支持该支付方式')
}
} catch (error: any) {
uni.hideLoading()
@ -263,7 +242,14 @@ const wechatPay = (payParams: any) => {
window.location.href = payParams.mweb_url
} else if (payParams.jsapi_params) {
// JSAPI
const { appId, timeStamp, nonceStr, package: packageStr, signType, paySign } = payParams.jsapi_params
const {
appId,
timeStamp,
nonceStr,
package: packageStr,
signType,
paySign
} = payParams.jsapi_params
if (typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
@ -328,35 +314,30 @@ const alipay = (payParams: any) => {
})
}
//
const alipayJspayPay = async (payParams: any) => {
const alipayJspayPay = async () => {
try {
//
const params = {
version: '3.0',
charset: 'UTF-8',
service: 'pay.alipay.jspay',
mch_id: paymentConfig.mch_id, //
out_trade_no: orderId.value,
total_fee: String(Math.round(parseFloat(amount.value) * 100)), //
const res = await alipayJspay({
amountFen: Math.round(parseFloat(String(amount.value)) * 100),
body: remark.value || '缴费支付',
mch_create_ip: '127.0.0.1', // IP
notify_url: paymentConfig.alipay.notify_url,
buyer_logon_id: '', //
'terminal_info.terminal_type': '11', //
'terminal_info.terminal_id': paymentConfig.terminal_id,
'terminal_info.app_version': '1.000000'
}
studentId: Number(studentId.value),
buyerLogonId: '',
includeRawResponse: false
})
const res = await alipayJspay(params)
// AjaxResult.data
if (res && res.status === '0' && res.resultCode === '0') {
outTradeNo.value = res.outTradeNo || ''
// #ifdef H5
if (res.payUrl) {
window.location.href = res.payUrl
return
}
// #endif
//
if (res && res.status === '0' && res.result_code === '0') {
// pay_info
const payInfo = JSON.parse(res.pay_info || '{}')
const payInfo = JSON.parse(res.payInfo || '{}')
if (payInfo.tradeNO) {
// #ifdef APP-PLUS
// APP 使 tradeNO
uni.requestPayment({
provider: 'alipay',
orderInfo: {
@ -373,30 +354,27 @@ const alipayJspayPay = async (payParams: any) => {
// #endif
// #ifdef H5
// H5
// 1使 pay_url
if (res.pay_url) {
window.location.href = res.pay_url
return
}
// 2使 tradeNO
if (payInfo.tradeNO && typeof AlipayJSBridge !== 'undefined') {
AlipayJSBridge.call('tradePay', {
tradeNO: payInfo.tradeNO
}, (result: any) => {
if (result.resultCode === '9000') {
checkPayResult()
} else {
goToResult(false)
if (typeof AlipayJSBridge !== 'undefined') {
AlipayJSBridge.call(
'tradePay',
{
tradeNO: payInfo.tradeNO
},
(result: any) => {
if (result.resultCode === '9000') {
checkPayResult()
} else {
goToResult(false)
}
}
})
)
}
// #endif
} else {
throw new Error('获取支付参数失败')
}
} else {
throw new Error(res.err_msg || res.message || '支付请求失败')
throw new Error(res.errMsg || res.message || '支付请求失败')
}
} catch (error: any) {
console.error('开放银行支付错误:', error)
@ -406,60 +384,29 @@ const alipayJspayPay = async (payParams: any) => {
}
// JSPay
const wechatJspayPay = async (payParams: any) => {
const wechatJspayPay = async () => {
try {
//
const params: any = {
version: '3.0',
charset: 'UTF-8',
service: 'pay.weixin.jspay',
mch_id: paymentConfig.mch_id, //
out_trade_no: orderId.value,
total_fee: String(Math.round(parseFloat(amount.value) * 100)), //
// openid / termId / mchId / notify
const res = await wechatJspay({
amountFen: Math.round(parseFloat(String(amount.value)) * 100),
body: remark.value || '缴费支付',
mch_create_ip: '127.0.0.1', // IP
notify_url: paymentConfig.wechat.notify_url,
'terminal_info.terminal_type': '11', //
'terminal_info.terminal_id': paymentConfig.terminal_id,
'terminal_info.app_version': '1.000000'
}
// / sub_appid sub_openid
// #ifdef MP-WEIXIN
params.is_minipg = '1' //
params.sub_appid = paymentConfig.wechat.sub_appid // appid
// openid
const openid = userStore.userInfo?.openid || ''
if (openid) {
params.sub_openid = openid
}
// #endif
// #ifdef H5
// H5 -
params.is_raw = '1' // JS
params.sub_appid = paymentConfig.wechat.sub_appid // appid
// openid
const openid = userStore.userInfo?.openid || ''
if (openid) {
params.sub_openid = openid
}
// #endif
const res = await wechatJspay(params)
studentId: Number(studentId.value),
includeRawResponse: false
})
//
if (res && res.status === '0' && res.result_code === '0') {
const payInfo = res.pay_info
if (res && res.status === '0' && res.resultCode === '0') {
outTradeNo.value = res.outTradeNo || ''
const payInfo = res.payInfo
if (payInfo) {
// #ifdef MP-WEIXIN
// - XML pay_info
const payParams = parseXmlPayInfo(payInfo)
if (payParams) {
uni.requestPayment({
const miniPayParams = parseXmlPayInfo(payInfo)
if (miniPayParams) {
;(uni as any).requestPayment({
provider: 'wxpay',
...payParams,
...miniPayParams,
success: () => {
checkPayResult()
},
@ -475,24 +422,20 @@ const wechatJspayPay = async (payParams: any) => {
// #ifdef H5
// H5 - XML pay_info
const payParams = parseXmlPayInfo(payInfo)
if (payParams && typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
payParams,
(res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
checkPayResult()
} else {
goToResult(false)
}
const h5PayParams = parseXmlPayInfo(payInfo)
if (h5PayParams && typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke('getBrandWCPayRequest', h5PayParams, (res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
checkPayResult()
} else {
goToResult(false)
}
)
} else if (payParams) {
})
} else if (h5PayParams) {
// 使 uni.requestPayment
uni.requestPayment({
;(uni as any).requestPayment({
provider: 'wxpay',
...payParams,
...h5PayParams,
success: () => {
checkPayResult()
},
@ -524,7 +467,7 @@ const wechatJspayPay = async (payParams: any) => {
throw new Error('获取支付参数失败')
}
} else {
throw new Error(res.err_msg || res.message || '支付请求失败')
throw new Error(res.errMsg || res.message || '支付请求失败')
}
} catch (error: any) {
console.error('开放银行微信支付错误:', error)
@ -538,9 +481,12 @@ const parseXmlPayInfo = (xmlStr: string) => {
try {
// XML
const getValue = (xml: string, tag: string) => {
const regex = new RegExp(`<${tag}><!\[CDATA\[(.*?)\]\]></${tag}>|<${tag}>(.*?)</${tag}>`, 'i')
const regex = new RegExp(
`<${tag}><!\[CDATA\[(.*?)\]\]></${tag}>|<${tag}>(.*?)</${tag}>`,
'i'
)
const match = xml.match(regex)
return match ? (match[1] || match[2]) : ''
return match ? match[1] || match[2] : ''
}
return {
@ -561,24 +507,18 @@ const parseXmlPayInfo = (xmlStr: string) => {
const checkPayResult = async () => {
try {
uni.showLoading({ title: '查询中...' })
//
const params = {
version: '3.0',
service: 'unified.trade.query',
mch_id: paymentConfig.mch_id, //
out_trade_no: orderId.value
if (!outTradeNo.value) {
throw new Error('缺少 outTradeNo')
}
const res = await unifiedTradeQuery(params)
const res = await bankFeeTradeQuery({ outTradeNo: outTradeNo.value })
uni.hideLoading()
//
if (res && res.status === '0' && res.result_code === '0') {
if (res && res.status === '0' && res.resultCode === '0') {
// trade_state: SUCCESS-, REFUND-退, NOTPAY-,
// CLOSED-, REVOKED-, USERPAYING-, PAYERROR-
const tradeState = res.trade_state
const tradeState = res.tradeState
if (tradeState === 'SUCCESS') {
//
@ -610,12 +550,12 @@ const checkPayResult = async () => {
// - 使
const fallbackCheckPayResult = async () => {
try {
const res = await getPayResult({ order_id: orderId.value })
if (res && res.pay_status === 1) {
goToResult(true)
} else {
goToResult(false)
}
//
if (!outTradeNo.value) return goToResult(false)
const res = await bankFeeTradeQuery({ outTradeNo: outTradeNo.value })
if (res && res.status === '0' && res.resultCode === '0' && res.tradeState === 'SUCCESS')
return goToResult(true)
return goToResult(false)
} catch (error) {
goToResult(false)
}
@ -624,19 +564,28 @@ const fallbackCheckPayResult = async () => {
//
const goToResult = (success: boolean) => {
uni.redirectTo({
url: `/pages/payment_result/payment_result?status=${success ? 'success' : 'fail'}&order_id=${orderId.value}`
url: `/pages/payment_result/payment_result?status=${success ? 'success' : 'fail'}&id=${
outTradeNo.value
}&from=bankFee`
})
}
onMounted(() => {
getPayWayData()
})
onLoad((options: any) => {
const sid = Number(options?.studentId || options?.student_id || 0)
if (sid > 0) {
studentId.value = sid
}
})
</script>
<style lang="scss" scoped>
.payment {
min-height: 100vh;
background-color: #F5F7FA;
background-color: #f5f7fa;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
@ -652,7 +601,7 @@ onMounted(() => {
//
.amount-section {
background: #FFFFFF;
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
@ -660,7 +609,7 @@ onMounted(() => {
.amount-input-wrapper {
display: flex;
align-items: center;
border-bottom: 2rpx solid #E5E7EB;
border-bottom: 2rpx solid #e5e7eb;
padding: 20rpx 0;
.currency {
@ -688,7 +637,7 @@ onMounted(() => {
//
.remark-section {
background: #FFFFFF;
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
@ -718,7 +667,7 @@ onMounted(() => {
//
.payway-section {
background: #FFFFFF;
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
@ -728,7 +677,7 @@ onMounted(() => {
align-items: center;
justify-content: space-between;
padding: 30rpx 0;
border-bottom: 2rpx solid #F3F4F6;
border-bottom: 2rpx solid #f3f4f6;
&:last-child {
border-bottom: none;
@ -756,7 +705,7 @@ onMounted(() => {
.check-circle {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #D1D5DB;
border: 2rpx solid #d1d5db;
border-radius: 50%;
}
}
@ -770,7 +719,7 @@ onMounted(() => {
left: 0;
right: 0;
bottom: 0;
background: #FFFFFF;
background: #ffffff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
display: flex;
@ -789,20 +738,20 @@ onMounted(() => {
.currency {
font-size: 32rpx;
color: #FF6B00;
color: #ff6b00;
font-weight: 600;
}
.amount {
font-size: 48rpx;
color: #FF6B00;
color: #ff6b00;
font-weight: 600;
}
}
.submit-btn {
background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%);
color: #FFFFFF;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
font-size: 32rpx;
font-weight: 500;
padding: 24rpx 60rpx;
@ -813,8 +762,8 @@ onMounted(() => {
}
&.disabled {
background: #D1D5DB;
color: #9CA3AF;
background: #d1d5db;
color: #9ca3af;
}
}
}
@ -823,7 +772,7 @@ onMounted(() => {
.password-popup {
width: 600rpx;
padding: 40rpx;
background: #FFFFFF;
background: #ffffff;
border-radius: 20rpx;
.popup-title {

View File

@ -1,165 +1,183 @@
<template>
<page-meta :page-style="$theme.pageStyle">
<!-- #ifndef H5 -->
<navigation-bar :front-color="$theme.navColor" :background-color="$theme.navBgColor" />
<!-- #endif -->
</page-meta>
<!-- 页面状态 -->
<page-status :status="status">
<template #error>
<u-empty text="订单不存在" mode="order"></u-empty>
</template>
<template #default>
<view class="payment-result p-[20rpx]">
<view class="result bg-white p-[20rpx] rounded-md">
<view class="flex flex-col items-center my-[40rpx]">
<!-- 支付状态图片 -->
<u-image
class="status-image"
:src="paymentStatus['image']"
width="100"
height="100"
shape="circle"
/>
<!-- 支付状态文字 -->
<text class="text-2xl font-medium mt-[20rpx]"
>{{ paymentStatus['text'] }}
</text>
<view class="text-3xl font-medium mt-[20rpx]">
¥ {{ orderInfo.order.order_amount }}
</view>
</view>
<!-- 支付信息 -->
<view class="result-info">
<view class="result-info__item">
<text>订单编号</text>
<text>{{ orderInfo.order.order_sn }}</text>
</view>
<view class="result-info__item">
<text>付款时间</text>
<text>{{ orderInfo.order.pay_time }}</text>
</view>
<view class="result-info__item">
<text>支付方式</text>
<template v-if="orderInfo.pay_status">
<text>{{ orderInfo.order.pay_way || '-' }}</text>
</template>
<template v-else>
<text>未支付</text>
</template>
</view>
</view>
</view>
<view class="mt-[40rpx]">
<view class="mb-[20rpx]">
<u-button
v-if="pageOptions.from == 'recharge'"
type="primary"
shape="circle"
hover-class="none"
@click="goOrder"
>
继续充值
</u-button>
</view>
<view class="mb-[20rpx]">
<u-button
type="primary"
plain
shape="circle"
hover-class="none"
@click="goHome"
>
返回首页
</u-button>
</view>
</view>
</view>
</template>
</page-status>
</template>
<script lang="ts" setup>
import { getPayResult } from '@/api/pay'
import { PageStatusEnum } from '@/enums/appEnums'
import { onLoad } from '@dcloudio/uni-app'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'uniapp-router-next'
import { getAliyunImageUrl } from '@/utils/imageUtils'
const router = useRouter()
const mapStatus = {
succeed: {
text: '支付成功',
image: getAliyunImageUrl('static/images/payment/icon_succeed.png')
},
waiting: {
text: '等待支付',
image: getAliyunImageUrl('static/images/payment/icon_waiting.png')
}
}
const status = ref(PageStatusEnum['LOADING'])
const pageOptions = ref({
id: '',
from: ''
})
const orderInfo = reactive<any>({
order: {}
})
const paymentStatus = computed(() => {
const status = !!orderInfo.pay_status
return mapStatus[status ? 'succeed' : 'waiting']
})
const initPageData = () => {
return new Promise((resolve, reject) => {
getPayResult({
order_id: pageOptions.value.id,
from: pageOptions.value.from
})
.then((data) => {
Object.assign(orderInfo, data)
resolve(data)
})
.catch((err) => {
reject(err)
})
})
}
const goHome = () => {
router.reLaunch('/pages/index/index')
}
const goOrder = () => {
switch (pageOptions.value.from) {
case 'recharge':
router.navigateBack()
break
}
}
onLoad(async (options: any) => {
try {
if (!options.id) throw new Error('订单不存在')
pageOptions.value = options
await initPageData()
status.value = PageStatusEnum['NORMAL']
} catch (err) {
console.log(err)
status.value = PageStatusEnum['ERROR']
}
})
</script>
<style lang="scss" scoped>
.result-info {
.result-info__item {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
}
}
</style>
<template>
<page-meta :page-style="$theme.pageStyle">
<!-- #ifndef H5 -->
<navigation-bar :front-color="$theme.navColor" :background-color="$theme.navBgColor" />
<!-- #endif -->
</page-meta>
<!-- 页面状态 -->
<page-status :status="status">
<template #error>
<u-empty text="订单不存在" mode="order"></u-empty>
</template>
<template #default>
<view class="payment-result p-[20rpx]">
<view class="result bg-white p-[20rpx] rounded-md">
<view class="flex flex-col items-center my-[40rpx]">
<!-- 支付状态图片 -->
<u-image
class="status-image"
:src="paymentStatus['image']"
width="100"
height="100"
shape="circle"
/>
<!-- 支付状态文字 -->
<text class="text-2xl font-medium mt-[20rpx]"
>{{ paymentStatus['text'] }}
</text>
<view class="text-3xl font-medium mt-[20rpx]">
¥ {{ orderInfo.order.order_amount }}
</view>
</view>
<!-- 支付信息 -->
<view class="result-info">
<view class="result-info__item">
<text>订单编号</text>
<text>{{ orderInfo.order.order_sn }}</text>
</view>
<view class="result-info__item">
<text>付款时间</text>
<text>{{ orderInfo.order.pay_time }}</text>
</view>
<view class="result-info__item">
<text>支付方式</text>
<template v-if="orderInfo.pay_status">
<text>{{ orderInfo.order.pay_way || '-' }}</text>
</template>
<template v-else>
<text>未支付</text>
</template>
</view>
</view>
</view>
<view class="mt-[40rpx]">
<view class="mb-[20rpx]">
<u-button
v-if="pageOptions.from == 'recharge'"
type="primary"
shape="circle"
hover-class="none"
@click="goOrder"
>
继续充值
</u-button>
</view>
<view class="mb-[20rpx]">
<u-button
type="primary"
plain
shape="circle"
hover-class="none"
@click="goHome"
>
返回首页
</u-button>
</view>
</view>
</view>
</template>
</page-status>
</template>
<script lang="ts" setup>
import { bankFeeTradeQuery, getPayResult } from '@/api/pay'
import { PageStatusEnum } from '@/enums/appEnums'
import { onLoad } from '@dcloudio/uni-app'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'uniapp-router-next'
import { getAliyunImageUrl } from '@/utils/imageUtils'
const router = useRouter()
const mapStatus = {
succeed: {
text: '支付成功',
image: getAliyunImageUrl('static/images/payment/icon_succeed.png')
},
waiting: {
text: '等待支付',
image: getAliyunImageUrl('static/images/payment/icon_waiting.png')
}
}
const status = ref(PageStatusEnum['LOADING'])
const pageOptions = ref({
id: '',
from: ''
})
const orderInfo = reactive<any>({
order: {},
pay_status: 0
})
const paymentStatus = computed(() => {
const status = !!orderInfo.pay_status
return mapStatus[status ? 'succeed' : 'waiting']
})
const initPageData = () => {
return new Promise((resolve, reject) => {
// 使 outTradeNo
if (pageOptions.value.from === 'bankFee') {
bankFeeTradeQuery({ outTradeNo: pageOptions.value.id })
.then((data: any) => {
const tradeState = data?.tradeState
const totalFeeFen = Number(data?.totalFeeFen || 0)
Object.assign(orderInfo, {
pay_status: tradeState === 'SUCCESS' ? 1 : 0,
order: {
order_amount: totalFeeFen ? (totalFeeFen / 100).toFixed(2) : '-',
order_sn: pageOptions.value.id,
pay_time: '-',
pay_way: data?.payWay ? String(data.payWay) : '-'
}
})
resolve(data)
})
.catch((err) => reject(err))
return
}
// /
getPayResult({ order_id: pageOptions.value.id, from: pageOptions.value.from })
.then((data) => {
Object.assign(orderInfo, data)
resolve(data)
})
.catch((err) => reject(err))
})
}
const goHome = () => {
router.reLaunch('/pages/index/index')
}
const goOrder = () => {
switch (pageOptions.value.from) {
case 'recharge':
router.navigateBack()
break
}
}
onLoad(async (options: any) => {
try {
if (!options.id) throw new Error('订单不存在')
pageOptions.value = options
await initPageData()
status.value = PageStatusEnum['NORMAL']
} catch (err) {
console.log(err)
status.value = PageStatusEnum['ERROR']
}
})
</script>
<style lang="scss" scoped>
.result-info {
.result-info__item {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
}
}
</style>

View File

@ -370,7 +370,12 @@ const submit = async () => {
console.log('========== 准备提交数据 ==========')
console.log('提交数据:', submitData)
console.log('majorId:', submitData.majorId, '类型:', typeof submitData.majorId)
console.log('recruitmentTeacherId:', submitData.recruitmentTeacherId, '类型:', typeof submitData.recruitmentTeacherId)
console.log(
'recruitmentTeacherId:',
submitData.recruitmentTeacherId,
'类型:',
typeof submitData.recruitmentTeacherId
)
uni.showLoading({
title: '提交中...',
@ -397,7 +402,13 @@ const submit = async () => {
})
setTimeout(() => {
router.navigateTo('/pages/submit_success/submit_success')
// studentId
const sid = res?.data?.studentId || res?.data?.id || null
if (sid) {
router.navigateTo(`/pages/payment/payment?studentId=${sid}`)
} else {
router.navigateTo('/pages/submit_success/submit_success')
}
}, 1500)
} else {
console.log('提交失败:', res?.msg || '未知错误')