跳到主要内容
极客日志极客日志
首页博客AI提示词GitHub精选代理工具
搜索
|注册
博客列表
JavaWeChatPay大前端java

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

基于 Spring Boot 与 Vue.js 构建的 Web 虚拟卡销售平台,涵盖前后端分离架构、数据库设计、订单流程及微信支付集成。核心包括用户认证、库存锁定机制、高并发下的卡密管理及支付回调安全验证。后端采用 Spring Security+JWT 进行权限控制,MyBatis Plus 操作数据库,Redis 缓存热点数据;前端使用 Vant 和 Element UI 分别构建移动端与管理端界面。支付环节对接微信支付 V3 接口,实现 H5 支付与 JSAPI 支付兼容,确保交易数据一致性与安全性。

RedisGeek发布于 2025/11/22更新于 2026/4/231 浏览
基于 Spring Boot 与 Vue 的 Web 虚拟卡销售平台实战

项目概述

随着数字经济的发展,虚拟卡(如礼品卡、会员卡、游戏点卡等)的市场需求日益增长。本项目旨在构建一个完整的 Web 虚拟卡销售平台,包含前端销售系统、后端管理系统和移动端 H5 支付功能。

系统采用前后端分离架构:

  • 前端:Vue.js + Element UI (管理端) + Vant (移动端)
  • 后端:Spring Boot + Spring Security + MyBatis Plus
  • 数据库:MySQL
  • 缓存:Redis
  • 支付:微信支付 H5 API

技术选型与环境搭建

后端技术栈

核心依赖配置如下,重点在于安全认证、数据库操作及微信支付的集成:

<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>
    </dependency>
    <!-- 数据库相关 -->
    
        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>
<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>

前端技术栈

管理端和用户端分别初始化,并安装必要的 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

开发环境配置

  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

数据库设计

数据表设计

主要涉及用户、产品、库存、订单、支付记录及管理员表。以下是关键表的建表语句,注意字段注释和索引优化:

-- 用户表
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='系统日志表';

后端实现

Spring Boot 项目结构

标准的分层架构,便于维护:

src/main/java/com/virtualcard/
 ├── config/          # 配置类 (Security, Swagger, Redis, WebMvc)
 ├── constant/        # 常量类 (OrderStatus, PaymentType, RedisKey)
 ├── controller/      # 控制器 (api/ 用户接口,admin/ 管理接口)
 ├── dao/             # 数据访问层 (entity, mapper)
 ├── dto/             # 数据传输对象 (request, response)
 ├── exception/       # 异常处理 (BusinessException, GlobalExceptionHandler)
 ├── service/         # 服务层 (impl, interface)
 ├── util/            # 工具类 (JwtUtil, RedisUtil, SnowFlakeUtil, WeChatPayUtil)
 └── VirtualCardApplication.java # 启动类

核心功能实现

用户认证与授权

使用 Spring Security 配合 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 工具类负责生成和验证 Token,注意密钥的安全性,不要硬编码在代码中,建议从配置文件读取。

@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();
    }
}
虚拟卡管理

库存扣减是核心难点。这里采用了乐观锁或事务内查询的方式,先锁定卡密再更新库存,防止超卖。同时利用 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;
    }
}
订单服务

订单创建需要原子性地完成多个步骤:生成单号、锁定库存、创建订单主表和明细表。如果中间任何一步失败,事务回滚能保证数据一致性。

@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;
    }
}
微信支付集成

V3 版本支付流程相对复杂,涉及到证书签名验证。这里封装了 WeChatPayUtil 来处理 HTTP 请求和签名校验。

@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 层负责接收前端请求并调用 Service,同时处理回调逻辑:

@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();
    }
}

前端实现

用户端前端实现

项目结构

用户端主要面向 C 端消费者,强调交互体验:

src/
 ├── api/           # API 请求 (auth, card, order, payment)
 ├── assets/        # 静态资源
 ├── components/    # 公共组件 (CardItem, Header, Footer)
 ├── router/        # 路由配置
 ├── store/         # Vuex 状态管理
 ├── utils/         # 工具函数 (axios 封装,微信相关)
 ├── views/         # 页面组件 (Home, Card, Order, Payment)
 ├── App.vue
 └── main.js
核心页面实现

虚拟卡列表页使用了 Vant 的 van-list 实现下拉加载更多,结合 van-pull-refresh 支持刷新。搜索和分类切换时重置分页状态。

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

订单创建页整合了地址选择、商品确认和金额计算。提交订单后直接跳转支付页。

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

微信支付页区分了微信浏览器内和非微信浏览器的支付场景。非微信环境下通过 H5 URL 唤起,并开启轮询检查支付状态。

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

管理端前端实现

项目结构

管理端结构与用户端类似,但使用 Element UI 作为 UI 框架,主要包含以下功能模块:

  • 管理员登录
  • 虚拟卡产品管理
  • 卡密库存管理
  • 订单管理
  • 用户管理
  • 数据统计
核心页面实现

虚拟卡产品管理页实现了增删改查及批量操作,图片上传使用了 Element UI 的 el-upload 组件。

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

微信 H5 支付集成

微信支付配置

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

支付流程实现

  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. 项目概述
  2. 技术选型与环境搭建
  3. 后端技术栈
  4. 前端技术栈
  5. 管理端前端
  6. 用户端前端
  7. 开发环境配置
  8. 数据库设计
  9. 数据表设计
  10. 后端实现
  11. Spring Boot 项目结构
  12. 核心功能实现
  13. 用户认证与授权
  14. 虚拟卡管理
  15. 订单服务
  16. 微信支付集成
  17. 前端实现
  18. 用户端前端实现
  19. 项目结构
  20. 核心页面实现
  21. 管理端前端实现
  22. 项目结构
  23. 核心页面实现
  24. 微信 H5 支付集成
  25. 微信支付配置
  26. 支付流程实现
  • 💰 8折买阿里云服务器限时8折了解详情
  • 💰 8折买阿里云服务器限时8折购买
  • 🦞 5分钟部署阿里云小龙虾了解详情
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

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

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

更多推荐文章

查看全部
  • H5 本地存储:localStorage 与 sessionStorage 用法及区别
  • 互联网后端开发核心面试题精选(MySQL/Java/Spring)
  • Java Math 类核心方法与实战应用
  • Linux 命令行趣味工具集锦
  • Kubernetes 核心技术与实践文章精选
  • Hadoop 集群启动常见异常排查与解决
  • 三层交换机实现 VLAN 间路由的配置指南
  • 基于 Docker 部署 FRP 实现内网穿透及配置详解
  • Java 类生命周期详解:从加载到卸载的七个阶段
  • Hibernate 集合映射实战:Set、List、Bag 与 Map 配置详解
  • ExtJS Grid 自定义行色时隔行变色失效的解决方案
  • ELK 日志分析方案为何如此火热?
  • Vue3 History 模式部署报错:Unexpected Token 问题排查
  • 分库分表无法解决无限扩容?聊聊单元化架构
  • C/C++ 基础:深入理解静态成员函数
  • Stability.ai 发布 Stable Video,免费文生视频工具上线
  • 国家精品公开课:Python 网络爬虫与数据分析实战
  • PAT 乙级 1043 输出 PATest 题目解析(Python 版)
  • Django Markdown 渲染中的 XSS 防护实践
  • HTTP 身份认证机制详解:Basic、Digest 与表单认证

相关免费在线工具

  • 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