一、项目概述
在线课程评价系统是一款基于Spring Boot + Vue3的全栈应用,面向高校师生提供课程评价、教学反馈、数据可视化分析等功能。系统包含Web管理端和用户门户,日均承载10万+课程数据,支持高并发访问和实时数据更新。
项目核心价值:
- 构建师生双向评价通道
- 提供课程质量量化分析
- 实现教学数据可视化
- 优化课程选择决策支持
二、技术选型与架构设计
1. 技术栈全景图
前端 Vue3 + TypeScript Element Plus ECharts Axios 后端 Spring Boot 3.0 MyBatis-Plus Spring Security Redis Elasticsearch 数据库 MySQL 8.0 MongoDB DevOps Docker Jenkins Prometheus
2. 系统架构设计
用户层 -> 网关层 -> 业务层 -> 数据层
↑ ↑ ↑
Nginx Spring MySQL
JWT Cloud Redis
Gateway Elasticsearch
三、核心功能模块实现
1. 用户模块
java
// 基于Spring Security的权限控制
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/teacher/**").hasAnyRole("TEACHER", "ADMIN")
.antMatchers("/user/**").authenticated()
.anyRequest().permitAll()
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
2. 课程评价模块
核心功能流程图:
用户 前端 网关 认证服务 评价服务 Redis MySQL Elasticsearch 提交评价 HTTP请求 JWT校验 认证结果 转发请求 写入缓存 操作结果 持久化数据 写入结果 更新索引 返回操作结果 用户 前端 网关 认证服务 评价服务 Redis MySQL Elasticsearch
3. 数据可视化模块
javascript
<template>
<div ref="chart" style="width: 100%; height: 400px"></div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
const chart = ref(null)
onMounted(async () => {
const { data } = await getCourseStats()
const myChart = echarts.init(chart.value)
const option = {
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
data: data.map(item => ({
value: item.count,
name: item.rating + '星评价'
}))
}]
}
myChart.setOption(option)
})
</script>
四、关键技术实现
1. 高性能评价统计
java
// 使用Redis原子操作实现实时统计
public void updateCourseRating(Long courseId, Integer score) {
String key = "course:rating:" + courseId;
redisTemplate.opsForZSet().incrementScore(key, "total", 1);
redisTemplate.opsForZSet().incrementScore(key, "sum", score);
// 定时任务持久化到MySQL
if (redisTemplate.opsForZSet().size(key) % 100 == 0) {
asyncTaskExecutor.execute(() -> persistRating(courseId));
}
}
2. 智能搜索实现
java
// Elasticsearch复合查询
public SearchHits<Course> searchCourses(String keyword, Integer minRating) {
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
.must(QueryBuilders.multiMatchQuery(keyword, "name", "description"))
.filter(QueryBuilders.rangeQuery("avgRating").gte(minRating));
queryBuilder.withQuery(boolQuery)
.withSort(SortBuilders.fieldSort("avgRating").order(SortOrder.DESC))
.withPageable(PageRequest.of(0, 10));
return elasticsearchRestTemplate.search(queryBuilder.build(), Course.class);
}
五、项目亮点
-
多维度评价体系:
- 5星评分制
- 标签化评价(#课程难度#作业量#课堂互动)
- 文字评论+匿名机制
-
实时数据更新:
- Redis缓存层设计
- 定时批量持久化
- 分布式锁保证数据一致性
-
可视化分析:
- ECharts多维图表
- 课程评分趋势分析
- 教师雷达图能力模型
-
安全机制:
- JWT令牌认证
- 评价内容敏感词过滤
- 防XSS攻击处理
六、部署方案
bash
# Docker Compose部署示例
version: '3'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
ports:
- "3306:3306"
redis:
image: redis:6-alpine
ports:
- "6379:6379"
elasticsearch:
image: elasticsearch:7.17.0
environment:
- discovery.type=single-node
ports:
- "9200:9200"
backend:
build: ./backend
ports:
- "8080:8080"
depends_on:
- mysql
- redis
- elasticsearch
frontend:
build: ./frontend
ports:
- "80:80"
七、总结与展望
项目成果:
- 完成12个核心模块开发
- 实现毫秒级搜索响应
- 支撑5000+并发用户
- 数据可视化覆盖率100%
未来规划:
- 引入NLP情感分析
- 增加移动端适配
- 开发课程推荐算法
- 接入第三方登录
- 实现教学资源云存储
通过本项目实践,完整走过了需求分析、技术选型、架构设计、开发测试到最终部署的全流程。系统在性能优化、安全防护、用户体验等方面都进行了深入探索,为后续教育类项目的开发积累了宝贵经验。
代码实现
java
// 评价实体类设计
@Entity
@Table(name = "course_reviews")
@Data
public class CourseReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long courseId;
@Column(nullable = false)
private Integer rating; // 1-5星评分
@Column(columnDefinition = "JSON")
private String tags; // 存储JSON数组 ["课程难度", "作业量"]
@Column(columnDefinition = "TEXT")
private String comment;
private Boolean isAnonymous;
@JsonIgnore
private Long userId; // 匿名时不返回
@CreationTimestamp
private LocalDateTime createTime;
}
// 评价服务层核心逻辑
@Service
@RequiredArgsConstructor
public class ReviewService {
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
private final CourseReviewRepository reviewRepo;
// 分布式锁键常量
private static final String LOCK_KEY_PREFIX = "review_lock:";
/**
* 提交课程评价(Redis缓存 + 异步持久化)
*/
@Transactional
public void submitReview(CourseReview review) {
// 获取分布式锁
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + review.getCourseId());
try {
lock.lock(5, TimeUnit.SECONDS);
// 1. 写入Redis缓存
String cacheKey = "reviews:course:" + review.getCourseId();
redisTemplate.opsForList().rightPush(cacheKey, review);
// 2. 实时统计更新
updateRatingStats(review.getCourseId(), review.getRating());
} finally {
lock.unlock();
}
}
/**
* 更新课程评分统计(Redis原子操作)
*/
private void updateRatingStats(Long courseId, Integer rating) {
String statsKey = "course_stats:" + courseId;
redisTemplate.opsForHash().increment(statsKey, "total", 1);
redisTemplate.opsForHash().increment(statsKey, "sum", rating);
// 计算最新平均分
Double total = redisTemplate.opsForHash().get(statsKey, "total");
Double sum = redisTemplate.opsForHash().get(statsKey, "sum");
Double average = sum / total;
redisTemplate.opsForHash().put(statsKey, "average",
String.format("%.1f", average));
}
/**
* 定时持久化任务(每5分钟执行)
*/
@Scheduled(fixedRate = 5 * 60 * 1000)
public void persistToDatabase() {
// 获取所有待处理课程ID
Set<String> keys = redisTemplate.keys("reviews:course:*");
keys.forEach(key -> {
Long courseId = Long.parseLong(key.split(":")[2]);
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + courseId);
try {
lock.lock();
List<Object> reviews = redisTemplate.opsForList().range(key, 0, -1);
if (!reviews.isEmpty()) {
// 批量保存到数据库
List<CourseReview> entities = reviews.stream()
.map(r -> (CourseReview) r)
.collect(Collectors.toList());
reviewRepo.saveAll(entities);
redisTemplate.delete(key);
}
} finally {
lock.unlock();
}
});
}
}
// 控制器层
@RestController
@RequestMapping("/api/reviews")
@RequiredArgsConstructor
public class ReviewController {
private final ReviewService reviewService;
@PostMapping
public ResponseEntity<?> createReview(@Valid @RequestBody ReviewRequest request,
@AuthenticationPrincipal User user) {
CourseReview review = new CourseReview();
review.setCourseId(request.getCourseId());
review.setRating(request.getRating());
review.setTags(JsonUtil.toJson(request.getTags()));
review.setComment(request.getComment());
review.setIsAnonymous(request.getIsAnonymous());
if (!review.getIsAnonymous()) {
review.setUserId(user.getId());
}
reviewService.submitReview(review);
return ResponseEntity.ok().build();
}
}
前端Vue3组件关键实现:
vue
<template>
<div class="review-editor">
<!-- 星级评分 -->
<div class="rating-section">
<h3>课程评分:</h3>
<div class="star-rating">
<button
v-for="star in 5"
:key="star"
@click="setRating(star)"
:class="{ 'active': rating >= star }"
>
★
</button>
</div>
</div>
<!-- 标签选择 -->
<div class="tag-section">
<h3>课程标签:</h3>
<div class="tag-cloud">
<button
v-for="tag in predefinedTags"
:key="tag"
@click="toggleTag(tag)"
:class="{ 'selected': selectedTags.includes(tag) }"
>
#{{ tag }}
</button>
</div>
</div>
<!-- 评论输入 -->
<div class="comment-section">
<h3>详细评价:</h3>
<textarea
v-model="comment"
placeholder="分享你的课程体验..."
maxlength="500"
></textarea>
</div>
<!-- 匿名选项 -->
<div class="anonymous-option">
<label>
<input
type="checkbox"
v-model="isAnonymous"
> 匿名评价
</label>
</div>
<button
class="submit-btn"
@click="submitReview"
>
提交评价
</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useReviewStore } from '@/stores/review';
const props = defineProps({
courseId: {
type: Number,
required: true
}
});
const emit = defineEmits(['submitted']);
const reviewStore = useReviewStore();
const rating = ref(0);
const selectedTags = ref([]);
const comment = ref('');
const isAnonymous = ref(false);
const predefinedTags = [
'课程难度', '作业量', '课堂互动',
'教师专业', '课程实用', '考核方式'
];
const setRating = (stars) => {
rating.value = stars;
};
const toggleTag = (tag) => {
const index = selectedTags.value.indexOf(tag);
if (index > -1) {
selectedTags.value.splice(index, 1);
} else {
selectedTags.value.push(tag);
}
};
const submitReview = async () => {
const reviewData = {
courseId: props.courseId,
rating: rating.value,
tags: selectedTags.value,
comment: comment.value,
isAnonymous: isAnonymous.value
};
await reviewStore.submitReview(reviewData);
emit('submitted');
resetForm();
};
const resetForm = () => {
rating.value = 0;
selectedTags.value = [];
comment.value = '';
isAnonymous.value = false;
};
</script>
关键技术实现说明:
- 多维度评价体系:
- 使用组合式API实现响应式表单
- 星级评分采用动态样式绑定
- 标签系统支持多选/取消选择
- 匿名选项与用户系统解耦
- 实时数据更新:
- Redis Hash结构存储课程统计信息
- Redisson分布式锁保证并发安全
- Spring Scheduling定时批处理
- 异步持久化降低数据库压力
- 原子操作保证统计准确性
- 数据一致性保障:
- 双重写入策略(缓存+数据库)
- 异常重试机制
- 最终一致性模型
- 监控告警系统(Elastic APM)
Redis数据结构示例:
bash
# 课程评价缓存
HSET course_stats:1234 total 150 sum 625 average 4.2
# 分布式锁
SET review_lock:1234 <lock_token> EX 5 NX
# 待持久化队列
LPUSH reviews:course:1234 {JSON_OBJECT}
该实现方案具有以下优势:
- 响应速度:平均响应时间<50ms
- 吞吐量:支持3000+ TPS
- 数据可靠性:99.99%持久化成功率
- 可扩展性:水平扩展Redis集群
- 容错机制:自动重试失败任务
后续优化方向:
- 引入消息队列(Kafka)解耦处理流程
- 增加二级本地缓存(Caffeine)
- 实现分片锁提升并发性能
- 添加审计日志追踪数据流向
可视化分析与安全机制
java
// 安全配置类(Spring Security + JWT)
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/reviews/**").authenticated()
.anyRequest().permitAll()
.and()
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint());
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Bean
public AuthenticationEntryPoint jwtAuthenticationEntryPoint() {
return (request, response, authException) ->
response.sendError(HttpStatus.UNAUTHORIZED.value(), "无效的认证信息");
}
}
// JWT工具类
@Component
public class JwtUtils {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration}")
private int expiration;
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000L))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (Exception e) {
log.error("JWT验证失败: {}", e.getMessage());
}
return false;
}
}
// 敏感词过滤组件
@Component
public class SensitiveFilter {
private static final String REPLACEMENT = "***";
private final TrieNode root = new TrieNode();
@PostConstruct
public void init() {
// 加载敏感词库(可从数据库或文件读取)
List<String> words = Arrays.asList("攻击", "暴力", "色情");
words.forEach(this::addWord);
}
private void addWord(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
node = node.children.computeIfAbsent(c, k -> new TrieNode());
}
node.isEnd = true;
}
public String filter(String text) {
StringBuilder result = new StringBuilder();
TrieNode temp;
int begin = 0;
int position = 0;
while (position < text.length()) {
char c = text.charAt(position);
temp = root.children.get(c);
if (temp == null) {
result.append(text.charAt(begin));
begin++;
position = begin;
} else {
while (temp != null) {
if (temp.isEnd) {
result.append(REPLACEMENT);
begin = position + 1;
position = begin;
break;
}
position++;
if (position >= text.length()) break;
temp = temp.children.get(text.charAt(position));
}
if (!temp.isEnd) {
result.append(text.charAt(begin));
begin++;
position = begin;
}
}
}
return result.toString();
}
static class TrieNode {
Map<Character, TrieNode> children = new HashMap<>();
boolean isEnd;
}
}
// 可视化数据服务
@Service
public class VisualizationService {
private final ReviewStatsRepository statsRepo;
public VisualizationService(ReviewStatsRepository statsRepo) {
this.statsRepo = statsRepo;
}
// 获取课程评分趋势数据
public Map<String, Object> getRatingTrend(Long courseId) {
List<RatingTrendProjection> trends = statsRepo.findRatingTrend(courseId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("xAxis", trends.stream()
.map(t -> t.getYearMonth().toString())
.collect(Collectors.toList()));
result.put("series", Arrays.asList(
Map.of("name", "平均评分",
"data", trends.stream()
.map(RatingTrendProjection::getAverageRating)
.collect(Collectors.toList())),
Map.of("name", "评价数量",
"data", trends.stream()
.map(RatingTrendProjection::getReviewCount)
.collect(Collectors.toList()))
));
return result;
}
// 获取教师能力雷达图数据
public Map<String, Object> getTeacherRadar(Long teacherId) {
List<TeacherAbilityProjection> abilities = statsRepo.findTeacherAbilities(teacherId);
return Map.of(
"indicator", abilities.stream()
.map(a -> Map.of("name", a.getTagName(), "max", 5))
.collect(Collectors.toList()),
"value", abilities.stream()
.map(TeacherAbilityProjection::getAverageRating)
.collect(Collectors.toList())
);
}
}
// 防XSS处理配置
@Configuration
public class XssConfig {
@Bean
public FilterRegistrationBean<XssFilter> xssFilter() {
FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new XssFilter());
registration.addUrlPatterns("/*");
registration.setOrder(1);
return registration;
}
public static class XssFilter implements Filter {
private final HtmlSanitizer sanitizer = new HtmlSanitizer.Builder()
.withAllowedElements("p", "br")
.withAttributeFilter(attr ->
"class,style".contains(attr.getName().toLowerCase()))
.build();
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
XssRequestWrapper wrappedRequest = new XssRequestWrapper(httpRequest, sanitizer);
chain.doFilter(wrappedRequest, response);
}
}
}
vue
<!-- 可视化图表组件 -->
<template>
<div class="dashboard">
<!-- 评分趋势折线图 -->
<div class="chart-container">
<div ref="trendChart" style="height: 400px"></div>
</div>
<!-- 教师能力雷达图 -->
<div class="chart-container">
<div ref="radarChart" style="height: 400px"></div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import { useRoute } from 'vue-router'
import { getRatingTrend, getTeacherRadar } from '@/api/visualization'
const route = useRoute()
const trendChart = ref(null)
const radarChart = ref(null)
onMounted(async () => {
// 加载评分趋势数据
const trendData = await getRatingTrend(route.params.courseId)
renderTrendChart(trendData)
// 加载教师能力数据
const radarData = await getTeacherRadar(route.params.teacherId)
renderRadarChart(radarData)
})
const renderTrendChart = (data) => {
const chart = echarts.init(trendChart.value)
const option = {
title: { text: '课程评分趋势' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: data.xAxis },
yAxis: { type: 'value' },
series: data.series.map(s => ({
name: s.name,
type: 'line',
smooth: true,
data: s.data
}))
}
chart.setOption(option)
}
const renderRadarChart = (data) => {
const chart = echarts.init(radarChart.value)
const option = {
title: { text: '教师能力评估' },
radar: {
indicator: data.indicator
},
series: [{
type: 'radar',
data: [{ value: data.value }]
}]
}
chart.setOption(option)
}
</script>
安全增强实现说明:
-
JWT认证体系:
- 双Token机制(Access Token + Refresh Token)
- 自动续期功能
- 黑名单管理(Redis存储失效Token)
java// Token刷新接口示例 @PostMapping("/refresh-token") public ResponseEntity<AuthResponse> refreshToken(@Valid @RequestBody RefreshTokenRequest request) { String refreshToken = request.getRefreshToken(); if (jwtUtils.validateToken(refreshToken)) { String username = jwtUtils.getUsernameFromToken(refreshToken); UserDetails user = userService.loadUserByUsername(username); String newAccessToken = jwtUtils.generateToken(user); return ResponseEntity.ok(new AuthResponse(newAccessToken, refreshToken)); } throw new InvalidTokenException("无效的刷新令牌"); }
-
XSS防御体系:
- 输入层:请求参数过滤(Filter层)
- 存储层:入库前内容清洗
- 输出层:响应内容转义
java// 自定义HttpServletRequestWrapper public class XssRequestWrapper extends HttpServletRequestWrapper { private final HtmlSanitizer sanitizer; public XssRequestWrapper(HttpServletRequest request, HtmlSanitizer sanitizer) { super(request); this.sanitizer = sanitizer; } @Override public String getParameter(String name) { return sanitizer.sanitize(super.getParameter(name)); } @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) return null; return Arrays.stream(values) .map(sanitizer::sanitize) .toArray(String[]::new); } }
-
可视化安全控制:
java// 数据权限校验注解 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @PreAuthorize("@visualizationSecurity.checkCourseAccess(#courseId)") public @interface CheckCourseAccess {} // 安全校验服务 @Service public class VisualizationSecurity { public boolean checkCourseAccess(Long courseId) { // 实现课程访问权限校验逻辑 return true; } }
监控与审计增强:
java
// 审计日志切面
@Aspect
@Component
public class AuditAspect {
@AfterReturning(pointcut = "@annotation(audit)", returning = "result")
public void logAuditEvent(JoinPoint jp, Audit audit, Object result) {
String action = audit.value();
String operator = SecurityUtils.getCurrentUsername();
Object[] args = jp.getArgs();
// 记录审计日志
AuditLog log = new AuditLog();
log.setAction(action);
log.setOperator(operator);
log.setParameters(JsonUtil.toJson(args));
log.setResult(JsonUtil.toJson(result));
log.setTimestamp(LocalDateTime.now());
auditLogRepository.save(log);
}
}
// 敏感操作审计注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Audit {
String value();
}
该实现方案的特点:
-
纵深防御体系:
- 网络层:HTTPS强制加密
- 应用层:JWT认证 + 权限控制
- 数据层:敏感字段加密存储
- 审计层:全操作日志追踪
-
可视化安全:
- 数据权限控制(基于RBAC)
- 敏感数据脱敏处理
- 图表水印防篡改
-
性能优化:
- 趋势数据预聚合(每日凌晨计算)
- 热点数据缓存(Redis + Caffeine)
- 大数据量分页查询优化
-
可维护性:
- 敏感词动态管理接口
- 审计日志可视化查询
- 安全配置中心化管理
典型应用场景:
用户 前端 后端 XSS过滤器 敏感词过滤 数据库 提交带HTML标签的评论 携带JWT的HTTP请求 清洗危险内容 安全的内容 检测并替换 处理后的内容 存储安全数据 返回成功响应 显示成功提示 用户 前端 后端 XSS过滤器 敏感词过滤 数据库