|
|
@@ -0,0 +1,308 @@
|
|
|
+package com.train.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.train.cfc.entity.CfcActivity;
|
|
|
+import com.train.cfc.mapper.CfcActivityMapper;
|
|
|
+import com.train.entity.TrainClass;
|
|
|
+import com.train.entity.TrainEnrollment;
|
|
|
+import com.train.entity.TrainOrder;
|
|
|
+import com.train.entity.TrainUser;
|
|
|
+import com.train.mapper.TrainClassMapper;
|
|
|
+import com.train.mapper.TrainEnrollmentMapper;
|
|
|
+import com.train.mapper.TrainOrderMapper;
|
|
|
+import com.train.mapper.TrainUserMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.ThreadLocalRandom;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 支付服务:订单创建、微信支付(test-mode 模拟)、回调幂等入账、取消、退款。
|
|
|
+ * <p>对齐 cfc(E:\cfc)PaymentService/VirtualPayService 的 test-mode 约定:
|
|
|
+ * test-mode=true 时跳过微信 API 调用——create 直接置订单 paid(模拟支付成功),
|
|
|
+ * notify 仅做幂等入账。金额一律以「分」为单位的整数存储。
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class PayService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private TrainOrderMapper trainOrderMapper;
|
|
|
+ @Resource
|
|
|
+ private TrainEnrollmentMapper trainEnrollmentMapper;
|
|
|
+ @Resource
|
|
|
+ private TrainClassMapper trainClassMapper;
|
|
|
+ @Resource
|
|
|
+ private TrainUserMapper trainUserMapper;
|
|
|
+ @Resource
|
|
|
+ private CfcActivityMapper cfcActivityMapper;
|
|
|
+
|
|
|
+ @Value("${wechat.test-mode}")
|
|
|
+ private boolean testMode;
|
|
|
+
|
|
|
+ /** 未支付订单的超时分钟数(超时由定时任务关闭) */
|
|
|
+ private static final long ORDER_TIMEOUT_MINUTES = 30;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建订单并发起支付(test-mode:直接落库为 paid)。
|
|
|
+ *
|
|
|
+ * @param enrollmentId 报名单ID
|
|
|
+ * @param userId 当前登录学员
|
|
|
+ * @return {orderNo, amount, status, prepayParams, testMode}
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public Map<String, Object> createOrder(Long enrollmentId, Long userId) {
|
|
|
+ TrainEnrollment en = trainEnrollmentMapper.selectById(enrollmentId);
|
|
|
+ if (en == null) {
|
|
|
+ throw new RuntimeException("报名单不存在");
|
|
|
+ }
|
|
|
+ if (!userId.equals(en.getUid())) {
|
|
|
+ throw new RuntimeException("无权操作该报名单");
|
|
|
+ }
|
|
|
+ if ("paid".equals(en.getStatus()) || "confirmed".equals(en.getStatus())) {
|
|
|
+ throw new RuntimeException("该报名单已支付,请勿重复下单");
|
|
|
+ }
|
|
|
+ if ("cancelled".equals(en.getStatus())) {
|
|
|
+ throw new RuntimeException("该报名单已取消");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 幂等:同一报名单已有未关闭订单则直接返回
|
|
|
+ TrainOrder exist = trainOrderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<TrainOrder>()
|
|
|
+ .eq(TrainOrder::getEnrollmentId, enrollmentId)
|
|
|
+ .ne(TrainOrder::getStatus, "closed")
|
|
|
+ .eq(TrainOrder::getStatus, "unpaid")
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (exist != null) {
|
|
|
+ return buildOrderResult(exist);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 金额:优先会员价(memberPrice),否则原价(price);单位为分
|
|
|
+ TrainClass trainClass = trainClassMapper.selectById(en.getClassId());
|
|
|
+ CfcActivity activity = null;
|
|
|
+ if (trainClass != null && trainClass.getActivityId() != null) {
|
|
|
+ activity = cfcActivityMapper.selectById(trainClass.getActivityId());
|
|
|
+ }
|
|
|
+ int price = activity != null && activity.getPrice() != null ? activity.getPrice() : 0;
|
|
|
+ int memberPrice = activity != null && activity.getMemberPrice() != null
|
|
|
+ ? activity.getMemberPrice() : price;
|
|
|
+ int amount = memberPrice > 0 ? memberPrice : price;
|
|
|
+
|
|
|
+ TrainOrder order = new TrainOrder();
|
|
|
+ order.setOrderNo(genOrderNo());
|
|
|
+ order.setEnrollmentId(enrollmentId);
|
|
|
+ order.setUid(userId);
|
|
|
+ order.setClassId(en.getClassId());
|
|
|
+ order.setAmount(amount);
|
|
|
+ order.setOriginalAmount(price);
|
|
|
+ order.setDiscountAmount(Math.max(0, price - amount));
|
|
|
+ order.setPayChannel("wechat");
|
|
|
+ order.setStatus("unpaid");
|
|
|
+ order.setRefundAmount(0);
|
|
|
+ order.setCreatedAt(new Date());
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ trainOrderMapper.insert(order);
|
|
|
+
|
|
|
+ // test-mode:模拟支付成功,直接入账
|
|
|
+ if (testMode) {
|
|
|
+ log.info("【测试模式】模拟支付成功: orderNo={}, amount={}", order.getOrderNo(), amount);
|
|
|
+ confirmPaid(order.getOrderNo(), "TEST" + System.currentTimeMillis());
|
|
|
+ }
|
|
|
+
|
|
|
+ TrainOrder saved = trainOrderMapper.selectById(order.getId());
|
|
|
+ return buildOrderResult(saved);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 微信支付回调处理(test-mode 下 create 已直接入账,此方法兜底幂等)。
|
|
|
+ * 生产环境需在此接入微信验签(商户接入后补充)。
|
|
|
+ *
|
|
|
+ * @param xmlBody 微信回调 XML
|
|
|
+ * @return 微信约定返回串(SUCCESS/FAIL)
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public String handleNotify(String xmlBody) {
|
|
|
+ if (xmlBody == null || xmlBody.trim().isEmpty()) {
|
|
|
+ log.warn("微信支付回调空报文");
|
|
|
+ return "FAIL";
|
|
|
+ }
|
|
|
+ String outTradeNo = extractXmlValue(xmlBody, "out_trade_no");
|
|
|
+ if (outTradeNo == null) {
|
|
|
+ log.warn("微信支付回调缺少 out_trade_no: {}", xmlBody);
|
|
|
+ return "FAIL";
|
|
|
+ }
|
|
|
+ String txnId = extractXmlValue(xmlBody, "transaction_id");
|
|
|
+ confirmPaid(outTradeNo, txnId);
|
|
|
+ return "SUCCESS";
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 简化 XML 取值(支持 CDATA),供回调解析使用 */
|
|
|
+ private String extractXmlValue(String xml, String tag) {
|
|
|
+ java.util.regex.Pattern p = java.util.regex.Pattern.compile(
|
|
|
+ "<" + tag + ">(?:<!\\[CDATA\\[)?(.*?)(?:\\]\\]>)?</" + tag + ">");
|
|
|
+ java.util.regex.Matcher m = p.matcher(xml);
|
|
|
+ return m.find() ? m.group(1).trim() : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 支付回调入账(微信支付回调 / 测试模式直付统一走此方法)。
|
|
|
+ * 幂等:订单已 paid 则跳过,防止重复发放权益。
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public void confirmPaid(String orderNo, String wechatTxnId) {
|
|
|
+ TrainOrder order = trainOrderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<TrainOrder>().eq(TrainOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null) {
|
|
|
+ log.warn("支付回调订单不存在: orderNo={}", orderNo);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if ("paid".equals(order.getStatus())) {
|
|
|
+ log.info("订单已支付,幂等跳过: orderNo={}", orderNo);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ order.setStatus("paid");
|
|
|
+ order.setPayTime(new Date());
|
|
|
+ order.setWechatTxnId(wechatTxnId);
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ trainOrderMapper.updateById(order);
|
|
|
+
|
|
|
+ // 报名单置 paid,并自动进班(class_id 回填)
|
|
|
+ if (order.getEnrollmentId() != null) {
|
|
|
+ TrainEnrollment en = trainEnrollmentMapper.selectById(order.getEnrollmentId());
|
|
|
+ if (en != null) {
|
|
|
+ en.setStatus("paid");
|
|
|
+ en.setUpdatedAt(new Date());
|
|
|
+ trainEnrollmentMapper.updateById(en);
|
|
|
+ if (order.getUid() != null && order.getClassId() != null) {
|
|
|
+ TrainUser user = trainUserMapper.selectById(order.getUid());
|
|
|
+ if (user != null && user.getClassId() == null) {
|
|
|
+ user.setClassId(order.getClassId());
|
|
|
+ user.setUpdatedAt(new Date());
|
|
|
+ trainUserMapper.updateById(user);
|
|
|
+ log.info("支付成功自动进班: uid={}, classId={}", user.getId(), order.getClassId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("支付入账完成: orderNo={}", orderNo);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询订单状态(校验归属)。
|
|
|
+ * @return {orderNo, amount, status, payTime, classId}
|
|
|
+ */
|
|
|
+ public Map<String, Object> getOrderStatus(String orderNo, Long userId) {
|
|
|
+ TrainOrder order = trainOrderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<TrainOrder>().eq(TrainOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null || !userId.equals(order.getUid())) {
|
|
|
+ throw new RuntimeException("订单不存在");
|
|
|
+ }
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("orderNo", order.getOrderNo());
|
|
|
+ row.put("amount", order.getAmount());
|
|
|
+ row.put("status", order.getStatus());
|
|
|
+ row.put("payTime", order.getPayTime());
|
|
|
+ row.put("classId", order.getClassId());
|
|
|
+ return row;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取消订单(仅未支付订单可关闭)。
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public boolean cancelOrder(String orderNo, Long userId) {
|
|
|
+ TrainOrder order = trainOrderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<TrainOrder>().eq(TrainOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null || !userId.equals(order.getUid())) {
|
|
|
+ throw new RuntimeException("订单不存在");
|
|
|
+ }
|
|
|
+ if ("paid".equals(order.getStatus()) || "refunded".equals(order.getStatus())) {
|
|
|
+ throw new RuntimeException("订单已支付,无法取消");
|
|
|
+ }
|
|
|
+ order.setStatus("closed");
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ trainOrderMapper.updateById(order);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 退款(后台触发;test-mode 直接置 refunded)。
|
|
|
+ * @param orderId 订单ID
|
|
|
+ * @param refundAmount 退款金额(分)
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public boolean refund(Long orderId, Integer refundAmount) {
|
|
|
+ TrainOrder order = trainOrderMapper.selectById(orderId);
|
|
|
+ if (order == null) {
|
|
|
+ throw new RuntimeException("订单不存在");
|
|
|
+ }
|
|
|
+ if (!"paid".equals(order.getStatus())) {
|
|
|
+ throw new RuntimeException("仅已支付订单可退款");
|
|
|
+ }
|
|
|
+ if (refundAmount == null || refundAmount <= 0 || refundAmount > order.getAmount()) {
|
|
|
+ throw new RuntimeException("退款金额不合法");
|
|
|
+ }
|
|
|
+ order.setRefundAmount(refundAmount);
|
|
|
+ order.setStatus("refunded");
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ trainOrderMapper.updateById(order);
|
|
|
+
|
|
|
+ // 报名单置 cancelled(名额释放;如需保留记录可后续调整)
|
|
|
+ if (order.getEnrollmentId() != null) {
|
|
|
+ TrainEnrollment en = trainEnrollmentMapper.selectById(order.getEnrollmentId());
|
|
|
+ if (en != null && !"cancelled".equals(en.getStatus())) {
|
|
|
+ en.setStatus("cancelled");
|
|
|
+ en.setUpdatedAt(new Date());
|
|
|
+ trainEnrollmentMapper.updateById(en);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 关闭超时未支付订单(定时任务用)。
|
|
|
+ */
|
|
|
+ public int closeTimeoutOrders() {
|
|
|
+ Date deadline = new Date(System.currentTimeMillis() - ORDER_TIMEOUT_MINUTES * 60 * 1000L);
|
|
|
+ int closed = 0;
|
|
|
+ java.util.List<TrainOrder> list = trainOrderMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<TrainOrder>()
|
|
|
+ .eq(TrainOrder::getStatus, "unpaid")
|
|
|
+ .lt(TrainOrder::getCreatedAt, deadline));
|
|
|
+ for (TrainOrder order : list) {
|
|
|
+ order.setStatus("closed");
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ trainOrderMapper.updateById(order);
|
|
|
+ closed++;
|
|
|
+ }
|
|
|
+ if (closed > 0) {
|
|
|
+ log.info("关闭超时未支付订单 {} 笔(超过 {} 分钟)", closed, ORDER_TIMEOUT_MINUTES);
|
|
|
+ }
|
|
|
+ return closed;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildOrderResult(TrainOrder order) {
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("orderNo", order.getOrderNo());
|
|
|
+ row.put("amount", order.getAmount());
|
|
|
+ row.put("status", order.getStatus());
|
|
|
+ row.put("payTime", order.getPayTime());
|
|
|
+ // prepayParams 为 null:test-mode 直接入账;生产接入微信统一下单后回填
|
|
|
+ row.put("prepayParams", null);
|
|
|
+ return row;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 生成订单号:yyyyMMddHHmmss + 6位随机 */
|
|
|
+ private String genOrderNo() {
|
|
|
+ String ts = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
|
|
|
+ int rand = ThreadLocalRandom.current().nextInt(100000, 1000000);
|
|
|
+ return "TR" + ts + rand;
|
|
|
+ }
|
|
|
+}
|