1. 项目概述
阳光音乐厅订票系统是一个基于Spring Boot和Vue.js的现代化票务管理平台。作为一名参与过多个类似项目的开发者,我深知传统票务系统存在的痛点:线下排队效率低、票务信息更新不及时、用户体验差等问题。这个项目正是为了解决这些问题而设计的。
系统采用前后端分离架构,后端使用Spring Boot提供RESTful API,前端采用Vue.js实现响应式界面,数据库使用MySQL存储核心数据,并通过Redis缓存热门演出信息。这种架构选择在当前企业级应用中非常普遍,因为它能很好地平衡开发效率、系统性能和可维护性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统架构设计
2.1 技术栈选型
后端技术栈:
- Spring Boot 2.7.x:提供快速开发能力和丰富的starter依赖
- Spring Security:处理用户认证和授权
- MyBatis-Plus:简化数据库操作
- Redis:缓存热门演出数据
- Swagger:API文档生成
选择Spring Boot是因为它的自动配置和约定优于配置的原则可以显著提高开发效率。Spring Security提供了完善的认证授权机制,MyBatis-Plus则简化了CRUD操作。
前端技术栈:
- Vue.js 3.x:响应式前端框架
- Element Plus:UI组件库
- Axios:HTTP客户端
- Vue Router:前端路由管理
- Vuex/Pinia:状态管理
Vue.js的渐进式特性使其非常适合这种中等复杂度的管理系统,Element Plus提供了丰富的现成组件,可以快速构建美观的界面。
2.2 系统架构图
系统采用典型的三层架构:
- 表现层:Vue前端应用
- 业务逻辑层:Spring Boot后端服务
- 数据访问层:MySQL+Redis
前端与后端通过RESTful API通信,使用JWT进行身份验证。这种架构的优点是前后端可以独立开发和部署,提高了团队的并行开发效率。
3. 数据库设计
3.1 核心表结构
演出信息表(performance)
sql复制CREATE TABLE `performance` (
`performance_id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(100) NOT NULL COMMENT '演出名称',
`show_time` datetime NOT NULL COMMENT '演出时间',
`venue` varchar(50) NOT NULL COMMENT '演出场地',
`price_range` varchar(20) NOT NULL COMMENT '票价区间',
`cover_url` varchar(255) DEFAULT NULL COMMENT '封面图片URL',
`status` tinyint DEFAULT '1' COMMENT '1-未开始 2-已结束',
`description` text COMMENT '演出详情',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`performance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
用户表(user)
sql复制CREATE TABLE `user` (
`user_id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(30) NOT NULL COMMENT '用户名',
`encrypted_pwd` varchar(100) NOT NULL COMMENT '加密密码',
`phone` varchar(15) NOT NULL COMMENT '手机号',
`last_login` datetime DEFAULT NULL COMMENT '最后登录时间',
`avatar_url` varchar(255) DEFAULT NULL COMMENT '头像URL',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`),
UNIQUE KEY `idx_username` (`username`),
UNIQUE KEY `idx_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单表(order)
sql复制CREATE TABLE `order` (
`order_id` varchar(32) NOT NULL COMMENT '订单号',
`user_id` bigint NOT NULL COMMENT '用户ID',
`performance_id` bigint NOT NULL COMMENT '演出ID',
`seat_info` json NOT NULL COMMENT '座位信息',
`total_amount` decimal(10,2) NOT NULL COMMENT '总金额',
`pay_status` tinyint DEFAULT '0' COMMENT '0-未支付 1-已支付',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`pay_method` tinyint DEFAULT NULL COMMENT '1-微信 2-支付宝',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_performance_id` (`performance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.2 设计考虑
-
性能优化:
- 为常用查询字段添加索引
- 使用JSON类型存储座位信息,便于灵活处理不同场地的座位布局
- 添加create_time和update_time便于数据追踪
-
安全考虑:
- 密码使用BCrypt加密存储
- 敏感字段如手机号可以考虑加密存储
- 订单号使用特定规则生成,避免连续可猜测
-
扩展性:
- 表结构设计考虑了未来可能的字段扩展
- 使用utf8mb4字符集支持emoji等特殊字符
4. 后端实现
4.1 Spring Boot配置
核心依赖:
xml复制<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<!-- Other -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.6.12</version>
</dependency>
</dependencies>
4.2 核心功能实现
4.2.1 JWT认证实现
java复制@Component
public class JwtTokenProvider {
private final String secretKey = "your-secret-key";
private final long validityInMilliseconds = 3600000; // 1h
public String createToken(String username, List<String> roles) {
Claims claims = Jwts.claims().setSubject(username);
claims.put("roles", roles);
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(getUsername(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (JwtException | IllegalArgumentException e) {
throw new InvalidJwtAuthenticationException("Expired or invalid JWT token");
}
}
}
4.2.2 演出服务实现
java复制@Service
@RequiredArgsConstructor
public class PerformanceServiceImpl implements PerformanceService {
private final PerformanceMapper performanceMapper;
private final RedisTemplate<String, Object> redisTemplate;
private static final String CACHE_KEY = "performance:hot";
private static final long CACHE_EXPIRE = 3600; // 1小时
@Override
@Cacheable(value = "performances", key = "#performanceId")
public Performance getPerformanceById(Long performanceId) {
return performanceMapper.selectById(performanceId);
}
@Override
public List<Performance> getHotPerformances() {
// 先查缓存
List<Performance> cached = (List<Performance>) redisTemplate.opsForValue().get(CACHE_KEY);
if (cached != null) {
return cached;
}
// 缓存没有则查数据库
List<Performance> performances = performanceMapper.selectList(
new QueryWrapper<Performance>()
.eq("status", 1)
.orderByDesc("create_time")
.last("limit 10")
);
// 写入缓存
redisTemplate.opsForValue().set(CACHE_KEY, performances, CACHE_EXPIRE, TimeUnit.SECONDS);
return performances;
}
@Override
@Transactional
public boolean createPerformance(Performance performance) {
int result = performanceMapper.insert(performance);
if (result > 0) {
// 清除热门演出缓存
redisTemplate.delete(CACHE_KEY);
return true;
}
return false;
}
}
4.3 支付接口集成
系统集成了微信支付和支付宝支付,以下是支付服务的核心逻辑:
java复制@Service
public class PaymentServiceImpl implements PaymentService {
private final OrderMapper orderMapper;
private final WechatPayConfig wechatPayConfig;
private final AlipayConfig alipayConfig;
@Override
public PaymentResponse createPayment(Order order, Integer payType) {
switch (payType) {
case 1: // 微信支付
return createWechatPayment(order);
case 2: // 支付宝
return createAlipayPayment(order);
default:
throw new IllegalArgumentException("不支持的支付类型");
}
}
private PaymentResponse createWechatPayment(Order order) {
// 构建微信支付请求
Map<String, String> params = new HashMap<>();
params.put("appid", wechatPayConfig.getAppId());
params.put("mch_id", wechatPayConfig.getMchId());
params.put("nonce_str", generateNonceStr());
params.put("body", "阳光音乐厅演出票-" + order.getPerformanceId());
params.put("out_trade_no", order.getOrderId());
params.put("total_fee", String.valueOf(order.getTotalAmount().multiply(new BigDecimal("100")).intValue()));
params.put("spbill_create_ip", "127.0.0.1");
params.put("notify_url", wechatPayConfig.getNotifyUrl());
params.put("trade_type", "JSAPI");
params.put("openid", order.getUser().getWechatOpenid());
// 签名并发送请求
String sign = generateWechatSign(params);
params.put("sign", sign);
// 调用微信支付API并返回支付参数
// 实际项目中这里应该是HTTP请求到微信服务器
return new PaymentResponse();
}
private String generateWechatSign(Map<String, String> params) {
// 实现签名逻辑
return "mock-sign";
}
}
5. 前端实现
5.1 Vue项目结构
code复制src/
├── api/ # API请求
├── assets/ # 静态资源
├── components/ # 公共组件
│ ├── PerformanceCard.vue
│ ├── SeatSelector.vue
│ └── PaymentDialog.vue
├── router/ # 路由配置
├── store/ # 状态管理
├── utils/ # 工具函数
├── views/ # 页面组件
│ ├── Home.vue # 首页
│ ├── Performance/ # 演出相关
│ ├── Order/ # 订单相关
│ └── User/ # 用户中心
└── App.vue # 根组件
5.2 核心组件实现
5.2.1 演出列表组件
vue复制<template>
<div class="performance-list">
<el-row :gutter="20">
<el-col
v-for="performance in performances"
:key="performance.performance_id"
:xs="24" :sm="12" :md="8" :lg="6"
>
<performance-card :performance="performance" @click="viewDetail(performance)"/>
</el-col>
</el-row>
<el-pagination
v-model="pagination.current"
:page-size="pagination.size"
:total="pagination.total"
@current-change="handlePageChange"
layout="prev, pager, next"
/>
</div>
</template>
<script>
import { getPerformanceList } from '@/api/performance'
import PerformanceCard from '@/components/PerformanceCard'
export default {
components: { PerformanceCard },
data() {
return {
performances: [],
pagination: {
current: 1,
size: 12,
total: 0
}
}
},
created() {
this.fetchData()
},
methods: {
async fetchData() {
try {
const { data } = await getPerformanceList({
page: this.pagination.current,
size: this.pagination.size
})
this.performances = data.records
this.pagination.total = data.total
} catch (error) {
this.$message.error('获取演出列表失败')
}
},
handlePageChange(page) {
this.pagination.current = page
this.fetchData()
},
viewDetail(performance) {
this.$router.push(`/performance/${performance.performance_id}`)
}
}
}
</script>
5.2.2 座位选择组件
vue复制<template>
<div class="seat-selector">
<div class="stage">舞台</div>
<div
v-for="(row, rowIndex) in seatMap"
:key="rowIndex"
class="seat-row"
>
<div
v-for="(seat, seatIndex) in row"
:key="seatIndex"
:class="['seat', {
'available': seat.status === 0,
'selected': isSelected(seat),
'unavailable': seat.status !== 0
}]"
@click="selectSeat(seat)"
>
{{ seat.seatNumber }}
</div>
</div>
<div class="selected-seats">
<div v-for="seat in selectedSeats" :key="seat.seatId" class="selected-seat">
{{ seat.area }}区 {{ seat.row }}排{{ seat.number }}座
<span class="price">¥{{ seat.price }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
performanceId: {
type: Number,
required: true
}
},
data() {
return {
seatMap: [],
selectedSeats: []
}
},
async created() {
await this.fetchSeatMap()
},
methods: {
async fetchSeatMap() {
try {
const { data } = await getSeatMap(this.performanceId)
this.seatMap = data
} catch (error) {
this.$message.error('获取座位图失败')
}
},
selectSeat(seat) {
if (seat.status !== 0) return
const index = this.selectedSeats.findIndex(s => s.seatId === seat.seatId)
if (index >= 0) {
this.selectedSeats.splice(index, 1)
} else {
if (this.selectedSeats.length >= 6) {
this.$message.warning('最多选择6个座位')
return
}
this.selectedSeats.push(seat)
}
},
isSelected(seat) {
return this.selectedSeats.some(s => s.seatId === seat.seatId)
}
}
}
</script>
<style scoped>
.seat-selector {
max-width: 800px;
margin: 0 auto;
}
.stage {
text-align: center;
margin-bottom: 20px;
font-weight: bold;
}
.seat-row {
display: flex;
justify-content: center;
margin-bottom: 10px;
}
.seat {
width: 40px;
height: 40px;
margin: 0 5px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
}
.available {
background: #e1f5fe;
border: 1px solid #4fc3f7;
}
.selected {
background: #81c784;
color: white;
}
.unavailable {
background: #e0e0e0;
color: #9e9e9e;
cursor: not-allowed;
}
.selected-seats {
margin-top: 20px;
padding: 10px;
border: 1px dashed #ddd;
}
.selected-seat {
display: inline-block;
margin: 5px;
padding: 5px 10px;
background: #f5f5f5;
border-radius: 4px;
}
.price {
color: #ff5722;
margin-left: 5px;
}
</style>
6. 系统部署
6.1 后端部署
Docker部署示例:
dockerfile复制# 基础镜像
FROM openjdk:11-jre-slim
# 维护者信息
LABEL maintainer="your-email@example.com"
# 设置工作目录
WORKDIR /app
# 复制构建好的jar包
COPY target/music-hall-0.0.1-SNAPSHOT.jar app.jar
# 暴露端口
EXPOSE 8080
# 启动命令
ENTRYPOINT ["java", "-jar", "app.jar"]
启动命令:
bash复制docker build -t music-hall .
docker run -d -p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:mysql://mysql-server:3306/music_hall \
-e SPRING_DATASOURCE_USERNAME=root \
-e SPRING_DATASOURCE_PASSWORD=yourpassword \
-e SPRING_REDIS_HOST=redis-server \
music-hall
6.2 前端部署
Nginx配置示例:
nginx复制server {
listen 80;
server_name your-domain.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
}
7. 开发经验分享
7.1 性能优化实践
-
缓存策略:
- 热门演出数据缓存1小时
- 使用Redis缓存用户权限信息,减少数据库查询
- 实现本地缓存(Caffeine)作为二级缓存
-
数据库优化:
- 为常用查询字段添加合适索引
- 使用EXPLAIN分析慢查询
- 批量操作代替循环单条操作
-
前端优化:
- 图片懒加载
- 路由懒加载
- 使用Webpack分包策略
7.2 常见问题解决
问题1:座位并发选择冲突
解决方案:
- 使用Redis分布式锁
- 乐观锁更新座位状态
- 前端轮询检查座位状态变化
实现代码:
java复制public boolean lockSeats(List<Long> seatIds, String lockId) {
String lockKey = "seat:lock:" + StringUtils.join(seatIds, ",");
return redisTemplate.opsForValue().setIfAbsent(lockKey, lockId, 5, TimeUnit.MINUTES);
}
public boolean reserveSeats(List<Long> seatIds, Long performanceId) {
// 使用乐观锁更新
int updated = seatMapper.updateSeatStatus(
seatIds,
performanceId,
0, // 原状态:可用
1, // 新状态:锁定中
new Date() // 锁定时间
);
return updated == seatIds.size();
}
问题2:支付结果异步通知处理
解决方案:
- 实现幂等性处理
- 使用消息队列解耦
- 提供手动补单接口
支付通知处理流程:
- 验证签名
- 检查订单是否存在
- 检查订单是否已处理
- 更新订单状态
- 发送购票成功通知
7.3 安全实践
-
输入验证:
- 使用Hibernate Validator验证DTO
- 自定义验证规则如手机号、邮箱格式
-
XSS防护:
- 前端使用vue-sanitize处理富文本
- 后端对输出内容进行转义
-
CSRF防护:
- 使用SameSite Cookie属性
- 敏感操作要求二次验证
-
SQL注入防护:
- 使用MyBatis参数绑定
- 避免拼接SQL语句
8. 项目扩展方向
-
多音乐厅支持:
- 增加音乐厅管理模块
- 不同音乐厅可以有不同座位图
-
会员体系:
- 积分系统
- 会员等级折扣
-
数据分析:
- 演出上座率分析
- 用户购票偏好分析
-
移动端适配:
- 开发微信小程序版本
- 响应式设计优化移动体验
-
第三方对接:
- 对接票务代理平台
- 对接电子票务系统
这个项目从技术选型到实现细节都考虑了很多实际应用场景,特别是在高并发场景下的座位选择和支付处理。我在开发过程中积累了不少经验,比如如何平衡系统响应速度和数据一致性,如何处理支付过程中的各种异常情况等。这些经验对于开发类似的在线交易系统都很有参考价值。
