1. 项目背景与核心需求
项目申报管理系统是企事业单位科研管理、行政管理中的核心支撑系统。传统申报流程普遍存在纸质材料多、审批周期长、进度不透明等问题。我们团队为某省级科研机构设计的这套系统,实现了从"纸质跑腿"到"全网通办"的转型升级。
系统需要解决三个核心痛点:
- 多角色协同难题:申报人、部门审核员、形式审查员、专家评审、管理员等角色权限差异大
- 全流程线上化:需覆盖申报书填写、附件上传、形式审查、专家评审、结果公示完整链路
- 数据统计分析:要求实时生成各类报表,包括申报数量统计、通过率分析、学科分布等
技术选型上采用前后端分离架构:
- 后端:SpringBoot 2.7 + MyBatis-Plus 3.5 + MySQL 8.0
- 前端:Vue 3 + Element Plus + Axios
- 辅助工具:Lombok、Hutool、EasyExcel
关键设计原则:采用RBAC权限模型实现动态权限控制,所有接口遵循Restful规范,前后端数据交互使用JSON格式
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统架构设计
2.1 整体技术架构
系统采用经典的三层架构:
code复制表现层:Vue3 + Element Plus
↑
业务逻辑层:SpringBoot + MyBatis
↑
数据持久层:MySQL + Redis缓存
核心模块划分:
- 用户中心:处理登录认证、权限管理
- 申报管理:申报书创建/修改/提交
- 评审管理:分配专家、在线评审
- 统计报表:数据可视化展示
- 系统管理:字典管理、日志监控
2.2 数据库设计要点
主要表结构设计:
sql复制CREATE TABLE `sys_user` (
`user_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`dept_id` bigint DEFAULT NULL COMMENT '部门ID',
`username` varchar(30) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '密码',
`role_ids` varchar(100) DEFAULT NULL COMMENT '角色ID集合',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB COMMENT='用户表';
CREATE TABLE `project_application` (
`application_id` bigint NOT NULL AUTO_INCREMENT,
`project_name` varchar(200) NOT NULL COMMENT '项目名称',
`applicant_id` bigint NOT NULL COMMENT '申请人ID',
`status` tinyint DEFAULT '0' COMMENT '状态(0:草稿 1:已提交 2:初审通过...)',
`attachment_url` varchar(500) DEFAULT NULL COMMENT '附件地址',
PRIMARY KEY (`application_id`)
) ENGINE=InnoDB COMMENT='项目申报表';
索引优化策略:
- 为高频查询字段建立组合索引(如status+create_time)
- 使用覆盖索引减少回表操作
- 大文本字段单独建表(如project_content)
3. 核心功能实现细节
3.1 动态权限控制实现
基于Spring Security的权限控制方案:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.addFilter(new JwtAuthenticationFilter(authenticationManager()));
}
}
前端路由守卫实现:
javascript复制router.beforeEach((to, from, next) => {
const hasToken = getToken()
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!hasToken) {
next('/login')
} else {
if (hasPermission(to)) {
next()
} else {
next('/403')
}
}
} else {
next()
}
})
3.2 申报书在线编辑实现
采用WangEditor富文本编辑器:
vue复制<template>
<div id="editor-container">
<toolbar :editor="editor" />
<editor
v-model="content"
:defaultConfig="editorConfig"
@onCreated="onCreated"
/>
</div>
</template>
<script>
export default {
data() {
return {
editor: null,
content: '',
editorConfig: {
MENU_CONF: {
uploadImage: {
server: '/api/upload',
fieldName: 'file'
}
}
}
}
}
}
</script>
后端文件上传处理:
java复制@PostMapping("/upload")
public Result uploadFile(@RequestParam("file") MultipartFile file) {
String originalName = file.getOriginalFilename();
String filePath = FileUtil.getUploadPath(originalName);
try {
file.transferTo(new File(filePath));
return Result.success(FileUtil.getUrl(filePath));
} catch (IOException e) {
log.error("文件上传失败", e);
return Result.error("上传失败");
}
}
4. 典型业务场景解决方案
4.1 专家评审分配算法
实现智能分配的三层过滤逻辑:
- 学科匹配度筛选(基于申报书关键词)
- 回避关系过滤(申报人推荐回避的专家)
- 工作量均衡分配(每个专家评审数量均衡)
核心代码片段:
java复制public List<Expert> matchExperts(Project project) {
// 获取学科标签
Set<String> tags = extractKeywords(project.getContent());
// 第一轮:学科匹配
List<Expert> candidates = expertMapper.selectByTags(tags);
// 第二轮:回避过滤
candidates.removeIf(e ->
project.getAvoidExpertIds().contains(e.getId()));
// 第三轮:负载均衡
candidates.sort(Comparator.comparingInt(Expert::getCurrentWorkload));
return candidates.stream()
.limit(project.getRequiredExpertCount())
.collect(Collectors.toList());
}
4.2 申报状态机设计
使用状态模式实现申报流程:
java复制public interface ApplicationState {
void submit(ApplicationContext context);
void approve(ApplicationContext context);
void reject(ApplicationContext context);
}
@Component
@Scope("prototype")
public class DraftState implements ApplicationState {
@Override
public void submit(ApplicationContext context) {
context.setState(new SubmittedState());
// 发送站内通知
noticeService.sendSubmitNotice(context.getApplication());
}
}
// 使用示例
public void changeStatus(Long applicationId, String action) {
Application app = getApplication(applicationId);
ApplicationContext context = new ApplicationContext(app);
context.handle(action); // 自动路由到对应状态处理
}
5. 性能优化实践
5.1 申报书导出优化
使用EasyExcel解决大数据量导出:
java复制public void exportApplications(HttpServletResponse response) {
response.setContentType("application/vnd.ms-excel");
response.setHeader("Content-Disposition", "attachment;filename=applications.xlsx");
// 分页查询避免OOM
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream())
.head(ApplicationExportVO.class).build();
int pageSize = 1000;
for (int page = 1; ; page++) {
List<Application> data = applicationMapper.selectPage(
new Page<>(page, pageSize)).getRecords();
if (data.isEmpty()) break;
excelWriter.write(convertToExportVO(data),
EasyExcel.writerSheet("申报数据").build());
}
excelWriter.finish();
}
5.2 评审结果缓存方案
采用多级缓存策略:
- 本地Caffeine缓存(高频访问数据)
- Redis集群缓存(分布式共享)
- MySQL持久化(最终存储)
缓存更新策略:
java复制@Cacheable(value = "reviewResult", key = "#applicationId")
public ReviewSummary getReviewSummary(Long applicationId) {
// 数据库查询逻辑
}
@CacheEvict(value = "reviewResult", key = "#applicationId")
public void updateReview(Review review) {
// 更新数据库
// 异步刷新缓存
}
6. 安全防护措施
6.1 XSS防御方案
前端+后端双重过滤:
- 前端使用DOMPurify净化输入
javascript复制import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize(dirtyHtml)
- 后端使用Jsoup过滤
java复制public String sanitize(String input) {
return Jsoup.clean(input,
Whitelist.basicWithImages()
.addTags("div","span")
.addAttributes(":all", "style"));
}
6.2 审计日志实现
基于AOP的操作日志记录:
java复制@Aspect
@Component
public class LogAspect {
@AfterReturning(pointcut = "@annotation(operLog)",
returning = "result")
public void afterReturning(JoinPoint jp, OperLog operLog, Object result) {
String username = SecurityUtils.getUsername();
String operation = operLog.value();
HttpServletRequest request =
((ServletRequestAttributes)RequestContextHolder
.getRequestAttributes()).getRequest();
SysLog log = new SysLog();
log.setUsername(username);
log.setOperation(operation);
log.setParams(JsonUtils.toJson(jp.getArgs()));
log.setIp(IpUtils.getIpAddr(request));
logMapper.insert(log);
}
}
7. 部署与监控方案
7.1 容器化部署
Docker Compose编排示例:
yaml复制version: '3'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- ./mysql/data:/var/lib/mysql
backend:
build: ./backend
ports:
- "8080:8080"
depends_on:
- mysql
frontend:
build: ./frontend
ports:
- "80:80"
7.2 Prometheus监控配置
SpringBoot Actuator集成:
yaml复制management:
endpoints:
web:
exposure:
include: "*"
metrics:
tags:
application: ${spring.application.name}
自定义业务指标监控:
java复制@RestController
public class ApplicationController {
private final Counter submitCounter;
public ApplicationController(MeterRegistry registry) {
this.submitCounter = registry.counter("application.submit.count");
}
@PostMapping("/submit")
public Result submitApplication(@RequestBody Application app) {
submitCounter.increment();
// 业务逻辑
}
}
8. 开发中的典型问题解决
8.1 跨域问题解决方案
SpringBoot配置类:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.maxAge(3600);
}
}
Vue axios配置:
javascript复制const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 10000,
withCredentials: true
})
8.2 大文件上传断点续传
前端实现:
javascript复制async function uploadFile(file) {
const chunkSize = 5 * 1024 * 1024 // 5MB
const chunks = Math.ceil(file.size / chunkSize)
for (let i = 0; i < chunks; i++) {
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize)
const formData = new FormData()
formData.append('file', chunk)
formData.append('chunkNumber', i)
formData.append('totalChunks', chunks)
await axios.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
}
后端处理:
java复制@PostMapping("/upload")
public Result uploadChunk(
@RequestParam("file") MultipartFile chunk,
@RequestParam int chunkNumber,
@RequestParam int totalChunks) {
String tempDir = "/temp/uploads/";
String chunkName = tempDir + "chunk-" + chunkNumber;
try {
chunk.transferTo(new File(chunkName));
if (chunkNumber == totalChunks - 1) {
mergeChunks(tempDir, totalChunks);
}
return Result.success();
} catch (IOException e) {
return Result.error("分片上传失败");
}
}
9. 项目扩展方向
9.1 移动端适配方案
基于Vant的移动端组件库集成:
vue复制<template>
<van-form @submit="onSubmit">
<van-field
v-model="form.projectName"
label="项目名称"
placeholder="请输入项目名称"
:rules="[{ required: true }]"
/>
<van-button block type="primary">提交</van-button>
</van-form>
</template>
响应式布局处理:
css复制@media screen and (max-width: 768px) {
.form-container {
padding: 10px;
}
.form-item {
flex-direction: column;
}
}
9.2 工作流引擎集成
Flowable集成配置:
yaml复制flowable:
database-schema-update: true
async-executor-activate: true
history-level: full
申报流程BPMN设计示例:
xml复制<process id="application_approval" name="项目申报审批流程">
<startEvent id="start"/>
<userTask id="department_approve" name="部门审核"/>
<userTask id="expert_review" name="专家评审"/>
<exclusiveGateway id="decision"/>
<endEvent id="end"/>
<sequenceFlow sourceRef="start" targetRef="department_approve"/>
<sequenceFlow sourceRef="department_approve" targetRef="expert_review"/>
<sequenceFlow sourceRef="expert_review" targetRef="decision"/>
<sequenceFlow sourceRef="decision" targetRef="end">
<conditionExpression xsi:type="tFormalExpression">
${approved}
</conditionExpression>
</sequenceFlow>
</process>
10. 项目总结与心得
在实际开发过程中,有几个关键经验值得分享:
-
状态管理陷阱:初期在Vuex中存储了过多表单状态,导致页面刷新数据丢失。后来改为仅在提交时从表单组件直接获取数据,减少对全局状态的依赖。
-
MyBatis动态SQL技巧:对于复杂的多条件查询,采用
<script>标签包裹SQL,比在注解中使用@SelectProvider更易维护:
xml复制<select id="selectApplications" resultType="Application">
SELECT * FROM project_application
<where>
<if test="projectName != null">
AND project_name LIKE CONCAT('%', #{projectName}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
ORDER BY create_time DESC
</select>
- Excel导入性能优化:处理大规模Excel导入时,采用SAX模式解析替代DOM模式,内存消耗降低90%:
java复制public List<Application> importExcel(InputStream is) {
AnalysisEventListener<Application> listener = new AnalysisEventListener<>() {
@Override
public void invoke(Application data, AnalysisContext context) {
// 单条数据处理
}
};
ExcelReader excelReader = EasyExcel.read(is, Application.class, listener).build();
excelReader.readAll();
excelReader.finish();
}
- 前端性能监控:集成Sentry捕获前端错误时,发现某些页面因加载过多富文本内容导致卡顿。通过懒加载和虚拟滚动优化后,FCP指标提升65%。
