简介:这是一份科技项目在线评审系统的完整源码,面向科研管理者、评审专家与科研人员,围绕项目申报、评审管理、进度跟踪、绩效评估等核心环节展开,适合Java Web开发者、高校学生以及需要搭建科研管理平台的技术人员参考学习。资源包共909个文件,压缩后仅1.61MB。文件以html前端页面、java与class后端逻辑、css/js交互脚本为主体,辅以png/gif图片素材、json配置及sql数据库脚本,前后端层次清晰,便于按功能模块对照研读。从内容预览可见,后台Controller分工明确,体现了较为完整的权限与业务管理划分。目前已有76人学习该资源。读者可获得系统化的在线评审功能实现代码、后台管理模块设计思路,以及科研申报评审流程的信息化落地参考,适合作为课程设计、毕业设计或实际项目改造的基础版本。
1. 科技项目在线评审系统到底要管什么:从一个评审季的混乱说起
每年申报季,某研究院科技处都要经历一轮折腾:几十份申报材料靠邮件来回传,外聘专家收件箱里躺着四五个不同版本的压缩包,打分口径全凭个人习惯,最后汇总排名靠手工Excel加微信群确认。这类工具有个通用名字——科技项目在线评审系统,它把申报、抽签、评审、汇总、留痕全部搬到线上。等到有人质疑结果,连中间谁改过材料都说不清。这篇笔记围绕这类系统的数据设计、核心链路、权限边界和部署排查展开,适合科研管理人员、接单开发者和准备自建评审工具的团队。
2. 评审系统的数据骨架:六张核心表与流程状态怎么设计
拿到这类需求,第一反应是画页面原型,但评审系统真正的复杂度在数据关系上。一个项目对应多个专家,每个专家要填一份包含多个评分项的评审表,评审表模板又会按学科群分成好几套。把这些关系理清了,后面的页面和接口都是顺水推舟。
2.1 六张核心表里藏着整个评审业务的边界
我一般会先建六张表:项目表、专家表、评审任务表、评分明细表、评审表模板表、流程日志表。前两张是基础档案,中间两张是业务核心,后两张是配置与追溯。下面是最关键的三张表结构。
-- 评审任务表:一个项目 + 一个专家 = 一条评审任务 CREATE TABLE review_task ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, -- 关联项目表 expert_id INTEGER NOT NULL, -- 关联专家表 status TINYINT DEFAULT 0, -- 0待评审 1已暂存 2已提交 3已退回 score_total DECIMAL(6,2), -- 总分,提交时写入 submitted_at DATETIME, -- 最后提交时间 UNIQUE(project_id, expert_id) -- 防止同一专家被重复分配同一项目 ); -- 评分明细表:一条任务对应多行评分项 CREATE TABLE review_score_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, -- 关联评审任务表 item_id INTEGER NOT NULL, -- 关联评分项配置 score_value DECIMAL(6,2) NOT NULL, -- 专家打的分 comment_text TEXT, -- 该项的文字意见 UNIQUE(task_id, item_id) ); -- 流程日志表:只追加,不修改,追溯全靠它 CREATE TABLE audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_type VARCHAR(20) NOT NULL, -- expert / admin / applicant user_id INTEGER NOT NULL, action VARCHAR(50) NOT NULL, -- assign / save / submit / rollback target_type VARCHAR(30) NOT NULL, -- project / task / score target_id INTEGER NOT NULL, detail_json TEXT, -- 变更前后的关键字段 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );review_task里的唯一约束UNIQUE(project_id, expert_id)是数据库层面的第一道保险,比代码里先查再插更可靠。review_score_detail同样用唯一约束防止同一评分项被重复写入。
audit_log的detail_json字段建议存变更前后的关键值,比如{"before": 0, "after": 2, "old_score": null, "new_score": 88.5}。这样事后查"谁改过分数"时不用翻应用日志,直接查这张表就能还原现场。
2.2 流程状态机:评审进度为什么不能只靠一个status字段
很多第一次做评审系统的开发者,会在一张项目表里放一个status字段,从1到6分别代表报名中、评审中、已汇总、已发布。这在演示时够用,但真正跑一个评审季就会出问题:状态变成"评审中"之后,哪几个专家提交了、哪几个还没动,项目表里完全看不出来。
评审任务的粒度应该落到"项目 × 专家"这个组合上,每个组合有自己的状态。常见状态模型是:
| 状态 | 含义 | 允许的下一步 |
|---|---|---|
| 0 | 待评审 | 保存为草稿、提交 |
| 1 | 已暂存 | 继续编辑、提交 |
| 2 | 已提交 | 被管理员退回 |
| 3 | 已退回 | 重新编辑、提交 |
状态迁移建议用显式校验,不要在业务代码里到处if status == 2然后直接UPDATE。下面这段状态机校验函数可以复用到所有写接口里。
# 状态机:定义每个状态允许跳转到哪些状态 STATE_MACHINE = { 0: {1, 2}, # 待评审 -> 暂存、提交 1: {1, 2}, # 已暂存 -> 继续暂存、提交 2: {3}, # 已提交 -> 退回 3: {1, 2}, # 已退回 -> 暂存、提交 } def transition_allowed(current_status, next_status): return next_status in STATE_MACHINE.get(current_status, set()) # 使用示例:管理员退回评审任务 def rollback_task(task_id, admin_id): task = get_task(task_id) if not transition_allowed(task.status, 3): raise BizError("当前状态不允许退回") update_task_status(task_id, 3) write_audit_log("admin", admin_id, "rollback", "task", task_id, { "before": task.status, "after": 3 })状态机写法把流转规则收敛到一处,新增状态时只改字典即可。这里的状态值用整数而不是字符串,一方面存储紧凑,另一方面加索引后查询更快。
2.3 评审表模板:评分项和权重怎么做到按学科群配置
评审系统一定会遇到个性化需求:某高校的项目按基础研究、应用研究、产业化三类划分,每类的评分项不一样,权重也不一样。如果为每类单独建表,后面加一个评分项就要改表结构。常见做法是把模板拆成两层:模板表 + 评分项表。
-- 评审表模板表 CREATE TABLE review_template ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_name VARCHAR(100) NOT NULL, -- 例如:基础研究类评审表 category_code VARCHAR(30), -- 关联申报类别 total_score DECIMAL(5,2) DEFAULT 100.00, is_active TINYINT DEFAULT 1 ); -- 评分项配置表 CREATE TABLE review_template_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, item_name VARCHAR(200) NOT NULL, -- 例如:科学价值 item_weight DECIMAL(5,2) NOT NULL, -- 权重百分比:0.25 表示占25% max_score DECIMAL(5,2) NOT NULL, -- 该项满分 sort_order INT DEFAULT 0 );模板与项目关联时,在项目表上加template_id字段即可。读取时先查出模板再逐行加载评分项,内存里拼成一份完整的评审表结构。
item_weight与max_score是两个不同概念。权重用于跨项比较时的加权汇总,满分用于页面打分条。比如"创新性"满分30分、权重0.3,专家打了27分,那么最终贡献就是27 / 30 * 100 * 0.3。有些团队只用权重不用满分,最后总分口径容易变成百分制,反而增加换算成本。
3. 从专家抽签到打分汇总:评审核心链路的代码实现与参数设置
数据模型建好后,评审系统最核心的一段链路是:抽签分派专家 → 专家打分提交 → 系统汇总成绩。这条链路直接决定评审结果是否让人信服,也是代码里最容易出逻辑漏洞的地方。
3.1 专家抽签:随机算法里的公平性与回避规则
抽签不是简单random.sample从专家池里取人,还要处理回避规则:申报项目成员不能评自己单位的材料,利益相关方要跳过。常见做法是先按项目所属学科方向筛出候选专家池,再做随机抽取,最后过滤回避关系。
import random def draw_experts(project, candidate_pool, required_count, forbidden_expert_ids): """ project: 项目对象 candidate_pool: 候选专家列表,已按学科方向过滤 required_count: 需要的专家人数 forbidden_expert_ids: 需要回避的专家id集合(利益相关、本单位) """ pool = [e for e in candidate_pool if e.id not in forbidden_expert_ids] if len(pool) < required_count: # 专家池深度不足时记录告警,由管理员介入增补 write_audit_log("system", 0, "shortfall", "project", project.id, {"require": required_count, "actual": len(pool)}) raise BizError(f"候选专家不足,当前可抽 {len(pool)} 人,需 {required_count} 人") # 随机抽取,sample 保证不重复 selected = random.sample(pool, required_count) return selectedrandom.sample是不放回抽样,天然避免同一个专家被抽中两次。注意required_count一般取评审规则的硬性下限,比如系统配置"每个项目至少3位专家",抽签时就按3位取,不要多抽。多抽的专家会造成评审意见冗余,后续汇总还要决定取哪些人的分数,平添争议。
抽签结果生成后,立即批量插入review_task表,并追加审计日志记录selected_expert_ids。这步很关键:结果一旦落库,后续任何人手动改分派都要走"退回重派"流程,不能直接改数据库。
3.2 打分提交与暂存:让专家在断网情况下也不丢数据
专家评审时通常在网页上边看材料边打分。一个项目十几项评分内容,很可能中途被打断。设计上必须支持"暂存"和"提交"两个动作。暂存允许部分评分项为空,提交则要求全部必填项都有值。
def save_score(task_id, expert_id, score_items, submit=False): task = get_task(task_id) if task.expert_id != expert_id: raise BizError("任务与当前专家不匹配") if task.status == 2: raise BizError("该任务已提交,不能修改") required_items = get_template_items(task.template_id) if submit: missing = [i.item_name for i in required_items if i.id not in [s["item_id"] for s in score_items]] if missing: raise BizError(f"还有评分项未填写:{', '.join(missing)}") # 写入评分明细,使用 INSERT ... ON CONFLICT 更新既有数据 for s in score_items: upsert_score_detail(task_id, s["item_id"], s["score_value"], s["comment_text"]) new_status = 2 if submit else 1 update_task_status(task_id, new_status) write_audit_log("expert", expert_id, "submit" if submit else "save", "task", task_id, {"status": new_status}) return {"ok": True, "status": new_status}这里用upsert而不是先删后插,避免并发场景下丢失上一次保存的数据。submit=True时会校验必填项,未填完提示具体缺哪一项,专家不用从头数。
一个值得注意的细节:分数范围校验不能只靠前端页面限制,接口里也要做0 <= score_value <= max_score的检查。之前见过前端做了滑动条但接口没校验,有人直接构造请求给某个评分项打了999分,直接把项目排名拉爆。
3.3 分数汇总:三种统计口径的适用场景与参数取舍
评审结果汇总看着简单,其实口径差异会直接影响最终排名。常见汇总算法有三种:
| 口径 | 做法 | 适用场景 | 风险 |
|---|---|---|---|
| 算术平均 | 全部专家分数直接平均 | 专家评分标准较一致 | 极端分数影响大 |
| 去掉极值平均 | 去掉最高最低再平均 | 多数省市级评审 | 专家少于5人时不适用 |
| 加权得分率 | 各项按权重折算后求和 | 多学科类别混合评审 | 权重参数需要脚本验证 |
def calc_project_total(project_id, method="winsorized"): tasks = get_submitted_tasks(project_id) scores = [t.score_total for t in tasks if t.score_total is not None] if not scores: return None if method == "average": return round(sum(scores) / len(scores), 2) if method == "winsorized": if len(scores) < 5: # 专家不足5人时退化为普通平均 return round(sum(scores) / len(scores), 2) sorted_scores = sorted(scores) trimmed = sorted_scores[1:-1] # 去掉一个最高分和一个最低分 return round(sum(trimmed) / len(trimmed), 2) if method == "weighted": detail_rows = get_score_details(project_id) # 按评分项权重折算得分率并累加 total = 0.0 for item_id, score_value, item_weight, item_max in detail_rows: total += (score_value / item_max) * item_weight return round(total * 100, 2)去掉极值的winsorized口径是最常用的,但注意它依赖专家数量。建议在配置文件里设ROUND_EXPERTS = 5,当提交人数不足5人时自动退化为算术平均,并在汇总结果页打上"采用算术平均口径"的标识,防止事后对排名口径产生误会。
加权口径适合模板权重差异大的场景,但它要求评分项配置非常稳定。实际项目中经常出现申报季中期微调权重的情况,一旦改过权重参数,历史暂存的草稿分数会按新口径重算,这种操作必须留下审计日志。
4. 评审权限与过程留痕:不同角色的数据边界怎么守住
评审系统的角色通常只有四类:申报人、评审专家、管理员、系统审计员。角色不多,但数据边界很容易画错。比如专家应该看到哪些项目、管理员能不能替专家打分、申报人在评审期间能不能看到评审进度,这些边界必须在接口层做统一拦截,而不是靠前端菜单藏起来。
4.1 RBAC之外还要有数据范围:专家只能看到分配给自己的项目
RBAC解决的是"能不能访问这个功能",数据范围解决的是"能访问哪些数据"。一个专家登录后,他的项目列表只能包含review_task表里分配给自己的行。常见做法是在查询层注入当前用户上下文,统一过滤。
def get_visible_projects(user, user_type): if user_type == "admin": # 管理员只看项目,不分学科和专业 return Project.query.all() if user_type == "expert": # 专家只能看分配给自己的、且状态是待评审/已暂存的 return (Project.query .join(ReviewTask, ReviewTask.project_id == Project.id) .filter(ReviewTask.expert_id == user.id, ReviewTask.status.in_([0, 1])) .all()) if user_type == "applicant": # 申报人只看自己提交的,评审期间隐藏评审进度 if review_phase == "ongoing": return [] # 评审期间不展示任何项目详情 return Project.query.filter(Project.applicant_id == user.id).all() return []这段代码的核心思想是:列表查询统一走一个函数,不允许业务代码里直接用Project.query.all()再在循环里判断。评审期间申报人什么都不显示,这是很多初次做评审系统的团队忽略的点——申报人在评审期反复刷新看进度,除了增加服务器压力,还会引发对评审公正性的不必要猜测。
4.2 双盲评审下的脱敏:附件元数据比页面字段更容易泄露
不少评审要求专家看不到申报人姓名、单位和成员信息。页面字段可以轻松隐藏,真正危险的是附件元数据。上传的Word、PDF文档属性里带着作者名、公司名,专家下载附件后右键一看属性就暴露了申报单位,双盲直接失效。
常见做法是做一层"附件净化"服务:文件上传后立即复制到内部存储,提取正文内容,剥离原始元数据,再以无元数据副本供专家下载。代码层面对已入库文件做元数据清洗的示意如下。
def sanitize_attachment(src_path, dst_path): """ 常见做法:先复制文件,再用库重写后另存, 保证原文件的作者、单位等属性不进入评审环节。 """ from docx import Document from openpyxl import load_workbook ext = src_path.rsplit(".", 1)[-1].lower() if ext == "docx": doc = Document(src_path) # 清空核心属性中的作者、最后修改人等字段 cp = doc.core_properties cp.author = "" cp.last_modified_by = "" cp.company = "" doc.save(dst_path) elif ext == "xlsx": wb = load_workbook(src_path) wb.properties.creator = "" wb.properties.lastModifiedBy = "" wb.save(dst_path) return dst_path注意这里要复制后再清洗,绝不能直接覆盖原始文件——一旦清洗逻辑有bug,原始申报材料就毁了。另外要注意压缩包内的嵌套文件,项目申报材料经常是zip包里几十个文档,清洗时要递归遍历内部每个Office文件。之前遇到的典型案例是:主文档属性清干净了,zip里一张流程图的png保留了拍摄设备信息和GPS坐标,专家直接看到了申报单位的具体地点,这点至今都是评审系统里最容易翻车的地方。
4.3 审计日志:谁在什么时间打了什么分
评审系统一旦上线,审计能力决定了系统抗争议的水平。常见做法是中间件层统一拦截所有写操作,而不在每个接口里手动调审计函数。运行时的请求上下文会自动带上用户身份、操作对象和请求体摘要。
import functools from flask import g, request def audit(module_action): def decorator(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): result = fn(*args, **kwargs) write_audit_log( user_type=g.user_type, user_id=g.user_id, action=module_action, target_type=request.headers.get("X-Target-Type", "unknown"), target_id=kwargs.get("id"), detail_json={ "path": request.path, "method": request.method, "result_status": getattr(result, "status_code", 200), } ) return result return wrapper return decorator @audit("score_submit") def api_submit_score(task_id, payload): return submit_score_logic(task_id, payload)审计日志至少要覆盖这些节点:项目提交、抽签分派、专家暂存、专家提交、管理员退回、模板权重修改、汇总口径变更。模板权重修改尤为关键,评审季中一旦微调权重,所有已提交专家分数的语义就变了,审计记录里必须留下旧权重和新权重的值。
write_audit_log内部建议用独立连接写入日志表,不要跟业务事务放在一起。否则审计库异常会导致业务操作失败,那就本末倒置了。审计库允许少量延迟,但不能丢。
5. 部署上线与常见问题排查:远程评审场景里的踩坑记录
评审系统大部分是单位内部网络部署,部署方式相对固定,但远程评审场景下带宽和并发都不稳定,踩坑记录集中在启动方式、并发写入和附件上传三块。以下按常见问题排查的视角写成记录。
5.1 最小可运行部署:启动配置与初始化参数
轻量级部署用内嵌数据库加单机Web服务就能撑起一个几百项目的评审季。启动配置一般包括数据库路径、上传目录、评审时间窗口和抽签参数。
# 启动前先初始化数据库结构 flask init-db # 启动服务,监听内网地址 export SECRET_KEY='review-secret' export DB_PATH='/data/review.db' export UPLOAD_DIR='/data/attachments' export REVIEW_PHASE='ongoing' flask run --host=0.0.0.0 --port=8000REVIEW_PHASE是评审季的开关,建议支持signup/ongoing/published三档。评审期间管理员把REVIEW_PHASE设为ongoing,申报人的进度查询自动失效。这个开关比在数据库里改某个配置更直接,运维同学在远程封锁期间改一行环境变量再重启服务,即可完成阶段切换。
5.2 并发写入的坑:同一专家重复提交、管理员并发指派
现象:评审截止前几小时,专家在浏览器里双击提交按钮,创建出两条同内容评分记录;管理员在后台同时给多个项目指派专家,抽签池被重复抽。
原因:前端按钮 disabled 逻辑没覆盖所有情况,后端又没有幂等校验;抽签分配的并发控制缺失。
解决:
- 评分明细表上加唯一约束
UNIQUE(task_id, item_id),重复提交时后一次upsert变成覆盖而不是新增。 - 抽签分派加一层分布式锁,或者用
INSERT ... SELECT ... WHERE NOT EXISTS做条件插入。锁的粒度是"同一个项目",可以在项目ID上做行锁。
# 用带条件插入的命令,保证同项目不会被重复插入评审任务 INSERT INTO review_task (project_id, expert_id, status) SELECT ?, ?, 0 WHERE NOT EXISTS ( SELECT 1 FROM review_task WHERE project_id = ? AND expert_id = ? )这段SQL在没有事务隔离问题的前提下,能够避免先查后插的竞态。配合数据库唯一约束双保险,基本能堵住并发重复分派的场景。线上曾经见过的是:不做唯一约束,专家重试三次提交后数据库里三行分数,汇总时平均数被直接拉高,事后对账发现分数对不上,最后只能锁库回滚重算。
5.3 附件上传的三个老坑:超时、乱码与病毒扫描
现象一:专家在低带宽环境下传一个50MB PDF,请求发出后一分多钟没回应,前端报网关超时。原因:Web服务器默认上传体大小限制和响应超时时间太短。解决:调大客户端请求体限制与超时时间。
# 反代配置中放宽上传限制 client_max_body_size 200m; proxy_read_timeout 300s; proxy_send_timeout 300s;现象二:Windows用户上传的中文文件名在Linux服务器上乱码。原因:文件名编码处理不当。解决:统一用uuid重命名存储在服务器上,原文件名存数据库字段,下载时响应头里Content-Disposition做URL编码回写。这个方案同时解决了路径穿越风险。
现象三:上传的评审材料携带宏病毒。原因:原始文件直接进入专家下载目录。解决:后端对接统一病毒扫描服务,扫描不通过的文件自动标记并隔离,专家端无法下载,管理员收到告警通知。
评审系统的附件目录通常建议独立挂载,与服务器系统盘分开。评审季大量材料上下载,磁盘写满时不能拖垮数据库。附件路径用YEAR/项目ID/文件UUID三级目录组织,方便后续归档和清理。
6. 进阶验证:用模拟数据把整个评审季完整跑一遍
功能开发完、部署上线前,很多人只测了页面跳转,没有测过完整数据流。有一个低成本验证方法:写一个数据生成脚本,模拟一个评审季的真实负载,检查排名结果、审计日志和状态流转是否都对。
首先生成模拟数据:200个项目、30位专家、5个学科分组,每个项目分派3位专家。然后脚本模拟每位专家对每个项目逐条提交评分,中间随机混入几次暂存和退回。
import random, itertools def generate_fake_review_season(): create_projects(200) # 模拟申报 create_experts(30) # 模拟专家库 for project in get_all_projects(): # 每个项目抽3位专家 experts = draw_experts(project, get_expert_pool(), 3, forbidden_ids=set()) assign_task(project, experts) for expert in experts: # 70%概率暂存一次再提交,模拟真实操作习惯 if random.random() < 0.7: save_score(project.id, expert.id, partial_items=True) save_score(project.id, expert.id, submit=True) recalc_all_rankings() generate_fake_review_season() run_report("ranking_summary") run_report("audit_integrity_check")脚本跑完之后重点核验三个检查点:
第一,汇总排名是否和手工计算一致。抽查10个项目的排名,用脚本再算一遍算术平均和去掉极值平均,对比系统输出结果。排名不一致的情况基本都是个别专家漏提交或分数写入顺序异常导致的。
第二,审计日志是否完整覆盖关键节点。检查每个项目是否有"分配专家"和"专家提交"对应记录,抽查若干已提交任务是否都有submit动作日志。很多系统平时没问题,一遇到分数争议回查时就发现日志缺了一半。
第三,状态机是否有卡死状态。统计所有评审任务的状态分布,如果出现status=1且submitted_at为空的长期暂存记录,说明专家界面的"暂存"按钮做得比"提交"醒目,需要调整UI权重。
这套模拟脚本也应该成为回归测试的一部分。每次上线新功能或改汇总算法,先把脚本跑一遍再走正式环境。我个人的习惯是,所有基于评审系统的算法更新都必须用过去赛季的完整数据回放一遍,确认新口径不会改变前二十名的基本格局,之后再考虑切换。
希望这些工作流和排查思路能帮到你。评审系统最怕的不是功能少,而是在关键时刻算错一个分数、漏掉一条日志。把数据链路和状态边界做扎实,比多塞十个华丽页面有用得多。
本文还有配套的精品资源,点击获取