跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
JavaWeChatPay大前端java

基于 Spring Boot 与 Vue 的 Web 虚拟卡销售平台实现方案

本项目设计并实现了一套完整的 Web 虚拟卡销售系统,涵盖用户端、管理端及微信支付集成。后端采用 Spring Boot 结合 MyBatis Plus 处理业务逻辑,前端使用 Vue.js 配合 Vant 和 Element UI 构建响应式界面。核心功能包括商品库存管理、订单状态流转、JWT 认证及安全支付回调验证。通过 Redis 缓存优化查询性能,利用分布式锁机制保障高并发下的库存准确性,适合中小型电商场景快速落地。

BackendPro发布于 2026/2/24更新于 2026/10/784 浏览
基于 Spring Boot 与 Vue 的 Web 虚拟卡销售平台实现方案

1. 项目概述

1.1 项目背景

随着数字经济的发展,虚拟卡(如礼品卡、会员卡、游戏点卡等)的市场需求日益增长。本项目旨在构建一个完整的 Web 虚拟卡销售平台,包含前端销售系统、后端管理系统和移动端 H5 支付功能。技术选型上采用 Java 作为后端核心,Vue.js 作为前端框架,并深度集成微信支付能力。

1.2 系统架构

系统采用前后端分离架构,确保高内聚低耦合:

  • 前端:管理端使用 Vue.js + Element UI,用户端及移动端适配使用 Vant。
  • 后端:Spring Boot + Spring Security + MyBatis Plus。
  • 数据库:MySQL。
  • 缓存:Redis。
  • 支付:微信支付 H5 API。

2. 技术选型与环境搭建

2.1 后端技术栈

在依赖管理上,我们主要引入了以下核心组件:

<dependencies>
    <!-- SpringBoot Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    
    
    
    
        mysql
        mysql-connector-java
        runtime
    
    
        com.baomidou
        mybatis-plus-boot-starter
        3.5.1
    
    
        com.alibaba
        druid-spring-boot-starter
        1.2.8
    
    
    
    
        org.apache.commons
        commons-lang3
    
    
        com.google.guava
        guava
        31.0.1-jre
    
    
    
    
        com.github.wechatpay-apiv3
        wechatpay-apache-httpclient
        0.4.7
    
    
    
    
        org.projectlombok
        lombok
        true
    

</dependency>
<!-- 数据库相关 -->
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<scope>
</scope>
</dependency>
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<version>
</version>
</dependency>
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<version>
</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
</dependency>
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<version>
</version>
</dependency>
<!-- 微信支付 SDK -->
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<version>
</version>
</dependency>
<!-- 其他 -->
<dependency>
<groupId>
</groupId>
<artifactId>
</artifactId>
<optional>
</optional>
</dependency>
</dependencies>

2.2 前端技术栈

管理端与用户端分别初始化:

# 管理端前端
vue create admin-frontend
cd admin-frontend
vue add element-ui
npm install axios vue-router vuex --save

# 用户端前端
vue create user-frontend
cd user-frontend
npm install vant axios vue-router vuex --save

2.3 开发环境配置

  1. JDK:1.8+
  2. Maven:3.6+
  3. Node.js:14+
  4. MySQL:5.7+
  5. Redis:5.0+
  6. IDE:推荐 IntelliJ IDEA + VS Code

3. 数据库设计

3.1 数据库 ER 图

主要实体包括:用户 (User)、虚拟卡产品 (CardProduct)、卡密库存 (CardSecret)、订单 (Order)、支付记录 (Payment) 以及管理员 (Admin)。

3.2 数据表设计

以下是核心表的建表语句,注意字段类型与索引的设计:

-- 用户表
CREATE TABLE `user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) NOT NULL COMMENT '密码',
  `email` varchar(100) DEFAULT NULL COMMENT '邮箱',
  `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
  `avatar` varchar(255) DEFAULT NULL COMMENT '头像',
  `status` tinyint(1) DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_username` (`username`),
  KEY `idx_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 虚拟卡产品表
CREATE TABLE `card_product` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL COMMENT '产品名称',
  `category_id` bigint(20) NOT NULL COMMENT '分类 ID',
  `description` text COMMENT '产品描述',
  `price` decimal(10,2) NOT NULL COMMENT '售价',
  `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价',
  `stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存',
  `image_url` varchar(255) DEFAULT NULL COMMENT '图片 URL',
  `detail_images` text COMMENT '详情图片,JSON 数组',
  `status` tinyint(1) DEFAULT '1' COMMENT '状态:0-下架,1-上架',
  `sort_order` int(11) DEFAULT '0' COMMENT '排序权重',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_category` (`category_id`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='虚拟卡产品表';

-- 卡密库存表
CREATE TABLE `card_secret` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `product_id` bigint(20) NOT NULL COMMENT '产品 ID',
  `card_no` varchar(100) NOT NULL COMMENT '卡号',
  `card_password` varchar(100) NOT NULL COMMENT '卡密',
  `status` tinyint(1) DEFAULT '0' COMMENT '状态:0-未售出,1-已售出,2-已锁定',
  `order_id` bigint(20) DEFAULT NULL COMMENT '订单 ID',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_card_no` (`card_no`),
  KEY `idx_product_id` (`product_id`),
  KEY `idx_status` (`status`),
  KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡密库存表';

