Browse Source

Phase4: 支付模块(test-mode 模拟支付+订单状态机)

liaoxg 2 weeks ago
parent
commit
c7f8b50

+ 1 - 0
train-backend/src/main/java/com/train/config/JwtInterceptor.java

@@ -33,6 +33,7 @@ public class JwtInterceptor implements HandlerInterceptor {
         "/api/admin/auth/login",      // 管理后台登录
         "/api/auth/wechat-phone-login", // 微信手机号登录(登录阶段无 token)
         "/api/class/join/verify",     // 进班校验(学号+邀请码,尚未登录时走公开?——实际需登录,调整)
+        "/api/pay/notify",            // 微信支付回调(公网)
         "/api/public/",
     };
 

+ 83 - 0
train-backend/src/main/java/com/train/controller/OrderController.java

@@ -0,0 +1,83 @@
+package com.train.controller;
+
+import com.train.common.Result;
+import com.train.service.PayService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+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 java.util.Map;
+
+/**
+ * 支付接口:订单创建 / 状态查询 / 取消 / 微信回调。
+ * <p>test-mode=true 时 create 直接模拟支付成功入账;
+ * notify 为公网回调路径,已在 JwtInterceptor 白名单放行。
+ */
+@Slf4j
+@Tag(name = "支付", description = "订单创建、状态查询、取消、微信回调")
+@RestController
+@RequestMapping("/api/pay")
+public class OrderController {
+
+    @Resource
+    private PayService payService;
+
+    @Operation(summary = "创建订单并发起支付")
+    @PostMapping("/create")
+    public Result<Map<String, Object>> createOrder(@RequestBody Map<String, Object> body,
+                                                   @RequestAttribute("userId") Long userId) {
+        Object idObj = body.get("enrollmentId");
+        if (idObj == null) {
+            return Result.error("缺少报名单ID");
+        }
+        Long enrollmentId = Long.valueOf(idObj.toString());
+        try {
+            return Result.success(payService.createOrder(enrollmentId, userId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "查询订单状态")
+    @PostMapping("/status")
+    public Result<Map<String, Object>> status(@RequestBody Map<String, Object> body,
+                                              @RequestAttribute("userId") Long userId) {
+        Object orderNo = body.get("orderNo");
+        if (orderNo == null || orderNo.toString().trim().isEmpty()) {
+            return Result.error("缺少订单号");
+        }
+        try {
+            return Result.success(payService.getOrderStatus(orderNo.toString(), userId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "取消订单")
+    @PostMapping("/cancel")
+    public Result<Boolean> cancel(@RequestBody Map<String, Object> body,
+                                  @RequestAttribute("userId") Long userId) {
+        Object orderNo = body.get("orderNo");
+        if (orderNo == null || orderNo.toString().trim().isEmpty()) {
+            return Result.error("缺少订单号");
+        }
+        try {
+            return Result.success(payService.cancelOrder(orderNo.toString(), userId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "微信支付回调(公网)")
+    @PostMapping("/notify")
+    public String notify(@RequestBody(required = false) String xmlBody) {
+        // 微信推送 XML,test-mode 下单即入账;此处兜底幂等,生产需按微信文档验签
+        return payService.handleNotify(xmlBody);
+    }
+}

+ 68 - 0
train-backend/src/main/java/com/train/controller/admin/AdminBizController.java

@@ -4,13 +4,17 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.train.common.Result;
 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 com.train.service.PayService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
@@ -32,6 +36,10 @@ public class AdminBizController {
     private TrainClassMapper trainClassMapper;
     @Resource
     private TrainUserMapper trainUserMapper;
+    @Resource
+    private TrainOrderMapper trainOrderMapper;
+    @Resource
+    private PayService payService;
 
     /**
      * 报名单列表(支持按班次/状态筛选)
@@ -71,4 +79,64 @@ public class AdminBizController {
         }
         return Result.success(result);
     }
+
+    /**
+     * 订单列表(支持按班次/状态筛选,关联展示报名人与班次)
+     */
+    @Operation(summary = "订单列表")
+    @PostMapping("/order/list")
+    public Result<List<Map<String, Object>>> orderList(@RequestBody(required = false) Map<String, Object> body) {
+        LambdaQueryWrapper<TrainOrder> qw = new LambdaQueryWrapper<>();
+        if (body != null && body.get("classId") != null) {
+            qw.eq(TrainOrder::getClassId, Long.valueOf(body.get("classId").toString()));
+        }
+        if (body != null && body.get("status") != null
+                && !body.get("status").toString().trim().isEmpty()) {
+            qw.eq(TrainOrder::getStatus, body.get("status").toString());
+        }
+        qw.orderByDesc(TrainOrder::getId);
+        List<TrainOrder> list = trainOrderMapper.selectList(qw);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (TrainOrder o : list) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", o.getId());
+            row.put("orderNo", o.getOrderNo());
+            row.put("enrollmentId", o.getEnrollmentId());
+            row.put("classId", o.getClassId());
+            TrainClass c = o.getClassId() == null ? null : trainClassMapper.selectById(o.getClassId());
+            row.put("className", c != null ? c.getName() : null);
+            row.put("uid", o.getUid());
+            TrainUser u = o.getUid() == null ? null : trainUserMapper.selectById(o.getUid());
+            row.put("userName", u != null ? u.getName() : null);
+            row.put("amount", o.getAmount());
+            row.put("originalAmount", o.getOriginalAmount());
+            row.put("discountAmount", o.getDiscountAmount());
+            row.put("status", o.getStatus());
+            row.put("payTime", o.getPayTime());
+            row.put("refundAmount", o.getRefundAmount());
+            row.put("createdAt", o.getCreatedAt());
+            result.add(row);
+        }
+        return Result.success(result);
+    }
+
+    /**
+     * 订单退款(test-mode 直接置 refunded;生产需接入微信退款)
+     */
+    @Operation(summary = "订单退款")
+    @PostMapping("/order/refund")
+    public Result<Boolean> refund(@RequestBody Map<String, Object> body,
+                                  @RequestAttribute("adminId") Long adminId) {
+        Object idObj = body.get("orderId");
+        if (idObj == null) {
+            return Result.error("缺少订单ID");
+        }
+        Object amountObj = body.get("amount");
+        Integer refundAmount = amountObj == null ? null : Integer.valueOf(amountObj.toString());
+        try {
+            return Result.success(payService.refund(Long.valueOf(idObj.toString()), refundAmount));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
 }

+ 308 - 0
train-backend/src/main/java/com/train/service/PayService.java

@@ -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;
+    }
+}

+ 12 - 0
train-frontend/pages.json

@@ -102,6 +102,18 @@
       "style": {
         "navigationBarTitleText": "报名信息"
       }
+    },
+    {
+      "path": "pages/pay/index",
+      "style": {
+        "navigationBarTitleText": "确认支付"
+      }
+    },
+    {
+      "path": "pages/pay/result",
+      "style": {
+        "navigationBarTitleText": "支付结果"
+      }
     }
   ],
   "globalStyle": {

+ 7 - 1
train-frontend/pages/enroll/form.vue

@@ -117,7 +117,13 @@ export default {
       createEnrollment(payload).then(function(resp) {
         uni.showToast({ title: '报名成功', icon: 'success' })
         setTimeout(function() {
-          uni.redirectTo({ url: '/pages/enroll/list' })
+          var enrollmentId = resp.data && resp.data.id ? resp.data.id : ''
+          if (enrollmentId) {
+            // 跳转支付页(test-mode 下自动模拟支付成功)
+            uni.redirectTo({ url: '/pages/pay/index?enrollmentId=' + enrollmentId })
+          } else {
+            uni.redirectTo({ url: '/pages/enroll/list' })
+          }
         }, 1200)
       }).catch(function(err) {
         uni.showToast({ title: (err && err.message) || '报名失败', icon: 'none' })

+ 170 - 0
train-frontend/pages/pay/index.vue

@@ -0,0 +1,170 @@
+<template>
+  <view class="pay-page">
+    <view class="pay-card" v-if="orderNo">
+      <text class="pay-title">确认支付</text>
+      <text class="pay-amount">¥{{ fenToYuan(amount) }}</text>
+      <text class="pay-order" v-if="orderNo">订单号:{{ orderNo }}</text>
+
+      <view v-if="status === 'paid'" class="paid-box">
+        <text class="paid-icon">✓</text>
+        <text class="paid-text">支付成功</text>
+        <button class="primary-btn" @click="goResult('success')">查看结果</button>
+      </view>
+      <view v-else>
+        <button class="pay-btn" :loading="paying" :disabled="paying" @click="handlePay">立即支付</button>
+        <view class="pay-tip">测试环境将模拟支付成功</view>
+      </view>
+    </view>
+
+    <view v-if="!orderNo && !loading" class="empty-tip">
+      <text>订单创建失败</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { createPayOrder, getPayStatus } from '@/utils/api.js'
+import { fenToYuan } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      enrollmentId: '',
+      orderNo: '',
+      amount: 0,
+      status: '',
+      prepayParams: null,
+      loading: false,
+      paying: false,
+      pollTimer: null
+    }
+  },
+  onLoad(options) {
+    this.enrollmentId = options && options.enrollmentId ? options.enrollmentId : ''
+    if (!this.enrollmentId) {
+      uni.showToast({ title: '缺少报名信息', icon: 'none' })
+      setTimeout(function() {
+        uni.navigateBack({ delta: 1 })
+      }, 1500)
+      return
+    }
+    this.initOrder()
+  },
+  onUnload() {
+    if (this.pollTimer) {
+      clearInterval(this.pollTimer)
+      this.pollTimer = null
+    }
+  },
+  methods: {
+    fenToYuan: fenToYuan,
+    initOrder() {
+      var self = this
+      self.loading = true
+      createPayOrder({ enrollmentId: self.enrollmentId }).then(function(resp) {
+        var data = resp.data || {}
+        self.orderNo = data.orderNo || ''
+        self.amount = data.amount || 0
+        self.status = data.status || ''
+        self.prepayParams = data.prepayParams || null
+        if (self.status === 'paid') {
+          setTimeout(function() {
+            self.goResult('success')
+          }, 800)
+        }
+      }).catch(function(err) {
+        uni.showToast({ title: (err && err.message) || '创建订单失败', icon: 'none' })
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    handlePay() {
+      var self = this
+      if (this.paying) return
+      // test-mode:后端 create 时已直接置 paid,直接查询确认
+      var pp = this.prepayParams
+      if (pp && pp.timeStamp) {
+        // 生产模式:调起微信支付(预留)
+        this.paying = true
+        uni.requestPayment({
+          provider: 'wxpay',
+          timeStamp: pp.timeStamp,
+          nonceStr: pp.nonceStr,
+          package: pp.package,
+          signType: pp.signType || 'MD5',
+          paySign: pp.paySign,
+          success: function() {
+            self.startPoll()
+          },
+          fail: function() {
+            self.paying = false
+            uni.showToast({ title: '支付已取消', icon: 'none' })
+          }
+        })
+      } else {
+        // 测试模式:模拟支付成功
+        this.paying = true
+        uni.showLoading({ title: '模拟支付中…' })
+        setTimeout(function() {
+          self.startPoll()
+        }, 800)
+      }
+    },
+    startPoll() {
+      var self = this
+      var max = 6
+      var count = 0
+      if (this.pollTimer) clearInterval(this.pollTimer)
+      this.pollTimer = setInterval(function() {
+        count++
+        getPayStatus({ orderNo: self.orderNo }).then(function(resp) {
+          var data = resp.data || {}
+          if (data.status === 'paid') {
+            clearInterval(self.pollTimer)
+            self.pollTimer = null
+            self.paying = false
+            uni.hideLoading()
+            self.status = 'paid'
+            self.goResult('success')
+          } else if (count >= max) {
+            clearInterval(self.pollTimer)
+            self.pollTimer = null
+            self.paying = false
+            uni.hideLoading()
+            uni.showToast({ title: '支付处理中,请稍后查看', icon: 'none' })
+          }
+        }).catch(function() {
+          if (count >= max) {
+            clearInterval(self.pollTimer)
+            self.pollTimer = null
+            self.paying = false
+            uni.hideLoading()
+          }
+        })
+      }, 1500)
+    },
+    goResult(status) {
+      uni.redirectTo({
+        url: '/pages/pay/result?status=' + status + '&amount=' + this.amount + '&orderNo=' + this.orderNo
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.pay-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.pay-card { background: #FFF; border-radius: 16rpx; padding: 48rpx 32rpx; display: flex; flex-direction: column; align-items: center; }
+.pay-title { font-size: 28rpx; color: #64748B; margin-bottom: 24rpx; }
+.pay-amount { font-size: 64rpx; font-weight: 700; color: #1E293B; margin-bottom: 16rpx; }
+.pay-order { font-size: 24rpx; color: #94A3B8; margin-bottom: 48rpx; }
+.paid-box { display: flex; flex-direction: column; align-items: center; width: 100%; }
+.paid-icon { width: 96rpx; height: 96rpx; border-radius: 50%; background: #22C55E; color: #FFF; font-size: 56rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 24rpx; }
+.paid-text { font-size: 32rpx; font-weight: 600; color: #1E293B; margin-bottom: 48rpx; }
+.pay-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #F97316; color: #FFF; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; }
+.pay-btn:active { opacity: 0.85; }
+.pay-tip { font-size: 24rpx; color: #94A3B8; margin-top: 16rpx; }
+.primary-btn { width: 400rpx; height: 80rpx; line-height: 80rpx; background: #F97316; color: #FFF; font-size: 28rpx; font-weight: 600; border-radius: 40rpx; border: none; }
+.primary-btn:active { opacity: 0.85; }
+.empty-tip { font-size: 26rpx; color: #94A3B8; padding: 64rpx 0; text-align: center; }
+</style>

+ 63 - 0
train-frontend/pages/pay/result.vue

@@ -0,0 +1,63 @@
+<template>
+  <view class="result-page">
+    <view class="result-card">
+      <view class="result-icon" :class="status === 'success' ? 'icon-success' : 'icon-fail'">
+        <text>{{ status === 'success' ? '✓' : '✕' }}</text>
+      </view>
+      <text class="result-title">{{ status === 'success' ? '支付成功' : '支付未完成' }}</text>
+      <text class="result-amount" v-if="amountText">支付金额:¥{{ amountText }}</text>
+      <text class="result-order" v-if="orderNo">订单号:{{ orderNo }}</text>
+
+      <button class="primary-btn" v-if="status === 'success'" @click="goList">查看我的报名</button>
+      <button class="primary-btn" v-else @click="goPay">重新支付</button>
+      <button class="ghost-btn" @click="goHome">返回首页</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { fenToYuan } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      status: 'success',
+      amount: 0,
+      amountText: '',
+      orderNo: ''
+    }
+  },
+  onLoad(options) {
+    this.status = options && options.status ? options.status : 'success'
+    this.amount = options && options.amount ? Number(options.amount) : 0
+    this.amountText = fenToYuan(this.amount)
+    this.orderNo = options && options.orderNo ? options.orderNo : ''
+  },
+  methods: {
+    goList() {
+      uni.redirectTo({ url: '/pages/enroll/list' })
+    },
+    goPay() {
+      uni.navigateBack({ delta: 1 })
+    },
+    goHome() {
+      uni.switchTab({ url: '/pages/index/index' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.result-page { min-height: 100vh; background: #F5F5F5; padding: 32rpx; }
+.result-card { background: #FFF; border-radius: 16rpx; padding: 64rpx 32rpx; display: flex; flex-direction: column; align-items: center; }
+.result-icon { width: 120rpx; height: 120rpx; border-radius: 50%; color: #FFF; font-size: 72rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 32rpx; }
+.icon-success { background: #22C55E; }
+.icon-fail { background: #EF4444; }
+.result-title { font-size: 36rpx; font-weight: 700; color: #1E293B; margin-bottom: 16rpx; }
+.result-amount { font-size: 28rpx; color: #475569; margin-bottom: 8rpx; }
+.result-order { font-size: 24rpx; color: #94A3B8; margin-bottom: 56rpx; }
+.primary-btn { width: 480rpx; height: 84rpx; line-height: 84rpx; background: #F97316; color: #FFF; font-size: 28rpx; font-weight: 600; border-radius: 42rpx; border: none; margin-bottom: 24rpx; }
+.primary-btn:active { opacity: 0.85; }
+.ghost-btn { width: 480rpx; height: 84rpx; line-height: 84rpx; background: #FFF; color: #64748B; font-size: 28rpx; border-radius: 42rpx; border: 2rpx solid #E2E8F0; }
+.ghost-btn:active { opacity: 0.7; }
+</style>

+ 11 - 0
train-frontend/utils/api.js

@@ -209,6 +209,17 @@ export const getMyEnrollments = () => {
   return request('/api/enroll/mine', 'POST')
 }
 
+// 支付
+export const createPayOrder = (data) => {
+  return request('/api/pay/create', 'POST', data)
+}
+export const getPayStatus = (data) => {
+  return request('/api/pay/status', 'POST', data)
+}
+export const cancelPayOrder = (data) => {
+  return request('/api/pay/cancel', 'POST', data)
+}
+
 // 文件上传
 export const uploadFile = (filePath) => {
   return new Promise((resolve, reject) => {

+ 9 - 0
train-web/src/api/order.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function orderList(data) {
+  return request.post('/api/admin/order/list', data || {})
+}
+
+export function orderRefund(data) {
+  return request.post('/api/admin/order/refund', data || {})
+}

+ 6 - 0
train-web/src/router/index.js

@@ -34,6 +34,12 @@ const routes = [
         component: () => import('@/views/Enrollments.vue'),
         meta: { title: '报名管理' }
       },
+      {
+        path: 'orders',
+        name: 'Orders',
+        component: () => import('@/views/Orders.vue'),
+        meta: { title: '订单管理' }
+      },
       {
         path: 'groups',
         name: 'Groups',

+ 4 - 0
train-web/src/views/Layout.vue

@@ -23,6 +23,10 @@
             <i class="el-icon-tickets"></i>
             <span slot="title">报名管理</span>
           </el-menu-item>
+          <el-menu-item index="/orders">
+            <i class="el-icon-wallet"></i>
+            <span slot="title">订单管理</span>
+          </el-menu-item>
           <el-menu-item index="/groups">
             <i class="el-icon-s-group"></i>
             <span slot="title">分组管理</span>

+ 177 - 0
train-web/src/views/Orders.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <h2 class="page-title">订单管理</h2>
+      <div class="filter-bar">
+        <el-select v-model="filters.status" placeholder="状态" clearable style="width:140px" @change="fetchList">
+          <el-option label="待支付" value="unpaid" />
+          <el-option label="已支付" value="paid" />
+          <el-option label="已退款" value="refunded" />
+          <el-option label="已关闭" value="closed" />
+          <el-option label="支付失败" value="failed" />
+        </el-select>
+        <el-button type="primary" icon="el-icon-refresh" @click="fetchList">刷新</el-button>
+      </div>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe style="width:100%">
+      <el-table-column prop="id" label="ID" width="70" />
+      <el-table-column prop="orderNo" label="订单号" min-width="180" />
+      <el-table-column prop="className" label="班次" min-width="130" />
+      <el-table-column prop="userName" label="报名人" width="100" />
+      <el-table-column label="实付金额" width="110">
+        <template slot-scope="scope">
+          <span>¥{{ fenToYuan(scope.row.amount) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="原价" width="100">
+        <template slot-scope="scope">
+          <span v-if="scope.row.originalAmount > 0">¥{{ fenToYuan(scope.row.originalAmount) }}</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="100">
+        <template slot-scope="scope">
+          <el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="payTime" label="支付时间" width="170" :formatter="formatTime" />
+      <el-table-column prop="createdAt" label="创建时间" width="170" :formatter="formatTime" />
+      <el-table-column label="操作" width="130" fixed="right">
+        <template slot-scope="scope">
+          <el-button v-if="scope.row.status === 'paid'" type="danger" size="mini" @click="openRefund(scope.row)">退款</el-button>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 退款对话框(test-mode 直接置 refunded) -->
+    <el-dialog title="订单退款" :visible.sync="refundVisible" width="420px" :close-on-click-modal="false">
+      <el-form label-width="90px">
+        <el-form-item label="订单号">
+          <span>{{ refundRow.orderNo }}</span>
+        </el-form-item>
+        <el-form-item label="实付金额">
+          <span>¥{{ fenToYuan(refundRow.amount) }}</span>
+        </el-form-item>
+        <el-form-item label="退款金额">
+          <el-input-number v-model="refundAmount" :min="0.01" :max="refundMax" :precision="2" :step="1" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="refundVisible = false">取消</el-button>
+        <el-button type="danger" :loading="refunding" @click="confirmRefund">确认退款</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { orderList, orderRefund } from '@/api/order'
+
+export default {
+  name: 'Orders',
+  data: function () {
+    return {
+      list: [],
+      loading: false,
+      filters: {
+        status: ''
+      },
+      refundVisible: false,
+      refundRow: {},
+      refundAmount: 0,
+      refundMax: 0,
+      refunding: false
+    }
+  },
+  mounted: function () {
+    this.fetchList()
+  },
+  methods: {
+    fenToYuan: function (fen) {
+      var n = Number(fen)
+      if (isNaN(n)) return '0.00'
+      return (n / 100).toFixed(2)
+    },
+    formatTime: function (row, col, val) {
+      if (!val) return '-'
+      return String(val).replace('T', ' ').substring(0, 19)
+    },
+    statusType: function (status) {
+      var map = { unpaid: 'warning', paid: 'success', refunded: 'info', closed: 'info', failed: 'danger' }
+      return map[status] || 'info'
+    },
+    statusLabel: function (status) {
+      var map = { unpaid: '待支付', paid: '已支付', refunded: '已退款', closed: '已关闭', failed: '支付失败' }
+      return map[status] || status || '-'
+    },
+    fetchList: function () {
+      var self = this
+      self.loading = true
+      var payload = {}
+      if (self.filters.status) {
+        payload.status = self.filters.status
+      }
+      orderList(payload).then(function (res) {
+        self.list = res.data || []
+      }).catch(function () {
+        self.$message.error('获取订单列表失败')
+      }).finally(function () {
+        self.loading = false
+      })
+    },
+    openRefund: function (row) {
+      this.refundRow = row
+      this.refundMax = Number((row.amount / 100).toFixed(2))
+      this.refundAmount = this.refundMax
+      this.refundVisible = true
+    },
+    confirmRefund: function () {
+      var self = this
+      if (!self.refundAmount || self.refundAmount <= 0) {
+        self.$message.warning('请输入退款金额')
+        return
+      }
+      self.refunding = true
+      var data = {
+        orderId: self.refundRow.id,
+        amount: Math.round(self.refundAmount * 100)
+      }
+      orderRefund(data).then(function (res) {
+        self.$message.success('退款成功')
+        self.refundVisible = false
+        self.fetchList()
+      }).catch(function () {
+        self.$message.error('退款失败')
+      }).finally(function () {
+        self.refunding = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+
+.page-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.filter-bar {
+  display: flex;
+  align-items: center;
+}
+
+.filter-bar .el-select {
+  margin-right: 12px;
+}
+</style>