跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
JavaWeChatPayjava

Web 虚拟卡销售平台架构设计与核心实现

综述由AI生成基于 Spring Boot 和 Vue.js 构建的 Web 虚拟卡销售平台,涵盖前后端分离架构、数据库设计及微信支付集成。后端采用 MyBatis Plus 配合 Redis 缓存优化库存查询,通过 JWT 实现无状态认证。核心流程包括商品上架、订单锁定库存、H5 支付回调处理及异常回滚机制。前端使用 Vant 组件库适配移动端,管理端基于 Element UI 实现可视化运营。系统重点解决了高并发下的库存超卖问题,并确保了支付安全与数据一致性。

微码行者发布于 2026/4/5更新于 2026/6/1115 浏览
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 后端技术栈

主要依赖配置如下,重点引入了 Spring Security 做安全控制,MyBatis Plus 简化 ORM,以及微信支付 SDK。

<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</>
    
    
    
        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
    

artifactId
</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 前端技术栈

管理端和用户端分别初始化,引入对应的 UI 库。

# 管理端前端
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 项目结构

标准的分层架构,Controller 分为用户 API 和管理端接口,Service 层处理业务逻辑,DAO 层对接数据库。

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/      # 实体类
│   │   ├── User.java
│   │   ├── CardProduct.java
│   │   ├── CardSecret.java
│   │   ├── Order.java
│   │   └── Payment.java
│   └── mapper/      # MyBatis Mapper 接口
│       ├── UserMapper.java
│       ├── CardProductMapper.java
│       ├── CardSecretMapper.java
│       ├── OrderMapper.java
│       └── PaymentMapper.java
├── dto/             # 数据传输对象
│   ├── request/     # 请求 DTO
│   │   ├── LoginReq.java
│   │   ├── OrderCreateReq.java
│   │   └── PaymentReq.java
│   └── response/    # 响应 DTO
│       ├── ApiResponse.java
│       ├── CardProductRes.java
│       └── OrderRes.java
├── exception/       # 异常处理
│   ├── BusinessException.java
│   └── GlobalExceptionHandler.java
├── service/         # 服务层
│   ├── impl/        # 服务实现
│   │   ├── AuthServiceImpl.java
│   │   ├── CardServiceImpl.java
│   │   ├── OrderServiceImpl.java
│   │   └── PaymentServiceImpl.java
│   └── AuthService.java
│       ├── CardService.java
│       ├── OrderService.java
│       └── PaymentService.java
├── util/            # 工具类
│   ├── JwtUtil.java
│   ├── RedisUtil.java
│   ├── SnowFlakeUtil.java
│   └── WeChatPayUtil.java
└── VirtualCardApplication.java  # 启动类

4.2 核心功能实现

4.2.1 用户认证与授权

这里使用 Spring Security 配合 JWT 实现无状态认证。注意在配置中关闭 CSRF,因为前后端分离通常不需要 Session 机制。

// SecurityConfig.java
@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 工具类负责生成和验证 Token,注意密钥的安全性,生产环境建议从配置文件或环境变量读取。

// JwtUtil.java
@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 缓存策略,减少数据库压力。库存扣减和卡密锁定是关键步骤,必须保证事务一致性。

// CardServiceImpl.java
@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 订单服务

订单创建时预占库存,支付成功后正式扣减。如果订单取消,需要回滚库存状态。

// OrderServiceImpl.java
@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 接口需要证书和签名验证,这里封装了 HttpClient 和签名校验逻辑。

// WeChatPayUtil.java
@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";
    }
}

Controller 层处理支付回调,务必验证签名防止伪造请求。

// PaymentController.java
@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 项目结构

用户端基于 Vant UI 构建,适配移动端体验。

src/
├── api/           # API 请求
│   ├── auth.js    # 认证相关 API
│   ├── card.js    # 虚拟卡相关 API
│   ├── order.js   # 订单相关 API
│   └── payment.js # 支付相关 API
├── assets/        # 静态资源
│   ├── css/       # 全局样式
│   └── images/    # 图片资源
├── components/    # 公共组件
│   ├── CardItem.vue  # 卡产品项组件
│   ├── Header.vue    # 头部组件
│   ├── Footer.vue    # 底部组件
│   └── Loading.vue   # 加载组件
├── router/        # 路由配置
│   └── index.js     # 路由定义
├── store/         # Vuex 状态管理
│   ├── modules/     # 模块化状态
│   │   ├── auth.js  # 认证模块
│   │   ├── card.js  # 虚拟卡模块
│   │   └── order.js # 订单模块
│   └── index.js     # 主入口
├── utils/         # 工具函数
│   ├── request.js   # axios 封装
│   ├── auth.js      # 认证工具
│   └── wechat.js    # 微信相关工具
├── views/         # 页面组件
│   ├── auth/        # 认证相关页面
│   │   ├── Login.vue  # 登录页
│   │   └── Register.vue # 注册页
│   ├── card/        # 虚拟卡相关页面
│   │   ├── List.vue   # 卡列表页
│   │   └── Detail.vue # 卡详情页
│   ├── order/       # 订单相关页面
│   │   ├── Create.vue # 订单创建页
│   │   ├── Detail.vue # 订单详情页
│   │   └── List.vue   # 订单列表页
│   ├── payment/     # 支付相关页面
│   │   └── Pay.vue    # 支付页
│   ├── Home.vue     # 首页
│   └── User.vue     # 用户中心页
├── App.vue          # 根组件
└── main.js          # 应用入口
5.1.2 核心页面实现

虚拟卡列表页 (Card/List.vue) 使用 Vant 的 List 和 PullRefresh 组件实现下拉刷新和上拉加载更多。

<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) 判断浏览器环境,微信内调起 JSAPI,外部跳转 H5 链接。

<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', // pending, success, failed
      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 作为 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 支付流程实现
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • Stable Diffusion 报错修复:CheckpointLoaderSimple 模型加载失败处理
  • Java 包装类详解:基本类型与引用类型的桥梁
  • Java 后端开发:网络通讯核心协议详解
  • 深入理解 C++ 异常机制
  • 算法专题:模拟算法实战(替换问号、提莫攻击、Z 字形变换等)
  • 工业大模型落地难点分析:数据、算力与行业壁垒
  • 基于 Whisper-large-v3 的短视频配音识别与字幕生成
  • 地瓜机器人 RDK 系列选型指南:X3 vs X5 vs S100 vs S100P
  • Ubuntu 22.04 系统安装 MuJoCo 完整指南
  • AI 开发不仅是调接口:技术深度与工程实践解析
  • OpenClaw 配置 Codex 5.3 个人 AI 编程方案实战
  • 【Project Aria】Meta新一代的AR眼镜及其数据集
  • SkyWalking 与 Spring Cloud Alibaba 全链路追踪实战
  • RabbitMQ 死信队列原理与配置实战
  • Midjourney AI 图像创作完全指南:提示词设计与风格探索
  • 阿里巴巴 Java 开发规约 IDEA 插件安装与使用指南
  • Visual Studio 关闭 Copilot AI 代码提示的方法
  • Go Web 开发核心理论:HTTP、数据库与模板
  • Web 可访问性最佳实践:构建包容平等的网页体验
  • Spring Boot 邮件与消息通知

相关免费在线工具

  • 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