ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot+Vue3的律所案件管理系统设计实践

基于SpringBoot+Vue3的律所案件管理系统设计实践 事情是从一个朋友的律所开始的。他们所不算大二十几个执业律师但案件量一上来整个所就乱成了一锅粥——Excel表格满天飞案卷散落在各个承办人手里领导问起某个案子的进度行政要打五六个电话才能拼出个大概更别提回款统计、结案归档这种一听就头大的活儿。那段时间我正好在给他们做内部系统于是就有了这套基于Java SpringBoot Vue3 MyBatis MySQL的律师事务所案件管理系统。这套系统解决的核心问题就是把律所里“人盯人、表找表”的事务性工作变成一套可持续运转的线上流程。先说一下这套系统适合谁。如果你正在给中小型律所、法律咨询公司、或者任何以“案件/项目”为核心交付物的服务型团队做管理软件那这套前后端分离的架构和模块设计可以直接抄作业。如果你是学Java的开发者想找一个贴近真实业务场景的项目来练手或者做毕业设计这套系统的业务复杂度也刚刚好——它比单纯的增删改查多了状态流转、权限控制、统计报表这些硬通货又不像电商系统那样有一堆坑爹的并发和支付逻辑。下面我会从业务拆解、技术选型、数据库建模、核心功能落地、前后端联调、权限设计、后续扩展这几个维度把整个系统怎么搭、为什么这么搭一篇讲透。1. 律所案件管理到底在管什么先理清业务再说代码很多人一拿到“案件管理系统”这种题目就急着建表结果做出来的东西就是个带搜索功能的Excel。要避免这个坑得先想清楚一件事律师的工作流本质上是围绕“案件”这条主线的状态流转。1.1 一个案件从进到出要经历哪些节点我当时的做法是把律所的业务拆成了这样一条链线索/咨询潜在客户来电或到访记录基本信息、咨询内容这是案源的起点但不一定产生正式委托。立案登记确定接受委托之后创建正式案件记录登记当事人信息、对方当事人信息、案件类型民事、刑事、行政、非诉等、标的额、承办律师。办案过程这里面有大量的中间动作——起草诉状、证据收集、立案、排期、开庭、调解、判决。不同动作之间有先后关系也有时间约束。结案归档拿到判决书或调解书后做案件复盘整理卷宗归档到档案室或电子卷宗。收费与结算委托代理费怎么收、分几期收、提成怎么算这也是律所行政工作的重头戏。这套系统里面“案件状态”是我单独设计的一张状态流转表不是简单地在案件表上挂一个state字段就完事。原因很简单律师在办一个案子的时候需要知道“下一步该干什么”系统要给出提示而不是让律师自己心里算。比如一个案子状态是“待开庭”系统就该在开庭日期前两天自动生成待办提醒推送给承办律师。1.2 系统边界哪些该做哪些不该做做这类管理系统最大的忌讳是“什么都想做”。法律文书自动生成能做吗能做但那是另外一个系统的事别硬塞进来。电子签章、在线阅卷、律协对接这些都是后续扩展项第一版统统不做。我定的边界是只做流程和数据的线上化不做法律专业判断的替代。也就是说系统负责告诉律师“这个案子还剩几步、每一步要交什么材料、什么时候截止”但材料内容怎么写、证据链怎么补那是律师的专业活。守住这个边界之后开发量一下子就可控了系统交付之后团队也真的愿意用——因为它是来帮忙的不是来添乱的。2. 技术选型背后的取舍为什么是SpringBootVue3MyBatis这套技术栈现在看起来挺“标准”但每个选择背后都有实际考量不是跟风。2.1 后端用SpringBoot图的是生态和确定性SpringBoot在Java后端领域的地位有点类似于Word在文档编辑里的地位——未必是最酷的但你要找什么能力它基本都现成。约定优于配置一个SpringBoot项目从零搭建到能跑接口十分钟搞定不需要像早期SSH那套绕一堆XML。生态成熟权限用Spring Security可以做做报表用POI导出Excel文件上传用自带的Multipart都是趟平了的坑。团队招聘和上手成本律所系统的长期维护大概率不是原作者做的Java技术栈的人才供给足够甲方或者下家接手都不会头疼。2.2 MyBatis灵活SQL是复杂查询的底气有人会问现在不是有MyBatis-Plus吗为什么不直接上我的建议是核心框架用MyBatis可以在项目里集成MyBatis-Plus的增强能力分页插件、代码生成、LambdaQueryWrapper但你要清楚它帮你省了什么、又可能掩盖了什么。律所系统的查询条件往往非常碎案件列表要按状态筛、按承办人筛、按时间范围筛、按标的额区间筛还要支持关键词模糊搜当事人姓名。这种多条件组合查询用MyBatis的where和if标签写动态SQL比在代码里拼条件字符串干净得多也比JPA那种自动生成的查询更可控——你一眼就能看出这条SQL会怎么执行。比如这是案件列表查询的核心SQLselect idselectCasePage resultTypecom.example.law.caseinfo.vo.CaseVO SELECT c.id, c.case_no, c.case_name, c.case_type, c.case_state, c.bid_amount, c.client_name, c.opposite_name, u.real_name AS lawyer_name, c.next_hearing_date, c.created_at FROM t_case c LEFT JOIN sys_user u ON c.lawyer_id u.id where if testparam.caseName ! null and param.caseName ! AND c.case_name LIKE CONCAT(%, #{param.caseName}, %) /if if testparam.lawyerId ! null AND c.lawyer_id #{param.lawyerId} /if if testparam.caseState ! null and param.caseState ! AND c.case_state #{param.caseState} /if if testparam.startTime ! null AND c.created_at gt; #{param.startTime} /if if testparam.endTime ! null AND c.created_at lt; #{param.endTime} /if /where ORDER BY c.created_at DESC /selectMapper public interface CaseMapper { IPageCaseVO selectCasePage(PageCaseVO page, Param(param) CaseQueryParam param); }这套写法的好处是前端传什么条件SQL就动态拼接什么条件不需要为每一种查询组合写一个独立方法。2.3 前端Vue3 Element Plus后台管理系统的省心方案前端框架当时对比过Vue2、Vue3和React。最后选Vue3核心原因是Vue3的Composition API让逻辑复用变得顺手比如案件列表的查询、分页、筛选逻辑可以封装成一个useCaseList()组合式函数多个页面共用。Element Plus组件库专门为后台管理系统设计表格、表单、弹窗、日期选择器、树形控件都是现成的配上vite的冷启动速度开发体验非常顺。一个典型的案件筛选表单长这样script setup langts import { reactive, ref } from vue import { getCasePage } from /api/case const queryForm reactive({ caseName: , caseState: , lawyerId: undefined, dateRange: [], }) const tableData ref([]) const total ref(0) const loading ref(false) async function loadPage(page 1) { loading.value true try { const params { pageNum: page, pageSize: 10, caseName: queryForm.caseName, caseState: queryForm.caseState, lawyerId: queryForm.lawyerId, startTime: queryForm.dateRange?.[0], endTime: queryForm.dateRange?.[1], } const res await getCasePage(params) tableData.value res.records total.value res.total } finally { loading.value false } } /script2.4 MySQL这个量级的业务用不着杀鸡用牛刀有的是朋友一上来就问我“要不要配Redis做缓存要不要上ES做搜索”我的回答是先别。律所系统一年撑死几万条案件记录MySQL单表百万级都能轻松应对加上合理的索引完全够用。引入Redis和ES意味着引入额外的运维复杂度对一个小团队来说是负担不是效率。真到了需要全文检索那天再演进也不迟——系统设计上预留好解耦的接口就行。3. 数据库设计把律师的业务关系翻译成表结构数据库设计是这类系统的灵魂。表结构一旦定得不合理后面写代码全是补丁。我这次是老老实实按业务关系设计的。3.1 核心表一览表名用途核心字段sys_user用户表用户名、密码、真实姓名、手机号、角色ID、执业证号sys_role角色表角色名称、权限编码sys_user_role用户角色关联表用户ID、角色IDt_case案件主表案号、案件名称、案件类型、状态、标的额、承办律师ID、客户ID、对方当事人t_client当事人/客户表姓名、身份证号、联系电话、地址、单位信息t_case_state_log案件状态流转日志案件ID、旧状态、新状态、操作人、备注t_task待办任务表案件ID、任务类型、标题、截止日期、负责人ID、完成状态t_document文书附件表案件ID、文件名称、存储路径、上传人ID、上传时间t_fee_record收费记录表案件ID、应收金额、实收金额、收费阶段、到账日期t_hearing开庭信息表案件ID、开庭日期、法庭、承办法官、备注3.2 案件状态用“日志表”而不是“状态字段”这是我在这次项目里比较满意的一个设计。t_case表里的case_state字段只保存当前状态但我们额外建了一张t_case_state_log表把每一次状态变更都记录下来。为什么要这么做两个原因审计需要律所合伙人需要知道一个案子什么时候立的、什么时候开的庭、是谁在哪个环节拖延了。这张日志表就是完整的操作轨迹出了纠纷能追责。统计需要结案率这个指标不是简单的当前状态已结案的数量/总数而是要区分“本月新立案件中本月结案”还是“全部在办案件中本月结案”。有状态日志才能算准这些口径。状态流转的代码里我封装了一个统一的服务方法Service public class CaseStateService { Transactional(rollbackFor Exception.class) public void changeState(Long caseId, String newState, String operatorId, String remark) { // 1. 先做状态合法性校验 TCase caseInfo caseMapper.selectById(caseId); assertStateChangeValid(caseInfo.getCaseState(), newState); // 2. 更新案件主表当前状态 caseMapper.updateState(caseId, newState); // 3. 写入状态流转日志 TCaseStateLog log new TCaseStateLog(); log.setCaseId(caseId); log.setOldState(caseInfo.getCaseState()); log.setNewState(newState); log.setOperatorId(operatorId); log.setRemark(remark); stateLogMapper.insert(log); } }事务注解是必须的主表状态更新和日志表插入要么一起成功要么一起回滚否则就会出现“状态变了但没记录”或者“记录了但状态没变”的脏数据。3.3 立案编号别用自增ID要可读的案号律所的案号是要对外打印在文书上的所以不能是数据库自增的1、2、3而是有一套可读性规则。我采用的格式是LS-2025-0001其中LS是律所代号2025是年份后面的流水号每年从0001重新开始。实现方案是单独建一张t_case_no_generator表CREATE TABLE t_case_no_generator ( id BIGINT PRIMARY KEY AUTO_INCREMENT, year_no VARCHAR(10) NOT NULL, current_seq INT DEFAULT 0, UNIQUE KEY uk_year (year_no) );生成案号的时候用SELECT ... FOR UPDATE锁住这一行的记录保证并发情况下也不会生成重复案号。不要用“先查max再加一”的方式两个请求同时进来必炸。3.4 时间字段的两个细节第一个细节是所有时间字段都存datetime而不是date。看起来date更省空间、更简洁但办案过程中有一个很容易被忽略的坑开庭日期、裁判文书日期经常发生在凌晨前后如果只存日期前端展示和后端计算时间差时会碰到边界问题。统一用datetime展示的时候再格式化省得后悔。第二个细节是创建时间和更新时间用数据库默认值不要由Java代码手填。MyBatis-Plus有自动填充功能但如果你用的原生MyBatis记得在SQL里写上created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP4. 核心功能模块从立案到归档的一条完整链路表结构定了功能模块就是水到渠成的事。我把整体拆成五个模块来开发。4.1 案件登记模块这个模块负责把一个“咨询线索”变成“正式在办案件”。前端表单字段很多案件类型、办理方式、风险代理还是固定收费、收费金额、承办团队等。一个比较实用的细节是级联选择和条件显隐。比如选了“风险代理”界面上就弹出回款比例输入框选“固定收费”就变成收费金额和付款期限。这在Vue3里实现非常直接el-form-item label收费方式 el-select v-modelform.feeMode placeholder请选择收费方式 el-option label固定收费 valueFIXED / el-option label风险代理 valueRISK / el-option label半风险 valueHALF_RISK / /el-select /el-form-item el-form-item v-ifform.feeMode RISK label回款比例(%) el-input-number v-modelform.riskRate :min1 :max100 / /el-form-item el-form-item v-ifform.feeMode FIXED label收费金额(元) el-input-number v-modelform.feeAmount :min1 / /el-form-item后端接口在接收到创建请求后会做一件事同时初始化案件主表记录和第一条状态日志状态为“已立案”并创建一个“提交立案材料”的待办任务给承办律师。一步到位避免漏创建。4.2 办案流程追踪办案期间律师或助理会随时更新案件动态。这个模块本质上就是围绕状态机做的操作。我为每个状态定义了允许流转到哪些后续状态public enum CaseState { REGISTERED(已立案, Arrays.asList(PREPARING, FILED)), PREPARING(准备中, Arrays.asList(FILED, HEARING)), FILED(已提交法院, Arrays.asList(HEARING, PAUSED)), HEARING(审理中, Arrays.asList(JUDGED, MEDIATED, PAUSED)), PAUSED(中止/暂停, Arrays.asList(PREPARING, HEARING, TERMINATED)), JUDGED(已判决, Arrays.asList(CLOSED, APPEAL)), MEDIATED(已调解, Arrays.asList(CLOSED)), CLOSED(已结案, Collections.emptyList()); private final String desc; private final ListString allowedTargets; public boolean canTransferTo(String target) { return allowedTargets.contains(target); } }前端页面上不在allowedTargets里的操作按钮直接置灰。这比后端校验更早一步挡住误操作但后端校验不能省——接口拿Postman一调就绕过前端了所以changeState方法里第一件事就是校验合法性。4.3 待办与日程提醒这个模块是律师愿不愿意用这套系统的关键。律师的工作模式是“多线程任务并行”五个案子同时推进没有人能靠脑子记住所有截止日期。待办任务表的设计上我加了一个source_type字段用来区分这个任务是“系统自动生成”还是“人工创建”系统自动生成案件立案后自动生成“整理委托材料”任务开庭日期确定后自动生成“庭前准备”任务结案后自动生成“整理卷宗”任务。人工创建律师或行政手动添加的临时任务比如“联系对方当事人协商调解方案”。前端用Vue3写一个“今日待办”看板按截止时间倒序排列超时未完成的标红。这个页面虽然技术上没什么难度但使用频率极高是整个系统里律师们点击最多的菜单。4.4 文书与卷宗管理文书模块踩过不少坑说一个最典型的上传大文件动不动就失败。SpringBoot默认的spring.servlet.multipart.max-file-size只有1MB扫描的PDF动不动几十MB不调配置必然被拦。需要在application.yml里配置spring: servlet: multipart: max-file-size: 128MB max-request-size: 128MB另外文件不要直接存进MySQL的BLOB字段。虽然技术上可行但几MB的PDF存数据库之后备份、迁移、查询性能全都会受影响。正确姿势是文件存本地磁盘或对象存储MinIO、阿里云OSS都行数据库只存文件的访问路径RequestMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file, RequestParam(caseId) Long caseId) { // 文件名重构防止路径穿越和中文乱码 String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String storeName UUID.randomUUID().toString().replace(-, ) ext; // 按案件ID分目录存放方便归档和查找 String baseDir ResourceUtils.getFile(classpath:static/upload/).toPath().toString(); Path targetPath Paths.get(baseDir, String.valueOf(caseId)).resolve(storeName); Files.createDirectories(targetPath.getParent()); file.transferTo(targetPath); // 记录到文书表 TDocument doc new TDocument(); doc.setCaseId(caseId); doc.setFileName(originalFilename); doc.setFilePath(/upload/ caseId / storeName); doc.setUploadBy(LoginUtil.getCurrentUserId()); documentMapper.insert(doc); return Result.success(doc.getFilePath()); }注意两点文件名一定要重构成UUID否则两个用户上传同名文件会互相覆盖文件路径里带案件ID这样按案件归档时目录结构清晰也方便后续清理。4.5 收费与报表收费这块最怕的是账实不符。我的做法是任何一笔收费记录创建时必须关联到具体的案件ID同时更新该案件的“已收金额”累计值。报表模块直接从案件主表和收费记录表聚合不要查历史快照——除非你要做多版本对比。几条常用的统计SQL分享一下-- 按律师统计承办案件数 SELECT u.real_name AS lawyer_name, COUNT(c.id) AS case_count FROM t_case c LEFT JOIN sys_user u ON c.lawyer_id u.id WHERE c.created_at DATE_SUB(NOW(), INTERVAL 1 YEAR) GROUP BY c.lawyer_id ORDER BY case_count DESC; -- 年度结案率 SELECT COUNT(CASE WHEN c.case_state CLOSED THEN 1 END) AS closed_count, COUNT(*) AS total_count, COUNT(CASE WHEN c.case_state CLOSED THEN 1 END) / COUNT(*) AS close_rate FROM t_case c WHERE YEAR(c.created_at) YEAR(NOW());这里有一个要注意的点统计口径一定要和合伙人确认清楚。“结案率”是按年度新立案件算还是按在办案件总数算同一句话可能大家理解不一样。系统上线前我专门给所里做了个统计口径说明文档让主任签字确认了才避免后面扯皮。5. 前后端联调里最磨人的四个问题前后端分离架构的好处是并行开发坏处是联调阶段有一堆“看上去很小但能卡一整天”的坑。我这次把这些坑都趟了一遍挑四个重点说。5.1 跨域配置别在后端硬开CORS用代理开发阶段前端跑在localhost:5173Vite后端跑在localhost:8080这俩端口不同浏览器默认会拦截跨域请求。一个常见做法是在后端写一个CorsFilter允许所有来源。但这样做的副作用是生产环境如果前后端不在同一域名还得单独处理而且一旦允许所有来源等于把CSRF防护也卸了一半。更好的做法是后端不做跨域处理前端开发环境用Vite代理。在vite.config.ts里配置import { defineConfig } from vite export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ), }, }, }, })这样前端请求/api/case/page在开发环境会被Vite转发到http://localhost:8080/case/page浏览器看到的是同源请求不存在跨域问题。生产环境部署时用Nginx把/api路径同样代理到后端服务就行配置思路完全一致。5.2 登录态管理JWT Token放哪里有讲究前后端分离系统的登录态我用的是JWT。但Token存哪里不同方案坑不一样存localStorage简单但XSS攻击一旦得手Token能被直接读走。存HttpOnly Cookie更安全但需要后端在响应头里Set-Cookie还要处理CSRF。我这次采用了一个折中方案Token存localStorage但配合前端路由守卫做统一鉴权同时后端所有接口都做Token校验。对于一个内网管理系统来说这个安全级别够用实现也简单。如果后面要过等保或者对公网开放再升级成HttpOnly Cookie CSRF Token的方案也来得及。Axios的请求拦截器里统一加Tokenimport axios from axios const service axios.create({ baseURL: /api, timeout: 15000, }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )后端用拦截器统一校验Token放行白名单接口登录、验证码等其余接口必须解析出合法用户ID才继续public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (request.getMethod().equals(OPTIONS)) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); if (claims ! null) { request.setAttribute(userId, claims.get(userId)); return true; } } response.setStatus(401); return false; } }5.3 时间格式化Jackson的时区坑前后端联调遇到过一个特别诡异的现象前端传的2025-06-01 09:30:00后端收到的却是2025-06-01 01:30:00白白少了8小时。查到最后发现是Jackson序列化和数据库连接的时区设置不一致导致的。解决办法有三处要统一MySQL连接串里强制指定时区jdbc:mysql://localhost:3306/law_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiJackson全局配置统一格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai如果使用了Java 8的时间类LocalDateTime要加JavaTimeModule依赖并在启动类里注册Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - builder .serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))) .deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }三个地方只要漏一个就会出现“数据库存对了、接口返回也对着、前端显示错了”或者反过来这种诡异问题。5.4 分页参数前端第1页后端第0页Element Plus的分页组件触发事件时currentPage是从1开始的但MyBatis-Plus的Page对象默认第一页是current1这一点没问题。真正的坑在PageHelper如果你用的是它的话——PageHelper的页码也是从1开始但有一种写法会让它内部把页码减一导致第一页数据查不到。用MyBatis-Plus的IPage就省心得多前端传pageNum1pageSize10后端直接public ResultIPageCaseVO page(int pageNum, int pageSize, RequestBody CaseQueryParam param) { PageCaseVO page new Page(pageNum, pageSize); IPageCaseVO result caseMapper.selectCasePage(page, param); return Result.success(result); }返回给前端的数据结构换成record统一包装器前端直接解构const { records, total } res.data不要返回一堆字段名不规范的对象前后端接口文档一旦写死字段名后面改起来极其痛苦。6. 权限模型主任、律师、助理各看各的律所系统最容易被忽视但又最重要的就是权限。不同角色能看到的数据范围完全不一样。合伙人/主任要能看全所的案源、收费、业绩普通律师只能看自己承办的案件助理可能只有录入权没有结案权财务只能看收费流水不能看案件内容。6.1 角色与菜单权限系统里我定了四个角色角色权限要点系统管理员用户管理、系统配置不看业务数据主任/合伙人全所案件查看、统计报表、收费审批执业律师自己承办案件的维护、文书上传、状态流转律师助理案件录入、卷宗上传不能修改关键字段菜单权限用RBAC模型实现用户-角色-菜单/权限点。后端用一个RequirePermission注解标记每个接口需要的权限码拦截器里查当前用户角色持有的权限码集合不匹配就返回403RequirePermission(case:approve) PostMapping(/approve) public ResultString approveCase(RequestBody ApproveDTO dto) { // 审批逻辑 }6.2 数据权限比按钮权限难一个量级按钮权限是控制“能不能点”数据权限是控制“看到哪些行”。这部分我用了一个比较简单直接的方案所有查询都强制带上lawyer_id过滤条件根据当前登录角色动态拼接。律师角色WHERE c.lawyer_id 当前用户ID助理角色只允许访问属于自己所属律师的案件主任角色不加过滤条件看全所这个逻辑不能写在每一个Mapper的SQL里那样太容易漏。我在MyBatis层面加了一个拦截器自动在查询SQL后面拼接数据权限条件。这样新增查询接口时不需要每处都记得写权限过滤减少了人为疏漏的概率。数据库设计的另一个细节案件表和用户表之间不要直接外键关联而是在t_case表里冗余一个lawyer_id字段。虽然不满足严格的范式但在这种多对多、经常需要跨表统计的场景里冗余字段能省掉大量JOIN查询性能也好很多。6.3 Vue3前端的路由守卫前端配合后端权限做了动态路由。用户登录后后端根据角色返回他可见的菜单前端用Vue Router的addRoute动态注册。路由守卫里做两层判断router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { return next(/login) } const userInfo localStorage.getItem(userInfo) if (token !userInfo) { // 拉取用户信息并动态注册路由 fetchUserInfo().then(menus { menus.forEach(menu router.addRoute(menu)) return next({ ...to, replace: true }) }) return } next() })这里有个小坑动态路由注册是异步的用户刷新页面后路由表是空的直接访问某个菜单URL会先被守卫拦下来。所以第一次拉取用户信息后要replace到原目标路由重新进入才能保证刷新后页面能正常显示。7. 从“能跑”到“好用”上线之后的优化与扩展方向第一版系统上线后律师们用是肯用了但反馈了很多“用得不顺”的细节。这些问题只有实际用起来才会暴露设计阶段很难想到。7.1 体检类优化不是炫技是真能救命最典型的是开庭日期临近提醒。第一版的做法是登录后右上角红点显示待办数但律师们抱怨“看到了也不会有感觉”。后来改成了开庭前48小时系统自动给承办律师发一条短信提醒开庭前3小时再次提醒。这个小改动上线后口碑立马上来了——律师是到处跑的职业不可能一直盯着管理系统看主动触达才是真帮忙。这个功能用Spring的Scheduled定时任务实现每天早上8点扫描一次t_hearing表Component public class HearingRemindTask { Scheduled(cron 0 0 8 * * ?) public void remindTodayAndTomorrowHearings() { ListHearingVO hearings hearingMapper.selectApproachingHearings(48); for (HearingVO h : hearings) { smsService.send(h.getLawyerPhone(), String.format(您代理的%s案件将于%s开庭请提前做好准备。, h.getCaseName(), h.getHearingTime())); } } }短信服务我封装了一个接口先接的阿里云短信SDK后面如果真的量大了换成公司自有短信平台改一个实现类就行。7.2 数据统计报表的可视化图表别堆太多统计报表模块前端用了ECharts。这里我的经验是图表不是越多越好。主任真正关心的核心指标就那几个——本月新立案件数、在办案件数、结案率、回款金额、律师业绩排名。一张看板页放五六个图表足够了再多就成了摆设。ECharts在Vue3里的集成也很简单template div refchartRef styleheight: 300px/div /template script setup langts import { onMounted, ref } from vue import * as echarts from echarts const chartRef refHTMLDivElement() onMounted(() { const chart echarts.init(chartRef.value) chart.setOption({ title: { text: 月度案件新增趋势 }, tooltip: {}, xAxis: { data: [1月, 2月, 3月] }, yAxis: {}, series: [ { name: 新立案数, type: line, data: [12, 18, 25] }, ], }) }) /script7.3 二次开发入口给代码留好扩展位做外包或内部系统最怕的是“做完即死”——验收完就没人维护半年后想加功能发现根本没法改动。我这次在几个关键位置刻意留了扩展点案件类型用数据字典实现不是写死在代码里。新增“知识产权”案件类型只要在字典表里加一行数据前端下拉框自动出来不用改代码。文档存储封装了StorageService接口定义了upload、delete、getUrl三个方法。当前实现是本地磁盘存储后面接MinIO对象存储或者OSS写一个新实现类替换Spring Bean即可。通知触达封装了NotifyService目前只接短信后续要接站内信、企业微信通知各自实现接口就成业务代码不感知。这一类设计成本其实很低但对系统生命周期的价值极大。7.4 部署上线别小看这一台阿里云机器系统最后部署在一台4核8G的ECS上。这个配置跑SpringBoot MySQL绰绰有余前后端都放同一台机器用Nginx托管前端静态资源、反向代理后端接口server { listen 80; server_name law.example.com; root /opt/law-front/dist; index index.html; client_max_body_size 128m; location /api/ { proxy_pass http://localhost:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /opt/law-upload/; } }注意Nginx的client_max_body_size必须配合后端multipart限制调大否则大卷宗PDF传到Nginx这里就被拦下了根本到不了SpringBoot。MySQL每天凌晨自动把数据源备份到远程存储。律所的数据对律师来说比命根子还重要这一条红线决不能省。8. 一些没必要踩的坑帮你标出来整套系统从设计到上线大概是六周左右中间有不少弯路。我把能提醒的都写在下面如果你准备复刻或者改造这套系统这些经验可以参考。第一先和业务方聊透“状态”和“权限”。这两个是整个系统的地基。我第一版做案件状态时只设计了“立案-办案-结案”三个阶段后来合伙人要求加“中止/恢复”“转代理”等场景我硬着头皮改了三天数据库。提前把流程图和合伙人、行政聊清楚能省整整一周时间。第二接口返回结构要约定好。我定义了统一返回对象ResultTcode200成功code401未登录code403无权限其余全部按业务错误处理。前端Axios拦截器里识别这几个码全局处理。不要在接口上返回五花八门的自定义错误码不然前后端联调能吵到打起来。第三案件删除永远是逻辑删除。律师行业的案子一旦录入就不能物理删除——重要的不只是当前数据还有操作轨迹。我在t_case表加了一个deleted字段所有的删除操作都变成更新一个标记。物理删除的权利永远只保留给系统管理员而且要写操作日志。第四密码存储用BCrypt。这个概念在博客里已经讲烂了但实际见过太多系统还在用MD5。Spring Security里有现成的BCryptPasswordEncoder别自己造轮子。即使用Spring Security只是为了密码库的BCrypt实现也值得引入。回头想想这套系统其实没有什么让人眼前一亮的“黑科技”但它完整地解决了一个真实业务场景里的实际问题。做管理系统的成就感不在于用了多新的技术而在于一套流程上线之后行政再也不用打电话追着律师问案子进展了——数据就在系统里实时躺着领导打开看板就能知道全所状态。这比写一个花哨的算法题有满足感得多。你的项目如果也是类似的业务系统不妨在动手写代码之前先花一周时间把业务方拉到会议室把所有流程节点和状态流转画清楚这套系统就已经成功了一半。
返回列表