-- 订单表
CREATE TABLE `order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(50) NOT NULL COMMENT '订单编号',
  `user_id` bigint(20) NOT NULL COMMENT '用户 ID',
  `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额',
  `payment_amount` decimal(10,2) NOT NULL COMMENT '实付金额',
  `payment_type` tinyint(1) DEFAULT NULL COMMENT '支付方式:1-微信,2-支付宝',
  `status` tinyint(1) DEFAULT '0' COMMENT '订单状态:0-待支付,1-已支付,2-已发货,3-已完成,4-已取消',
  `payment_time` datetime DEFAULT NULL COMMENT '支付时间',
  `complete_time` datetime DEFAULT NULL COMMENT '完成时间',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_order_no` (`order_no`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_status` (`status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

-- 订单明细表
CREATE TABLE `order_item` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_id` bigint(20) NOT NULL COMMENT '订单 ID',
  `order_no` varchar(50) NOT NULL COMMENT '订单编号',
  `product_id` bigint(20) NOT NULL COMMENT '产品 ID',
  `product_name` varchar(100) NOT NULL COMMENT '产品名称',
  `product_image` varchar(255) DEFAULT NULL COMMENT '产品图片',
  `quantity` int(11) NOT NULL COMMENT '购买数量',
  `price` decimal(10,2) NOT NULL COMMENT '单价',
  `total_price` decimal(10,2) NOT NULL COMMENT '总价',
  `card_secret_id` bigint(20) DEFAULT NULL COMMENT '卡密 ID',
  `card_no` varchar(100) DEFAULT NULL COMMENT '卡号',
  `card_password` varchar(100) DEFAULT NULL COMMENT '卡密',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_order_id` (`order_id`),
  KEY `idx_order_no` (`order_no`),
  KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';

-- 支付记录表
CREATE TABLE `payment` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_id` bigint(20) NOT NULL COMMENT '订单 ID',
  `order_no` varchar(50) NOT NULL COMMENT '订单编号',
  `payment_no` varchar(50) NOT NULL COMMENT '支付流水号',
  `payment_type` tinyint(1) NOT NULL COMMENT '支付方式:1-微信,2-支付宝',
  `payment_amount` decimal(10,2) NOT NULL COMMENT '支付金额',
  `payment_status` tinyint(1) DEFAULT '0' COMMENT '支付状态:0-未支付,1-支付成功,2-支付失败',
  `payment_time` datetime DEFAULT NULL COMMENT '支付时间',
  `callback_time` datetime DEFAULT NULL COMMENT '回调时间',
  `callback_content` text COMMENT '回调内容',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_payment_no` (`payment_no`),
  KEY `idx_order_id` (`order_id`),
  KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付记录表';

-- 管理员表
CREATE TABLE `admin` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) NOT NULL COMMENT '密码',
  `nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
  `avatar` varchar(255) DEFAULT NULL COMMENT '头像',
  `email` varchar(100) DEFAULT NULL COMMENT '邮箱',
  `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
  `status` tinyint(1) DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
  `last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员表';

-- 系统日志表
CREATE TABLE `sys_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户 ID',
  `username` varchar(50) DEFAULT NULL COMMENT '用户名',
  `operation` varchar(50) DEFAULT NULL COMMENT '用户操作',
  `method` varchar(200) DEFAULT NULL COMMENT '请求方法',
  `params` text COMMENT '请求参数',
  `time` bigint(20) DEFAULT NULL COMMENT '执行时长 (毫秒)',
  `ip` varchar(64) DEFAULT NULL COMMENT 'IP 地址',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统日志表';

4. 后端实现

4.1 Spring Boot 项目结构

标准的分层架构有助于维护性,目录结构如下:

src/main/java/com/virtualcard/
 ├── config/          # 配置类
 │   ├── SecurityConfig.java
 │   ├── SwaggerConfig.java
 │   ├── RedisConfig.java
 │   └── WebMvcConfig.java
 ├── constant/        # 常量类
 │   ├── OrderStatus.java
 │   ├── PaymentType.java
 │   └── RedisKey.java
 ├── controller/      # 控制器
 │   ├── api/         # 用户 API 接口
 │   │   ├── AuthController.java
 │   │   ├── CardController.java
 │   │   ├── OrderController.java
 │   │   └── PaymentController.java
 │   └── admin/       # 管理端接口
 │       ├── AdminAuthController.java
 │       ├── AdminCardController.java
 │       ├── AdminOrderController.java
 │       └── AdminUserController.java
 ├── dao/             # 数据访问层
 │   ├── entity/      # 实体类
 │   └── mapper/      # MyBatis Mapper 接口
 ├── dto/             # 数据传输对象
 │   ├── request/
 │   └── response/
 ├── exception/       # 异常处理
 ├── service/         # 服务层
 │   ├── impl/
 │   └── ...
 ├── util/            # 工具类
 │   ├── JwtUtil.java
 │   ├── RedisUtil.java
 │   ├── SnowFlakeUtil.java
 │   └── WeChatPayUtil.java
 └── VirtualCardApplication.java

4.2 核心功能实现

4.2.1 用户认证与授权

安全配置是系统的基石。这里我们使用 JWT 进行无状态认证,并配合 BCrypt 加密密码。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    @Autowired
    private JwtAccessDeniedHandler jwtAccessDeniedHandler;
    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/api/auth/**").permitAll()
            .antMatchers("/api/payment/callback/**").permitAll()
            .antMatchers("/swagger-ui/**","/swagger-resources/**","/v2/api-docs").permitAll()
            .antMatchers("/api/**").authenticated()
            .antMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling()
            .accessDeniedHandler(jwtAccessDeniedHandler)
            .authenticationEntryPoint(jwtAuthenticationEntryPoint);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

JWT 工具类负责令牌生成与校验,注意密钥管理的安全性:

@Component
public class JwtUtil {
    private static final String SECRET = "your_jwt_secret";
    private static final long EXPIRATION = 86400L; // 24 小时

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("sub", userDetails.getUsername());
        claims.put("created", new Date());
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION * 1000))
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .compact();
    }

    public String getUsernameFromToken(String token) {
        return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody().getSubject();
    }

    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    private boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    private Date getExpirationDateFromToken(String token) {
        return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody().getExpiration();
    }
}
4.2.2 虚拟卡管理

商品列表查询加入了 Redis 缓存策略,减少数据库压力。库存扣减则需要在事务中严格控制,防止超卖。

@Service
public class CardServiceImpl implements CardService {
    @Autowired
    private CardProductMapper cardProductMapper;
    @Autowired
    private CardSecretMapper cardSecretMapper;
    @Autowired
    private RedisUtil redisUtil;

    private static final String CARD_PRODUCT_CACHE_KEY = "card:product:list";
    private static final long CACHE_EXPIRE = 3600; // 1 小时

    @Override
    public List<CardProductRes> listAllProducts() {
        // 先查缓存
        String cache = redisUtil.get(CARD_PRODUCT_CACHE_KEY);
        if (StringUtils.isNotBlank(cache)) {
            return JSON.parseArray(cache, CardProductRes.class);
        }
        // 缓存没有则查数据库
        QueryWrapper<CardProduct> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("status", 1).orderByAsc("sort_order");
        List<CardProduct> products = cardProductMapper.selectList(queryWrapper);
        List<CardProductRes> result = products.stream().map(this::convertToRes).collect(Collectors.toList());
        // 存入缓存
        redisUtil.set(CARD_PRODUCT_CACHE_KEY, JSON.toJSONString(result), CACHE_EXPIRE);
        return result;
    }

    @Override
    @Transactional
    public List<CardSecret> lockCardSecrets(Long productId, int quantity, Long orderId) {
        // 查询可用的卡密
        List<CardSecret> availableSecrets = cardSecretMapper.selectAvailableSecrets(productId, quantity);
        if (availableSecrets.size() < quantity) {
            throw new BusinessException("库存不足");
        }
        // 锁定卡密
        List<Long> ids = availableSecrets.stream().map(CardSecret::getId).collect(Collectors.toList());
        cardSecretMapper.lockSecrets(ids, orderId);
        // 更新产品库存
        cardProductMapper.decreaseStock(productId, quantity);
        // 清除缓存
        redisUtil.del(CARD_PRODUCT_CACHE_KEY);
        return availableSecrets;
    }

    @Override
    @Transactional
    public void unlockCardSecrets(List<Long> cardSecretIds) {
        if (CollectionUtils.isEmpty(cardSecretIds)) {
            return;
        }
        // 查询卡密对应的产品 ID 和数量
        List<CardSecret> secrets = cardSecretMapper.selectBatchIds(cardSecretIds);
        if (CollectionUtils.isEmpty(secrets)) {
            return;
        }
        Map<Long, Long> productCountMap = secrets.stream().collect(Collectors.groupingBy(CardSecret::getProductId, Collectors.counting()));
        // 解锁卡密
        cardSecretMapper.unlockSecrets(cardSecretIds);
        // 恢复产品库存
        for (Map.Entry<Long, Long> entry : productCountMap.entrySet()) {
            cardProductMapper.increaseStock(entry.getKey(), entry.getValue().intValue());
        }
        // 清除缓存
        redisUtil.del(CARD_PRODUCT_CACHE_KEY);
    }

    private CardProductRes convertToRes(CardProduct product) {
        CardProductRes res = new CardProductRes();
        BeanUtils.copyProperties(product, res);
        return res;
    }
}
4.2.3 订单服务

订单创建涉及库存锁定与资金流转,必须保证原子性。这里使用了 Spring 的事务注解来保障一致性。

@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private OrderItemMapper orderItemMapper;
    @Autowired
    private CardService cardService;
    @Autowired
    private PaymentService paymentService;
    @Autowired
    private SnowFlakeUtil snowFlakeUtil;

    @Override
    @Transactional
    public OrderRes createOrder(OrderCreateReq req, Long userId) {
        // 生成订单号
        String orderNo = generateOrderNo();
        // 锁定卡密
        List<CardSecret> cardSecrets = cardService.lockCardSecrets(req.getProductId(), req.getQuantity(), null);
        // 计算总金额
        CardProduct product = cardService.getProductById(req.getProductId());
        BigDecimal totalAmount = product.getPrice().multiply(new BigDecimal(req.getQuantity()));
        // 创建订单
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setUserId(userId);
        order.setTotalAmount(totalAmount);
        order.setPaymentAmount(totalAmount);
        order.setStatus(OrderStatus.UNPAID.getCode());
        orderMapper.insert(order);
        // 创建订单明细
        List<OrderItem> orderItems = new ArrayList<>();
        for (CardSecret secret : cardSecrets) {
            OrderItem item = new OrderItem();
            item.setOrderId(order.getId());
            item.setOrderNo(orderNo);
            item.setProductId(req.getProductId());
            item.setProductName(product.getName());
            item.setProductImage(product.getImageUrl());
            item.setQuantity(1);
            item.setPrice(product.getPrice());
            item.setTotalPrice(product.getPrice());
            item.setCardSecretId(secret.getId());
            orderItems.add(item);
        }
        orderItemMapper.batchInsert(orderItems);
        // 更新卡密的订单 ID
        List<Long> cardSecretIds = cardSecrets.stream().map(CardSecret::getId).collect(Collectors.toList());
        cardService.updateCardSecretsOrderId(cardSecretIds, order.getId());
        // 返回订单信息
        OrderRes res = new OrderRes();
        BeanUtils.copyProperties(order, res);
        res.setItems(orderItems.stream().map(this::convertToItemRes).collect(Collectors.toList()));
        return res;
    }

    @Override
    @Transactional
    public void cancelOrder(Long orderId, Long userId) {
        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            throw new BusinessException("订单不存在");
        }
        if (!order.getUserId().equals(userId)) {
            throw new BusinessException("无权操作此订单");
        }
        if (order.getStatus() != OrderStatus.UNPAID.getCode()) {
            throw new BusinessException("订单状态不允许取消");
        }
        // 更新订单状态
        order.setStatus(OrderStatus.CANCELLED.getCode());
        orderMapper.updateById(order);
        // 查询订单明细获取卡密 ID
        List<OrderItem> items = orderItemMapper.selectByOrderId(orderId);
        List<Long> cardSecretIds = items.stream().map(OrderItem::getCardSecretId).filter(Objects::nonNull).collect(Collectors.toList());
        // 解锁卡密
        if (!cardSecretIds.isEmpty()) {
            cardService.unlockCardSecrets(cardSecretIds);
        }
    }

    @Override
    @Transactional
    public void payOrderSuccess(String orderNo, String paymentNo, BigDecimal paymentAmount, Date paymentTime) {
        Order order = orderMapper.selectByOrderNo(orderNo);
        if (order == null) {
            throw new BusinessException("订单不存在");
        }
        if (order.getStatus() != OrderStatus.UNPAID.getCode()) {
            throw new BusinessException("订单状态不正确");
        }
        // 更新订单状态
        order.setStatus(OrderStatus.PAID.getCode());
        order.setPaymentTime(paymentTime);
        orderMapper.updateById(order);
        // 更新卡密状态为已售出
        List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId());
        List<Long> cardSecretIds = items.stream().map(OrderItem::getCardSecretId).filter(Objects::nonNull).collect(Collectors.toList());
        if (!cardSecretIds.isEmpty()) {
            cardService.sellCardSecrets(cardSecretIds);
        }
        // 创建支付记录
        Payment payment = new Payment();
        payment.setOrderId(order.getId());
        payment.setOrderNo(orderNo);
        payment.setPaymentNo(paymentNo);
        payment.setPaymentType(PaymentType.WECHAT.getCode());
        payment.setPaymentAmount(paymentAmount);
        payment.setPaymentStatus(1);
        payment.setPaymentTime(paymentTime);
        payment.setCallbackTime(new Date());
        paymentService.createPayment(payment);
    }

    private String generateOrderNo() {
        return "ORD" + snowFlakeUtil.nextId();
    }

    private OrderItemRes convertToItemRes(OrderItem item) {
        OrderItemRes res = new OrderItemRes();
        BeanUtils.copyProperties(item, res);
        return res;
    }
}
4.2.4 微信支付集成

支付模块需要严格验证签名,防止伪造回调。V3 版本推荐使用证书方式验证。

@Component
public class WeChatPayUtil {
    @Value("${wechat.pay.appid}")
    private String appId;
    @Value("${wechat.pay.mchid}")
    private String mchId;
    @Value("${wechat.pay.apikey}")
    private String apiKey;
    @Value("${wechat.pay.serialNo}")
    private String serialNo;
    @Value("${wechat.pay.privateKey}")
    private String privateKey;
    @Value("${wechat.pay.notifyUrl}")
    private String notifyUrl;

    private CloseableHttpClient httpClient;

    @PostConstruct
    public void init() {
        // 加载商户私钥
        PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(new ByteArrayInputStream(privateKey.getBytes()));
        // 构造 HttpClient
        httpClient = WechatPayHttpClientBuilder.create()
                .withMerchant(mchId, serialNo, merchantPrivateKey)
                .withValidator(new WechatPay2Validator(apiKey.getBytes()))
                .build();
    }

    public Map<String, String> createH5Payment(String orderNo, BigDecimal amount, String description, String clientIp) throws Exception {
        // 构造请求参数
        Map<String, Object> params = new HashMap<>();
        params.put("appid", appId);
        params.put("mchid", mchId);
        params.put("description", description);
        params.put("out_trade_no", orderNo);
        params.put("notify_url", notifyUrl);
        params.put("amount", new HashMap<String, Object>() {{
            put("total", amount.multiply(new BigDecimal(100)).intValue());
            put("currency", "CNY");
        }});
        params.put("scene_info", new HashMap<String, Object>() {{
            put("payer_client_ip", clientIp);
            put("h5_info", new HashMap<String, Object>() {{
                put("type", "Wap");
            }});
        }});
        // 发送请求
        HttpPost httpPost = new HttpPost("https://api.mch.weixin.qq.com/v3/pay/transactions/h5");
        httpPost.addHeader("Accept", "application/json");
        httpPost.addHeader("Content-type", "application/json");
        httpPost.setEntity(new StringEntity(JSON.toJSONString(params), "UTF-8"));
        CloseableHttpResponse response = httpClient.execute(httpPost);
        try {
            String responseBody = EntityUtils.toString(response.getEntity());
            if (response.getStatusLine().getStatusCode() == 200) {
                Map<String, String> result = new HashMap<>();
                JSONObject json = JSON.parseObject(responseBody);
                result.put("h5_url", json.getString("h5_url"));
                result.put("prepay_id", json.getString("prepay_id"));
                return result;
            } else {
                throw new BusinessException("微信支付创建失败:" + responseBody);
            }
        } finally {
            response.close();
        }
    }

    public boolean verifyNotify(Map<String, String> params, String signature, String serial, String nonce, String timestamp, String body) {
        try {
            // 验证签名
            String message = timestamp + "\n" + nonce + "\n" + body + "\n";
            boolean verifyResult = verifySignature(message.getBytes("utf-8"), serial, signature.getBytes("utf-8"));
            if (!verifyResult) {
                return false;
            }
            // 验证订单状态
            JSONObject json = JSON.parseObject(body);
            String orderNo = json.getJSONObject("resource").getString("out_trade_no");
            String tradeState = json.getJSONObject("resource").getString("trade_state");
            return "SUCCESS".equals(tradeState);
        } catch (Exception e) {
            return false;
        }
    }

    private boolean verifySignature(byte[] message, String serial, byte[] signature) {
        try {
            // 根据证书序列号查询证书
            String cert = getWechatPayCert(serial);
            if (cert == null) {
                return false;
            }
            // 加载证书
            X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(cert));
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
            // 验证签名
            Signature sign = Signature.getInstance("SHA256withRSA");
            sign.initVerify(publicKey);
            sign.update(message);
            return sign.verify(signature);
        } catch (Exception e) {
            return false;
        }
    }

    private String getWechatPayCert(String serial) {
        // 这里应该实现从微信支付平台获取证书的逻辑
        // 实际项目中应该缓存证书,避免频繁请求
        // 简化实现,返回配置的证书
        return "your_wechat_pay_cert_content";
    }
}

支付控制器负责接收前端请求并转发给微信,同时处理异步回调:

@RestController
@RequestMapping("/api/payment")
public class PaymentController {
    @Autowired
    private OrderService orderService;
    @Autowired
    private PaymentService paymentService;
    @Autowired
    private WeChatPayUtil weChatPayUtil;

    @PostMapping("/create")
    public ApiResponse<Map<String, String>> createPayment(@RequestBody PaymentReq req, HttpServletRequest request) {
        // 查询订单
        Order order = orderService.getOrderByNo(req.getOrderNo());
        if (order == null) {
            return ApiResponse.fail("订单不存在");
        }
        if (order.getStatus() != OrderStatus.UNPAID.getCode()) {
            return ApiResponse.fail("订单状态不正确");
        }
        // 创建微信支付
        try {
            Map<String, String> result = weChatPayUtil.createH5Payment(
                    order.getOrderNo(),
                    order.getPaymentAmount(),
                    "虚拟卡购买 -" + order.getOrderNo(),
                    getClientIp(request)
            );
            // 保存支付记录
            Payment payment = new Payment();
            payment.setOrderId(order.getId());
            payment.setOrderNo(order.getOrderNo());
            payment.setPaymentNo(result.get("prepay_id"));
            payment.setPaymentType(PaymentType.WECHAT.getCode());
            payment.setPaymentAmount(order.getPaymentAmount());
            payment.setPaymentStatus(0);
            paymentService.createPayment(payment);
            return ApiResponse.success(result);
        } catch (Exception e) {
            return ApiResponse.fail("支付创建失败:" + e.getMessage());
        }
    }

    @PostMapping("/callback/wechat")
    public String wechatPayCallback(HttpServletRequest request) {
        try {
            // 获取请求头信息
            String signature = request.getHeader("Wechatpay-Signature");
            String serial = request.getHeader("Wechatpay-Serial");
            String nonce = request.getHeader("Wechatpay-Nonce");
            String timestamp = request.getHeader("Wechatpay-Timestamp");
            // 获取请求体
            String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
            // 验证回调
            if (!weChatPayUtil.verifyNotify(null, signature, serial, nonce, timestamp, body)) {
                return "FAIL";
            }
            // 解析回调内容
            JSONObject json = JSON.parseObject(body);
            JSONObject resource = json.getJSONObject("resource");
            String orderNo = resource.getString("out_trade_no");
            String transactionId = resource.getString("transaction_id");
            BigDecimal amount = resource.getJSONObject("amount").getBigDecimal("total").divide(new BigDecimal(100));
            Date paymentTime = new Date(resource.getLong("success_time") * 1000);
            // 处理支付成功逻辑
            orderService.payOrderSuccess(orderNo, transactionId, amount, paymentTime);
            return "SUCCESS";
        } catch (Exception e) {
            return "FAIL";
        }
    }

    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (StringUtils.isNotEmpty(ip) && !"unKnown".equalsIgnoreCase(ip)) {
            int index = ip.indexOf(",");
            if (index != -1) {
                return ip.substring(0, index);
            } else {
                return ip;
            }
        }
        ip = request.getHeader("X-Real-IP");
        if (StringUtils.isNotEmpty(ip) && !"unKnown".equalsIgnoreCase(ip)) {
            return ip;
        }
        return request.getRemoteAddr();
    }
}

5. 前端实现

5.1 用户端前端实现

5.1.1 项目结构

用户端侧重于移动端体验,目录组织清晰:

src/
 ├── api/           # API 请求
 │   ├── auth.js
 │   ├── card.js
 │   ├── order.js
 │   └── payment.js
 ├── assets/        # 静态资源
 ├── components/    # 公共组件
 ├── router/        # 路由配置
 ├── store/         # Vuex 状态管理
 ├── utils/         # 工具函数
 ├── views/         # 页面组件
 │   ├── auth/
 │   ├── card/
 │   ├── order/
 │   ├── payment/
 │   ├── Home.vue
 │   └── User.vue
 ├── App.vue
 └── main.js
5.1.2 核心页面实现

虚拟卡列表页 (Card/List.vue) 利用 Vant 组件库快速构建移动端界面,支持搜索与分类切换。

<template>
  <div>
    <header-component title="虚拟卡商城" :show-back="false" />
    <div>
      <van-search v-model="searchKeyword" placeholder="搜索虚拟卡" shape="round" @search="onSearch" />
    </div>
    <van-tabs v-model="activeCategory" @click="onCategoryChange">
      <van-tab v-for="category in categories" :key="category.id" :title="category.name" />
    </van-tabs>
    <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
      <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
        <card-item v-for="card in cardList" :key="card.id" :card="card" @click="goToDetail(card.id)" />
      </van-list>
    </van-pull-refresh>
  </div>
</template>

<script>
import { Search, Tab, Tabs, List, PullRefresh } from 'vant';
import HeaderComponent from '@/components/Header.vue';
import CardItem from '@/components/CardItem.vue';
import { getCardProducts, getCardCategories } from '@/api/card';

export default {
  components: {
    [Search.name]: Search,
    [Tab.name]: Tab,
    [Tabs.name]: Tabs,
    [List.name]: List,
    [PullRefresh.name]: PullRefresh,
    HeaderComponent,
    CardItem
  },
  data() {
    return {
      searchKeyword: '',
      activeCategory: 0,
      categories: [],
      cardList: [],
      loading: false,
      finished: false,
      refreshing: false,
      page: 1,
      pageSize: 10
    };
  },
  created() {
    this.loadCategories();
  },
  methods: {
    async loadCategories() {
      try {
        const res = await getCardCategories();
        this.categories = [{ id: 0, name: '全部' }, ...res.data];
      } catch (error) {
        console.error('加载分类失败', error);
      }
    },
    async onLoad() {
      if (this.refreshing) {
        this.cardList = [];
        this.refreshing = false;
      }
      try {
        const params = {
          page: this.page,
          pageSize: this.pageSize,
          categoryId: this.activeCategory === 0 ? null : this.activeCategory,
          keyword: this.searchKeyword
        };
        const res = await getCardProducts(params);
        this.cardList = [...this.cardList, ...res.data.list];
        this.loading = false;
        if (res.data.list.length < this.pageSize) {
          this.finished = true;
        } else {
          this.page++;
        }
      } catch (error) {
        this.loading = false;
        this.finished = true;
        console.error('加载卡片列表失败', error);
      }
    },
    onRefresh() {
      this.page = 1;
      this.finished = false;
      this.loading = true;
      this.onLoad();
    },
    onSearch() {
      this.page = 1;
      this.cardList = [];
      this.finished = false;
      this.loading = true;
      this.onLoad();
    },
    onCategoryChange() {
      this.page = 1;
      this.cardList = [];
      this.finished = false;
      this.loading = true;
      this.onLoad();
    },
    goToDetail(id) {
      this.$router.push(`/card/detail/${id}`);
    }
  }
};
</script>

<style scoped>
.card-list {
  padding-bottom: 50px;
}
.search-box {
  padding: 10px;
}
</style>

订单创建页 (Order/Create.vue) 确认订单时展示商品信息与支付方式选择。

<template>
  <div>
    <header-component title="确认订单" :show-back="true" />
    <div v-if="!isVirtual">
      <van-contact-card type="edit" :name="address.name" :tel="address.phone" @click="editAddress" />
    </div>
    <div>
      <van-card :num="quantity" :price="card.price" :title="card.name" :thumb="card.imageUrl">
        <template #tags>
          <van-tag plain type="danger">虚拟商品</van-tag>
        </template>
      </van-card>
    </div>
    <div>
      <van-cell-group>
        <van-cell title="购买数量">
          <van-stepper v-model="quantity" integer min="1" :max="card.stock" />
        </van-cell>
        <van-cell title="商品金额" :value="`¥${(card.price * quantity).toFixed(2)}`" />
        <van-cell title="优惠金额" value="¥0.00" />
        <van-cell title="实付金额" :value="`¥${(card.price * quantity).toFixed(2)}`" />
      </van-cell-group>
    </div>
    <div>
      <van-radio-group v-model="paymentType">
        <van-cell-group title="支付方式">
          <van-cell title="微信支付" clickable @click="paymentType = 1">
            <template #right-icon>
              <van-radio :name="1" />
            </template>
          </van-cell>
        </van-cell-group>
      </van-radio-group>
    </div>
    <div>
      <van-submit-bar :price="totalPrice * 100" button-text="提交订单" @submit="createOrder" />
    </div>
  </div>
</template>

<script>
import { ContactCard, Card, Tag, Cell, CellGroup, Radio, RadioGroup, Stepper, SubmitBar } from 'vant';
import HeaderComponent from '@/components/Header.vue';
import { getCardDetail } from '@/api/card';
import { createOrder } from '@/api/order';

export default {
  components: {
    [ContactCard.name]: ContactCard,
    [Card.name]: Card,
    [Tag.name]: Tag,
    [Cell.name]: Cell,
    [CellGroup.name]: CellGroup,
    [Radio.name]: Radio,
    [RadioGroup.name]: RadioGroup,
    [Stepper.name]: Stepper,
    [SubmitBar.name]: SubmitBar,
    HeaderComponent
  },
  data() {
    return {
      cardId: null,
      card: { id: null, name: '', price: 0, stock: 0, imageUrl: '' },
      quantity: 1,
      paymentType: 1,
      address: { name: '张三', phone: '13800138000', address: '北京市朝阳区' },
      isVirtual: true
    };
  },
  computed: {
    totalPrice() {
      return this.card.price * this.quantity;
    }
  },
  created() {
    this.cardId = this.$route.params.id;
    this.loadCardDetail();
  },
  methods: {
    async loadCardDetail() {
      try {
        const res = await getCardDetail(this.cardId);
        this.card = res.data;
      } catch (error) {
        this.$toast.fail('加载卡片详情失败');
        console.error(error);
      }
    },
    editAddress() {
      this.$router.push('/address/edit');
    },
    async createOrder() {
      try {
        this.$toast.loading({ message: '创建订单中...', forbidClick: true });
        const params = { productId: this.cardId, quantity: this.quantity };
        const res = await createOrder(params);
        this.$toast.clear();
        // 跳转到支付页面
        this.$router.push({ path: '/payment/pay', query: { orderNo: res.data.orderNo, amount: this.totalPrice } });
      } catch (error) {
        this.$toast.clear();
        this.$toast.fail(error.message || '创建订单失败');
        console.error(error);
      }
    }
  }
};
</script>

<style scoped>
.order-create {
  padding-bottom: 100px;
}
.address-section {
  margin-bottom: 10px;
}
.card-info {
  margin-bottom: 10px;
}
.total-price {
  font-weight: bold;
  color: #ee0a24;
}
</style>

微信支付页 (Payment/Pay.vue) 区分微信浏览器内与外部浏览器的支付唤起方式。

<template>
  <div>
    <header-component title="支付订单" :show-back="true" />
    <div>
      <van-cell-group>
        <van-cell title="订单编号" :value="orderNo" />
        <van-cell title="支付金额">
          <span>¥{{ amount.toFixed(2) }}</span>
        </van-cell>
      </van-cell-group>
    </div>
    <div>
      <van-radio-group v-model="paymentMethod">
        <van-cell-group title="选择支付方式">
          <van-cell title="微信支付" clickable @click="paymentMethod = 'wechat'">
            <template #right-icon>
              <van-radio name="wechat" />
            </template>
            <template #icon>
              <img src="@/assets/images/wechat-pay.png" />
            </template>
          </van-cell>
        </van-cell-group>
      </van-radio-group>
    </div>
    <div>
      <van-button type="primary" block round :loading="loading" @click="handlePayment">
        立即支付
      </van-button>
    </div>
    <van-dialog v-model="showPaymentDialog" title="微信支付" show-cancel-button :before-close="beforeClose">
      <div>
        <div v-if="paymentStatus === 'pending'">
          <van-loading size="24px">正在调起支付...</van-loading>
        </div>
        <div v-else-if="paymentStatus === 'success'">
          <van-icon name="checked" color="#07c160" size="50px" />
          <p>支付成功</p>
        </div>
        <div v-else>
          <van-icon name="close" color="#ee0a24" size="50px" />
          <p>支付失败</p>
          <p>{{ errorMsg }}</p>
        </div>
      </div>
    </van-dialog>
  </div>
</template>

<script>
import { Cell, CellGroup, Radio, RadioGroup, Button, Dialog, Loading, Icon } from 'vant';
import HeaderComponent from '@/components/Header.vue';
import { createPayment } from '@/api/payment';
import { getOrderDetail } from '@/api/order';
import { isWeixinBrowser, wechatPay } from '@/utils/wechat';

export default {
  components: {
    [Cell.name]: Cell,
    [CellGroup.name]: CellGroup,
    [Radio.name]: Radio,
    [RadioGroup.name]: RadioGroup,
    [Button.name]: Button,
    [Dialog.name]: Dialog,
    [Loading.name]: Loading,
    [Icon.name]: Icon,
    HeaderComponent
  },
  data() {
    return {
      orderNo: this.$route.query.orderNo,
      amount: parseFloat(this.$route.query.amount),
      paymentMethod: 'wechat',
      loading: false,
      showPaymentDialog: false,
      paymentStatus: 'pending',
      errorMsg: '',
      timer: null,
      isWeixin: isWeixinBrowser()
    };
  },
  beforeDestroy() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  },
  methods: {
    async handlePayment() {
      if (this.paymentMethod !== 'wechat') {
        this.$toast('请选择微信支付');
        return;
      }
      this.loading = true;
      try {
        // 创建支付
        const res = await createPayment({ orderNo: this.orderNo, paymentType: 1 });
        this.loading = false;
        if (this.isWeixin) {
          // 微信浏览器内使用 JSAPI 支付
          await this.wechatJsApiPay(res.data);
        } else {
          // 非微信浏览器使用 H5 支付
          this.showPaymentDialog = true;
          window.location.href = res.data.h5Url;
          // 启动轮询检查支付状态
          this.startPaymentCheck();
        }
      } catch (error) {
        this.loading = false;
        this.$toast.fail(error.message || '支付创建失败');
        console.error(error);
      }
    },
    async wechatJsApiPay(paymentData) {
      try {
        await wechatPay(paymentData);
        // 支付成功,跳转到结果页
        this.$router.push({ path: '/payment/result', query: { orderNo: this.orderNo, status: 'success' } });
      } catch (error) {
        this.$toast.fail(error.message || '支付失败');
        console.error(error);
      }
    },
    startPaymentCheck() {
      this.timer = setInterval(async () => {
        try {
          const res = await getOrderDetail(this.orderNo);
          if (res.data.status === 1) {
            // 已支付
            this.paymentStatus = 'success';
            clearInterval(this.timer);
            // 3 秒后自动跳转
            setTimeout(() => {
              this.showPaymentDialog = false;
              this.$router.push({ path: '/payment/result', query: { orderNo: this.orderNo, status: 'success' } });
            }, 3000);
          }
        } catch (error) {
          console.error('检查支付状态失败', error);
        }
      }, 3000);
    },
    beforeClose(action, done) {
      if (action === 'confirm') {
        if (this.paymentStatus === 'pending') {
          this.$toast('支付处理中,请稍候');
          done(false);
        } else {
          done();
          this.$router.push({ path: '/payment/result', query: { orderNo: this.orderNo, status: this.paymentStatus } });
        }
      } else {
        done();
      }
    }
  }
};
</script>

<style scoped>
.payment-page {
  padding-bottom: 100px;
}
.payment-info {
  margin-bottom: 10px;
}
.price {
  color: #ee0a24;
  font-weight: bold;
}
.pay-icon {
  width: 24px;
  height: 24px;
  margin-right: 10px;
}
.payment-btn {
  margin: 20px 15px;
}
.payment-dialog {
  padding: 20px;
  text-align: center;
}
.payment-success, .payment-failed {
  padding: 20px 0;
}
.payment-success p, .payment-failed p {
  margin-top: 10px;
  font-size: 16px;
}
.error-msg {
  color: #ee0a24;
  font-size: 14px;
}
</style>

5.2 管理端前端实现

5.2.1 项目结构

管理端侧重数据管理与操作效率,使用 Element UI 构建桌面端后台。

5.2.2 核心页面实现

虚拟卡产品管理页 (Card/List.vue) 提供增删改查及批量操作功能。

<template>
  <div>
    <el-card>
      <el-form :inline="true" :model="searchForm">
        <el-form-item label="产品名称">
          <el-input v-model="searchForm.name" placeholder="请输入产品名称" clearable />
        </el-form-item>
        <el-form-item label="产品分类">
          <el-select v-model="searchForm.categoryId" placeholder="请选择分类" clearable>
            <el-option v-for="category in categories" :key="category.id" :label="category.name" :value="category.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
            <el-option label="上架" :value="1" />
            <el-option label="下架" :value="0" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">查询</el-button>
          <el-button @click="resetSearch">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>
    <el-card>
      <el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增产品</el-button>
      <el-button type="danger" icon="el-icon-delete" :disabled="!selectedItems.length" @click="handleBatchDelete">
        批量删除
      </el-button>
    </el-card>
    <el-card>
      <el-table :data="tableData" border @selection-change="handleSelectionChange" v-loading="loading">
        <el-table-column type="selection" />
        <el-table-column prop="id" label="ID" />
        <el-table-column prop="name" label="产品名称" min-width="150" />
        <el-table-column label="分类">
          <template slot-scope="scope"> {{ getCategoryName(scope.row.categoryId) }} </template>
        </el-table-column>
        <el-table-column prop="price" label="价格">
          <template slot-scope="scope"> ¥{{ scope.row.price.toFixed(2) }} </template>
        </el-table-column>
        <el-table-column prop="stock" label="库存" />
        <el-table-column label="状态">
          <template slot-scope="scope">
            <el-tag :type="scope.row.status ? 'success' : 'danger'"> {{ scope.row.status ? '上架' : '下架' }} </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createTime" label="创建时间" />
        <el-table-column label="操作" fixed="right">
          <template slot-scope="scope">
            <el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
            <el-button size="mini" :type="scope.row.status ? 'danger' : 'success'" @click="handleStatusChange(scope.row)">
              {{ scope.row.status ? '下架' : '上架' }}
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-pagination @size-change="handleSizeChange" @current-change="handleCurrentChange" :current-page="pagination.current" :page-sizes="[10, 20, 50, 100]" :page-size="pagination.size" layout="total, sizes, prev, pager, next, jumper" :total="pagination.total" />
    </el-card>
    <!-- 新增/编辑对话框 -->
    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible">
      <el-form :model="dialogForm" :rules="rules" ref="dialogForm" label-width="100px">
        <el-form-item label="产品名称" prop="name">
          <el-input v-model="dialogForm.name" placeholder="请输入产品名称" />
        </el-form-item>
        <el-form-item label="产品分类" prop="categoryId">
          <el-select v-model="dialogForm.categoryId" placeholder="请选择分类">
            <el-option v-for="category in categories" :key="category.id" :label="category.name" :value="category.id" />
          </el-select>
        </el-form-item>
        <el-form-item label="产品价格" prop="price">
          <el-input-number v-model="dialogForm.price" :min="0" :precision="2" :step="0.1" />
        </el-form-item>
        <el-form-item label="原价" prop="originalPrice">
          <el-input-number v-model="dialogForm.originalPrice" :min="0" :precision="2" :step="0.1" />
        </el-form-item>
        <el-form-item label="产品图片" prop="imageUrl">
          <el-upload action="/api/upload" :show-file-list="false" :on-success="handleImageSuccess" :before-upload="beforeImageUpload">
            <img v-if="dialogForm.imageUrl" :src="dialogForm.imageUrl" />
            <i v-else></i>
          </el-upload>
        </el-form-item>
        <el-form-item label="详情图片" prop="detailImages">
          <el-upload action="/api/upload" list-type="picture-card" :file-list="detailImageList" :on-success="handleDetailImageSuccess" :on-remove="handleDetailImageRemove" :before-upload="beforeImageUpload" multiple>
            <i></i>
          </el-upload>
        </el-form-item>
        <el-form-item label="产品描述" prop="description">
          <el-input type="textarea" :rows="4" v-model="dialogForm.description" placeholder="请输入产品描述" />
        </el-form-item>
        <el-form-item label="排序权重" prop="sortOrder">
          <el-input-number v-model="dialogForm.sortOrder" :min="0" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-switch v-model="dialogForm.status" :active-value="1" :inactive-value="0" />
        </el-form-item>
      </el-form>
      <span slot="footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitForm">确 定</el-button>
      </span>
    </el-dialog>
  </div>
</template>

<script>
import { getCardProducts, addCardProduct, updateCardProduct, deleteCardProduct, updateCardProductStatus } from '@/api/card';
import { getCardCategories } from '@/api/category';

export default {
  data() {
    return {
      searchForm: { name: '', categoryId: null, status: null },
      tableData: [],
      selectedItems: [],
      categories: [],
      loading: false,
      pagination: { current: 1, size: 10, total: 0 },
      dialogVisible: false,
      dialogTitle: '新增产品',
      dialogForm: { id: null, name: '', categoryId: null, price: 0, originalPrice: 0, imageUrl: '', detailImages: [], description: '', sortOrder: 0, status: 1 },
      detailImageList: [],
      rules: {
        name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
        categoryId: [{ required: true, message: '请选择产品分类', trigger: 'change' }],
        price: [{ required: true, message: '请输入产品价格', trigger: 'blur' }]
      }
    };
  },
  created() {
    this.loadCategories();
    this.loadTableData();
  },
  methods: {
    async loadCategories() {
      try {
        const res = await getCardCategories();
        this.categories = res.data;
      } catch (error) {
        console.error('加载分类失败', error);
      }
    },
    async loadTableData() {
      this.loading = true;
      try {
        const params = { ...this.searchForm, page: this.pagination.current, pageSize: this.pagination.size };
        const res = await getCardProducts(params);
        this.tableData = res.data.list;
        this.pagination.total = res.data.total;
      } catch (error) {
        console.error('加载产品列表失败', error);
      } finally {
        this.loading = false;
      }
    },
    getCategoryName(categoryId) {
      const category = this.categories.find(item => item.id === categoryId);
      return category ? category.name : '--';
    },
    handleSearch() {
      this.pagination.current = 1;
      this.loadTableData();
    },
    resetSearch() {
      this.searchForm = { name: '', categoryId: null, status: null };
      this.pagination.current = 1;
      this.loadTableData();
    },
    handleSelectionChange(val) {
      this.selectedItems = val;
    },
    handleSizeChange(val) {
      this.pagination.size = val;
      this.loadTableData();
    },
    handleCurrentChange(val) {
      this.pagination.current = val;
      this.loadTableData();
    },
    handleAdd() {
      this.dialogTitle = '新增产品';
      this.dialogForm = { id: null, name: '', categoryId: null, price: 0, originalPrice: 0, imageUrl: '', detailImages: [], description: '', sortOrder: 0, status: 1 };
      this.detailImageList = [];
      this.dialogVisible = true;
    },
    handleEdit(row) {
      this.dialogTitle = '编辑产品';
      this.dialogForm = { ...row, detailImages: row.detailImages ? JSON.parse(row.detailImages) : [] };
      this.detailImageList = this.dialogForm.detailImages.map(url => ({ url, name: url.substring(url.lastIndexOf('/') + 1) }));
      this.dialogVisible = true;
    },
    async handleStatusChange(row) {
      try {
        await updateCardProductStatus(row.id, row.status ? 0 : 1);
        this.$message.success('状态更新成功');
        this.loadTableData();
      } catch (error) {
        this.$message.error('状态更新失败');
        console.error(error);
      }
    },
    handleBatchDelete() {
      this.$confirm('确定要删除选中的产品吗?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(async () => {
        try {
          const ids = this.selectedItems.map(item => item.id);
          await deleteCardProduct(ids);
          this.$message.success('删除成功');
          this.loadTableData();
        } catch (error) {
          this.$message.error('删除失败');
          console.error(error);
        }
      }).catch(() => {});
    },
    handleImageSuccess(res, file) {
      this.dialogForm.imageUrl = res.data.url;
    },
    handleDetailImageSuccess(res, file) {
      this.dialogForm.detailImages.push(res.data.url);
    },
    handleDetailImageRemove(file, fileList) {
      const url = file.url || file.response.data.url;
      this.dialogForm.detailImages = this.dialogForm.detailImages.filter(item => item !== url);
    },
    beforeImageUpload(file) {
      const isImage = file.type.startsWith('image/');
      const isLt2M = file.size / 1024 / 1024 < 2;
      if (!isImage) {
        this.$message.error('只能上传图片!');
      }
      if (!isLt2M) {
        this.$message.error('图片大小不能超过 2MB!');
      }
      return isImage && isLt2M;
    },
    submitForm() {
      this.$refs.dialogForm.validate(async valid => {
        if (!valid) {
          return;
        }
        try {
          const formData = { ...this.dialogForm, detailImages: JSON.stringify(this.dialogForm.detailImages) };
          if (this.dialogForm.id) {
            await updateCardProduct(formData);
            this.$message.success('更新成功');
          } else {
            await addCardProduct(formData);
            this.$message.success('添加成功');
          }
          this.dialogVisible = false;
          this.loadTableData();
        } catch (error) {
          this.$message.error(error.message || '操作失败');
          console.error(error);
        }
      });
    }
  }
};
</script>

<style scoped>
.search-card {
  margin-bottom: 20px;
}
.search-form {
  display: flex;
  flex-wrap: wrap;
}
.operation-card {
  margin-bottom: 20px;
}
.pagination {
  margin-top: 20px;
  text-align: right;
}
.avatar-uploader {
  border: 1px dashed #d9d9d9;
  border-radius: 6px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  width: 150px;
  height: 150px;
}
.avatar-uploader:hover {
  border-color: #409EFF;
}
.avatar-uploader-icon {
  font-size: 28px;
  color: #8c939d;
  width: 150px;
  height: 150px;
  line-height: 150px;
  text-align: center;
}
.avatar {
  width: 150px;
  height: 150px;
  display: block;
}
</style>

6. 微信 H5 支付集成

6.1 微信支付配置

  1. 申请微信支付商户号
    • 登录微信支付商户平台。
    • 完成商户号申请和资质认证。
  2. 配置支付域名
    • 在商户平台配置支付域名(需备案)。
    • 配置授权目录和回调域名。
  3. 获取 API 密钥和证书
    • 设置 APIv2 密钥(32 位)。
    • 申请 API 证书(用于 V3 接口)。
  4. 配置应用信息
    • 在商户平台配置 H5 支付信息。
    • 设置支付场景和域名。

6.2 支付流程实现

  1. 前端发起支付请求
// src/utils/wechat.js
import axios from 'axios';

export function isWeixinBrowser() {
  return /micromessenger/i.test(navigator.userAgent);
}

export async function wechatPay(paymentData) {
  return new Promise((resolve, reject) => {
    if (typeof WeixinJSBridge === 'undefined') {
      if (document.addEventListener) {
        document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
      } else if (document.attachEvent) {
        document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
        document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
      }
      reject(new Error('请在微信中打开页面'));
    } else {
      onBridgeReady();
    }
    function onBridgeReady() {
      WeixinJSBridge.invoke('getBrandWCPayRequest', {
        appId: paymentData.appId,
        timeStamp: paymentData.timeStamp,
        nonceStr: paymentData.nonceStr,
        package: paymentData.package,
        signType: paymentData.signType,
        paySign: paymentData.paySign
      }, function (res) {
        if (res.err_msg === 'get_brand_wcpay_request:ok') {
          resolve();
        } else {
          reject(new Error(res.err_msg || '支付失败'));
        }
      });
    }
  });
}
  1. 后端处理支付回调
// PaymentController.java
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
    // ... 其他代码 ...

    @PostMapping("/callback/wechat")
    public String wechatPayCallback(HttpServletRequest request) {
        try {
            // 获取请求头信息
            String signature = request.getHeader("Wechatpay-Signature");
            String serial = request.getHeader("Wechatpay-Serial");
            String nonce = request.getHeader("Wechatpay-Nonce");
            String timestamp = request.getHeader("Wechatpay-Timestamp");
            // 获取请求体
            String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
            // 验证回调
            if (!weChatPayUtil.verifyNotify(null, signature, serial, nonce, timestamp, body)) {
                log.error("微信支付回调验证失败");
                return "FAIL";
            }
            // 解析回调内容
            JSONObject json = JSON.parseObject(body);
            JSONObject resource = json.getJSONObject("resource");
            String orderNo = resource.getString("out_trade_no");
            String transactionId = resource.getString("transaction_id");
            BigDecimal amount = resource.getJSONObject("amount").getBigDecimal("total").divide(new BigDecimal(100));
            Date paymentTime = new Date(resource.getLong("success_time") * 1000);
            // 处理支付成功逻辑
            orderService.payOrderSuccess(orderNo, transactionId, amount, paymentTime);
            log.info("微信支付回调处理成功,orderNo: {}", orderNo);
            return "SUCCESS";
        } catch (Exception e) {
            log.error("微信支付回调处理失败", e);
            return "FAIL";
        }
    }
}

目录

  1. 1. 项目概述
  2. 1.1 项目背景
  3. 1.2 系统架构
  4. 2. 技术选型与环境搭建
  5. 2.1 后端技术栈
  6. 2.2 前端技术栈
  7. 管理端前端
  8. 用户端前端
  9. 2.3 开发环境配置
  10. 3. 数据库设计
  11. 3.1 数据库 ER 图
  12. 3.2 数据表设计
  13. 4. 后端实现
  14. 4.1 Spring Boot 项目结构
  15. 4.2 核心功能实现
  16. 4.2.1 用户认证与授权
  17. 4.2.2 虚拟卡管理
  18. 4.2.3 订单服务
  19. 4.2.4 微信支付集成
  20. 5. 前端实现
  21. 5.1 用户端前端实现
  22. 5.1.1 项目结构
  23. 5.1.2 核心页面实现
  24. 5.2 管理端前端实现
  25. 5.2.1 项目结构
  26. 5.2.2 核心页面实现
  27. 6. 微信 H5 支付集成
  28. 6.1 微信支付配置
  29. 6.2 支付流程实现

更多推荐文章

查看全部
  • ROS1 全局参数:定义、获取与修改实战
  • 从哈希表到 unordered_map 与 unordered_set 的封装过程
  • Android 使用 ViewPager 实现自动无限轮播图
  • Gemini Pro 提示词设计与多模态应用最佳实践
  • LLM 大语言模型入门指南:术语、路径与资源精选
  • Nano Banana 生成中文模糊?用 Seedream 4.5 重新渲染更清晰
  • 基于微信小程序与 SpringBoot 的新农改管理系统设计与实现
  • SpringBoot 统一异常处理
  • VR + 具身智能 + 人形机器人:通往现实世界的智能接口
  • 基于 AI 编程助手的在线图书借阅平台设计与实现
  • 基于 OpenClaw 与 Claude 构建自动化写作工作流实践
  • 智能仿真无人机平台多线程实现:任务分配与碰撞规避
  • macOS 微信多开教程:修改 Bundle Identifier 与重签名
  • 为什么浏览器能跑的请求在 OkHttp 里却挂了?
  • Linux 下调试 C/C++ 程序的核心命令:GDB 基础与进阶
  • 鸿蒙开发:方舟框架自适应布局能力详解
  • Git 远程协作从安装到提交:常见问题的实战解决方案
  • 分布式系统唯一 ID 生成方案技术详解
  • Spring 事务与传播机制详解
  • Python 协程基础与 asyncio 异步编程实战

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online