1. 项目背景与技术选型
校园电影网站作为学生群体的娱乐信息平台,需要兼顾内容展示、用户互动和后台管理的综合需求。SpringBoot+Vue的组合完美契合了这类Web应用的开发需求,形成了清晰的前后端分离架构。
SpringBoot作为后端框架的选择主要基于以下考量:
- 内嵌Tomcat服务器简化部署流程
- 自动配置机制减少XML配置
- 丰富的Starter依赖快速集成MyBatis、Redis等组件
- Actuator模块提供完善的监控端点
Vue.js作为前端框架的优势体现在:
- 组件化开发模式提高代码复用率
- 响应式数据绑定简化DOM操作
- Vue Router实现无缝页面切换
- Axios轻松处理RESTful API调用
2. 系统架构设计
2.1 整体架构分层
code复制┌───────────────────────────────────────┐
│ 客户端层 │
│ ┌───────────┐ ┌─────────────┐ │
│ │ 移动端 │ │ PC浏览器 │ │
│ └───────────┘ └─────────────┘ │
└───────────────────┬───────────────────┘
│ HTTP(S)
┌───────────────────▼───────────────────┐
│ 表现层 │
│ ┌─────────────────────────────────┐ │
│ │ Vue前端工程 │ │
│ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │
│ │ │组件库 │ │路由管理│ │状态管理│ │ │
│ │ └───────┘ └───────┘ └───────┘ │ │
│ └─────────────────────────────────┘ │
└───────────────────┬───────────────────┘
│ REST API
┌───────────────────▼───────────────────┐
│ 业务逻辑层 │
│ ┌─────────────────────────────────┐ │
│ │ SpringBoot应用 │ │
│ │ ┌───────┐ ┌───────┐ ┌───────┐ │ │
│ │ │控制层 │ │服务层 │ │数据层 │ │ │
│ │ └───────┘ └───────┘ └───────┘ │ │
│ └─────────────────────────────────┘ │
└───────────────────┬───────────────────┘
│ JDBC/ORM
┌───────────────────▼───────────────────┐
│ 数据持久层 │
│ ┌───────┐ ┌───────┐ ┌─────────────┐ │
│ │ MySQL │ │ Redis │ │ 文件存储系统 │ │
│ └───────┘ └───────┘ └─────────────┘ │
└───────────────────────────────────────┘
2.2 数据库设计要点
核心表结构设计示例:
sql复制-- 电影信息表
CREATE TABLE `movie` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(100) NOT NULL COMMENT '电影名称',
`cover_url` varchar(255) DEFAULT NULL COMMENT '封面图URL',
`duration` int(11) DEFAULT NULL COMMENT '时长(分钟)',
`release_date` date DEFAULT NULL COMMENT '上映日期',
`score` decimal(3,1) DEFAULT '0.0' COMMENT '评分',
`director` varchar(50) DEFAULT NULL COMMENT '导演',
`actors` varchar(255) DEFAULT NULL COMMENT '主演',
`plot` text COMMENT '剧情简介',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
FULLTEXT KEY `ft_idx` (`title`,`actors`) COMMENT '全文检索索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 放映场次表
CREATE TABLE `schedule` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`movie_id` int(11) NOT NULL,
`room_id` int(11) NOT NULL COMMENT '放映厅',
`start_time` datetime NOT NULL COMMENT '开始时间',
`end_time` datetime NOT NULL COMMENT '结束时间',
`price` decimal(10,2) NOT NULL COMMENT '票价',
`seat_info` text COMMENT '座位状态JSON',
PRIMARY KEY (`id`),
KEY `idx_movie` (`movie_id`),
KEY `idx_time` (`start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户收藏表
CREATE TABLE `user_favorite` (
`user_id` int(11) NOT NULL,
`movie_id` int(11) NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`,`movie_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3. 前端工程实现
3.1 Vue项目结构规划
code复制src/
├── api/ # Axios接口封装
│ ├── movie.js # 电影相关接口
│ └── user.js # 用户相关接口
├── assets/ # 静态资源
├── components/ # 公共组件
│ ├── MovieCard.vue # 电影卡片
│ ├── RatingStar.vue # 评分组件
│ └── SeatPicker.vue # 选座组件
├── router/ # 路由配置
│ └── index.js
├── store/ # Vuex状态管理
│ ├── modules/
│ │ ├── movie.js
│ │ └── user.js
│ └── index.js
├── utils/ # 工具函数
├── views/ # 页面组件
│ ├── Home.vue # 首页
│ ├── Movie/
│ │ ├── Detail.vue # 详情页
│ │ └── List.vue # 列表页
│ └── User/
│ ├── Login.vue # 登录
│ └── Center.vue # 个人中心
└── main.js # 应用入口
3.2 典型组件实现示例
电影卡片组件(MovieCard.vue):
vue复制<template>
<div class="movie-card" @click="goDetail">
<div class="cover-wrapper">
<img :src="coverUrl" :alt="title" class="cover-img">
<div class="score-badge" v-if="score > 0">
{{ score.toFixed(1) }}
</div>
</div>
<div class="info">
<h3 class="title">{{ title }}</h3>
<p class="meta">
<span>{{ duration }}分钟</span>
<span>{{ releaseDate }}</span>
</p>
<button
class="favorite-btn"
@click.stop="toggleFavorite"
:class="{active: isFavorite}"
>
♥
</button>
</div>
</div>
</template>
<script>
export default {
props: {
id: Number,
title: String,
coverUrl: String,
score: Number,
duration: Number,
releaseDate: String,
isFavorite: Boolean
},
methods: {
goDetail() {
this.$router.push(`/movie/${this.id}`)
},
async toggleFavorite() {
try {
const action = this.isFavorite ? 'removeFavorite' : 'addFavorite'
await this.$store.dispatch(`user/${action}`, this.id)
this.$emit('favorite-change', !this.isFavorite)
} catch (e) {
this.$message.error(e.message)
}
}
}
}
</script>
<style scoped>
.movie-card {
width: 200px;
margin: 10px;
cursor: pointer;
transition: all 0.3s;
}
.movie-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.cover-wrapper {
position: relative;
height: 280px;
overflow: hidden;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s;
}
.movie-card:hover .cover-img {
transform: scale(1.05);
}
.score-badge {
position: absolute;
right: 10px;
top: 10px;
background: rgba(255,215,0,0.9);
color: #fff;
padding: 3px 8px;
border-radius: 4px;
font-weight: bold;
}
.info {
padding: 10px 5px;
}
.title {
font-size: 16px;
margin: 5px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
color: #999;
font-size: 12px;
display: flex;
justify-content: space-between;
}
.favorite-btn {
border: none;
background: none;
font-size: 20px;
color: #ccc;
cursor: pointer;
padding: 0;
float: right;
}
.favorite-btn.active {
color: #f56c6c;
}
</style>
4. 后端关键实现
4.1 SpringBoot应用配置
application.yml关键配置:
yaml复制server:
port: 8080
servlet:
context-path: /api
spring:
datasource:
url: jdbc:mysql://localhost:3306/campus_movie?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
password:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
4.2 电影模块接口示例
MovieController.java:
java复制@RestController
@RequestMapping("/movie")
@Api(tags = "电影管理接口")
public class MovieController {
@Autowired
private MovieService movieService;
@GetMapping("/list")
@ApiOperation("分页获取电影列表")
public Result<PageInfo<MovieVO>> list(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String keyword) {
PageHelper.startPage(page, size);
List<MovieVO> list = movieService.listMovies(keyword);
return Result.success(PageInfo.of(list));
}
@GetMapping("/{id}")
@ApiOperation("获取电影详情")
public Result<MovieDetailVO> detail(@PathVariable Integer id) {
return Result.success(movieService.getMovieDetail(id));
}
@PostMapping("/{id}/rate")
@ApiOperation("评分电影")
@RequireLogin
public Result rateMovie(
@PathVariable Integer id,
@RequestParam @Min(1) @Max(10) Integer score,
@RequestAttribute Integer userId) {
movieService.rateMovie(id, userId, score);
return Result.success();
}
}
MovieServiceImpl.java核心逻辑:
java复制@Service
public class MovieServiceImpl implements MovieService {
@Autowired
private MovieMapper movieMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String RATE_LOCK_PREFIX = "movie:rate:lock:";
private static final String HOT_MOVIE_KEY = "movie:hot";
@Override
public List<MovieVO> listMovies(String keyword) {
// 使用全文检索查询
if (StringUtils.isNotBlank(keyword)) {
return movieMapper.searchByKeyword(keyword);
}
// 优先从缓存获取热门电影
List<Object> cacheList = redisTemplate.opsForList().range(HOT_MOVIE_KEY, 0, 9);
if (cacheList != null && !cacheList.isEmpty()) {
return cacheList.stream()
.map(obj -> (MovieVO) obj)
.collect(Collectors.toList());
}
// 数据库查询并缓存
List<MovieVO> list = movieMapper.selectPopularMovies();
if (!list.isEmpty()) {
redisTemplate.opsForList().rightPushAll(HOT_MOVIE_KEY, list.toArray());
redisTemplate.expire(HOT_MOVIE_KEY, 1, TimeUnit.HOURS);
}
return list;
}
@Override
@Transactional
public void rateMovie(Integer movieId, Integer userId, Integer score) {
// 分布式锁防止重复评分
String lockKey = RATE_LOCK_PREFIX + movieId + ":" + userId;
boolean locked = false;
try {
locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("操作太频繁,请稍后再试");
}
// 检查是否已评分
UserRating existing = movieMapper.selectUserRating(movieId, userId);
if (existing != null) {
throw new BusinessException("您已经评过分了");
}
// 新增评分记录
UserRating rating = new UserRating();
rating.setMovieId(movieId);
rating.setUserId(userId);
rating.setScore(score);
rating.setCreateTime(new Date());
movieMapper.insertUserRating(rating);
// 更新电影平均分
movieMapper.updateMovieScore(movieId);
// 清除缓存
redisTemplate.delete(HOT_MOVIE_KEY);
} finally {
if (locked) {
redisTemplate.delete(lockKey);
}
}
}
}
5. 系统特色功能实现
5.1 选座功能实现
前端选座组件核心逻辑:
javascript复制// SeatPicker.vue
data() {
return {
rows: 10,
cols: 12,
seats: [],
selected: [],
booked: []
}
},
async created() {
// 获取已预订座位
const res = await getBookedSeats(this.scheduleId)
this.booked = res.data
// 初始化座位矩阵
this.initSeats()
},
methods: {
initSeats() {
const seats = []
for (let i = 0; i < this.rows; i++) {
const row = []
for (let j = 0; j < this.cols; j++) {
const seatId = `${i+1}-${j+1}`
row.push({
id: seatId,
status: this.booked.includes(seatId) ? 'booked' : 'available'
})
}
seats.push(row)
}
this.seats = seats
},
toggleSelect(seat) {
if (seat.status !== 'available') return
const index = this.selected.findIndex(s => s.id === seat.id)
if (index >= 0) {
this.selected.splice(index, 1)
seat.status = 'available'
} else {
if (this.selected.length >= 6) {
this.$message.warning('最多选择6个座位')
return
}
this.selected.push(seat)
seat.status = 'selected'
}
},
confirmSelection() {
if (this.selected.length === 0) {
this.$message.warning('请至少选择一个座位')
return
}
this.$emit('confirm', this.selected.map(s => s.id))
}
}
后端座位锁定接口:
java复制@PostMapping("/schedule/{id}/lock")
@ApiOperation("锁定座位")
@RequireLogin
public Result lockSeats(
@PathVariable Integer id,
@RequestBody List<String> seatIds,
@RequestAttribute Integer userId) {
String lockKey = "schedule:" + id + ":lock";
String userKey = "user:" + userId + ":locking";
// 检查座位是否可用
Schedule schedule = scheduleMapper.selectById(id);
Map<String, Boolean> seatMap = JSON.parseObject(schedule.getSeatInfo(),
new TypeReference<Map<String, Boolean>>() {});
for (String seatId : seatIds) {
if (!seatMap.containsKey(seatId) || !seatMap.get(seatId)) {
return Result.error("座位" + seatId + "不可选");
}
}
// 分布式锁防止并发操作
String requestId = UUID.randomUUID().toString();
try {
// 获取锁
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (!locked) {
return Result.error("系统繁忙,请稍后再试");
}
// 检查用户是否有未完成订单
if (redisTemplate.hasKey(userKey)) {
return Result.error("您有未完成的订单,请先处理");
}
// 锁定座位
for (String seatId : seatIds) {
seatMap.put(seatId, false);
}
schedule.setSeatInfo(JSON.toJSONString(seatMap));
scheduleMapper.updateById(schedule);
// 设置用户锁定状态(15分钟有效期)
redisTemplate.opsForValue().set(userKey, seatIds.toString(), 15, TimeUnit.MINUTES);
return Result.success();
} finally {
// 释放锁
if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
5.2 电影推荐算法
基于协同过滤的混合推荐实现:
java复制public List<MovieVO> recommendMovies(Integer userId) {
// 1. 基于用户的协同过滤
List<MovieVO> userCF = recommendByUserCF(userId);
// 2. 基于内容的推荐
List<MovieVO> contentBased = recommendByContent(userId);
// 3. 热门电影补全
List<MovieVO> hotMovies = movieMapper.selectPopularMovies();
// 混合推荐结果
Map<Integer, MovieVO> resultMap = new LinkedHashMap<>();
// 优先加入协同过滤结果
userCF.forEach(movie -> resultMap.put(movie.getId(), movie));
// 补充内容推荐结果
contentBased.stream()
.filter(movie -> !resultMap.containsKey(movie.getId()))
.forEach(movie -> resultMap.put(movie.getId(), movie));
// 用热门电影补足数量
hotMovies.stream()
.filter(movie -> !resultMap.containsKey(movie.getId()))
.limit(10 - resultMap.size())
.forEach(movie -> resultMap.put(movie.getId(), movie));
return new ArrayList<>(resultMap.values());
}
private List<MovieVO> recommendByUserCF(Integer userId) {
// 获取相似用户(基于评分行为)
List<Integer> similarUsers = userMapper.selectSimilarUsers(userId, 5);
if (similarUsers.isEmpty()) {
return Collections.emptyList();
}
// 获取相似用户喜欢但当前用户未看过的电影
return movieMapper.selectMoviesLikedByUsers(
similarUsers,
userId,
5
);
}
private List<MovieVO> recommendByContent(Integer userId) {
// 获取用户最近评分的电影
List<RatedMovie> ratedMovies = movieMapper.selectUserRecentRatings(userId, 3);
if (ratedMovies.isEmpty()) {
return Collections.emptyList();
}
// 提取关键词
Set<String> keywords = new HashSet<>();
for (RatedMovie movie : ratedMovies) {
if (movie.getScore() >= 7) { // 只考虑高评分电影
keywords.addAll(extractKeywords(movie.getTitle()));
keywords.addAll(extractKeywords(movie.getDirector()));
keywords.addAll(extractKeywords(movie.getActors()));
}
}
if (keywords.isEmpty()) {
return Collections.emptyList();
}
// 基于关键词搜索相似电影
return movieMapper.searchByKeywords(
new ArrayList<>(keywords),
userId,
5
);
}
private List<String> extractKeywords(String text) {
// 使用IKAnalyzer分词
List<String> words = new ArrayList<>();
try (StringReader reader = new StringReader(text)) {
IKSegmenter ik = new IKSegmenter(reader, true);
Lexeme lexeme;
while ((lexeme = ik.next()) != null) {
String word = lexeme.getLexemeText();
if (word.length() > 1) { // 过滤单字
words.add(word);
}
}
} catch (IOException e) {
log.error("分词异常", e);
}
return words;
}
6. 部署与性能优化
6.1 前端部署方案
使用Nginx部署Vue项目的配置示例:
nginx复制server {
listen 80;
server_name movie.campus.edu;
# 前端静态资源
location / {
root /usr/share/nginx/html/dist;
index index.html;
try_files $uri $uri/ /index.html;
# 开启gzip压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# 长期缓存静态资源
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# API反向代理
location /api {
proxy_pass http://backend:8080/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
6.2 后端性能优化措施
- 缓存策略优化:
- 使用Redis多级缓存结构
- 热点数据预加载
- 缓存击穿防护
java复制// 缓存注解配置示例
@Cacheable(value = "movie", key = "#id", unless = "#result == null")
public MovieDetailVO getMovieDetail(Integer id) {
return movieMapper.selectMovieDetail(id);
}
// 缓存击穿防护示例
public MovieDetailVO getMovieDetailWithProtection(Integer id) {
String cacheKey = "movie:detail:" + id;
// 1. 先查缓存
MovieDetailVO detail = (MovieDetailVO)redisTemplate.opsForValue().get(cacheKey);
if (detail != null) {
return detail;
}
// 2. 获取分布式锁
String lockKey = "movie:lock:" + id;
boolean locked = false;
try {
locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (!locked) {
// 未获取到锁,短暂等待后重试
Thread.sleep(100);
return getMovieDetailWithProtection(id);
}
// 3. 再次检查缓存(可能其他线程已经加载)
detail = (MovieDetailVO)redisTemplate.opsForValue().get(cacheKey);
if (detail != null) {
return detail;
}
// 4. 查询数据库
detail = movieMapper.selectMovieDetail(id);
if (detail != null) {
// 5. 写入缓存
redisTemplate.opsForValue().set(
cacheKey,
detail,
1,
TimeUnit.HOURS
);
}
return detail;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
if (locked) {
redisTemplate.delete(lockKey);
}
}
}
- 数据库优化:
- 读写分离配置
- 慢SQL监控
- 索引优化
yaml复制# 多数据源配置示例
spring:
datasource:
master:
url: jdbc:mysql://master:3306/campus_movie
username: root
password: 123456
slave:
url: jdbc:mysql://slave:3306/campus_movie
username: readuser
password: 123456
- 接口性能监控:
- SpringBoot Actuator端点
- Prometheus + Grafana监控面板
- 关键接口日志记录
7. 安全防护措施
7.1 常见Web安全防护
- XSS防护:
- 前端使用vue-sanitize过滤HTML
- 后端统一响应头配置
java复制// XSS过滤器配置
@Bean
public FilterRegistrationBean<XssFilter> xssFilter() {
FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new XssFilter());
registration.addUrlPatterns("/*");
registration.setName("xssFilter");
return registration;
}
// 安全响应头配置
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.headers()
.xssProtection()
.and()
.contentSecurityPolicy("default-src 'self'");
return http.build();
}
- CSRF防护:
- 前后端配合的Token验证机制
- SameSite Cookie属性设置
javascript复制// 前端axios拦截器添加CSRF Token
axios.interceptors.request.use(config => {
const token = localStorage.getItem('csrfToken');
if (token) {
config.headers['X-CSRF-TOKEN'] = token;
}
return config;
});
- SQL注入防护:
- 严格使用MyBatis参数绑定
- 定期SQL审计
xml复制<!-- MyBatis映射文件示例 -->
<select id="searchByKeyword" resultType="MovieVO">
SELECT * FROM movie
WHERE MATCH(title,actors,director) AGAINST(#{keyword} IN BOOLEAN MODE)
<!-- 错误示例:直接拼接SQL -->
<!-- WHERE title LIKE '%${keyword}%' -->
</select>
7.2 业务安全设计
- 购票防刷机制:
- 用户行为分析
- 限流措施
java复制// 限流注解实现
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int value() default 10; // 默认10次/分钟
}
// 限流切面
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getMethod().getName();
String key = "rate:limit:" + methodName + ":" + getClientIp();
// 令牌桶算法实现
Long current = System.currentTimeMillis();
Long lastTime = (Long)redisTemplate.opsForValue().get(key + ":time");
Double tokens = (Double)redisTemplate.opsForValue().get(key + ":tokens");
if (lastTime == null) {
lastTime = current;
tokens = rateLimit.value() * 1.0;
}
// 计算新增令牌数
long elapsedTime = current - lastTime;
double newTokens = elapsedTime * (rateLimit.value() / 60000.0);
tokens = Math.min(tokens + newTokens, rateLimit.value());
if (tokens < 1) {
throw new BusinessException("操作太频繁,请稍后再试");
}
// 消耗令牌
tokens -= 1;
redisTemplate.opsForValue().set(key + ":time", current);
redisTemplate.opsForValue().set(key + ":tokens", tokens);
redisTemplate.expire(key + ":time", 1, TimeUnit.MINUTES);
redisTemplate.expire(key + ":tokens", 1, TimeUnit.MINUTES);
return joinPoint.proceed();
}
private String getClientIp() {
// 获取真实客户端IP
}
}
- 敏感数据保护:
- 用户密码加盐哈希存储
- 敏感字段加密
java复制// 密码加密工具类
public class PasswordUtil {
private static final int SALT_LENGTH = 16;
private static final int ITERATIONS = 1000;
private static final int KEY_LENGTH = 256;
public static String encrypt(String password) {
byte[] salt = generateSalt();
byte[] hash = pbkdf2(password.toCharArray(), salt);
return Base64.getEncoder().encodeToString(salt) + ":" +
Base64.getEncoder().encodeToString(hash);
}
public static boolean validate(String password, String stored) {
String[] parts = stored.split(":");
byte[] salt = Base64.getDecoder().decode(parts[0]);
byte[] hash = Base64.getDecoder().decode(parts[1]);
byte[] testHash = pbkdf2(password.toCharArray(), salt);
return slowEquals(hash, testHash);
}
private static byte[] generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[SALT_LENGTH];
random.nextBytes(salt);
return salt;
}
private static byte[] pbkdf2(char[] password, byte[] salt) {
try {
PBEKeySpec spec = new PBEKeySpec(
password, salt, ITERATIONS, KEY_LENGTH
);
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
return skf.generateSecret(spec).getEncoded();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static boolean slowEquals(byte[] a, byte[] b) {
int diff = a.length ^ b.length;
for (int i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}
8. 项目扩展方向
8.1 移动端适配方案
- 响应式布局优化:
- 使用Vue的响应式设计
- 媒体查询适配不同设备
css复制/* 响应式布局示例 */
.movie-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
@media (max-width: 768px) {
.movie-list {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.movie-card {
width: 150px;
}
.cover-wrapper {
height: 200px;
}
}
- PWA支持:
- Service Worker缓存策略
- Web App Manifest配置
javascript复制// service-worker.js
const CACHE_NAME = 'campus-movie-v1';
const ASSETS = [
'/',
'/index.html',
'/static/js/main.js',
'/static/css/main.css',
'/static/media/logo.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
8.2 数据分析模块
- 用户行为分析:
- 埋点数据收集
- 可视化分析
javascript复制// 前端埋点示例
export function trackEvent(category, action, label) {
if (window.ga) {
ga('send', 'event', category, action, label);
}
// 自定义数据收集
axios.post('/api/analytics/event', {
category,
action,
label,
timestamp: new Date().getTime(),
path: window.location.pathname
});
}
// Vue混入
export const analyticsMixin = {
methods: {
trackEvent(category, action, label) {
trackEvent(category, action, label);
}
}
};
- 电影热度计算:
- 综合浏览量、评分、收藏数
- 实时更新机制
java复制// 热度计算服务
@Service
public class HotScoreService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String VIEW_COUNT_KEY = "movie:view:count:";
private static final String HOT_SCORE_KEY = "movie:hot:score";
public void incrementViewCount(Integer movieId) {
String key = VIEW_COUNT_KEY + movieId;
redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, 7, TimeUnit.DAYS);
}
@Scheduled(fixedRate = 3600000) // 每小时计算一次
public void calculateHotScores() {
// 获取所有有浏览记录的电影
Set<String> keys = redisTemplate.keys(VIEW_COUNT_KEY + "*");
Map<Integer, Double> scores = new HashMap<>();
for (String key : keys) {
Integer movieId = Integer.parseInt(key.substring(key.lastIndexOf(":") + 1));
Long viewCount = Long.valueOf(redisTemplate.opsForValue().get(key).toString());
// 获取其他指标
MovieStats stats = movieMapper.selectMovieStats(movieId);
// 计算热度分(公式可根据业务调整)
double score = viewCount * 0.5 +
stats.getRatingCount() * 0.3 +
stats.getFavoriteCount() * 0.2;
scores.put(movieId, score);
}
// 更新ZSet
redisTemplate.delete(HOT_SCORE_KEY);
scores.forEach((movieId, score) -> {
redisTemplate.opsForZSet().add(HOT_SCORE_KEY, movieId, score);
});
}
}
8.3 社交功能扩展
- 影评互动系统:
- 评论与回复
- 点赞功能
vue复制<!-- CommentItem.vue -->
<template>
<div class="comment-item">
<div class="user-info">
<img :src="comment.avatar" class="avatar
