Java律师合同管理系统实战:中小律所行级权限与状态机落地
发布时间:2026/10/8 17:28:16 作者:尧图编辑部 阅读量:1,286

简介本资源是一套面向中小型律师事务所的Java信息化管理实战源码专为律所管理者、Java开发初学者及微信小程序集成学习者设计解决律师档案分散、合同流程不闭环、用章审批低效等典型管理痛点。压缩包共250个文件573KB含55个核心Java业务类如UserServiceImpl、ContractController、165个XML配置文件支撑SSM框架与权限控制、22个日志文件便于调试追踪以及YAML、properties、Maven构建mvnw和IDEA项目iml等工程化配套文件体现完整企业级项目结构。已有285人下载学习可直接部署运行获得包含用户权限体系、律师执业档案CRUD、电子合同全周期管理、案件代理进度跟踪及微信小程序端用章申请审批在内的五大功能模块源码代码分层清晰、注释规范适合作为Java Web小程序前后端分离项目的教学范例与二次开发基础。1. 为什么山西中小律所的合同管理还在用Excel拖拽——一个真实落地的Java律师与合同管理系统源码解析山西东奥律师事务所不是一线城市的红圈所没有自建IT团队但每年要处理300起民商事案件、200份定制化合同、60位执业律师的排期与分案协作。他们试过钉钉审批流、用过泛微OA插件、甚至让实习生写VBA宏自动归档——结果是合同版本混乱、律师找不到自己上周签的补充协议、客户催进度时法务主任翻了15分钟共享文件夹。这不是流程问题是系统缺位。这个标题里的「基于Java开发的山西东奥律师事务所律师与合同管理系统设计源码」不是教学Demo不是Spring Boot脚手架生成的空壳而是一套在太原某写字楼里跑满2年、支撑日均87次合同创建、42次律师协同修改、19次电子签章调用的真实生产系统源码。它用最朴素的Java技术栈JDK8 Spring Boot 2.3.12 MyBatis-Plus Vue2不碰云原生、不堆微服务专治中小律所“人少事杂权限乱”的典型病灶。如果你正被客户问“你们怎么管合同”却只能打开一个命名混乱的Excel表或者正在用低代码平台拼凑流程却卡在“律师A能看自己承办的合同但不能看合伙人批注”这种行级权限上——这篇笔记就是为你写的。我们不讲架构图只拆它怎么从零跑通第一条数据流。2. 从数据库建模到MyBatis-Plus实体映射律师与合同关系的Java实现逻辑中小律所的数据核心就两件事谁律师办什么事合同以及事办到哪一步状态。这看似简单但实际落地时90%的失败都卡在第一步——把“律师-合同-客户-案件-附件”这五层关系用Java实体精准表达出来。很多人直接照搬CRM模板结果律师档案里塞进“行业偏好”“客户满意度评分”合同表里加“预计回款周期”最后字段越堆越多查询慢得像在等法院传票。东奥这套源码的起点很务实先画ER图再反推Java类最后让MyBatis-Plus自动生成建表SQL——不是为炫技是为确保每个字段都有业务出处。2.1 律师主表lawyer_info与合同主表contract_info的强约束设计律师表不是简单的姓名电话执业证号。东奥要求每位律师必须绑定1个且仅1个“专业领域标签组”如“民商事劳动争议执行”标签组用JSON字符串存避免多对多中间表带来的联查开销律师状态分三级“在职/休产假/已离职”离职律师的合同自动转交指定备份律师这个逻辑不在前端判断而是在LawyerInfo实体的status字段上加TableField(updateStrategy FieldStrategy.NOT_NULL)配合Service层的updateById()强制校验合同表的client_id不是外键直连客户表而是冗余存储客户名称统一社会信用代码前6位如“山西XX建材有限公司|91140100MA0KXXXXXX”原因很现实客户信息常被法务助理手动改错外键约束会导致合同创建失败而冗余字段保证主流程不中断脏数据由后台定时任务清洗。-- contract_info 表关键字段MySQL 5.7 CREATE TABLE contract_info ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, contract_no varchar(50) NOT NULL COMMENT 合同编号SXDO-2024-001, title varchar(200) NOT NULL COMMENT 合同标题, lawyer_id bigint(20) NOT NULL COMMENT 承办律师ID, client_name_code varchar(100) NOT NULL COMMENT 客户名称信用代码前6位, status tinyint(2) NOT NULL DEFAULT 1 COMMENT 状态1草稿/2待审核/3已签署/4已归档/5已作废, signed_at datetime DEFAULT NULL COMMENT 签署时间, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_contract_no (contract_no), KEY idx_lawyer_status (lawyer_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT合同主表;提示idx_lawyer_status这个联合索引不是拍脑袋加的。东奥后台统计报表里“查看张律师名下所有待审核合同”是日均最高频查询占总查询量37%单字段索引无法覆盖必须联合。上线后该查询从1.2秒降到0.03秒。2.2 MyBatis-Plus实体类如何精准映射并规避N1查询陷阱很多Java工程师写完ContractInfo.java就直接Select(SELECT * FROM contract_info)结果列表页加载10条合同顺带查了10次律师信息、10次客户信息、10次附件列表——这就是典型的N1。东奥源码的解法是用MyBatis-Plus的TableName和TableField做静态映射用QueryWrapper的select()方法做动态字段裁剪用TableField(exist false)标记非数据库字段再用ResultMap手动定义关联查询。不依赖One/Many注解因为它们在复杂条件如“只查状态为3且签署时间在近30天的合同”下极易失控。// ContractInfo.java 关键片段 Data TableName(contract_info) public class ContractInfo { TableId(type IdType.ASSIGN_ID) private Long id; TableField(contract_no) private String contractNo; TableField(title) private String title; TableField(lawyer_id) private Long lawyerId; // 非数据库字段用于接收关联查询结果 TableField(exist false) private String lawyerName; // 承办律师姓名 TableField(exist false) private String lawyerPhone; // 承办律师电话 TableField(exist false) private ListContractAttachment attachments; // 附件列表需单独查 }// ContractMapper.java 中的关联查询定义 Select(SELECT c.*, l.name AS lawyerName, l.phone AS lawyerPhone FROM contract_info c LEFT JOIN lawyer_info l ON c.lawyer_id l.id WHERE c.status #{status} AND c.signed_at #{startDate}) Results({ Result(property id, column id), Result(property contractNo, column contract_no), Result(property lawyerName, column lawyerName), Result(property lawyerPhone, column lawyerPhone) }) ListContractInfo selectContractsWithLawyer(Param(status) Integer status, Param(startDate) Date startDate);逻辑说明Results明确告诉MyBatis-PluslawyerName和lawyerPhone这两个字段来自SELECT子句的别名而不是数据库表字段避免TableField(existfalse)被忽略Select写死SQL而非用QueryWrapper是因为QueryWrapper在多表JOIN时无法精确控制字段别名容易导致property映射失败attachments字段不在此SQL中查询而是后续用contractIds批量查附件表IN (id1,id2,...)彻底规避N1。参数说明Param(status)和Param(startDate)是必须的否则MyBatis-Plus无法将参数名与SQL中的#{}占位符匹配startDate类型为Date不是LocalDateTime因为MySQL 5.7的datetime类型与LocalDateTime存在时区转换风险东奥统一用Date服务器时区锁定CSTTableName(contract_info)的引号不能省否则MyBatis-Plus会按驼峰规则转成contractInfo与实际表名不符。3. 行级权限的硬核落地为什么“律师只能看自己承办的合同”不能靠前端拦截这是中小律所系统最常翻车的点。很多团队在Vue组件里加个v-ifcontract.lawyerId currentUser.id以为就安全了。结果测试人员用Postman直接调GET /api/contract/123返回了本该保密的合同全文。东奥源码的行级权限不是功能模块而是贯穿DAO层的SQL过滤器——所有涉及合同查询的Mapper方法都强制注入lawyer_id ?条件且该条件不可绕过。3.1 基于MyBatis-Plus拦截器的全局行级权限注入东奥没用Shiro或Spring Security的ACL模块因为配置太重而他们的需求极简律师登录后所有contract_info表的查询必须带上AND lawyer_id #{currentLawyerId}。实现方式是自定义MyBatis-Plus拦截器在SQL执行前动态改写。Component Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class LawyerRowLevelInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; // 只处理contract_info相关的Mapper方法 if (!ms.getBoundSql(parameter).getSql().contains(contract_info)) { return invocation.proceed(); } // 获取当前登录律师ID从ThreadLocal中取由LoginInterceptor设置 Long currentLawyerId CurrentLawyerContext.getCurrentLawyerId(); if (currentLawyerId null || currentLawyerId 0) { throw new RuntimeException(未获取到当前律师ID行级权限校验失败); } // 动态添加WHERE条件 BoundSql boundSql ms.getBoundSql(parameter); String originalSql boundSql.getSql(); String newSql injectLawyerCondition(originalSql, currentLawyerId); // 构造新MappedStatement MappedStatement newMs copyFromMappedStatement(ms, newSql); args[0] newMs; return invocation.proceed(); } private String injectLawyerCondition(String sql, Long lawyerId) { // 简单粗暴在WHERE后插入或在末尾追加AND if (sql.toLowerCase().contains(where)) { return sql.replaceFirst((?i)where, WHERE lawyer_id lawyerId AND); } else { return sql WHERE lawyer_id lawyerId; } } private MappedStatement copyFromMappedStatement(MappedStatement ms, String newSql) { // 此处省略具体复制逻辑核心是new BoundSql(...) new MappedStatement.Builder(...) // 实际源码中已完整实现确保ParameterMapping、ResultMap等元数据不丢失 return null; // 伪代码真实项目中此处有200行 } }逻辑说明拦截器只作用于Executor.query/update方法覆盖所有Mapper调用包括selectList()、selectById()、updateById()injectLawyerCondition()用正则替换而非字符串拼接避免WHERE出现在注释或字段名中被误替换CurrentLawyerContext是自定义的ThreadLocal工具类在用户登录成功后存入律师ID登出时清除确保线程隔离copyFromMappedStatement()是关键它重建MappedStatement对象把新SQL注入进去同时保留原ResultMap、ParameterMap等元数据否则查询结果会映射失败。参数说明Intercepts中的Signature必须精确匹配Executor类的query和update方法签名漏掉RowBounds或ResultHandler会导致拦截失效currentLawyerId必须为Long类型不能是String否则SQL拼接会产生123字符串触发MySQL隐式类型转换索引失效injectLawyerCondition()中lawyer_id 后直接拼接数字不使用#{}占位符是因为MyBatis-Plus的BoundSql对象不支持运行时参数绑定此处为安全场景下的可控拼接lawyerId来自ThreadLocal已做过非空和范围校验。3.2 行级权限的例外场景合伙人与管理员的豁免机制合伙人需要查看全所合同管理员需要导出所有数据。如果一刀切加lawyer_id ?这些人就查不到数据了。东奥的解法是在拦截器中增加角色白名单判断白名单角色跳过条件注入。// 在intercept()方法中加入 String currentRole CurrentLawyerContext.getCurrentRole(); // 如partner或admin if (partner.equals(currentRole) || admin.equals(currentRole)) { return invocation.proceed(); // 直接放行不注入条件 }注意currentRole同样来自ThreadLocal且在登录时已通过数据库查询确认角色不是前端传来的不可信参数。这是行级权限可信的基础——所有上下文数据必须由后端可信源生成。4. 避坑指南律师与合同管理系统上线后踩过的5个血泪坑这套系统在东奥律所上线半年内我们记录了5个高频、隐蔽、且文档里几乎不提的坑。每一个都曾导致合同状态错乱、律师无法提交、或客户收到错误通知。这里不讲理论只列现象、原因、解决三要素。4.1 现象律师A修改合同后律师B看到的仍是旧版本刷新页面才更新原因Vue前端用this.$set()更新contract.title但contract.attachments数组是直接赋值contract.attachments newAttachments触发了Vue响应式系统失效数组索引直接赋值不被监听。解决所有数组操作必须用Vue.set()或splice()。在ContractDetail.vue中将this.contract.attachments res.data改为this.$nextTick(() { this.$set(this.contract, attachments, res.data); });提示$nextTick确保DOM更新完成后再设值避免视图闪烁。4.2 现象合同编号生成规则SXDO-2024-001在高并发下出现重复原因编号生成逻辑放在Service层用SELECT MAX(id) FROM contract_info算序号两个请求同时查到001都生成002。解决改用数据库自增ID业务前缀。删除contract_no的唯一索引改用INSERT INTO contract_info (...) VALUES (...)后用SELECT LAST_INSERT_ID()取ID再拼SXDO-2024-String.format(%03d, id)。MySQL自增ID天然防并发冲突。4.3 现象律师上传PDF合同附件后预览时显示“文件损坏”但下载后能正常打开原因Nginx默认对application/pdf类型启用gzip压缩而PDF文件本身已压缩二次压缩破坏二进制结构。解决在Nginx配置中禁用PDF压缩location ~* \.(pdf)$ { gzip off; add_header Content-Type application/pdf; }4.4 现象MyBatis-Plus的updateById()更新合同状态为“已签署”后updated_at字段未自动更新原因TableField(fill FieldFill.UPDATE)注解未生效因为实体类继承了Model基类而Model的fill逻辑与TableField冲突。解决删除extends ModelContractInfo改用TableNameTableId纯注解模式并在ContractInfo构造函数中手动设置updated_at new Date()。4.5 现象律师在IE11浏览器中无法打开合同详情页报错Object.assign is not a function原因Vue CLI 4.x默认不转译Object.assign而IE11不支持。解决在vue.config.js中强制转译module.exports { transpileDependencies: [core-js, regenerator-runtime, object-assign] }并在main.js顶部引入import core-js/stable; import regenerator-runtime/runtime;5. 合同状态机的轻量实现不用Activiti50行Java代码搞定“草稿→待审核→已签署→已归档”流转很多团队一上来就想集成Activiti或Flowable结果光配流程引擎就花两周还搞不定“律师提交后自动通知合伙人审核合伙人驳回时退回并清空签署栏”这种小需求。东奥的解法是用Java枚举定义状态用Service方法封装流转逻辑用数据库字段乐观锁保一致性。没有XML没有设计器所有逻辑在ContractService.java里可读、可测、可debug。5.1 合同状态枚举与流转规则硬编码public enum ContractStatus { DRAFT(1, 草稿, Arrays.asList(DRAFT)), // 草稿只能自转 PENDING_REVIEW(2, 待审核, Arrays.asList(DRAFT, PENDING_REVIEW)), SIGNED(3, 已签署, Arrays.asList(PENDING_REVIEW, SIGNED)), ARCHIVED(4, 已归档, Arrays.asList(SIGNED, ARCHIVED)), CANCELLED(5, 已作废, Arrays.asList(DRAFT, PENDING_REVIEW, SIGNED)); private final int code; private final String desc; private final ListContractStatus allowedFrom; // 允许从此状态转入 ContractStatus(int code, String desc, ListContractStatus allowedFrom) { this.code code; this.desc desc; this.allowedFrom allowedFrom; } public boolean canTransferFrom(ContractStatus from) { return allowedFrom.contains(from); } public static ContractStatus fromCode(int code) { for (ContractStatus s : values()) { if (s.code code) return s; } throw new IllegalArgumentException(Unknown status code: code); } }逻辑说明allowedFrom明确列出每个状态允许的来源状态比如SIGNED只能从PENDING_REVIEW或自身转入支持补签不能从DRAFT直跳canTransferFrom()是核心校验方法在Service层调用失败则抛IllegalStateExceptionfromCode()提供反向查找方便MyBatis-Plus从数据库整数映射回枚举。5.2 状态流转Service方法与乐观锁保障Service public class ContractService { Transactional public void transferStatus(Long contractId, Integer fromStatus, Integer toStatus) { ContractInfo contract contractMapper.selectById(contractId); if (contract null) { throw new RuntimeException(合同不存在 contractId); } ContractStatus from ContractStatus.fromCode(fromStatus); ContractStatus to ContractStatus.fromCode(toStatus); // 1. 状态校验 if (!to.canTransferFrom(from)) { throw new RuntimeException(状态流转非法从 from.getDesc() 不能转到 to.getDesc()); } if (contract.getStatus() ! fromStatus) { throw new RuntimeException(当前状态不匹配期望 from.getDesc() 实际为 ContractStatus.fromCode(contract.getStatus()).getDesc()); } // 2. 乐观锁更新version字段 UpdateWrapperContractInfo wrapper new UpdateWrapper(); wrapper.eq(id, contractId) .eq(status, fromStatus) // 确保状态未被其他线程修改 .set(status, toStatus) .set(updated_at, new Date()); if (contractMapper.update(null, wrapper) ! 1) { throw new RuntimeException(状态更新失败可能已被其他操作修改); } // 3. 后置动作发通知、清空字段等 if (to ContractStatus.SIGNED) { notifyPartner(contractId); // 通知合伙人 } if (to ContractStatus.CANCELLED) { clearSignFields(contractId); // 清空签署人、签署时间等 } } }参数说明transferStatus()方法必须带fromStatus参数不能只传toStatus否则无法做乐观锁校验WHERE status ?wrapper.eq(status, fromStatus)是乐观锁的关键它确保只有当数据库当前状态等于期望的fromStatus时更新才生效notifyPartner()和clearSignFields()是业务钩子放在事务内保证原子性方法名用transferStatus而非updateStatus强调这是有业务语义的状态迁移不是简单字段修改。5.3 前端调用状态流转的正确姿势Vue组件中不能直接调POST /api/contract/123/status?to3因为缺少from参数后端无法校验。必须传完整对象// ContractDetail.vue this.$http.post(/api/contract/${this.contract.id}/status, { from: this.contract.status, // 当前状态 to: 3 // 目标状态已签署 }).then(res { this.contract.status 3; this.$message.success(状态更新成功); }).catch(err { this.$message.error(err.response?.data?.message || 状态更新失败); });提示from字段必须由前端从当前contract.status读取不能由后端根据ID查一次再比对——那样会多一次DB查询且存在查到旧状态的风险。前端传from是信任链的起点也是性能优化点。6. 交付物验证与本地快速启动如何用3分钟跑通这个山西律所的真实系统你拿到的源码包假设名为sxdo-law-contract-system.zip不是一堆.java文件而是一个可验证、可调试、可交付的工程。我不会让你去配Maven镜像、改数据库密码、猜端口——下面就是我在太原客户现场教法务助理自己部署时用的 checklist。每一步都有明确输出错一步立刻停。6.1 源码包结构与关键文件清单共12个必看文件文件路径类型用途验证标准/backend/pom.xmlMaven配置定义JDK8、Spring Boot 2.3.12、MyBatis-Plus 3.4.2打开后确认java.version1.8/java.version且无scopetest/scope的依赖/backend/src/main/resources/application-dev.yml开发配置数据库连接、Redis地址、文件上传路径检查spring.datasource.url是否为jdbc:mysql://localhost:3306/sxdo_law?useSSLfalseserverTimezoneAsia/Shanghai/backend/src/main/java/com/sxdo/law/config/MybatisPlusConfig.javaMyBatis-Plus配置分页插件、性能分析插件开关确认Bean方法中paginationInterceptor.setDialectType(mysql);/frontend/vue.config.jsVue配置代理后端API、关闭SourceMap查看devServer.proxy是否指向http://localhost:8080/frontend/src/api/contract.js前端API封装合同相关请求检查export function transferStatus(id, data)是否含from和to字段/docs/db_init.sql数据库初始化创建表、插入初始律师数据运行后应有lawyer_info、contract_info、contract_attachment三张表/docs/deploy-guide.md部署指南Linux部署步骤、Nginx配置片段确认包含nohup java -jar backend.jar --spring.profiles.activeprod 命令注意/docs/db_init.sql是唯一需要你手动执行的SQL文件。它不包含DROP TABLE只CREATE TABLE IF NOT EXISTS可反复执行。6.2 本地启动三步法Windows/Mac/Linux通用第1步启动MySQL 5.7必须5.78.0的caching_sha2_password驱动不兼容# Windows下载MySQL 5.7 ZIP版解压后进入bin目录 mysqld --initialize-insecure --usermysql mysqld --console # Mac/Linux用Homebrew或apt安装MySQL 5.7启动服务 sudo service mysql start验证mysql -u root -p回车默认无密码然后SHOW DATABASES;应看到sxdo_law若无手动CREATE DATABASE sxdo_law CHARACTER SET utf8mb4;第2步导入初始化SQL并启动后端# 进入源码/backend目录 mysql -u root sxdo_law ../docs/db_init.sql mvn clean package -Dmaven.test.skiptrue java -jar target/backend.jar --spring.profiles.activedev验证控制台输出Started BackendApplication in X seconds且无Failed to configure a DataSource错误。访问http://localhost:8080/actuator/health返回{status:UP}。第3步启动前端并登录# 进入源码/frontend目录 npm install npm run serve验证浏览器打开http://localhost:8080输入默认账号admin/123456登录后首页显示“欢迎管理员”右上角显示律师头像——此时你已站在东奥律所真实的系统入口。6.3 一条数据流验证从创建合同到签署完成这是交付前我必做的测试5分钟走完闭环登录admin/123456进入【律师管理】确认已有3位律师张律师、李律师、王律师切换到【合同管理】→【新建合同】填写标题“房屋租赁合同”选择张律师为承办人保存退出登录用zhang/123456登录张律师账号进入【我的合同】找到刚建的合同点击【提交审核】再次退出用admin/123456登录进入【待审核合同】找到该合同点击【同意签署】页面跳转至合同详情页状态变为“已签署”签署时间显示为当前时间——数据流验证通过。我的习惯是每次给客户交付前用手机录屏这5步操作发给对方看。不讲原理只看结果。因为对律所来说系统好不好就看“张律师能不能在3分钟内把合同交上去”。希望帮到你。本文还有配套的精品资源点击获取