跳到主要内容
Web 虚拟卡销售平台架构设计与实战 | 极客日志
Java SaaS WeChat Pay 大前端 java
Web 虚拟卡销售平台架构设计与实战 本项目构建前后端分离的 Web 虚拟卡销售系统。后端基于 Spring Boot 与 MyBatis Plus,集成微信支付 H5 API 实现支付闭环;前端采用 Vue.js 搭配 Element UI 与 Vant 覆盖管理端与移动端。核心模块包含商品库存锁定、订单状态流转及安全鉴权,数据库设计兼顾事务一致性与查询效率,适用于中小型电商场景快速落地。
极客零度 发布于 2026/4/8 更新于 2026/4/25 1 浏览项目概述
在数字经济背景下,虚拟卡(如礼品卡、会员卡、游戏点卡等)市场需求激增。本项目旨在构建一个完整的 Web 虚拟卡销售平台,涵盖前端销售系统、后端管理系统以及移动端 H5 支付功能。技术选型上,后端采用 Java Spring Boot,前端使用 Vue.js,并深度集成微信支付能力。
系统整体采用前后端分离架构:
前端 :管理端基于 Vue.js + Element UI,用户端基于 Vant 适配移动端。
后端 :Spring Boot + Spring Security + MyBatis Plus。
数据存储 :MySQL 持久化,Redis 缓存热点数据。
支付 :微信支付 H5 API。
技术选型与环境搭建
后端核心依赖
我们选择了成熟的生态组合来保证开发效率与系统稳定性。pom.xml 中主要包含以下关键依赖:
<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
<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 >
前端工程初始化 管理端和用户端分开构建,便于维护。管理端侧重后台操作效率,用户端侧重移动端体验。
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
开发环境配置
JDK 1.8+
Maven 3.6+
Node.js 14+
MySQL 5.7+
Redis 5.0+
IDE 推荐:IntelliJ IDEA + VS Code
数据库设计
核心实体 主要涉及用户 (User)、虚拟卡产品 (CardProduct)、卡密库存 (CardSecret)、订单 (Order)、支付记录 (Payment) 及管理员 (Admin)。
表结构定义 以下是关键表的 SQL 定义,注意字段注释和索引优化对查询性能的影响。
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= '系统日志表' ;
后端实现
项目结构规范 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/
│ └── mapper/
├── dto/
│ ├── request/
│ └── response/
├── exception/
├── service/
│ ├── impl/
│ └── ...
├── util/
│ ├── JwtUtil.java
│ ├── RedisUtil.java
│ ├── SnowFlakeUtil.java
│ └── WeChatPayUtil.java
└── VirtualCardApplication.java
核心功能实现
用户认证与授权 安全是电商系统的基石。这里使用 Spring Security 配合 JWT 实现无状态认证。JWT 的过期时间设置为 24 小时,密钥需在生产环境中替换为强随机字符串。
@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,注意 SECRET 必须保密。
@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();
}
}
虚拟卡管理 库存扣减是并发场景下的难点。这里利用 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 ;
@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;
}
}
订单服务 订单创建流程包括生成单号、锁定库存、计算金额、插入订单及明细。这里使用了雪花算法生成全局唯一的订单号。
@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;
}
}
微信支付集成 支付环节最需要注意签名验证和幂等性处理。V3 版本接口推荐使用证书方式验证签名,确保回调数据的真实性。
@Component
public class WeChatPayUtil {
@Value("${wechat.pay.appid}")
private String appId;
@Value("${wechat.pay.mchid}")
private String mchId;
@Value("${wechat.pay.apikey}")
private String apiKey;
@Value("${wechat.pay.serialNo}")
private String serialNo;
@Value("${wechat.pay.privateKey}")
private String privateKey;
@Value("${wechat.pay.notifyUrl}")
private String notifyUrl;
private CloseableHttpClient httpClient;
@PostConstruct
public void init () {
PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(new ByteArrayInputStream (privateKey.getBytes()));
httpClient = 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();
}
}
前端实现
用户端前端实现 用户端主要面向移动端,使用 Vant UI 库提升交互体验。
项目结构 src/
├── api/ # API 请求
│ ├── auth.js
│ ├── card.js
│ ├── order.js
│ └── payment.js
├── assets/ # 静态资源
├── components/ # 公共组件
├── router/ # 路由配置
├── store/ # Vuex 状态管理
├── utils/ # 工具函数
├── views/ # 页面组件
│ ├── auth/
│ ├── card/
│ ├── order/
│ ├── payment/
│ ├── Home.vue
│ └── User.vue
├── App.vue
└── main.js
核心页面实现 虚拟卡列表页 支持搜索、分类筛选和下拉刷新,这是用户浏览商品的第一入口。
<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 调起和 JSAPI 支付逻辑,兼容不同浏览器环境。
<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,模块包括管理员登录、虚拟卡产品管理、卡密库存管理、订单管理及数据统计。
核心页面实现 虚拟卡产品管理页 实现了复杂的搜索过滤、批量操作和图片上传功能。
<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 支付集成
微信支付配置
申请微信支付商户号 :登录微信支付商户平台,完成资质认证。
配置支付域名 :需在商户平台配置支付域名(需备案),并设置授权目录和回调域名。
获取 API 密钥和证书 :设置 APIv2 密钥(32 位),申请 API 证书(用于 V3 接口)。
配置应用信息 :在商户平台配置 H5 支付信息,设置支付场景和域名。
支付流程实现 前端需判断当前是否在微信浏览器内,以决定使用 JSAPI 还是 H5 链接唤起支付。
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" ;
}
}
}
其他业务逻辑可根据实际需求扩展,例如增加退款接口、对账功能等。
相关免费在线工具 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