简介:这是一套面向Java后端与全栈开发学习者的律师事务所案件管理实战项目,基于Spring Boot构建,适用于高校计算机专业课程设计、毕业设计及中小型律所信息化系统入门实践。资源包共442个文件,以121个Java核心业务类、59个Vue前端组件、161个SVG图标及25个JPG/PNG素材为主,辅以XML配置、YML参数、SQL建库脚本和BAT一键部署脚本,完整覆盖前后端分离架构的典型工程结构;压缩包体积17.87MB,轻量易部署。已有39人下载学习,适合希望掌握Spring Boot+Vue企业级项目开发流程的学习者。读者可直接运行获得含律师注册登录、案件全流程管理、Excel批量导入、字典动态维护、文件上传下载及数据可视化(饼图/柱状图)等功能的可执行系统,并通过清晰的模块划分(如app.d4b61c10.css、chunk-vendors.a48a7cc1.css等构建产物)理解现代Web工程打包逻辑。
1. 为什么律师事务所的案件管理,不能只靠 Excel 和微信群?
很多中小型律所还在用 Excel 表格登记委托人信息、案件阶段、开庭日期和收费金额,靠微信群同步进度、用本地 Word 文档存代理词和证据目录。表面看能跑通,但实际已埋下三类硬伤:数据不一致(同一案件在律师手机、助理电脑、合伙人笔记本上出现三个版本)、权限失控(实习律师误删核心证据清单、行政人员看到不该看的收费明细)、响应滞后(客户突然来电问“上次提交的管辖异议书法院是否签收”,没人能 10 秒内给出准确状态)。这套系统不是“够用”,而是“将就到出事”。基于 Spring Boot 的律师事务所案件管理系统,本质是把律所真实业务流——从咨询接待、委托签约、案卷归档、费用结算、结案归档——拆解成可验证、可追踪、可审计的原子操作,并用 Java 生态中成熟度最高、文档最全、企业落地最稳的 Spring Boot 框架来承载。它不追求炫技,而是让每个律师登录后看到的“我的待办”真正反映手头案件的真实瓶颈;让财务人员导出的“本月未回款清单”自动过滤掉已结案但未归档的干扰项;让主任律师在后台一眼识别出“超 30 天未更新进展的案件”并触发预警。这不是一个通用 OA 的套壳,而是针对法律服务交付过程中的时间敏感性、责任强绑定、文档强版本、角色强隔离等刚性需求,做的一次精准技术适配。
2. 用 Spring Boot 四层架构落地案件管理:为什么 Controller/Service/Repository/Entity 必须分清?
Spring Boot 的四层架构(Controller → Service → Repository → Entity)不是教条,而是对法律业务复杂性的自然映射。比如一个“新增委托案件”的操作,表面是填表单,背后涉及至少 5 类校验与联动:委托人身份证号需调用公安接口核验真实性(Service 层调外部 API)、案件类型决定默认收费模板(Service 层查配置表)、案号需按“年份+律所简称+流水号”规则自动生成(Service 层封装生成逻辑)、所有附件必须先存入独立文件服务再写入案件关联表(Repository 层处理多表事务)、最终返回的 JSON 数据里,委托人姓名要脱敏显示为“张*伟”(Controller 层做视图转换)。若把这些混写在 Controller 里,代码会迅速变成无法维护的“意大利面”。下面以案件状态变更为例,展示四层如何各司其职:
2.1 Controller 层:只做协议转换与基础校验
@RestController @RequestMapping("/api/cases") public class CaseController { @Autowired private CaseService caseService; // POST /api/cases/123/status?newStatus=IN_COURT @PostMapping("/{caseId}/status") public ResponseEntity<ApiResponse<CaseDTO>> updateCaseStatus( @PathVariable Long caseId, @RequestParam String newStatus, @RequestHeader("X-User-Role") String userRole) { // 仅校验 HTTP 层参数:ID 是否数字、状态值是否在预设枚举内 if (!Arrays.asList("CONSULTING", "ACCEPTED", "IN_COURT", "SETTLED", "CLOSED").contains(newStatus)) { return ResponseEntity.badRequest().body(ApiResponse.error("非法状态值")); } // 将请求参数转为领域对象,交由 Service 处理 CaseStatusUpdateCommand command = new CaseStatusUpdateCommand(caseId, newStatus, userRole); CaseDTO updatedCase = caseService.updateStatus(command); return ResponseEntity.ok(ApiResponse.success(updatedCase)); } }提示:Controller 层绝不处理业务规则。例如“只有主办律师才能将案件状态改为 IN_COURT”这一规则,必须下沉到 Service 层判断,而非在 Controller 里
if ("IN_COURT".equals(newStatus)) { checkPermission() }。否则当未来增加“协办律师可协同提交开庭申请”时,Controller 会迅速膨胀。
2.2 Service 层:承载全部业务逻辑与跨域协作
@Service @Transactional public class CaseServiceImpl implements CaseService { @Autowired private CaseRepository caseRepository; @Autowired private UserService userService; // 查询律师角色 @Autowired private NotificationService notificationService; // 发送站内信 @Override public CaseDTO updateStatus(CaseStatusUpdateCommand command) { CaseEntity caseEntity = caseRepository.findById(command.getCaseId()) .orElseThrow(() -> new CaseNotFoundException("案件不存在")); // 1. 权限校验:当前用户是否为主办律师或管理员 UserEntity currentUser = userService.getCurrentUser(); boolean isOwnerOrAdmin = caseEntity.getOwnerUserId().equals(currentUser.getId()) || "ADMIN".equals(currentUser.getRole()); if (!isOwnerOrAdmin && "IN_COURT".equals(command.getNewStatus())) { throw new PermissionDeniedException("只有主办律师可设置为开庭中"); } // 2. 状态流转校验:禁止从 CLOSED 直接跳回 ACCEPTED if (CaseStatus.CLOSED.equals(caseEntity.getStatus()) && !CaseStatus.CLOSED.name().equals(command.getNewStatus())) { throw new InvalidStatusTransitionException("已结案案件不可修改状态"); } // 3. 更新实体并保存 caseEntity.setStatus(CaseStatus.valueOf(command.getNewStatus())); caseEntity.setLastUpdatedTime(LocalDateTime.now()); caseRepository.save(caseEntity); // 4. 触发下游:通知协办律师、更新统计看板缓存 notificationService.notifyCoCounsel(caseEntity.getId(), command.getNewStatus()); dashboardCacheService.invalidateCaseCountByLawyer(caseEntity.getOwnerUserId()); return caseEntity.toDTO(); // 转换为 DTO 返回 } }注意:
@Transactional注解确保状态更新、通知发送、缓存失效三者要么全成功要么全回滚。若去掉该注解,可能出现“状态已改但通知没发”,导致协办律师不知情。
2.3 Repository 层:专注数据存取,屏蔽 JPA 细节
@Repository public interface CaseRepository extends JpaRepository<CaseEntity, Long> { // 自定义查询:找出某律师名下所有“已受理但未开庭”的案件 List<CaseEntity> findByOwnerUserIdAndStatusIn(Long ownerId, List<CaseStatus> statuses); // 原生 SQL 查询:统计各案件类型本月新增数量(避免 N+1) @Query(value = "SELECT type, COUNT(*) as cnt FROM t_case " + "WHERE create_time >= DATE_SUB(NOW(), INTERVAL 1 MONTH) " + "GROUP BY type", nativeQuery = true) List<Object[]> countCasesByTypeThisMonth(); // 按案号模糊搜索(支持中文分词前缀匹配) List<CaseEntity> findByCaseNumberContaining(String keyword); }| 方法名 | 用途 | 为什么不用通用方法 |
|---|---|---|
findByOwnerUserIdAndStatusIn | 查律师待办列表 | findAll()会加载全部字段,性能差;JPA 的@Query可指定投影减少数据传输 |
countCasesByTypeThisMonth | 后台统计报表 | CriteriaBuilder写法冗长,原生 SQL 更直观且易优化索引 |
findByCaseNumberContaining | 案号快速检索 | Containing自动生成LIKE '%keyword%',满足律所“输入‘2024民初’查所有民事一审案号”需求 |
2.4 Entity 层:定义法律实体的核心约束
@Entity @Table(name = "t_case", uniqueConstraints = @UniqueConstraint(columnNames = {"case_number"})) public class CaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "case_number", nullable = false, length = 32) private String caseNumber; // 案号,如“2024沪0105民初12345号” @Column(name = "client_name", nullable = false, length = 100) private String clientName; // 委托人姓名,非空 @Column(name = "status", nullable = false) @Enumerated(EnumType.STRING) private CaseStatus status; // 枚举:CONSULTING/ACCEPTED/IN_COURT... @Column(name = "create_time", nullable = false, updatable = false) @CreatedDate private LocalDateTime createTime; // 创建时间,自动填充 @Column(name = "last_updated_time", nullable = false) @LastModifiedDate private LocalDateTime lastUpdatedTime; // 最后更新时间,自动填充 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "owner_user_id", nullable = false) private UserEntity ownerUser; // 主办律师,外键关联 // getter/setter 省略... }关键设计点:
@CreatedDate和@LastModifiedDate由 Spring Data JPA 自动维护,避免手动赋值出错;@Enumerated(EnumType.STRING)存储枚举名而非序号,数据库可读性强;uniqueConstraints强制案号唯一,防止律所内部重复立案。
3. 案件管理系统的核心功能实现:从案号生成到附件安全存储
案件管理系统的价值不在界面美观,而在能否精准支撑律师每日高频操作。以下四个功能模块,覆盖了律所 80% 的日常痛点,全部基于 Spring Boot 原生能力实现,无需引入额外中间件。
3.1 智能案号生成器:符合司法文书规范的自动编码
律所案号不是随机字符串,需包含年份、地域代码、案件类型、流水号等要素。例如“2024京0101刑初789号”表示 2024 年北京市东城区人民法院刑事一审第 789 号案件。系统需支持两种模式:
- 对外案号(提交法院用):严格遵循《人民法院案件信息标准》格式,由管理员在后台配置模板(如
${year}${courtCode}${caseType}${seq}); - 对内案号(律所内部管理用):采用“律所简称+年份+类型+流水号”,如“天元2024民00123”。
@Component public class CaseNumberGenerator { @Value("${case.number.template:internal}") // 配置文件控制模式 private String templateMode; @Autowired private CaseRepository caseRepository; public String generateCaseNumber(String caseType) { String prefix; if ("external".equals(templateMode)) { prefix = buildExternalPrefix(caseType); } else { prefix = "天元" + Year.now() + caseType; // 如“天元2024民” } // 查询当前前缀下最大流水号,+1 后补零至 5 位 Long maxSeq = caseRepository.findMaxSequenceByPrefix(prefix) .orElse(0L); // JPA 自定义查询:SELECT MAX(seq) FROM t_case WHERE case_number LIKE 'prefix%' String sequence = String.format("%05d", maxSeq + 1); return prefix + sequence; } private String buildExternalPrefix(String caseType) { // 实际项目中此处对接法院编码库或配置中心 Map<String, String> courtMap = Map.of("民", "京0101", "刑", "京0101", "行", "京0101"); return Year.now() + courtMap.getOrDefault(caseType, "未知") + caseType; } }参数说明:
case.number.template在application.yml中配置,切换内外案号模式;findMaxSequenceByPrefix是 Repository 中的自定义 JPQL 查询,避免用findAll()加载全部案号再内存计算,提升并发性能。
3.2 客户与案件双向关联:解决“同一委托人多个案件”的数据复用
委托人信息(姓名、身份证号、联系方式)常被多个案件复用。若每次新建案件都重新录入,极易出现同一个人在不同案件中电话号码不一致。系统采用“客户主数据”模式:
- 先创建客户档案(含实名认证标识);
- 案件创建时选择已有客户或新建;
- 修改客户手机号时,自动同步到其名下所有“进行中”案件的联系人字段。
// CustomerEntity.java @Entity @Table(name = "t_customer") public class CustomerEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "id_card", unique = true, length = 18) private String idCard; // 身份证号唯一,用于实名核验 @Column(name = "phone", length = 20) private String phone; @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true) private List<CaseEntity> cases = new ArrayList<>(); } // CaseEntity.java 中新增关联 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private CustomerEntity customer;// CustomerService.java 中的级联更新 @Transactional public void updateCustomerPhone(Long customerId, String newPhone) { CustomerEntity customer = customerRepository.findById(customerId) .orElseThrow(() -> new CustomerNotFoundException()); customer.setPhone(newPhone); customerRepository.save(customer); // 同步更新所有“进行中”案件的联系人字段 List<CaseEntity> activeCases = caseRepository.findByCustomerIdAndStatusIn( customerId, Arrays.asList(CaseStatus.ACCEPTED, CaseStatus.IN_COURT)); for (CaseEntity c : activeCases) { c.setClientPhone(newPhone); // CaseEntity 中新增 clientPhone 字段,非外键冗余 } caseRepository.saveAll(activeCases); }为什么冗余
clientPhone字段?:避免每次查案件详情都 JOINt_customer表,尤其当客户表有 10 万+ 记录时,JOIN 会显著拖慢查询。冗余字段通过 Service 层保证一致性,是典型的“用空间换时间”策略。
3.3 附件安全存储:上传文件并绑定案件,同时防病毒与防超大文件
律师每天上传起诉状、证据目录、代理词等 PDF/Word 文件。系统需满足:
- 单文件 ≤ 50MB(防止上传整本扫描版案卷);
- 自动扫描病毒(集成 ClamAV);
- 文件名脱敏(原始名
张三借款纠纷证据.zip→ 存储为case_12345_evidence_20240520143022.zip); - 支持按案件 ID 批量下载所有附件。
@PostMapping("/cases/{caseId}/attachments") public ResponseEntity<ApiResponse<AttachmentDTO>> uploadAttachment( @PathVariable Long caseId, @RequestParam("file") MultipartFile file, @RequestParam("description") String description) { // 1. 文件大小校验 if (file.getSize() > 50 * 1024 * 1024) { return ResponseEntity.badRequest() .body(ApiResponse.error("文件大小不能超过 50MB")); } // 2. 病毒扫描(调用本地 ClamAV REST API) boolean isClean = clamAvClient.scan(file.getInputStream()); if (!isClean) { throw new VirusDetectedException("附件检测到病毒,请检查后重传"); } // 3. 生成安全文件名 String safeFilename = String.format("case_%d_%s_%s%s", caseId, description.replaceAll("[^a-zA-Z0-9]", "_"), // 替换非法字符 LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")), FilenameUtils.getExtension(file.getOriginalFilename())); // 4. 存储到本地磁盘(生产环境建议用 MinIO 或阿里云 OSS) Path uploadDir = Paths.get("uploads/cases/", String.valueOf(caseId)); Files.createDirectories(uploadDir); Path targetPath = uploadDir.resolve(safeFilename); Files.write(targetPath, file.getBytes()); // 5. 保存元数据到数据库 AttachmentEntity attachment = new AttachmentEntity(); attachment.setCaseId(caseId); attachment.setOriginalName(file.getOriginalFilename()); attachment.setStoredName(safeFilename); attachment.setFileSize(file.getSize()); attachment.setDescription(description); attachment.setUploadTime(LocalDateTime.now()); attachmentRepository.save(attachment); return ResponseEntity.ok(ApiResponse.success(attachment.toDTO())); }关键配置:在
application.yml中设置spring.servlet.multipart.max-file-size=50MB和spring.servlet.multipart.max-request-size=50MB,这是 Spring Boot 内置的上传限制,比代码层校验更早拦截超大请求。
3.4 案件进度看板:用 WebSocket 实现实时状态推送
律师最怕“客户刚问完,系统还没刷新出最新进展”。传统轮询(每 5 秒 AJAX 请求)浪费带宽且延迟高。Spring Boot 内置 WebSocket 支持,可让服务器主动推送状态变更:
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic"); // 订阅地址前缀 config.setApplicationDestinationPrefixes("/app"); // 发送地址前缀 } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").withSockJS(); // WebSocket 连接端点 } } // Service 层在状态更新后推送 @Service public class CaseNotificationService { @Autowired private SimpMessagingTemplate messagingTemplate; public void notifyCaseStatusChange(Long caseId, String newStatus) { // 向所有订阅 /topic/case/123 的客户端推送 messagingTemplate.convertAndSend("/topic/case/" + caseId, Map.of("caseId", caseId, "status", newStatus, "timestamp", System.currentTimeMillis())); } }前端 JavaScript 订阅:
const socket = new SockJS('/ws'); const stompClient = Stomp.over(socket); stompClient.connect({}, () => { // 订阅特定案件 stompClient.subscribe('/topic/case/123', (message) => { const data = JSON.parse(message.body); document.getElementById('case-status').innerText = data.status; showNotification(`案件 ${data.caseId} 状态更新为 ${data.status}`); }); });为什么用
/topic/case/{id}而非/topic/cases?:精准推送,避免所有律师收到无关案件消息。每个案件独立 Topic,扩展性好,即使 1000 个案件同时更新也互不影响。
4. 生产环境关键配置与避坑指南:从 Actuator 到 SQL Server 连接
Spring Boot 项目上线后,真正的挑战才开始。以下配置和排查点,来自多个律所系统的真实运维记录,直击高频故障。
4.1 Actuator 端点安全加固:关闭未授权访问风险
/actuator/env、/actuator/beans等端点若暴露在公网,攻击者可获取数据库密码、密钥等敏感信息。必须严格限制:
# application-prod.yml management: endpoints: web: exposure: include: "health,info,metrics,loggers" # 只开放必要端点 endpoint: health: show-details: when_authorized # 健康检查详情需鉴权 endpoints: web: base-path: "/manage" # 将管理端点移到非默认路径 security: roles: ADMIN # 仅 ADMIN 角色可访问同时,在 WebSecurityConfigurerAdapter 中添加:
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ADMIN") // 所有 actuator 端点需 ADMIN .antMatchers("/manage/**").hasRole("ADMIN") // 显式声明 .anyRequest().authenticated(); }提示:切勿在
application.yml中设置management.endpoints.web.exposure.include="*",这是生产环境最常见配置错误,相当于把服务器钥匙交给所有人。
4.2 连接 SQL Server:驱动、URL 与连接池参数详解
律所常用 SQL Server,Spring Boot 3.x 默认使用 Microsoft 官方 JDBC 驱动mssql-jdbc:
<!-- pom.xml --> <dependency> <groupId>com.microsoft.sqlserver</groupId> <artifactId>mssql-jdbc</artifactId> <scope>runtime</scope> </dependency># application.yml spring: datasource: url: jdbc:sqlserver://192.168.1.100:1433;databaseName=law_firm_db;encrypt=true;trustServerCertificate=true username: sa password: yourStrong(!)Password driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver sql: init: mode: always # 启动时执行 schema.sql 和 data.sql| 参数 | 说明 | 必填性 |
|---|---|---|
encrypt=true | 启用 SSL 加密传输 | 强烈建议 |
trustServerCertificate=true | 开发环境跳过证书校验(生产环境应配真实证书) | 开发可选,生产禁用 |
databaseName | 指定数据库名,避免连接后需USE db | 必填 |
sendStringParametersAsUnicode=false | 防止中文插入乱码(SQL Server 默认 Unicode,此参数可省略) | 通常不需 |
HikariCP 连接池关键参数:
spring: datasource: hikari: maximum-pool-size: 20 # 律所并发用户一般 ≤ 50,20 足够 minimum-idle: 5 connection-timeout: 30000 # 30秒超时,避免线程卡死 idle-timeout: 600000 # 空闲 10 分钟回收连接 max-lifetime: 1800000 # 连接最长存活 30 分钟,防数据库重启后 stale connection4.3 日志与监控:Filebeat + ELK 快速定位案件操作异常
当客户投诉“我昨天提交的材料怎么不见了”,需 5 分钟内定位是前端没发请求、后端没收到、还是存储失败。推荐轻量级日志链路:
应用层:用 Logback 输出结构化 JSON 日志
<!-- logback-spring.xml --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder"> <providers> <timestamp/> <version/> <context/> <args/> <stackTrace/> <customFields>{"service":"law-case-system"}</customFields> </providers> </encoder> </appender>采集层:Filebeat 监控日志文件,过滤含
caseId的操作日志# filebeat.yml filebeat.inputs: - type: filestream paths: - /var/log/law-case/*.log processors: - if: contains: message: "caseId" then: - drop_event: ~ output.elasticsearch: hosts: ["http://es-server:9200"]查询层:Kibana 中输入
caseId: "12345" AND level: "ERROR",立即看到该案件所有报错堆栈。
避坑:不要用
log.info("用户 {} 更新案件 {} 状态为 {}", userId, caseId, status)这种拼接日志,会导致 Kibana 无法提取caseId字段。必须用结构化参数:log.info("案件状态更新", Map.of("caseId", caseId, "userId", userId, "newStatus", status));
4.4 Docker 部署:最小化镜像与健康检查
生产环境用 Docker 部署,镜像体积和启动健康检查至关重要:
# Dockerfile FROM openjdk:17-jdk-slim VOLUME ["/opt/app/logs"] ARG JAR_FILE=target/law-case-system.jar COPY ${JAR_FILE} app.jar ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1构建命令:
# 构建时跳过测试,加速流程 mvn clean package -DskipTests # 构建镜像 docker build -t law-case-system:1.0 . # 运行(挂载日志卷、配置文件) docker run -d \ --name law-case \ -v /data/law-case/logs:/opt/app/logs \ -v /data/law-case/config:/config \ -p 8080:8080 \ --health-cmd="curl -f http://localhost:8080/actuator/health || exit 1" \ law-case-system:1.0注意:
-Djava.security.egd=file:/dev/./urandom解决容器内 SecureRandom 初始化慢的问题,否则 Spring Boot 启动可能卡 2 分钟。
5. 案件管理系统的进阶技巧:用 Lucene 实现法律文书全文检索
律所积压的代理词、答辩状、质证意见等 Word/PDF 文档,总量常超 10TB。单纯用数据库LIKE '%关键词%'查询,效率极低且不支持同义词(如“违约金”查不到“滞纳金”)。集成 Apache Lucene 可构建轻量级本地全文检索引擎,无需部署 Elasticsearch 集群。
5.1 文档解析与索引构建:从 Word/PDF 提取文本
使用 Tika 解析各类文档,Lucene 建立倒排索引:
@Component public class DocumentIndexer { private final Directory indexDir; private final IndexWriter indexWriter; public DocumentIndexer(@Value("classpath:lucene/index") File indexPath) throws IOException { this.indexDir = FSDirectory.open(indexPath.toPath()); IndexWriterConfig config = new IndexWriterConfig(new IKAnalyzer()); // 中文分词 this.indexWriter = new IndexWriter(indexDir, config); } public void indexCaseDocument(Long caseId, String docType, InputStream content) throws IOException { // 1. 用 Tika 提取纯文本 AutoDetectParser parser = new AutoDetectParser(); BodyContentHandler handler = new BodyContentHandler(-1); // 不限制长度 Metadata metadata = new Metadata(); parser.parse(content, handler, metadata, new ParseContext()); String text = handler.toString(); // 2. 构建 Lucene Document Document doc = new Document(); doc.add(new StringField("caseId", String.valueOf(caseId), Field.Store.YES)); doc.add(new StringField("docType", docType, Field.Store.YES)); doc.add(new TextField("content", text, Field.Store.NO)); // 内容不存储,只索引 doc.add(new StoredField("originalName", metadata.get(Metadata.RESOURCE_NAME_KEY))); // 3. 写入索引 indexWriter.addDocument(doc); indexWriter.commit(); } }5.2 检索服务:支持案件上下文与高亮
@Service public class DocumentSearchService { @Autowired private Directory indexDir; public SearchResult searchInCaseDocuments(Long caseId, String keyword) throws IOException { IndexReader reader = DirectoryReader.open(indexDir); IndexSearcher searcher = new IndexSearcher(reader); // 构建查询:必须属于指定案件 + 内容匹配 BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder(); queryBuilder.add(new TermQuery(new Term("caseId", String.valueOf(caseId))), BooleanClause.Occur.MUST); queryBuilder.add(new QueryParser("content", new IKAnalyzer()).parse(keyword), BooleanClause.Occur.MUST); TopDocs topDocs = searcher.search(queryBuilder.build(), 10); ScoreDoc[] hits = topDocs.scoreDocs; List<SearchResultItem> items = new ArrayList<>(); Highlighter highlighter = new Highlighter(new SimpleHTMLFormatter("<em>", "</em>"), new QueryScorer(queryBuilder.build())); for (ScoreDoc hit : hits) { Document doc = searcher.doc(hit.doc); String snippet = highlighter.getBestFragment( new IKAnalyzer(), "content", doc.get("content")); items.add(new SearchResultItem( doc.get("originalName"), snippet != null ? snippet : doc.get("content").substring(0, 100), doc.get("docType"))); } reader.close(); return new SearchResult(items, topDocs.totalHits.value); } }前端调用示例:
# 查询案件 12345 中含“违约责任”的所有文档 GET /api/cases/12345/documents/search?q=违约责任返回:
{ "total": 2, "items": [ { "fileName": "代理词_张三借款案.docx", "snippet": "根据合同第5条,被告应承担... <em>违约责任</em>,赔偿原告直接损失..." } ] }性能关键:Lucene 索引文件放在 SSD 磁盘,
indexDir路径避开 Docker overlayfs 层;每次搜索后reader.close()防止句柄泄漏;IKAnalyzer使用最新版,支持法律术语(如“无独立请求权第三人”不被拆分为“无/独立/请求/权/第三人”)。
本文还有配套的精品资源,点击获取