简介:这是一份关于科研成果申报管理系统设计与实现的完整论文文档,面向高校计算机相关专业学生、准备毕业设计的开发者,以及需要了解科研管理信息化方案的政府或企业人员。论文详细阐述了基于JSP+JavaBean与SQL Server 2000数据库相结合的系统开发全过程,从系统开发背景、设计目标、技术选型出发,逐步展开需求分析、功能模块划分(包括申报管理、审批管理、用户管理等)、数据库设计(含数据流图、实体关系图及数据表结构),并给出了系统主界面、科研申报页面、审批页面等关键模块的设计思路与核心源代码分析,对读者完成类似Web管理信息系统的设计或论文写作具有较强的参考价值。资源包共1个doc文件,大小约516KB,内容结构完整、目录清晰,便于直接阅读和引用。目前已有62人学习,适合作为毕业设计选题参考或JSP技术实战的学习资料。
1. Jsp1016科研成果申报管理系统:把Excel和邮件来回传的老流程搬上Web
高校科研处、医院科研科、设计院总工办,每年一到申报期就头疼:成果登记表通过邮件和微信群传来传去,几十个课题组共用一份Excel模板,格式稍改动就全乱了,汇总时打开哪个版本都像开盲盒。Jsp1016科研成果申报管理系统,就是针对这种场景的Web化解决方案——用户在线填报成果信息、上传佐证附件,管理员在后台审核、驳回、归档,整个过程不再依赖本地文件流转。这套系统以JSP作为页面技术、Servlet作为请求入口、数据库统一存储,技术路线成熟,交付周期短,特别适合几十到几百人规模的内部申报业务。下面按设计、实现、排障三个层次把它拆开讲。
2. 技术选型与数据库设计:JSP方案在Jsp1016里的真实边界
2.1 为什么还在用JSP:选型要对着业务说话
先回答一个绕不开的问题:为什么不用Spring Boot,不用前后端分离?
科研成果申报管理系统的特征很明确:用户量小(几十到几百人)、表单字段固定、核心流程就是填报-审核-归档、部署环境往往是单位内网或者用了很多年的老服务器。对这种系统来说,JSP加Servlet的请求响应模型已经能覆盖全部需求,Spring Boot带来的自动配置、内嵌容器、依赖管理,在小团队维护场景里反而成了学习负担。
我通常按下面这张表来判断该不该走JSP路线:
| 维度 | JSP + Servlet | Spring Boot |
|---|---|---|
| 团队门槛 | 会Java基础就能上手 | 需要理解IoC、AOP、自动配置 |
| 部署条件 | Tomcat 7/8即可,JDK 6以上能跑 | 一般要求JDK 8以上 |
| 构建产物 | 一个几百KB的WAR包 | 几十MB的可执行jar |
| 排障路径 | 页面、Servlet、DAO同工程,直接翻 | 多层依赖,定位偏慢 |
| 适用规模 | 500用户以内的内部系统 | 无特殊限制 |
结论不是“JSP更好”,而是在限定条件下JSP的投入产出比更高。同样逻辑也适用于毕业论文过程管理系统、个人信息展示站这类以表单流转为主的项目,问题模型是一致的。真正要避开的是把JSP当万能方案,硬去做高并发门户网站。
2.2 目录规划与三层架构落位
确定了技术路线,下一步是目录结构。我把工程拆成标准三层,页面归WebContent,业务归src,互不越界:
Jsp1016/ ├── src/ │ ├── com.jsp1016.dao // 数据访问层,只写SQL和JDBC │ ├── com.jsp1016.entity // 实体类,对应数据库表 │ ├── com.jsp1016.service // 业务逻辑,状态流转判断放这里 │ ├── com.jsp1016.servlet // 控制器,负责取参和跳转 │ └── com.jsp1016.filter // 过滤器,登录拦截、编码统一 ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── lib/ │ ├── css/ │ ├── js/ │ ├── login.jsp │ ├── user/ │ │ ├── apply.jsp │ │ └── myList.jsp │ └── admin/ │ └── auditList.jsp三层架构的核心约束有两条:Servlet里不拼SQL,JSP页面里不写大段<% %>脚本。我见过太多“JSP里连数据库”的做法,页面和业务全搅在一起,改一个字段要翻三个页面。Servlet收到请求后只做三件事:取参数、调Service、跳转。所有SQL都收敛到DAO层,这样后期从MySQL换到其他数据库,只改DAO实现即可。
提示:
WEB-INF下的JSP页面不能通过URL直接访问,只有Servlet forward才能渲染。像admin底下的审核页面,我会放到WEB-INF/jsp/admin里,相当于白送一层访问控制。
2.3 数据库设计:三张核心表与索引取舍
数据层是这个系统的地基,我把表浓缩成三张:用户表、成果申报表、审核日志表。
-- 用户表,role字段区分普通用户和管理员 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(20), dept_id INT, role CHAR(1) DEFAULT '0' -- 0普通用户 1管理员 ); -- 成果申报主表 CREATE TABLE achievement_apply ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, title VARCHAR(200) NOT NULL, category VARCHAR(50), -- 论文/专利/软著/奖项 author_list VARCHAR(500), -- 完成人列表,逗号分隔 publish_date DATE, description TEXT, file_path VARCHAR(200), -- 附件相对路径 status TINYINT DEFAULT 0, -- 0草稿 1待审核 2已通过 3已驳回 create_time DATETIME, update_time DATETIME ); -- 审核日志表,每次操作留痕 CREATE TABLE audit_log ( id INT PRIMARY KEY AUTO_INCREMENT, apply_id INT NOT NULL, operator_id INT NOT NULL, action VARCHAR(20), -- submit/approve/reject comment VARCHAR(500), create_time DATETIME );字段细节上有几个习惯:status用TINYINT而不用VARCHAR,因为数字比较在SQL里更干净,也省空间;时间字段用DATETIME不用TIMESTAMP,规避2038年溢出和时区换算的坑;description用TEXT,成果说明动辄几百上千字,VARCHAR(255)不够用。
索引不要乱加,我一般只加两个:
ALTER TABLE achievement_apply ADD INDEX idx_user_status (user_id, status); ALTER TABLE achievement_apply ADD INDEX idx_create_time (create_time);userId + status联合索引对应两位最常用的查询:用户看“我的申报单”、管理员按状态筛列表。create_time单独索引应对排序和统计。至于status单列,区分度太低,建了索引MySQL优化器也大概率不用,纯属浪费写放大。
外键这层我故意去掉,原因是申报数据到年底要归档到历史表,物理外键会让迁移绑手绑脚,事务控制交给Service层就够了。如果团队里新人多,怕写脏数据,保留外键也不是问题,但后续归档时要有心理准备。
3. 核心模块实现:从登录到申报单落库的最小链路
3.1 登录态与权限拦截:Filter是性价比最高的安全组件
一个管理系统最怕的是每个Servlet里都手动查一遍Session。代码量翻倍不说,漏一个就是权限漏洞。我用Filter统一做登录拦截,处理顺序在web.xml里配置好。
package com.jsp1016.filter; import javax.servlet.*; import javax.servlet.http.*; import java.io.IOException; public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); String uri = request.getRequestURI(); // 放行登录页、登录处理、注册页和静态资源 if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.endsWith("/register.jsp") || uri.contains("/css/") || uri.contains("/js/") || uri.contains("/images/")) { chain.doFilter(req, resp); return; } // 未登录用户一律踢回登录页 if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }web.xml里对应注册:
<filter> <filter-name>loginFilter</filter-name> <filter-class>com.jsp1016.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>这里有三个关键点:getSession(false)不会为未登录用户新建Session,避免大量无效Session占用内存;sendRedirect里拼了getContextPath(),工程部署时即使改了上下文名,跳转路径也不会错;放行列表里漏掉CSS和JS的话,登录页会裸奔成纯HTML。
3.2 申报表单:JSP页面与Servlet的取值对账
申报页面是整个系统里用户最常碰的界面。表单字段和后台Servlet的getParameter名称必须一一对应,错一个后台就拿到null,用户还看不出是哪错了。
<form action="${pageContext.request.contextPath}/apply/submit" method="post" enctype="multipart/form-data"> <div> <label>成果标题</label> <input type="text" name="title" required maxlength="200"> </div> <div> <label>成果类别</label> <select name="category"> <option value="paper">论文</option> <option value="patent">专利</option> <option value="software">软著</option> <option value="award">奖项</option> </select> </div> <div> <label>完成人列表</label> <input type="text" name="authorList" placeholder="张三, 李四, 王五"> </div> <div> <label>发表日期</label> <input type="date" name="publishDate"> </div> <div> <label>成果说明</label> <textarea name="description" rows="4" cols="50"></textarea> </div> <div> <label>附件</label> <input type="file" name="attachment"> </div> <button type="submit">提交申报</button> </form>注意action里的${pageContext.request.contextPath},这是EL表达式取上下文路径。直接写死/Jsp1016/apply/submit也可以,但换部署名就翻车。
Servlet端用Servlet 3.0的Part接口接收上传,不再需要commons-fileupload那一大堆配置:
package com.jsp1016.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.MultipartConfig; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.*; @WebServlet("/apply/submit") @MultipartConfig(maxFileSize = 1024 * 1024 * 10, maxRequestSize = 1024 * 1024 * 20) public class ApplySubmitServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 编码必须放在取参数之前 request.setCharacterEncoding("UTF-8"); String title = request.getParameter("title"); String category = request.getParameter("category"); String authorList = request.getParameter("authorList"); String publishDate = request.getParameter("publishDate"); String description = request.getParameter("description"); HttpSession session = request.getSession(); Integer userId = (Integer) session.getAttribute("userId"); // 附件保存逻辑 String filePath = null; Part part = request.getPart("attachment"); if (part != null && part.getSize() > 0) { String fileName = getFileName(part); String ext = fileName.substring(fileName.lastIndexOf(".")); // 时间戳重命名,同时解决中文名和重名覆盖两个问题 filePath = "uploads/" + System.currentTimeMillis() + ext; String fullPath = getServletContext().getRealPath("/") + filePath; part.write(fullPath); } int rows = new ApplyDao().insert(userId, title, category, authorList, publishDate, description, filePath); response.sendRedirect(request.getContextPath() + "/user/myList.jsp"); } private String getFileName(Part part) { String header = part.getHeader("Content-Disposition"); for (String token : header.split(";")) { if (token.trim().startsWith("filename")) { return token.substring(token.indexOf("=") + 1).trim().replace("\"", ""); } } return "unknown.txt"; } }@MultipartConfig的三个参数值得说:maxFileSize限制了单个文件10MB,maxRequestSize限制整个请求20MB,超出会抛异常。对论文PDF、获奖证书扫描件来说,10MB足够,又不会撑爆服务器磁盘。文件保存路径我建议最好放到webapps外面,比如/data/jsp1016/uploads/,这样重新部署WAR时不会把用户附件一起清掉。
3.3 数据写入:PreparedStatement是底线
数据层没什么玄学,但有一条底线:所有SQL必须走PreparedStatement,禁止字符串拼接。成果申报系统的标题、完成人列表都是用户输入,用拼接SQL等于把注入漏洞敞开在登录用户面前。
public int insert(int userId, String title, String category, String authorList, String publishDate, String description, String filePath) { String sql = "INSERT INTO achievement_apply" + "(user_id, title, category, author_list, publish_date, description, file_path, status) " + "VALUES(?,?,?,?,?,?,?,0)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId); ps.setString(2, title); ps.setString(3, category); ps.setString(4, authorList); ps.setString(5, publishDate); ps.setString(6, description); ps.setString(7, filePath); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }这里的status直接写死为0,意思是新插入的记录永远从草稿开始,不接受页面传来的状态值。这个细节很重要——如果让前端把状态一起传上来,用户构造一个请求就能把自己的申报单改成已通过。数据库里所有会改变业务状态的字段,都应当由后端决定,而不是由页面参数决定。
4. 审核流程与状态机设计:让申报单按规则流转
4.1 四态状态机与条件更新
前面数据库里定义了四个状态:0草稿、1待审核、2已通过、3已驳回。状态不多,但最容易写出Bug的地方就是状态流转。常见翻车写法是“先SELECT出来看状态,再UPDATE”,这在单机单线程下没问题,可两个管理员同时审核同一张单时,后执行的会根据自己读到的旧状态去更新,直接把对方的审核结果覆盖掉。
我一般用“条件更新”一杆子到底:
public boolean changeStatus(int applyId, int fromStatus, int toStatus) { String sql = "UPDATE achievement_apply SET status = ?, update_time = NOW() " + "WHERE id = ? AND status = ?"; int rows = dao.update(sql, toStatus, applyId, fromStatus); return rows == 1; }这个SQL的精髓在AND status = ?:只有当数据库里的当前状态等于预期状态时才更新。两个管理员同时审核,一个批通过一个批驳回,后执行的那条会因为status已经被改掉而更新0行,方法返回false,前端就能提示“该申报单已被审核”。这是用数据库条件更新代替“先查再改”,从根上消除竞态。
4.2 审核操作与日志回溯的落地写法
审核列表页不能直接放“通过”链接,把action=approve&id=1写在URL里,爬虫或浏览器预取都可能误触发。我统一用POST表单加CSRF令牌:
@WebServlet("/admin/audit") public class AuditServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); // CSRF令牌校验,防止跨站请求伪造 HttpSession session = request.getSession(); String token = (String) session.getAttribute("csrfToken"); if (token == null || !token.equals(request.getParameter("csrfToken"))) { response.sendError(403); return; } String action = request.getParameter("action"); // approve / reject int applyId = Integer.parseInt(request.getParameter("id")); String comment = request.getParameter("comment"); int toStatus = "approve".equals(action) ? 2 : 3; boolean ok = applyService.changeStatus(applyId, 1, toStatus); if (ok) { Integer adminId = (Integer) session.getAttribute("userId"); auditDao.insert(applyId, adminId, action, comment, new Date()); } response.sendRedirect(request.getContextPath() + "/admin/auditList.jsp"); } }注意toStatus是后端从action映射出来的,不是页面直接传数字。这样即使攻击者伪造参数,也没法把状态改成不存在的值。审核日志是另一个必须项:谁在什么时间对哪张单做了什么操作,全部落库。没有这张日志表,年底对账时会陷入各说各话的困境。
4.3 驳回后修改再提:把状态机用活
被驳回的申报单(status=3)不应该让用户重新填一遍,应该是“编辑旧数据→重新提交”。但系统必须守住一个边界:用户只能编辑自己名下、且状态为草稿或被驳回的单子。
public boolean canUserEdit(ApplyInfo apply, int userId) { // 本人 + 状态为草稿(0)或已驳回(3) return apply.getUserId() == userId && (apply.getStatus() == 0 || apply.getStatus() == 3); }编辑完再提交时,调用changeStatus(applyId, 3, 1)或changeStatus(applyId, 0, 1),把状态重新拉回“待审核”。如果少了这个判断,用户直接改一个已经“通过”的单子,整个审核链路的可信度就崩了。至于要不要上Activiti之类的工作流引擎,我的观点是三四个状态的流转用数据库字段加条件更新就足够了,引引擎反而是给自己埋一个黑匣子。
5. Jsp1016避坑与排障:乱码、404、重复提交与附件四连坑
5.1 POST中文乱码:编码不统一,前端后端互相甩锅
现象:JSP页面填的中文,入库后变成“????”或者乱码。
原因:JSP页面是UTF-8,Tomcat 8默认的POST请求体编码却是ISO-8859-1,Servlet取参数时没指定编码自然乱。更隐蔽的是,数据库连接串里没加characterEncoding=UTF-8,存进去之前已经错了一次。
解决:三层同时统一。JSP头部写<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>;Servlet里在取参数前调用request.setCharacterEncoding("UTF-8");JDBC URL拼上useUnicode=true&characterEncoding=UTF-8。为了不每个Servlet都写一遍,我习惯用一个EncodingFilter接管全局:
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); }这个Filter的XML映射要放在所有Filter的最前面,并且url-pattern写成/*,确保任何请求进来都先过编码。
5.2 静态资源404:相对路径的“玄学”
现象:明明文件都在,login.jsp打开后CSS全丢,控制台一堆404。
原因:页面里写href="css/style.css"是相对路径。浏览器会拿当前URL的最后一级目录去拼路径,当前URL若是/Jsp1016/user/apply.jsp,那没问题;可一旦请求被Servlet forward到另一个路径下,相对路径就会拼错位置,404随之而来。
解决:统一用上下文根拼绝对路径。
<link href="${pageContext.request.contextPath}/css/style.css" rel="stylesheet"> <script src="${pageContext.request.contextPath}/js/common.js"></script>${pageContext.request.contextPath}在部署名变化时自动适应,换环境不用改代码。这是我踩过最深的一个坑,当年被它折磨了大半天,后来养成习惯:JSP里所有静态资源引用一律不用相对路径。
5.3 双击提交与并发审核:重复数据从哪来
现象:用户手快双击“提交”按钮,同一条成果变成了两条申报记录;两个管理员同时审核一张单,各自都显示操作成功。
原因:点击第二次时,Servlet又执行了一次INSERT;“先查后改”导致审核操作互相覆盖。
解决:前端给提交按钮加onclick="this.disabled=true;"防双击。后端给achievement_apply加一个业务唯一键,比如UNIQUE KEY uk_user_title(user_id, title),同一个人提交同标题成果时,第二次INSERT会直接报错。审核并发则用第4章的条件更新,更新行数为0时提示用户稍后再试。
还有一点容易被忽略:POST请求处理完不要直接输出或forward到带数据的页面,要执行sendRedirect。否则用户刷新结果页面时,浏览器会询问“是否重新提交表单”,一确定就是二次入库。这就是经典的PRG模式(Post-Redirect-Get)。
5.4 附件上传:文件名、目录、大小三座山
现象:PDF传上去了但下载后文件名是一串数字,或者报FileNotFoundException,再或者文件超过10MB直接500。
原因:保存时用了System.currentTimeMillis()重命名,浏览器侧看到的就是一串数字;part.write(fullPath)要求父目录必须存在,没先mkdirs()就会炸;超过限制的请求Tomcat直接抛异常,不捕获就是500。
解决:下载时按用户期望的文件名动态生成响应头Content-Disposition,用URLEncoder处理中文名,避免乱码;上传前先执行new File(uploadDir).mkdirs(),确保目录就位;Servlet里对IOException分情况捕获,超过大小就返回提示页,而不是让Tomcat报错误页。
5.5 Session超时提交:填了半小时的表全丢了
现象:用户花半小时填好成果信息,一点提交,页面跳回登录页,内容全部蒸发。
原因:Tomcat默认Session过期时间是30分钟,填表时间一长就触发。
解决:最简单的是前端加一个定时心跳,每60秒发一个轻量请求刷新Session,但这会掩盖掉“会话周期异常”这个事实。更稳妥的做法是把表单状态自动保存成草稿:前端每30秒把当前表单数据AJAX到后端,以status=0的草稿记录存库;用户手动提交时才改成“待审核”。刷新页面后自动填充回表单,Session过期也不怕。这正是当初把status=0定义为草稿的用途——不只是“没填完”,而是真正的可恢复状态。
6. 上线前验证与加固:从“能跑”到“能交差”
本地能跑通和线上能交差是两回事,尤其数据库连接池、编码过滤器、权限校验这些基础问题,不能指望上线后亡羊补牢。我习惯在交付前做三层验证。
第一层是Service层的单元测试,重点打状态机的竞争条件:
@Test public void testApproveThenRejectFails() { ApplyService service = new ApplyService(); // 第一次以“待审核”状态审核通过,应成功 boolean first = service.changeStatus(1, 1, 2); // 第二次同样以“待审核”状态驳回,已过期条件,应失败 boolean second = service.changeStatus(1, 1, 3); assertTrue(first); assertFalse(second); }这个测试跑一次,比手工点十遍页面都管用。第二层是手工用例矩阵,我交付时通常附带一张清单,让验收人员按表勾选:
| 模块 | 用例 | 预期结果 |
|---|---|---|
| 登录 | 错误密码连续输入5次 | 提示稍后再试 |
| 申报 | 空标题提交 | 前端拦截,后端也返回错误 |
| 审核 | 两个账号同时审核同一单 | 后操作的被拒绝 |
| 附件 | 上传超过10MB的文件 | 友好提示,不抛500 |
| 权限 | 普通用户直接访问/admin/路径 | 被Filter拦回 |
第三层是安全加固的优先级,按投入产出比排序:SQL全部PreparedStatement、登录密码加盐哈希、Filter统一做权限校验、CSRF令牌覆盖所有POST接口。这四个守住,系统就不会在安全上砸锅。
做了几个类似的JSP管理系统之后,我现在对自己团队的要求就三条:状态流转必须带条件更新,操作日志必须落库,权限必须靠Filter兜底。什么炫酷前端组件反而是次要的。系统上线三个月后需要紧急打补丁的,十有八九是这三条里没守住的地方。希望帮到你。
本文还有配套的精品资源,点击获取