跳到主要内容 Web 虚拟卡销售店铺实现方案 | 极客日志
Java WeChat Pay 大前端 java
Web 虚拟卡销售店铺实现方案 一个基于 Spring Boot 和 Vue.js 的 Web 虚拟卡销售平台实现方案。系统采用前后端分离架构,后端使用 Spring Security 进行认证授权,MyBatis Plus 操作数据库,Redis 缓存产品列表。前端分为用户端和管理端,分别使用 Vant 和 Element UI。核心功能包括虚拟卡商品管理、订单处理、库存锁定及微信支付 H5 集成。文章详细展示了数据库表设计、后端服务逻辑(如订单创建、支付回调验证)以及前端页面组件实现,为构建类似的电商交易系统提供参考。
协议工匠 发布于 2026/4/5 更新于 2026/4/18 13 浏览1. 项目概述
1.1 项目背景
随着数字经济的发展,虚拟卡 (如礼品卡、会员卡、游戏点卡等) 的市场需求日益增长。本项目旨在构建一个完整的 Web 虚拟卡销售平台,包含前端销售系统、后端管理系统和移动端 H5 支付功能,采用 Java 作为后端技术栈,Vue.js 作为前端框架,并集成微信支付功能。
1.2 系统架构
系统采用前后端分离架构:
前端:Vue.js + Element UI (管理端) + Vant (移动端)
后端:Spring Boot + Spring Security + MyBatis Plus
数据库:MySQL
缓存:Redis
支付:微信支付 H5 API
2. 技术选型与环境搭建
2.1 后端技术栈
<dependencies >
<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
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 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
<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 >
<dependency >
<groupId >
</groupId >
<artifactId >
</artifactId >
<version >
</version >
</dependency >
<dependency >
<groupId >
</groupId >
<artifactId >
</artifactId >
<optional >
</optional >
</dependency >
</dependencies >
2.2 前端技术栈
vue create admin-frontend
cd admin-frontend
vue add element-ui
npm install axios vue-router vuex --save
vue create user-frontend
cd user-frontend
npm install vant axios vue-router vuex --save
2.3 开发环境配置
JDK 1.8+
Maven 3.6+
Node.js 14+
MySQL 5.7+
Redis 5.0+
IDE 推荐:IntelliJ IDEA + VS Code
3. 数据库设计
3.1 数据库 ER 图
用户 (User)
虚拟卡产品 (CardProduct)
卡密库存 (CardSecret)
订单 (Order)
支付记录 (Payment)
管理员 (Admin)
3.2 数据表设计
CREATE TABLE `user ` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`username` varchar (50 ) NOT NULL COMMENT '用户名' ,
`password` varchar (100 ) NOT NULL COMMENT '密码' ,
`email` varchar (100 ) DEFAULT NULL COMMENT '邮箱' ,
`phone` varchar (20 ) DEFAULT NULL COMMENT '手机号' ,
`avatar` varchar (255 ) DEFAULT NULL COMMENT '头像' ,
`status` tinyint(1 ) DEFAULT '1' COMMENT '状态:0-禁用,1-正常' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`),
KEY `idx_phone` (`phone`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '用户表' ;
CREATE TABLE `card_product` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`name` varchar (100 ) NOT NULL COMMENT '产品名称' ,
`category_id` bigint (20 ) NOT NULL COMMENT '分类 ID' ,
`description` text COMMENT '产品描述' ,
`price` decimal (10 ,2 ) NOT NULL COMMENT '售价' ,
`original_price` decimal (10 ,2 ) DEFAULT NULL COMMENT '原价' ,
`stock` int (11 ) NOT NULL DEFAULT '0' COMMENT '库存' ,
`image_url` varchar (255 ) DEFAULT NULL COMMENT '图片 URL' ,
`detail_images` text COMMENT '详情图片,JSON 数组' ,
`status` tinyint(1 ) DEFAULT '1' COMMENT '状态:0-下架,1-上架' ,
`sort_order` int (11 ) DEFAULT '0' COMMENT '排序权重' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ,
PRIMARY KEY (`id`),
KEY `idx_category` (`category_id`),
KEY `idx_status` (`status`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '虚拟卡产品表' ;
CREATE TABLE `card_secret` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`product_id` bigint (20 ) NOT NULL COMMENT '产品 ID' ,
`card_no` varchar (100 ) NOT NULL COMMENT '卡号' ,
`card_password` varchar (100 ) NOT NULL COMMENT '卡密' ,
`status` tinyint(1 ) DEFAULT '0' COMMENT '状态:0-未售出,1-已售出,2-已锁定' ,
`order_id` bigint (20 ) DEFAULT NULL COMMENT '订单 ID' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_card_no` (`card_no`),
KEY `idx_product_id` (`product_id`),
KEY `idx_status` (`status`),
KEY `idx_order_id` (`order_id`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '卡密库存表' ;
CREATE TABLE `order ` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`order_no` varchar (50 ) NOT NULL COMMENT '订单编号' ,
`user_id` bigint (20 ) NOT NULL COMMENT '用户 ID' ,
`total_amount` decimal (10 ,2 ) NOT NULL COMMENT '订单总金额' ,
`payment_amount` decimal (10 ,2 ) NOT NULL COMMENT '实付金额' ,
`payment_type` tinyint(1 ) DEFAULT NULL COMMENT '支付方式:1-微信,2-支付宝' ,
`status` tinyint(1 ) DEFAULT '0' COMMENT '订单状态:0-待支付,1-已支付,2-已发货,3-已完成,4-已取消' ,
`payment_time` datetime DEFAULT NULL COMMENT '支付时间' ,
`complete_time` datetime DEFAULT NULL COMMENT '完成时间' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '订单表' ;
CREATE TABLE `order_item` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`order_id` bigint (20 ) NOT NULL COMMENT '订单 ID' ,
`order_no` varchar (50 ) NOT NULL COMMENT '订单编号' ,
`product_id` bigint (20 ) NOT NULL COMMENT '产品 ID' ,
`product_name` varchar (100 ) NOT NULL COMMENT '产品名称' ,
`product_image` varchar (255 ) DEFAULT NULL COMMENT '产品图片' ,
`quantity` int (11 ) NOT NULL COMMENT '购买数量' ,
`price` decimal (10 ,2 ) NOT NULL COMMENT '单价' ,
`total_price` decimal (10 ,2 ) NOT NULL COMMENT '总价' ,
`card_secret_id` bigint (20 ) DEFAULT NULL COMMENT '卡密 ID' ,
`card_no` varchar (100 ) DEFAULT NULL COMMENT '卡号' ,
`card_password` varchar (100 ) DEFAULT NULL COMMENT '卡密' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_product_id` (`product_id`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '订单明细表' ;
CREATE TABLE `payment` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`order_id` bigint (20 ) NOT NULL COMMENT '订单 ID' ,
`order_no` varchar (50 ) NOT NULL COMMENT '订单编号' ,
`payment_no` varchar (50 ) NOT NULL COMMENT '支付流水号' ,
`payment_type` tinyint(1 ) NOT NULL COMMENT '支付方式:1-微信,2-支付宝' ,
`payment_amount` decimal (10 ,2 ) NOT NULL COMMENT '支付金额' ,
`payment_status` tinyint(1 ) DEFAULT '0' COMMENT '支付状态:0-未支付,1-支付成功,2-支付失败' ,
`payment_time` datetime DEFAULT NULL COMMENT '支付时间' ,
`callback_time` datetime DEFAULT NULL COMMENT '回调时间' ,
`callback_content` text COMMENT '回调内容' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_payment_no` (`payment_no`),
KEY `idx_order_id` (`order_id`),
KEY `idx_order_no` (`order_no`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '支付记录表' ;
CREATE TABLE `admin` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`username` varchar (50 ) NOT NULL COMMENT '用户名' ,
`password` varchar (100 ) NOT NULL COMMENT '密码' ,
`nickname` varchar (50 ) DEFAULT NULL COMMENT '昵称' ,
`avatar` varchar (255 ) DEFAULT NULL COMMENT '头像' ,
`email` varchar (100 ) DEFAULT NULL COMMENT '邮箱' ,
`phone` varchar (20 ) DEFAULT NULL COMMENT '手机号' ,
`status` tinyint(1 ) DEFAULT '1' COMMENT '状态:0-禁用,1-正常' ,
`last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '管理员表' ;
CREATE TABLE `sys_log` (
`id` bigint (20 ) NOT NULL AUTO_INCREMENT,
`user_id` bigint (20 ) DEFAULT NULL COMMENT '用户 ID' ,
`username` varchar (50 ) DEFAULT NULL COMMENT '用户名' ,
`operation` varchar (50 ) DEFAULT NULL COMMENT '用户操作' ,
`method ` varchar (200 ) DEFAULT NULL COMMENT '请求方法' ,
`params` text COMMENT '请求参数' ,
`time ` bigint (20 ) DEFAULT NULL COMMENT '执行时长 (毫秒)' ,
`ip` varchar (64 ) DEFAULT NULL COMMENT 'IP 地址' ,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ,
PRIMARY KEY (`id`)
) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '系统日志表' ;
4. 后端实现
4.1 Spring Boot 项目结构 src/main/java/com/virtualcard/
├── config/
│ ├── SecurityConfig.java
│ ├── SwaggerConfig.java
│ ├── RedisConfig.java
│ └── WebMvcConfig.java
├── constant/
│ ├── OrderStatus.java
│ ├── PaymentType.java
│ └── RedisKey.java
├── controller/
│ ├── api/
│ │ ├── 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/
│ ├── UserMapper.java
│ ├── CardProductMapper.java
│ ├── CardSecretMapper.java
│ ├── OrderMapper.java
│ └── PaymentMapper.java
├── dto/
│ ├── request/
│ │ ├── LoginReq.java
│ │ ├── OrderCreateReq.java
│ │ └── PaymentReq.java
│ └── response/
│ ├── 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 用户认证与授权
@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();
}
}
@Component
public class JwtUtil {
private static final String SECRET = "your_jwt_secret" ;
private static final long EXPIRATION = 86400L ;
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 虚拟卡管理
@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 ;
@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 ;
}
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 订单服务
@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);
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);
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 微信支付集成
@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 = WechatPayHttpClientBuilder.create()
.withMerchant(mchId, serialNo, merchantPrivateKey)
.withValidator(new WechatPay2Validator (apiKey.getBytes()))
.build();
}
public Map<String, String> createH5Payment (String orderNo, BigDecimal amount, String description, String clientIp) throws Exception {
Map<String, Object> params = new HashMap <>();
params.put("appid" , appId);
params.put("mchid" , mchId);
params.put("description" , description);
params.put("out_trade_no" , orderNo);
params.put("notify_url" , notifyUrl);
params.put("amount" , new HashMap <String, Object>() {{
put("total" , amount.multiply(new BigDecimal (100 )).intValue());
put("currency" , "CNY" );
}});
params.put("scene_info" , new HashMap <String, Object>() {{
put("payer_client_ip" , clientIp);
put("h5_info" , new HashMap <String, Object>() {{
put("type" , "Wap" );
}});
}});
HttpPost httpPost = new HttpPost ("https://api.mch.weixin.qq.com/v3/pay/transactions/h5" );
httpPost.addHeader("Accept" , "application/json" );
httpPost.addHeader("Content-type" , "application/json" );
httpPost.setEntity(new StringEntity (JSON.toJSONString(params), "UTF-8" ));
CloseableHttpResponse response = httpClient.execute(httpPost);
try {
String responseBody = EntityUtils.toString(response.getEntity());
if (response.getStatusLine().getStatusCode() == 200 ) {
Map<String, String> result = new HashMap <>();
JSONObject json = JSON.parseObject(responseBody);
result.put("h5_url" , json.getString("h5_url" ));
result.put("prepay_id" , json.getString("prepay_id" ));
return result;
} else {
throw new BusinessException ("微信支付创建失败:" + responseBody);
}
} finally {
response.close();
}
}
public boolean verifyNotify (Map<String, String> params, String signature, String serial, String nonce, String timestamp, String body) {
try {
String message = timestamp + "\n" + nonce + "\n" + body + "\n" ;
boolean verifyResult = verifySignature(message.getBytes("utf-8" ), serial, signature.getBytes("utf-8" ));
if (!verifyResult) {
return false ;
}
JSONObject json = JSON.parseObject(body);
String orderNo = json.getJSONObject("resource" ).getString("out_trade_no" );
String tradeState = json.getJSONObject("resource" ).getString("trade_state" );
return "SUCCESS" .equals(tradeState);
} catch (Exception e) {
return false ;
}
}
private boolean verifySignature (byte [] message, String serial, byte [] signature) {
try {
String cert = getWechatPayCert(serial);
if (cert == null ) {
return false ;
}
X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec (Base64.getDecoder().decode(cert));
KeyFactory keyFactory = KeyFactory.getInstance("RSA" );
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
Signature sign = Signature.getInstance("SHA256withRSA" );
sign.initVerify(publicKey);
sign.update(message);
return sign.verify(signature);
} catch (Exception e) {
return false ;
}
}
private String getWechatPayCert (String serial) {
return "your_wechat_pay_cert_content" ;
}
}
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
@Autowired
private OrderService orderService;
@Autowired
private PaymentService paymentService;
@Autowired
private WeChatPayUtil weChatPayUtil;
@PostMapping("/create")
public ApiResponse<Map<String, String>> createPayment (@RequestBody PaymentReq req, HttpServletRequest request) {
Order order = orderService.getOrderByNo(req.getOrderNo());
if (order == null ) {
return ApiResponse.fail("订单不存在" );
}
if (order.getStatus() != OrderStatus.UNPAID.getCode()) {
return ApiResponse.fail("订单状态不正确" );
}
try {
Map<String, String> result = weChatPayUtil.createH5Payment(
order.getOrderNo(),
order.getPaymentAmount(),
"虚拟卡购买 -" + order.getOrderNo(),
getClientIp(request)
);
Payment payment = new Payment ();
payment.setOrderId(order.getId());
payment.setOrderNo(order.getOrderNo());
payment.setPaymentNo(result.get("prepay_id" ));
payment.setPaymentType(PaymentType.WECHAT.getCode());
payment.setPaymentAmount(order.getPaymentAmount());
payment.setPaymentStatus(0 );
paymentService.createPayment(payment);
return ApiResponse.success(result);
} catch (Exception e) {
return ApiResponse.fail("支付创建失败:" + e.getMessage());
}
}
@PostMapping("/callback/wechat")
public String wechatPayCallback (HttpServletRequest request) {
try {
String signature = request.getHeader("Wechatpay-Signature" );
String serial = request.getHeader("Wechatpay-Serial" );
String nonce = request.getHeader("Wechatpay-Nonce" );
String timestamp = request.getHeader("Wechatpay-Timestamp" );
String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
if (!weChatPayUtil.verifyNotify(null , signature, serial, nonce, timestamp, body)) {
return "FAIL" ;
}
JSONObject json = JSON.parseObject(body);
JSONObject resource = json.getJSONObject("resource" );
String orderNo = resource.getString("out_trade_no" );
String transactionId = resource.getString("transaction_id" );
BigDecimal amount = resource.getJSONObject("amount" ).getBigDecimal("total" ).divide(new BigDecimal (100 ));
Date paymentTime = new Date (resource.getLong("success_time" ) * 1000 );
orderService.payOrderSuccess(orderNo, transactionId, amount, paymentTime);
return "SUCCESS" ;
} catch (Exception e) {
return "FAIL" ;
}
}
private String getClientIp (HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For" );
if (StringUtils.isNotEmpty(ip) && !"unKnown" .equalsIgnoreCase(ip)) {
int index = ip.indexOf("," );
if (index != -1 ) {
return ip.substring(0 , index);
} else {
return ip;
}
}
ip = request.getHeader("X-Real-IP" );
if (StringUtils.isNotEmpty(ip) && !"unKnown" .equalsIgnoreCase(ip)) {
return ip;
}
return request.getRemoteAddr();
}
}
5. 前端实现
5.1 用户端前端实现
5.1.1 项目结构 src/
├── api/
│ ├── auth.js
│ ├── card.js
│ ├── order.js
│ └── payment.js
├── assets/
│ ├── css/
│ └── images/
├── components/
│ ├── CardItem.vue
│ ├── Header.vue
│ ├── Footer.vue
│ └── Loading.vue
├── router/
│ └── index.js
├── store/
│ ├── modules/
│ │ ├── auth.js
│ │ ├── card.js
│ │ └── order.js
│ └── index.js
├── utils/
│ ├── request.js
│ ├── 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 核心页面实现 <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>
<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 核心页面实现 <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 微信支付配置
申请微信支付商户号
配置支付域名
在商户平台配置支付域名 (需备案)
配置授权目录和回调域名
获取 API 密钥和证书
设置 APIv2 密钥 (32 位)
申请 API 证书 (用于 V3 接口)
配置应用信息
在商户平台配置 H5 支付信息
设置支付场景和域名
6.2 支付流程实现
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 || '支付失败' ));
}
});
}
});
}
@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" ;
}
}
}