基于Node.js与Vue构建高校创业项目申报评审系统

发布时间:2026/9/30 9:22:26
基于Node.js与Vue构建高校创业项目申报评审系统 1. 先想清楚业务边界这个系统到底要管哪些事1.1 被Word文档和共享表格淹没的申报季每年高校创新创业项目申报季很多学院还是老办法创新创业学院发通知学生交Word申报书和附件指导老师签字学院汇总Excel评审专家线下开会打分最后由工作人员熬夜加总排名。这套流程走下来至少有四个明显的痛点——材料格式五花八门有人交PDF有人交Docx有人直接把文件名写成最终版2汇总信息全靠人工比对项目名称、负责人、学院字段经常对不上评审过程不透明容易出争议个别项目连评审意见都找不到记录数据统计和归档基本靠手工复制等要年度总结时又是一轮翻箱倒柜。我被这种流程折磨过不止一次后决定用Node.js和Vue搭一套大学生创业项目申报评比系统把在线填报、附件上传、学院初审、评委打分、结果公示、数据归档整个链路搬到网页上。系统上线后原来一周的申报汇总工作压缩到几十分钟评审专家不用再挤在会议室里传阅纸质材料。这篇文章就把整个设计和落地过程完整复盘一遍包括业务建模、后端接口、前端页面、环境配置的坑希望能给准备做同类系统的同学一个可以直接参考的蓝本。1.2 角色划分和申报评审的完整路由真正动工之前我先拉了一张人×事的矩阵表。这个系统里至少有五类角色学生/项目负责人、指导老师、学院管理员、校级管理员、评审专家。他们关心的东西完全不一样项目负责人在线填写申报书、上传附件、查看当前状态和评审结果。指导老师确认项目真实性和可行性给学生提供修改意见但不必参与系统内的打分流程。学院管理员审核本院项目做第一道把关可以退回补充材料。校级管理员分配评审专家、设置评审维度、发布最终结果是系统里权限最高的角色。评审专家查看被分配到的项目材料按维度打分并填写评审意见。角色定清楚之后业务链路就顺理成章了。我把申报评审的流程拆成六个核心节点草稿draft→ 已提交submitted→ 学院初审中college_reviewing→ 初审退回college_rejected→ 校级评审中under_review→ 已完成completed。细一点还可以在已完成后面分出立项通过和立项不通过但主流程保持六态因为状态越多前后端联调的分支就越多做到后面你会发现每个状态都要配一套按钮和提示文案工作量是叠加的。在这个状态机里有一条很关键的业务规则每个项目在同一时刻只能由一个角色主导推进。比如学生提交之后只有学院管理员能把状态推入初审或者退回校级管理员把项目分配给评委后只有评委能填写分数所有人都不能绕过前置状态直接跳转。这条规则在代码里体现为后端接口除了校验身份还必须校验当前用户对当前状态是否有操作权而不是只看角色名称。1.3 需求优先级和技术选型为什么是Node.js加Vue我把需求排成三个优先级。P0是系统生存线注册登录、申报表单、附件上传、评委打分、结果查看P1是效率线学院初审、评分汇总、Excel导出、公告管理P2是体验线消息通知、数据图表、多轮答辩管理。一开始就上P2的团队往往会把项目拖垮我先盯着P0和P1做P2留给二期。技术选型上标题已经定了Node.js加Vue这个选择放在业务背景下看相当合理。这套系统本质上是高并发并不夸张、但CRUD和文件流转密度很高的业务系统Node.js的异步模型在文件上传和IO密集操作上很顺手Express加Sequelize这套组合写增删改查的效率比SpringBoot高不少尤其适合两三个人的小团队短期交付。前端选Vue 3则是因为组件化写表单和评审看板都很方便Element Plus把表格、上传、校验这些麻烦事都封装好了开发速度肉眼可见地快。顺带说一句不要因为项目看起来像大系统就引入微服务、容器编排、消息队列。我在初期评估时明确砍掉了RabbitMQ和Redis因为单机部署的Node.js配合MySQL连接池已经能扛住几千个申报项目多加组件只会增加运维负担。系统的复杂度应该跟着业务复杂度走而不是跟着技术名词走。2. Node.js后端落地从表结构设计到评分接口实现2.1 技术栈定调和项目骨架后端我选了下面这套组合Node.js 16 LTS、Express 4、MySQL 8、Sequelize 6、jsonwebtoken、multer、dayjs。不用Koa是因为Express的中间件生态更老练网上能搜到的方案多出了问题好排查不用MongoDB是因为评审和汇总需要多表关联查询关系型数据库在这种场景下更稳。项目结构没有用脚手架生成而是手动分成几个目录强制自己遵守分层约定project-review-server/ ├── app.js # 入口注册中间件和路由 ├── config/ │ └── index.js # 数据库连接、JWT密钥、上传路径配置 ├── models/ # Sequelize模型定义 │ ├── user.js │ ├── project.js │ └── review.js ├── routes/ # 路由定义只负责分发 │ ├── auth.js │ ├── project.js │ ├── admin.js │ └── review.js ├── controllers/ # 业务逻辑处理 ├── middlewares/ # 鉴权、错误处理、文件上传 ├── utils/ └── uploads/ # 上传文件的本地存储目录分层的时候有个原则路由文件里不要写业务逻辑只做参数解析和调用controller。一开始图省事把查询全堆在路由里到后来评分接口要复用项目查询逻辑时才发现没法组织代码。这个坑不值得再踩一遍。2.2 数据库核心表和为什么这么设计数据库一共三张核心表外加一张评审维度表。先看users表CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(200) NOT NULL, role ENUM(student,college_admin,admin,judge) NOT NULL DEFAULT student, real_name VARCHAR(50) NOT NULL, student_no VARCHAR(20), college VARCHAR(100), phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP );password_hash字段一定要存加盐后的哈希值。项目中我用Node.js内置的crypto.scrypt生成虽然比bcrypt慢但好处是不用装第三方本机编译依赖。注册接口里salt随机生成存储格式是salt:hash校验时拆开重新算一遍。projects表是整张业务核心CREATE TABLE projects ( id INT PRIMARY KEY AUTO_INCREMENT, project_no VARCHAR(30) NOT NULL UNIQUE, applicant_id INT NOT NULL, project_name VARCHAR(200) NOT NULL, category VARCHAR(50) NOT NULL, summary TEXT NOT NULL, members TEXT, -- 成员信息存JSON字符串 advisor VARCHAR(100), attachment_url VARCHAR(500), status VARCHAR(30) NOT NULL DEFAULT draft, college_review_comment VARCHAR(500), final_score DECIMAL(5,2), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status (status), INDEX idx_applicant (applicant_id), CONSTRAINT fk_applicant FOREIGN KEY (applicant_id) REFERENCES users(id) );project_no是申报编号生成规则是年份类别缩写四位序号比如2025CX0001。为什么不用自增id直接给用户看因为申报编号要对外公示暴露自增id会把系统里的项目总数泄露出去这种细节在政企系统里很敏感。members字段存JSON字符串查出来JSON.parse更新时整个覆盖因为成员人数少、不需要按成员单独检索没必要拆一张表。reviews表负责评委打分CREATE TABLE reviews ( id INT PRIMARY KEY AUTO_INCREMENT, project_id INT NOT NULL, judge_id INT NOT NULL, scores TEXT NOT NULL, -- 各维度得分{innovation:85,...} total_score DECIMAL(5,2) NOT NULL, comment TEXT, status ENUM(pending,submitted) NOT NULL DEFAULT pending, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_project_judge (project_id, judge_id) );scores字段的设计我想解释一下。评审维度是可配置的——今年可能是创新性、可行性、市场价值、团队能力明年可能加一个社会效益。把维度拆成单独一张review_items表、分数再拆一张review_scores表的方案更正统但会给查询带来大量JOIN。实际项目里我选择了把维度权重配置放在一张dict表里分数直接存JSON读取后在前端做加权计算后端只保存总分。这样改维度不需要改表结构代价是统计SQL略麻烦需要通过应用层去做平均分计算。对于一个评审小组几十人规模的项目这个取舍完全划得来。2.3 最关键的接口申报状态流转和评分汇总接口设计遵循RESTful风格重点说三个接口。申报提交接口学生保存草稿和正式提交是两个接口。POST /api/projects创建草稿PUT /api/projects/:id保存POST /api/projects/:id/submit正式提交。submit接口里做了三件事校验必填字段是否完整、校验附件是否上传、把状态从draft改为submitted。这里有个容易忽略的点既然前端都做了表单校验后端为什么还要校验因为接口可以被curl直接调绕过前端直接提交非法数据。我后来遇到过有人用脚本批量提交空项目全靠后端校验拦住了。评审分配接口校级管理员调用POST /api/admin/review-assign参数是项目id列表和评委id列表。分配逻辑看着简单就是要往reviews表插入多条记录但必须做一件事检查同一个项目不能重复分配给同一个评委。我在写了UNIQUE KEY约束的基础上还在controller里用findOrCreate做了兜底防止并发请求下重复插入。评分提交接口评委打分是业务核心代码用事务包裹const transaction await sequelize.transaction(); try { const review await Review.findOne({ where: { id: reviewId, judge_id: req.user.id }, transaction }); if (!review) throw new Error(评审任务不存在); if (review.status submitted) throw new Error(该评分已提交不能修改); const total calcWeightedTotal(scores, weights); // 加权总分 await review.update({ scores: JSON.stringify(scores), total_score: total, comment: comment || , status: submitted }, { transaction }); const avgResult await Review.findAll({ where: { project_id: review.project_id, status: submitted }, attributes: [ [sequelize.fn(AVG, sequelize.col(total_score)), avgScore], [sequelize.fn(COUNT, sequelize.col(id)), cnt] ], transaction }); await Project.update( { final_score: Number(avgResult[0].dataValues.avgScore).toFixed(2) }, { where: { id: review.project_id }, transaction } ); await transaction.commit(); } catch (err) { await transaction.rollback(); throw err; }这块有两件事必须说第一事务防止评分提交一半、项目平均分没更新的情况第二查询时同时限定reviewId和judgeId保证评委只能改自己的评分这是数据权限的核心不能把judgeId交给前端传。计算总分时我在配置表里存了每个维度的权重比如创新性20%、可行性30%、市场价值30%、团队能力20%总分 每个维度原始分乘以权重后求和。评审结束后还要统计最终排名SQL用ORDER BY final_score DESC再LIMIT分页就行。2.4 鉴权中间件、文件上传和错误处理的实战注意点鉴权是中间件里最基础的一层。我用jsonwebtoken生成token有效期7天。auth中间件从Authorization头里解析Bearer token解出来用户id和角色挂到res.locals.user上。然后在此基础上封装了一个roleGuard工厂函数const roleGuard (...roles) { return (req, res, next) { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: 没有操作权限 }); } next(); }; };路由里这样用router.post(/submit, auth, roleGuard(student), projectController.submit)。要注意的是角色校验只是第一道门数据权限还要在controller里二次判断——比如学院管理员只能操作本学院项目写SQL时必须带college过滤条件否则一个管理员能查全校数据就是安全事故。文件上传用multer的diskStorage保存到uploads目录。我踩过几个坑一是文件名直接用中文会乱码二是上传大文件时默认内存存储会爆内存三是访问路径的问题。我的做法是文件存储名用时间戳随机数扩展名数据库只存相对路径/uploads/xxx.pdf然后通过Express的express.static中间件直接映射静态目录app.use(/uploads, express.static(path.join(__dirname, uploads)));限制文件大小我用limits: { fileSize: 20 * 1024 * 1024 }扩展名白名单只允许.pdf、.doc、.docx和.zip。这里有一个容易被忽略的不要用multer的fileFilter去校验MIME类型因为MIME可以伪造还要配合检查扩展名才算稳。最后说错误处理。我在app.js末尾挂了一个全局错误处理中间件所有controller里的业务错误统一抛出一个带statusCode的自定义错误对象由这个中间件统一返回JSON。这样至少有两点好处不会因为某个接口忘了try/catch导致进程崩溃前端也能统一处理401、403、500这些状态码。3. Vue前端实战申报表单、评审看板与状态流转3.1 页面路由规划按角色组织前端模块前端用的Vue 3加Vite加Vue Router加Pinia加Element Plus这套组合现在跑得很顺。路由规划按角色拆页面不算多但每类角色看到的菜单完全不同/login 登录页/dashboard 工作台根据角色重定向到对应首页/apply 学生端我的申报列表、新建申报、编辑申报、查看详情/review 评委端我的评审任务、项目详情、打分页面/admin 管理员端全院项目、分配评委、结果发布、评审维度配置路由守卫是重点。router.beforeEach里做两件事先判断本地有没有token没有就去登录页有token就调/auth/me拿用户信息存到Pinia再根据角色判断当前路由是否允许进入。有人会把允许访问的路径写死在meta里后端返回的菜单数据和路由的name做比对这样权限配置灵活一些。我用的是meta里定义roles数组前端简单直接但记住这只是体验优化真正的权限拦截永远在后端。路由传参这里多说一句。项目详情页如果通过query传projectId刷新页面后会丢失参数因为URL里的query还在但变量没初始化。我的做法是详情页路由路径设计为/review/detail/:projectId用路由参数而不是query刷新后参数依然存在。这两个方式很多新手分不清实际用下来path参数更符合资源定位的语义。3.2 申报表单的动态校验和附件上传申报表单是整个学生端最复杂的页面因为它要处理的内容类型多基础信息、成员列表、附件。我用element-plus的el-form和rules校验基础字段比如项目名称、类别、摘要都是必填校验规则写在script里集中管理比散落在模板里好维护得多。成员列表用动态表单实现。我的做法是在form里维护一个members数组页面上v-for循环渲染每一行每行包含姓名、学号、专业三个输入框加一个删除按钮添加成员按钮只push一个新对象。这里要注意给每一行加上唯一的key否则删除中间某行时Vue的复用机制会让后面的输入框内容错位。这个bug排查起来很隐蔽第一次遇到时我足足找了半个小时。附件上传用el-upload组件关键配置是action指向后端上传接口headers里带上JWT的Authorization头上传成功后on-success回调把后端返回的url写入表单。我这里特别强调一下el-upload的v-model和表单的状态值要分开维护不要把上传组件的fileList直接交给后端。因为fileList里是包含status和raw对象的结构直接提交会带出一堆无关字段。保存草稿和正式提交是两个按钮。保存草稿只做简单的字段收集不调用validate正式提交前调用formRef.validate()通过后再调用submit接口。这个设计解决了一个真实痛点学生填到一半想退出可以直接保存草稿下次进来还能接着填不用重新写一遍项目摘要。3.3 评委打分界面和结果展示的实现评委端的核心页面是打分页。布局分成左右两栏左栏展示项目申报材料包括项目名称、类别、摘要、附件下载链接右栏是打分表单。评分维度从后端配置接口拉取前端遍历动态渲染这样后端改了维度前端不用发版。打分控件我选的是el-slider滑块一个维度一个滑块旁边实时显示分值。页面用computed实时计算加权总分公式和后端保持一致const totalScore computed(() { const weights dimensionConfig.value; // [{name:创新性, weight:0.2}, ...] return weights.reduce((acc, dim) { return acc (scoreForm[dim.name] || 0) * dim.weight; }, 0).toFixed(2); });提交时有一个二次确认弹窗文案是提交后无法修改评分是否确认这个弹窗非常有必要因为确实有评委手滑点错了如果没有二次确认分数直接进库会产生很大争议。结果公示页就是项目排名表格加统计图表。排名表格用el-table按final_score降序排列。统计图表我用了ECharts按需引入柱状图和雷达图管理员看总体分数分布用柱状图单个项目的各维度得分用雷达图更直观。ECharts按需引入的写法是import * as echarts from echarts/core; import { BarChart, RadarChart } from echarts/charts; import { GridComponent, TooltipComponent } from echarts/components; echarts.use([BarChart, RadarChart, GridComponent, TooltipComponent]);之所以这么写而不是全量引入echarts是因为全量打出来的包超过1MB首屏加载会很慢。按需引入后体积降到300KB左右效果立竿见影。3.4 调试过程中被问到最多的Vue问题排查聊几个实际开发中频繁踩的Vue相关问题。第一个是Vue DevTools插件。刚接触Vue的同学装了插件后发现看不到组件树十有八九是项目在本地开发服务器上运行但页面没有加载插件或者插件版本和Vue版本不匹配。Vue 3项目要装对应Vue 3版本的DevTools在Chrome扩展商店搜Vue.js devtools确认版本是beta或标注支持Vue 3的构建版本。装好之后打开localhost页面控制台里才能看到Components和Pinia状态。第二个是package install时卡住或报ERESOLVE。这个问题通常和依赖冲突有关常见解法是删除node_modules和package-lock.json后重新安装。更稳的做法是先用npm update升级一下小版本或者明确锁定Vue和Element Plus的大版本不要混着升。Element Plus 1.x和2.x的API有差异混装容易出现表格组件渲染异常。第三个是组合式API和选项式API混用。我的项目早期用选项式写业务组件后来切到组合式结果就是有的组件里setup和data同时存在变量来源不清晰维护时要两头找。这种混合开发偶尔解决迁移问题可以但新代码统一用组合式更舒服别学我早期那种骑墙写法。4. 从环境配置到联调上线踩坑记录与解决路径4.1 Node.js安装和环境变量新手的第一道坎写这套系统前少说要先装好Node.js。我见过太多人在这一步卡住最常见的安装方式是官网下载安装包一路Next装完发现node和npm路径带空格比如D:\Program Files (x86)\nodejs后续很多脚本会莫名其妙报错。更稳妥的做法是直接下载LTS版本的.msi安装包安装时自定义目录到一个没有空格的路径比如D:\nodejs。安装后第一件事验证环境变量。打开命令行输入node -v和npm -v如果提示不是内部或外部命令说明安装时的PATH没写进去。手动在系统环境变量里加一条D:\nodejs然后重开终端。这里有个小技巧Windows下可以用where node查看当前生效的node路径如果发现指向了某个奇怪的旧版本优先检查PATH顺序。另外强烈建议装一个nvm-windows来管理Node版本。不同项目依赖的Node版本不一样——老项目node-sass在Node 17以上编译不过新项目Vite又要求Node 18以上。nvm-windows可以一键切换版本省去反复卸载安装的痛苦。我自己后来所有项目都先用nvm装好指定版本再往下走依赖安装。4.2 npm脚本执行权限和PowerShell的坑我相信很多人都见过这个报错npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这个问题的根因不是npm坏了而是Windows PowerShell默认的脚本执行策略是Restricted禁止运行任何.ps1脚本。npm的终端入口是npm.ps1自然被拦住了。解决办法有两种第一种是用管理员身份打开PowerShell执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser这条命令的含义是本地创建的脚本可以运行从网络下载的脚本必须有可信签名。RemoteSigned比Unrestricted安全得多够用了。第二种更省事在cmd里运行npmcmd不经过PowerShell脚本策略直接就能执行npm.cmd。这个坑本身不难难的是很多人不知道报错和脚本策略相关跑去重装Node或者删注册表反而越弄越乱。如果执行策略改完还是报错再检查一下是不是某些IDE的集成终端仍然继承旧的策略设置重启IDE通常能解决。4.3 前后端联调跨域开发环境和生产环境两套方案前后端分离模式下跨域是逃不掉的问题。前端跑在localhost:5173后端跑在localhost:3000直接用axios请求后端会触发CORS错误。我在开发阶段没有使用后端cors插件而是用了Vite的服务端代理。在vite.config.js里配置server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }前端请求的baseURL写成/api发出去后由Vite的开发服务器转发到3000端口。这样做的最大好处是浏览器看到的请求是同源的不会触发CORS预检也省去了后端配置跨域白名单的麻烦。但生产环境就不能依赖Vite了。我的部署方案是Nginx做静态资源服务和反向代理前端构建后的dist目录直接交给Nginx托管所有/api路径的请求通过location /api转发到Node进程server { listen 80; server_name your-domain.com; root /var/www/project-review/dist; index index.html; location /api { proxy_pass http://127.0.0.1:3000/api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads { alias /var/www/project-review-server/uploads; } }这里有个Nginx反向代理的潜规则location /api里的proxy_pass如果带URI会把原始URI里匹配前缀的部分按规则替换如果只写proxy_pass http://127.0.0.1:3000;不带路径则会原样透传。我用不带路径的写法后端路由保持不变少一层适配逻辑。同时/uploads静态目录单独暴露和/api区分开避免让Node进程去处理文件下载的带宽压力。4.4 用PM2和Nginx把系统跑上服务器服务器我选了Ubuntu 22.04原因很简单系统和Node生态配合最好。部署流程分四步。第一步装环境。通过nvm安装Node 16再用npm安装pm2全局包。pm2的作用是让Node进程在后台常驻崩溃自动重启还能记录日志。进程启动命令是pm2 start app.js --name project-review-server pm2 save pm2 startuppm2 save把进程列表保存下来重启服务器后pm2 startup会自动恢复进程。这一步必须做否则服务器一重启全站挂掉。第二步构建前端。在本地或CI环境跑npm run build生成dist目录然后上传到服务器的/var/www/project-review/dist。上传我用rsync而不是scp因为rsync只同步差异文件几十MB项目第一次同步慢后续每次只推几百KB的增量效率完全不是一个级别。第三步初始化数据库。在服务器MySQL里建库、建用户、导入表结构。注意不要用root账号给应用连数据库单独建一个project_app用户只授权一个库的所有权限防止SQL注入或漏洞影响到其他库。这是安全基线不是什么高深技巧。第四步配置Nginx并启动。把上面的server块写进/etc/nginx/conf.d然后nginx -t检查语法systemctl reload nginx生效。SSL证书这块如果域名和备案条件允许建议加上HTTPS因为登录接口传输的是密码哈希虽然是哈希值不是明文但网络中间人依然可能重放攻击。我的做法是先用Lets Encrypt配置证书然后环境太复杂可以直接用HTTP内网部署看团队对合规性的要求。5. 系统上线后我最想重构的几处设计5.1 权限模型从role判断升级为RBAC第一版权限模型非常简单users表一个role字段前端根据role控制菜单后端用roleGuard校验角色。这套模型在一类用户只对应一个角色时够用但真实高校场景里一个老师可能既是指导老师又是评审专家甚至有人一人兼了学院管理员和校级管理员。role是字段不能并存硬性分配必然出现权限缺失或越权。重构方向是RBAC模型user、role、permission三层用户和角色多对多角色和权限多对多。后端中间件从校验角色变成校验权限点比如can:project:review前端菜单从按角色过滤变成按权限点过滤。这个改造的难点不在建表而在存量数据和接口校验逻辑的迁移。我的建议是如果系统还处在早期越早引入RBAC越省力不要等用户数据积累多了再动。5.2 评审公平性和多轮评审的业务设计系统上线后有老师提了一个灵魂问题评委打分的公平性能不能保证这提醒我把评分业务再往深做了一层。现在固定评委分布不均可能出现某个评委在创新性维度上特别宽容导致他评的项目整体偏高。解决方案是统计汇总时对每个维度的原始分先做标准化或者采用去掉一个最高分去掉一个最低分再取平均的规则。去掉最高最低的SQL不复杂但要在事务里保证一致性和之前评分提交接口一样。更规范的评审流程会把初评和答辩分开初评筛掉一部分明显不合格的项目进入答辩环节的项目再由现场评委重新打分。这套逻辑在状态机里相当于从under_review之后又插了一个答辩节点。我在设计时其实已经预留了评审轮次字段只是第一版没有排期去做。如果你要复刻这套系统建议一开始就把轮次概念加进去否则后期加会改很多关联SQL。5.3 申报高峰期的性能与文件存储扩展系统第一个申报季就遇到了真实压力全校上千个项目同时提交集中在填报截止日当天晚上8点后的三小时。Node单进程实际表现没问题因为提交接口是IO密集型的轻计算逻辑数据库连接池配置了20个连接也撑住了。但有一个瓶颈非常明显——uploads目录存在本地磁盘文件备份和扩容都受限于单机磁盘大小。第二年申报如果附件的平均大小涨到30MB1000个项目就是30GB单机磁盘会快速告急。重构路径是对象存储附件上传后转存到云OSS数据库存对象存储的key访问时生成预签名URL。这个改动会把multer的逻辑改成stream上传而不是diskStorage好在接口路径不变前端基本无感。另外我建议在uploads之外加一层访问日志记录谁在什么时间下载过哪个项目的附件这在出现材料泄露纠纷时可以追踪源头属于低成本高保障的设计。整个项目做下来我最大的体会是这类系统技术门槛不高真正难的是把业务规则想清楚——状态怎么流转、角色怎么隔离、评分怎么保证可信。Node.js和Vue只是一对趁手的工具它们负责把复杂的业务流程变成顺畅的点击体验。如果你正在做类似的申报评比系统先把角色权限和状态机画明白再动手写代码后面会顺很多。