这个电影院购票管理系统是一个典型的B/S架构应用,采用前后端分离设计模式。前端使用Vue.js框架构建用户界面,后端基于SpringBoot实现业务逻辑,数据持久层采用MyBatis框架操作MySQL数据库。系统主要面向影院管理者、售票员和普通观众三类用户群体,实现了从影片管理、排期设置到在线选座购票的全流程数字化管理。
在实际影院运营场景中,这类系统需要同时满足高并发售票、实时座位锁定、多终端适配等核心需求。我们团队在开发过程中特别注重系统的响应速度和数据一致性,通过Redis缓存热点数据和分布式锁机制来应对秒杀场景。
SpringBoot 2.7.x作为基础框架,其自动配置特性大幅简化了传统SSM框架的整合工作。我们特别采用了以下关键配置:
java复制@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class
})
public class CinemaApplication {
public static void main(String[] args) {
SpringApplication.run(CinemaApplication.class, args);
}
}
这种排除自动配置的方式让我们可以更灵活地管理多数据源。MyBatis-Plus 3.5.x作为ORM框架,其Lambda表达式查询大大提升了开发效率:
java复制public List<Movie> getShowingMovies() {
return movieMapper.selectList(new QueryWrapper<Movie>()
.lambda()
.ge(Movie::getReleaseDate, LocalDate.now())
.orderByAsc(Movie::getReleaseDate));
}
Vue 3.x组合式API配合TypeScript提供了更好的类型安全。Element Plus作为UI组件库,其表格和表单组件经过二次封装后形成了影院管理专用组件:
vue复制<template>
<el-table :data="scheduleList" @row-click="handleSelect">
<el-table-column prop="movieName" label="影片名称" />
<el-table-column prop="hallName" label="放映厅" />
<el-table-column prop="showTime" label="放映时间" />
</el-table>
</template>
MySQL 8.0采用InnoDB引擎,关键表结构设计如下:
sql复制CREATE TABLE `t_seat` (
`id` bigint NOT NULL AUTO_INCREMENT,
`hall_id` bigint NOT NULL COMMENT '放映厅ID',
`row_num` int NOT NULL COMMENT '排号',
`col_num` int NOT NULL COMMENT '列号',
`seat_type` tinyint NOT NULL COMMENT '座位类型',
`x_coordinate` int DEFAULT NULL COMMENT 'X坐标',
`y_coordinate` int DEFAULT NULL COMMENT 'Y坐标',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_hall_position` (`hall_id`,`row_num`,`col_num`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
座位表采用联合唯一索引确保同一影厅内座位位置不重复,坐标字段用于前端可视化选座。
购票业务是系统的核心难点,我们采用状态机模式管理订单生命周期:
java复制public enum OrderStatus {
INIT(0),
LOCKED(1),
PAID(2),
COMPLETED(3),
CANCELLED(4);
// 状态转换校验逻辑
public boolean canTransferTo(OrderStatus target) {
// 具体校验规则...
}
}
座位锁定采用Redis分布式锁实现:
java复制public boolean lockSeats(List<Long> seatIds, Long userId) {
String lockKey = "seat_lock:" + StringUtils.join(seatIds, ",");
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, userId, 10, TimeUnit.MINUTES);
return Boolean.TRUE.equals(locked);
} catch (Exception e) {
log.error("锁定座位异常", e);
return false;
}
}
系统集成支付宝和微信支付双渠道,采用策略模式封装支付逻辑:
java复制public interface PaymentService {
PaymentResult createPayment(Order order);
PaymentResult queryPayment(String orderNo);
}
@Service
@RequiredArgsConstructor
public class PaymentContext {
private final Map<String, PaymentService> serviceMap;
public PaymentService getService(String channel) {
PaymentService service = serviceMap.get(channel + "PaymentService");
if (service == null) {
throw new IllegalArgumentException("不支持的支付渠道");
}
return service;
}
}
排片算法考虑以下因素:
java复制public List<Schedule> autoArrange(Movie movie, DateRange range) {
List<Hall> availableHalls = hallService.getAvailableHalls(movie.getFormat());
return availableHalls.stream()
.flatMap(hall -> {
LocalDateTime start = range.getStart().withHour(9).withMinute(0);
LocalDateTime end = range.getEnd().withHour(23).withMinute(0);
return generateSlots(start, end, movie.getDuration(), hall).stream();
})
.collect(Collectors.toList());
}
采用多级缓存架构:
缓存更新策略采用"先更新数据库再删除缓存":
java复制@Transactional
public void updateMovie(Movie movie) {
movieMapper.updateById(movie);
redisTemplate.delete("movie:" + movie.getId());
caffeineCache.invalidate(movie.getId());
}
针对分页查询进行优化:
sql复制-- 反例(深度分页性能差)
SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10000, 20;
-- 正例(基于游标的分页)
SELECT * FROM t_order
WHERE create_time < '2025-03-20 15:00:00'
ORDER BY create_time DESC LIMIT 20;
javascript复制const router = createRouter({
routes: [
{
path: '/movie/:id',
component: () => import('../views/MovieDetail.vue')
}
]
})
java复制@RestController
@RequestMapping("/api")
public class OrderController {
@PostMapping("/order")
@RateLimiter(value = 10, key = "#userId")
public Result createOrder(@Valid @RequestBody OrderDTO dto) {
// 业务逻辑
}
}
java复制@Column(columnDefinition = "varchar(64) comment '手机号'")
@FieldEncrypt
private String phone;
Docker Compose编排文件示例:
yaml复制version: '3'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- ./mysql/data:/var/lib/mysql
redis:
image: redis:6
ports:
- "6379:6379"
backend:
build: ./backend
ports:
- "8080:8080"
depends_on:
- mysql
- redis
GitLab CI配置示例:
yaml复制stages:
- build
- test
- deploy
backend-build:
stage: build
script:
- mvn clean package -DskipTests
artifacts:
paths:
- backend/target/*.jar
推荐算法可基于协同过滤实现:
python复制# Python示例代码
from surprise import Dataset, KNNBasic
data = Dataset.load_builtin('ml-100k')
algo = KNNBasic()
algo.fit(data.build_full_trainset())
可能原因:
解决方案:
java复制// 增加锁续期机制
private void renewLock(String lockKey, String value, long expireTime) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('expire', KEYS[1], ARGV[2]) " +
"else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(lockKey), value, expireTime);
}
支付回调处理注意事项:
java复制@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handlePayNotify(PayNotifyDTO dto) {
Order order = orderMapper.selectByOrderNo(dto.getOrderNo());
if (order.getStatus() != OrderStatus.PAID) {
order.setStatus(OrderStatus.PAID);
orderMapper.updateById(order);
// 触发后续业务逻辑
}
}
领域模型设计时,将"场次(Schedule)"与"座位(Seat)"解耦,通过关联表维护关系,这样既支持不同影厅的座位布局差异,又能复用相同的排片逻辑。
前端选座组件采用Canvas渲染而非DOM元素,在500+座位的影厅场景下性能提升显著。核心渲染逻辑:
javascript复制function drawSeats() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
seats.forEach(seat => {
ctx.beginPath();
ctx.arc(seat.x, seat.y, radius, 0, Math.PI * 2);
ctx.fillStyle = getSeatColor(seat);
ctx.fill();
});
}
定时任务使用xxl-job替代Spring自带的@Scheduled,获得更好的分布式调度能力和可视化管控。
日志收集采用ELK栈,通过MDC注入traceId实现全链路追踪:
java复制@Slf4j
@Aspect
@Component
public class LogAspect {
@Around("execution(* com.cinema..*.*(..))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
String traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId);
try {
log.info("Start {}.{}",
joinPoint.getSignature().getDeclaringTypeName(),
joinPoint.getSignature().getName());
return joinPoint.proceed();
} finally {
MDC.clear();
}
}
}
lua复制local key = KEYS[1]
local delta = tonumber(ARGV[1])
local remain = tonumber(redis.call('GET', key))
if remain >= delta then
return redis.call('INCRBY', key, -delta)
else
return -1
end