去年接手了一个让我印象很深的小需求某高校的老师找到我说每年期末都要收几百份学生作品作品分散在邮箱、网盘、QQ群里评委老师们要一个一个打开看然后在Excel里登记分数最后还要人工处理最高分、最低分、平均分排名。听起来不算难但真正把需求拆开之后我发现这个“在线作品评分网站”要做的事远比想象中多作品怎么传、怎么展示、评委怎么评分、怎么防止重复评、分数怎么聚合成排名还要考虑不同身份的人看到的内容不一样。这个项目最终落地的技术方案是后端用 Spring BootJava前端用 Vue数据库用 MySQL部署阶段用 Nginx 做静态资源服务与反向代理。整套系统既能上传展示作品也能在线打分并自动生成排行榜前后端分离开发整个流程走通之后确实把老师从Excel里解放出来了。这篇文章把我从需求梳理、技术选型、后端设计、前端交互到联调部署的关键过程和踩坑经验完整记录下来希望对正在做类似“作品展示 评分系统”或“内部评审系统”的朋友有一点参考价值。1. 重新定义需求不止是“把Excel搬到网上”1.1 最初的需求和真正的问题老师最初给我的需求描述只有一句话“做一个网站学生传作品评委打分最后出排名。” 但我们在需求沟通会上把使用流程走了一遍之后发现这里面藏着好几个容易忽略的点。首先是作品的形态问题。叫“作品”太笼统了——有的是文档有的是图片有的是视频有的是打包的压缩源码。如果只做单一格式的上传很多学生根本没法提交所以系统必须兼容所有常见文件类型同时为图片、视频这类可以在线预览的格式做单独的展示处理。其次是评分规则不统一。有的评委习惯打百分制有的习惯打等级分还有的想给作品写一段评语。我们在设计评分表时直接把“分制”定成了统一百分制评语作为可选项这样后续聚合排名时逻辑最简单。如果某些场景非要等级制也可以在前端做一次映射但数据库存的一定是规范化的数值。再就是身份权限。学生能上传作品、看别人的作品和最终排名但不能给别人打分评委老师可以看作品、打分、看已评作品列表但不能修改已提交的分数管理员负责审核作品、处理违规、管理用户。这三类角色的数据边界在项目一开始就必须定义清楚否则后面加权限逻辑会非常痛苦。1.2 功能拆分与范围控制梳理完之后我把项目范围拆成了四个模块每个模块再往下分具体功能模块功能点用户与权限注册、登录学生/评委/管理员三种角色、JWT鉴权、角色拦截作品管理上传作品、编辑信息、作品广场、作品详情页、管理员下架评分系统评分提交、防重复评分、评语、评分详情、自动聚合排行与统计平均分排行、已评分作品数、作品热度、评委评分进度这四个模块基本覆盖了老师所有的需求场景。我给老师的承诺是学生端和评委端的核心流程必须在第一版全部可用管理员端可以先粗糙一点。因为一个内部系统管理员就那么几个人但学生和评委的使用体验决定了这个系统成不成功。经验就是这种小中型的内部系统别一上来就想着做得很全把“上传作品 - 评委打分 - 出排名”这条主链路做通畅就已经成功了80%。剩下20%是权限、异常和边界情况的处理。2. 技术选型与项目初始化为什么是 Spring Boot Vue2.1 后端框架选择背后的考虑后端选 Spring Boot主要出于三点考虑。第一团队技术栈的匹配度。Java Spring Boot 在高校和中小型公司里仍然是存量最大、参考资料最多的技术栈无论是后续找人维护还是学生参与二次开发门槛都比较低。相比 Node.js 或者 Python 系的框架Spring Boot 的生态更“标准”什么人接手都不会觉得陌生。第二Spring Boot 对传统业务场景的支持非常完善。文件上传MultipartFile、数据校验Validation、权限控制Spring Security JWT、数据访问Spring Data JPA 或 MyBatis-Plus都有成熟方案。这个项目里我们不需要那些高并发、分布式之类的复杂特性Spring Boot 自带的 Web 能力就完全够用而且它的自动配置能让我们把更多精力放在业务逻辑上。第三部署和运维相对简单。Spring Boot 最终打成一个可执行 JAR 包服务器上只要装了 JDK一条java -jar命令就能跑起来对部署环境的要求很低。这一点在后来的实际部署中省了很多事。2.2 前端框架为什么用 Vue 而不是服务端模板最初老师提过一个想法能不能用传统的 JSP 直接做这样不用搭两套环境我当场否了这个方案。原因有两个。第一这个系统有大量的异步交互文件上传进度、评分按钮的即时反馈、列表的筛选切换、排行榜的实时刷新。如果用 JSP 做每交互一次就刷新页面体验会比较生硬。第二Vue 的组件化开发特别适合这种“页面结构相似但状态不同”的场景。比如作品卡片在“广场页”和“我的作品页”都要用但一个展示全部作品、一个展示当前用户自己上传的抽成公共组件后只需要通过 props 控制数据来源就行。具体选型我用了Vue 3 Vite Element Plus。Vue 3 的 Composition API 在处理评分状态、登录状态这类跨组件共享数据时比 Options API 更顺手配合reactive和ref代码写在单文件组件里很清晰。Element Plus 组件库则帮助我们快速搭出了 el-table、el-form、el-upload、el-rate 这些评分相关的核心界面表格 表单 上传 星级评分四件套正好覆盖了系统的全部交互需求。2.3 数据库设计四张核心表数据库我用的是 MySQL 8.0建了四张核心表用户表、作品表、评分表、作品分类表。分类表是为了让不同课程、不同赛道的作品分开排名这在实际使用中很有用。用户表和作品表不用多说重点说一下评分表的设计CREATE TABLE score ( id BIGINT AUTO_INCREMENT PRIMARY KEY, work_id BIGINT NOT NULL, judge_id BIGINT NOT NULL, score INT NOT NULL, comment VARCHAR(500) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_work_judge (work_id, judge_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;评分表里最关键的是uk_work_judge这个唯一索引。一个评委对同一个作品只能给一次分这个约束在数据库层面直接锁死。很多同类系统在应用层用“先查后插”来判断是否重复评分但并发情况下容易出漏子直接在数据库里加唯一索引才是最稳妥的。有了这个唯一索引后端的评分接口就简单了先用findByWorkIdAndJudgeId查一下如果存在就直接返回“该作品已评分”不存在就插入新记录。即便两个并发请求同时进来数据库的唯一索引也会拦住第二个插入抛出 DuplicateKeyException我们再捕获这个异常做友好提示即可。3. 后端核心实现上传、评分防重与排行榜统计3.1 文件上传不只是存文件那么简单作品上传是第一个让我觉得“不能想当然”的功能。当时我最初的想法很简单接收 MultipartFile然后写到本地某个目录就完事了。但真正实现时需要考虑的问题至少有四个第一文件存储路径怎么组织不建议把所有文件直接丢到一个根目录下时间长了文件数量会非常庞大。我按年/月分目录存储文件名用 UUID 重命名避免中文文件名和重名造成的乱码与覆盖问题。这样既方便排查也利于后续做备份策略。PostMapping(/api/work/upload) public Result upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件不能为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString().replace(-, ) ext; String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM)); String relaPath /upload/ datePath / newFileName; File dir new File(uploadDir / datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(uploadDir / relaPath)); String url fileProperties.getUrlPrefix() relaPath; return Result.success(url); }第二上传大小限制。Spring Boot 默认的单个文件上传上限是 1MB如果学生传一个十几 MB 的视频会直接报错。所以我在application.yml里调整了限制spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB对于纯图片作品来说 50MB 完全够用如果碰到视频类作品 50MB 也是合理范围。这个限制值是可以按实际业务调整的但在项目上线前一定要先和需求方确认清楚否则学生在线上传时频繁失败体验会非常差。第三文件类型要不要过滤这种内部系统不需要像商业平台那样做严格的文件类型白名单但如果完全不管学生传上来一个乱七八糟的格式评委下载下来都打不开。我的做法是在后端做一个简单的扩展名白名单校验常见的文档doc、docx、pdf、ppt、图片jpg、png、gif、webp、视频mp4、avi、mov都放行同时把文件类型存到数据库里方便前端决定用图片预览组件还是视频播放组件还是普通下载按钮。3.2 评分接口唯一索引兜底 业务校验评分接口是整个系统逻辑最核心的部分。前端传过来的是作品ID、分数和评语后端要做三步操作。第一步校验评委身份。通过 JWT 从当前上下文中拿到正在操作的评委ID然后通过角色判断是否是评委如果不是直接返回权限不足。第二步校验作品状态。只能给“已审核通过”的作品打分还在“待审核”状态或者“已下架”状态的作品不能参与评分否则会出现学生故意上传一个违规作品诱导评委打分的场景。第三步插入评分记录。插入之前先查一次是否已评分如果已评分就返回提示如果没评分就直接 insert。正如前面说的即便有并发冲突数据库的唯一索引也会兜底。Transactional public ScoreResult submitScore(Long workId, Integer score, String comment) { Long judgeId SecurityUtils.getCurrentUserId(); if (!securityService.isJudge(judgeId)) { throw new BizException(无评分权限); } if (score null || score 0 || score 100) { throw new BizException(分数必须在0到100之间); } if (workService.getWorkStatus(workId) ! WorkStatus.APPROVED) { throw new BizException(该作品当前不可评分); } if (scoreRepository.findByWorkIdAndJudgeId(workId, judgeId).isPresent()) { throw new BizException(您已经对该作品评过分); } try { Score saved scoreRepository.save( new Score(null, workId, judgeId, score, comment) ); // 更新作品的平均分与评分人数 workService.updateScoreStat(workId); return new ScoreResult(saved.getId()); } catch (DuplicateKeyException e) { throw new BizException(您已经对该作品评过分请勿重复提交); } }这里有一个容易被忽略的细节评分提交之后要立即更新作品表里的平均分字段。如果没有这个冗余字段查询排行榜时需要实时AVG()聚合虽然数据量小也能跑但接口响应会慢一些而且每次按平均分排序还要连表子查询SQL 写起来也比较绕。直接在作品表加avg_score和score_count两个冗余字段每次评分后同步更新查询排行榜就简单多了。3.3 排行榜统计平均分 人数 热度的合理组合排行榜不能只看平均分这一点是我和老师讨论后确认的。如果只看平均分一个只被1个人打了100分的作品排名会压在50个人打了95分的作品上面显然不合理。所以最终的排行规则是综合排序主排序用平均分但平均分相同的情况下按评分人数降序排另外再加一个“人气值”等于评分人数加浏览数用于首页的热门推荐列表。主排行榜的 SQL 很简单SELECT w.* FROM work w WHERE w.status 1 ORDER BY w.avg_score DESC, w.score_count DESC, w.create_time DESC LIMIT #{offset}, #{limit}这个 SQL 在数据量几百条、几千条时性能完全没问题。如果以后作品量涨到几万条再考虑在avg_score字段上加索引或者引入 Redis 缓存。3.4 角色权限基于 Spring Security JWT 的轻量方案权限方案我选了 Spring Security JWT。刚开始的时候想过用 Shiro但 Spring Security 在 Spring Boot 3 时代的集成更顺滑而且PreAuthorize注解的方式控制接口权限很直观。核心思路是登录接口返回一个 JWT前端存储在 localStorage 里每次请求在 header 里带Authorization: Bearer xxx。后端通过过滤器解析 JWT把用户ID和角色放进SecurityContext然后在 Controller 方法上用注解控制访问权限PostMapping(/api/score/submit) PreAuthorize(hasRole(JUDGE)) public Result submitScore(RequestBody SubmitScoreRequest request) { return Result.success(scoreService.submitScore(request)); }角色我用了三个常量STUDENT、JUDGE、ADMIN。学生可以调用上传、浏览类接口评委额外拥有评分类接口管理员则拥有审核、下架、用户管理类接口。整个角色体系通过 Security 配置类统一声明没有散落到各个 Controller 里后期维护非常省心。4. Vue 前端落地页面组织、状态共享与上传组件细节4.1 路由与页面结构前端项目结构我按功能模块拆分不是按页面组件类型硬拆。页面路由如下/login—— 登录注册页/works—— 作品广场页所有人都能看/works/:id—— 作品详情页作品展示 评分入口/ranking—— 排行榜页/profile—— 个人中心学生管理自己的作品/admin—— 管理后台管理员专用/judge—— 评委工作台已评/未评列表路由守卫用了 Vue Router 的beforeEach钩子根据本地存储的角色信息判断能否进入对应页面不能进入的直接重定向到首页。这种前端路由守卫只是用户体验层面的拦截真正的安全校验永远在后端接口上这一点要在文档里和前端同学讲清楚。4.2 axios 封装请求拦截与统一错误处理所有接口请求我都封装在一个统一的 axios 实例里。主要做两件事请求拦截时自动带上 JWT响应拦截时统一处理 401 和 500 错误。service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(error.response?.data?.msg || 网络异常请稍后再试) } return Promise.reject(error) } )这个封装的好处是业务组件里只需要调用workApi.getList()就能拿到res.data所有关于 token 和错误弹窗的逻辑都收敛到了一个地方团队协作时代码风格也非常统一。4.3 作品上传组件进度条 类型控制 链接回填上传组件用的是 Element Plus 的el-upload。但它的默认行为是“选完文件立刻上传”而我们这里需要等待用户填写作品标题和简介再点击“发布作品”按钮时才真正上传。所以我没有用el-upload自带的自动上传模式而是把auto-upload设为 false然后自己维护一个待上传文件列表。el-upload refuploadRef :auto-uploadfalse :limit1 :before-uploadbeforeUpload accept.jpg,.png,.gif,.pdf,.doc,.docx,.mp4,.zip drag div classupload-tip将作品文件拖到此处或点击选择文件/div /el-upload在提交发布时先拿到选择的 File 对象调用/api/work/upload上传拿到返回的文件 URL 后再调用/api/work/publish把标题、简介、文件 URL 一起提交。这种“先传文件再提交业务数据”的方式比直接把文件塞进业务提交请求里更灵活后续上传进度也更好控制。before-upload里我做了文件大小预检超过 50MB 直接拒绝并提示避免用户等了很久上传才发现失败const beforeUpload (file) { const maxSize 50 * 1024 * 1024 if (file.size maxSize) { ElMessage.warning(文件大小不能超过 50MB) return false } return true }进度条通过on-progress事件控制当法上传大视频时用户能直观看到进度条在走就不会反复刷新页面了。4.4 评分交互星级组件提交后锁定评分页面我用el-rate做星级打分组件同时旁边配一个数值显示当前分数点击“提交评分”按钮后调用评分接口成功后把按钮置为禁用状态改成“已评分”。这里要注意一个状态同步的问题用户在作品详情页评分成功之后如果再进入另一个作品详情页评分组件不能把上一次的评分状态带过来。我在每个评分组件挂载时根据当前作品 ID 动态查询“该评委是否已评分”如果已评分就直接显示已有分数并禁用组件如果没评分则恢复可操作状态。这个逻辑很小但很容易被忽略一旦漏掉评委就会看到一个重置过的可评分组件以为可以重复评分很困惑。5. 联调与部署踩过的三个坑跨域、上传限制和并发评分5.1 前后端联调时的 CORS 问题前端跑在http://localhost:5173Vite 默认端口后端跑在http://localhost:8080联调时第一个遇到的就是跨域问题。浏览器会拦截前端发出的 Ajax 请求报错信息大家都见过Access to XMLHttpRequest ... has been blocked by CORS policy。解决方式是在后端加一个全局 CORS 配置Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }有一个细节值得提醒如果前端请求携带了Authorization请求头addAllowedHeader(*)在部分 Spring Security 版本里会被识别为“不允许通配符加凭证”需要显式把请求头列出来或者使用allowedHeaders(*)配合allowCredentials(true)的兼容写法。这个坑我当时调了小半天才定位清楚。不过在正式部署时前后端是用 Nginx 同域部署的跨域问题就自然消失了因为所有请求都走了同一个端口Nginx 把/api反代到后端服务静态资源直接本地返回。这个 CORS 配置只是开发期用。5.2 上传限制的三个层级Spring Boot、Nginx、Tomcat文件上传大小限制不是一个地方能完全控制的它实际上是一个链路前端、后端、反向代理层都可能限制。后端的max-file-size我上面已经说过但部署时还遇到了 Nginx 默认上传上限的问题。Nginx 配置中默认的client_max_body_size是 1MB。也就是说即使 Spring Boot 允许上传 50MB如果 Nginx 没改这个参数请求在 Nginx 那一层就被拦下来了返回 413 Request Entity Too Large。这个问题在本地开发时根本不会出现只有部署到服务器上才会暴露。server { listen 80; server_name your-domain.com; client_max_body_size 50m; location /api/ { proxy_pass http://127.0.0.1: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 /upload/ { alias /data/app/uploads/; } }这里有另一个隐蔽的点Spring Boot 内嵌的 Tomcat 也有一个max-swallow-size的配置虽然它默认不限制上传大小但如果你用了自定义的TomcatConnector配置要注意别误设了限制。嵌入式 Tomcat 在大多数情况下不需要单独调但知道有这么一层存在排查问题时思路会清晰很多。5.3 并发提交评分数据库唯一索引是唯一可靠的保证有一次模拟测试中我们同时用两个浏览器标签页对一个作品发起评分请求结果发现两个请求都返回了“评分成功”但数据库里只有一条记录。这个现象其实最危险的地方不是评分成功提示错误了而是有一个用户明明只评了一次前端却认为他评了两次导致他觉得自己的第二次分数被系统吞了反复点击提交又反复报“已评分”体验非常差。这个问题的根源在于如果应用层用“先查后插”两个并发请求可能在“查”的阶段都拿到不存在然后都往 insert 走由于数据库的唯一索引最终只插入成功一条另一个 insert 抛异常。但异常是关键——如果对重复键异常处理不好前端收到的就会是 500 错误而不是友好的业务提示。我在前面代码里已经通过try/catch(DuplicateKeyException)做了处理这时前端会收到“您已经对该作品评过分请勿重复提交”的提示。测试下来无论是并发还是连续点击用户都能得到正确反馈。这种场景下任何应用层的锁或者标志位都没有数据库唯一索引可靠因为应用是多实例并发只有数据库是全局统一的。6. 评分公平性设计从“人人可评”到“评委打分”6.1 初版设计的失误项目第一版我做了一个很天真的设计只要登录了所有人都可以打分想着这样参与度高一些分数也更有参考意义。结果测试阶段就暴露了问题——学生在内部测试群里互相拉票、互刷高分导致排行榜被个别刷分作品占据老师们普遍不认可这个排名。这个教训让我意识到评分系统的公平性设计必须和技术功能一样重视。虽然这是一个内部系统但一旦涉及评比、竞争、排名人性中的博弈就会显现。系统不能假设所有人都是善意的必须从规则上把刷分的口子堵住。6.2 最终的权限与投票规则经过和老师反复讨论我们最终落地了以下规则学生可以上传作品、浏览作品、查看排行榜但完全没有评分权限。评委老师可以评分每个评委对同一作品只能评一次提交后不可修改。每个作品至少需要3个评委评分分数才计入排行避免“一人独打分”导致的失真。管理员可以重置某个作品的评分记录以便处理“评错分”或“恶意评分”的异常情况。评分结束后按截止日期判断系统自动锁定评分入口排行榜冻结。这些规则的背后是同一个目的让这个分数尽量反映作品质量而不是人脉广度。一个作品至少要3个评委评分才进入排行榜这条规则直接避免了“某个作品只有1个评委给了100分就排第一”的荒谬情况。6.3 评委工作台的实现为了配合上述规则我为评委单独设计了一个工作台页面。页面的核心是一个分栏列表左侧显示“待评分作品列表”右侧显示“已评分作品列表”。评委在左侧点击作品进入详情评分提交后该作品自动从左栏移动到右栏并标记已评分数。这个交互看似简单但实现时要在前端处理好列表的状态更新。我用了 Vue 3 的reactive来维护两个列表数组评分成功后从“待评列表”里过滤掉该作品同时推入“已评列表”并更新分数展示。因为数据量不大前端直接用内存状态管理就足够了不需要引 Vuex/Pinia 这种重量级状态库这也是小项目里“够用就好”的一个典型例子。7. 项目上线前后的经验沉淀整个项目从需求梳理到上线部署前后大约用了一个半月。其中有半个月是在开发和联调剩下时间花在了测试、修 bug 和部署上。最后总结几条我个人的经验供后面做类似系统的人参考。第一这类内部系统的成败关键从来不是技术多炫而是业务规则是否清晰。评分规则、身份权限、文件大小、作品状态流转这些业务规则的优先级要排在技术架构之上。建议在动手写代码前先用一张表格把所有角色所有操作列出来和需求方逐条确认。第二上传功能一定要提前做“全链路限制检查”。前端限制、后端限制、Nginx 限制、超时设置任何一个节点没设置好用户上传大文件就会失败。这类问题有个共同特征是本地一切正常部署到服务器就挂了原因就在于多了一层 Nginx 或网关配置。排查这种问题时建议按“浏览器 - Nginx - 后端 - 数据库”的链路逐层看日志和返回状态码效率最高。第三数据库的唯一约束是数据的最后一道底线。像评分这样“一次操作不可重复”的业务一定不能只依赖应用层判断必须配合数据库唯一索引。我在多个项目里验证过这是最简单、最可靠、最不容易出错的方案强烈建议在所有类似场景里直接采用。另外一个小建议是如果需求方允许在上线前先找一小批真实用户比如三五个学生、两个老师试用几天收集反馈再正式推广。这个系统第一版上线后老师们反馈最多的其实是“作品列表能不能按分类筛选”这种小需求提前试用的好处就是能在小范围内快速迭代优化避免在大规模使用后手忙脚乱。这个评分网站本身只是一个小项目但它的开发过程几乎覆盖了前后端分离开发中的典型问题权限设计、文件上传、并发控制、跨域调试、服务器部署。做完这个项目之后我也发现很多所谓“简单的小需求”只要你把业务场景吃透、把边界情况想清楚做出来的系统和未经思考就堆代码的结果会有本质区别。希望这篇记录能帮到你也欢迎有类似项目经验的朋友一起交流更优的评分策略和实现思路。
阅读完成 · 觉得有帮助?