Kaynağa Gözat

feat(extract-service): 第三阶段 - 项目与文档管理功能

完成项目与来源文档的完整 CRUD 功能:

请求 DTO(6个):
- CreateProjectRequest / UpdateProjectRequest
- AddSourceDocumentRequest / BatchAddSourceDocumentsRequest
- UpdateSourceDocumentRequest / ReorderSourceDocumentsRequest

响应 DTO(3个):
- ProjectDetailResponse(含统计信息)
- ProjectListResponse(含进度计算)
- SourceDocumentResponse

ProjectService:
- create/getById/getProjectDetail/getProjectWithDocuments
- listByUserId(分页)/ listByUserIdAndStatus
- update/delete(级联删除)/archive
- hasAccess(权限检查)/ getProjectStatistics

SourceDocumentService:
- add/batchAdd/getById/listByProjectId
- findByDocumentId/findByProjectIdAndAlias
- update/remove/forceRemove(同时删除关联规则)
- reorder/countByProjectId/updateParseStatus

ProjectController API:
- POST   /api/v1/extract/projects - 创建项目
- GET    /api/v1/extract/projects/{id} - 获取详情
- GET    /api/v1/extract/projects - 分页列表
- GET    /api/v1/extract/projects/by-status/{status} - 按状态筛选
- PUT    /api/v1/extract/projects/{id} - 更新
- DELETE /api/v1/extract/projects/{id} - 删除
- POST   /api/v1/extract/projects/{id}/archive - 归档
- GET    /api/v1/extract/projects/statistics - 统计

SourceDocumentController API:
- POST   /api/v1/extract/projects/{projectId}/documents - 添加
- POST   /api/v1/extract/projects/{projectId}/documents/batch - 批量添加
- GET    /api/v1/extract/projects/{projectId}/documents - 列表
- GET    /api/v1/extract/projects/{projectId}/documents/{id} - 详情
- PUT    /api/v1/extract/projects/{projectId}/documents/{id} - 更新
- DELETE /api/v1/extract/projects/{projectId}/documents/{id} - 移除
- POST   /api/v1/extract/projects/{projectId}/documents/reorder - 调整顺序
何文松 1 ay önce
ebeveyn
işleme
6536b4a0ef

+ 163 - 0
backend/extract-service/src/main/java/com/lingyue/extract/controller/ProjectController.java

@@ -0,0 +1,163 @@
+package com.lingyue.extract.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.extract.dto.request.CreateProjectRequest;
+import com.lingyue.extract.dto.request.UpdateProjectRequest;
+import com.lingyue.extract.dto.response.ProjectDetailResponse;
+import com.lingyue.extract.dto.response.ProjectListResponse;
+import com.lingyue.extract.entity.Project;
+import com.lingyue.extract.service.ProjectService;
+import com.lingyue.extract.service.SourceDocumentService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 项目管理控制器
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/extract/projects")
+@RequiredArgsConstructor
+@Tag(name = "项目管理", description = "数据提取项目的 CRUD 操作")
+public class ProjectController {
+    
+    private final ProjectService projectService;
+    private final SourceDocumentService sourceDocumentService;
+    
+    @PostMapping
+    @Operation(summary = "创建项目")
+    public AjaxResult<ProjectDetailResponse> create(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Valid @RequestBody CreateProjectRequest request) {
+        
+        Project project = projectService.create(userId, request);
+        ProjectDetailResponse response = ProjectDetailResponse.fromEntity(project);
+        response.setDocumentCount(0);
+        response.setRuleCount(0);
+        response.setCompletedRuleCount(0);
+        
+        return AjaxResult.success("创建项目成功", response);
+    }
+    
+    @GetMapping("/{id}")
+    @Operation(summary = "获取项目详情")
+    public AjaxResult<?> getById(
+            @Parameter(description = "项目ID") @PathVariable String id,
+            @Parameter(description = "是否包含来源文档列表") @RequestParam(defaultValue = "false") boolean includeDocuments) {
+        
+        ProjectDetailResponse response;
+        if (includeDocuments) {
+            response = projectService.getProjectWithDocuments(id, sourceDocumentService);
+        } else {
+            response = projectService.getProjectDetail(id);
+        }
+        
+        if (response == null) {
+            return AjaxResult.error("项目不存在");
+        }
+        
+        return AjaxResult.success(response);
+    }
+    
+    @GetMapping
+    @Operation(summary = "分页查询项目列表")
+    public AjaxResult<Page<ProjectListResponse>> list(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "页码") @RequestParam(defaultValue = "1") int pageNum,
+            @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") int pageSize) {
+        
+        Page<ProjectListResponse> page = projectService.listByUserId(userId, pageNum, pageSize);
+        return AjaxResult.success(page);
+    }
+    
+    @GetMapping("/by-status/{status}")
+    @Operation(summary = "按状态查询项目列表")
+    public AjaxResult<List<ProjectListResponse>> listByStatus(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "状态: draft/extracting/completed/archived") @PathVariable String status) {
+        
+        List<ProjectListResponse> list = projectService.listByUserIdAndStatus(userId, status);
+        return AjaxResult.success(list);
+    }
+    
+    @PutMapping("/{id}")
+    @Operation(summary = "更新项目")
+    public AjaxResult<?> update(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String id,
+            @Valid @RequestBody UpdateProjectRequest request) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(id, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        Project project = projectService.update(id, request, userId);
+        if (project == null) {
+            return AjaxResult.error("项目不存在");
+        }
+        
+        ProjectDetailResponse response = projectService.getProjectDetail(id);
+        return AjaxResult.success("更新项目成功", response);
+    }
+    
+    @DeleteMapping("/{id}")
+    @Operation(summary = "删除项目")
+    public AjaxResult<?> delete(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String id) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(id, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        boolean success = projectService.delete(id);
+        if (!success) {
+            return AjaxResult.error("项目不存在");
+        }
+        
+        return AjaxResult.success("删除项目成功");
+    }
+    
+    @PostMapping("/{id}/archive")
+    @Operation(summary = "归档项目")
+    public AjaxResult<?> archive(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String id) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(id, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        Project project = projectService.archive(id, userId);
+        if (project == null) {
+            return AjaxResult.error("项目不存在");
+        }
+        
+        ProjectDetailResponse response = projectService.getProjectDetail(id);
+        return AjaxResult.success("归档项目成功", response);
+    }
+    
+    @GetMapping("/statistics")
+    @Operation(summary = "获取用户项目统计")
+    public AjaxResult<Map<String, Integer>> getStatistics(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId) {
+        
+        Map<String, Integer> statistics = projectService.getProjectStatistics(userId);
+        return AjaxResult.success(statistics);
+    }
+}

+ 188 - 0
backend/extract-service/src/main/java/com/lingyue/extract/controller/SourceDocumentController.java

@@ -0,0 +1,188 @@
+package com.lingyue.extract.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.extract.dto.request.AddSourceDocumentRequest;
+import com.lingyue.extract.dto.request.BatchAddSourceDocumentsRequest;
+import com.lingyue.extract.dto.request.ReorderSourceDocumentsRequest;
+import com.lingyue.extract.dto.request.UpdateSourceDocumentRequest;
+import com.lingyue.extract.dto.response.SourceDocumentResponse;
+import com.lingyue.extract.entity.SourceDocument;
+import com.lingyue.extract.service.ProjectService;
+import com.lingyue.extract.service.SourceDocumentService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 来源文档管理控制器
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/extract/projects/{projectId}/documents")
+@RequiredArgsConstructor
+@Tag(name = "来源文档管理", description = "项目来源文档的 CRUD 操作")
+public class SourceDocumentController {
+    
+    private final SourceDocumentService sourceDocumentService;
+    private final ProjectService projectService;
+    
+    @PostMapping
+    @Operation(summary = "添加来源文档")
+    public AjaxResult<?> add(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String projectId,
+            @Valid @RequestBody AddSourceDocumentRequest request) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(projectId, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        SourceDocument doc = sourceDocumentService.add(projectId, request);
+        SourceDocumentResponse response = SourceDocumentResponse.fromEntity(doc);
+        
+        return AjaxResult.success("添加来源文档成功", response);
+    }
+    
+    @PostMapping("/batch")
+    @Operation(summary = "批量添加来源文档")
+    public AjaxResult<?> batchAdd(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String projectId,
+            @Valid @RequestBody BatchAddSourceDocumentsRequest request) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(projectId, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        List<SourceDocument> docs = sourceDocumentService.batchAdd(projectId, request);
+        List<SourceDocumentResponse> responses = docs.stream()
+                .map(SourceDocumentResponse::fromEntity)
+                .collect(Collectors.toList());
+        
+        return AjaxResult.success("批量添加来源文档成功", responses);
+    }
+    
+    @GetMapping
+    @Operation(summary = "获取项目的来源文档列表")
+    public AjaxResult<List<SourceDocumentResponse>> list(
+            @Parameter(description = "项目ID") @PathVariable String projectId) {
+        
+        List<SourceDocumentResponse> list = sourceDocumentService.listByProjectId(projectId);
+        return AjaxResult.success(list);
+    }
+    
+    @GetMapping("/{id}")
+    @Operation(summary = "获取来源文档详情")
+    public AjaxResult<?> getById(
+            @Parameter(description = "项目ID") @PathVariable String projectId,
+            @Parameter(description = "来源文档ID") @PathVariable String id) {
+        
+        SourceDocumentResponse response = sourceDocumentService.getSourceDocumentResponse(id);
+        if (response == null) {
+            return AjaxResult.error("来源文档不存在");
+        }
+        
+        // 验证文档属于该项目
+        if (!response.getProjectId().equals(projectId)) {
+            return AjaxResult.error("来源文档不属于该项目");
+        }
+        
+        return AjaxResult.success(response);
+    }
+    
+    @PutMapping("/{id}")
+    @Operation(summary = "更新来源文档")
+    public AjaxResult<?> update(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String projectId,
+            @Parameter(description = "来源文档ID") @PathVariable String id,
+            @Valid @RequestBody UpdateSourceDocumentRequest request) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(projectId, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        // 验证文档属于该项目
+        SourceDocument existing = sourceDocumentService.getById(id);
+        if (existing == null) {
+            return AjaxResult.error("来源文档不存在");
+        }
+        if (!existing.getProjectId().equals(projectId)) {
+            return AjaxResult.error("来源文档不属于该项目");
+        }
+        
+        SourceDocument doc = sourceDocumentService.update(id, request);
+        SourceDocumentResponse response = SourceDocumentResponse.fromEntity(doc);
+        
+        return AjaxResult.success("更新来源文档成功", response);
+    }
+    
+    @DeleteMapping("/{id}")
+    @Operation(summary = "移除来源文档")
+    public AjaxResult<?> remove(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String projectId,
+            @Parameter(description = "来源文档ID") @PathVariable String id,
+            @Parameter(description = "是否强制删除(同时删除关联规则)") @RequestParam(defaultValue = "false") boolean force) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(projectId, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        // 验证文档属于该项目
+        SourceDocument existing = sourceDocumentService.getById(id);
+        if (existing == null) {
+            return AjaxResult.error("来源文档不存在");
+        }
+        if (!existing.getProjectId().equals(projectId)) {
+            return AjaxResult.error("来源文档不属于该项目");
+        }
+        
+        try {
+            boolean success;
+            if (force) {
+                success = sourceDocumentService.forceRemove(id);
+            } else {
+                success = sourceDocumentService.remove(id);
+            }
+            
+            if (!success) {
+                return AjaxResult.error("移除来源文档失败");
+            }
+            
+            return AjaxResult.success("移除来源文档成功");
+        } catch (IllegalStateException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+    
+    @PostMapping("/reorder")
+    @Operation(summary = "调整来源文档顺序")
+    public AjaxResult<?> reorder(
+            @RequestHeader(value = "X-User-Id", required = false, defaultValue = "anonymous") String userId,
+            @Parameter(description = "项目ID") @PathVariable String projectId,
+            @Valid @RequestBody ReorderSourceDocumentsRequest request) {
+        
+        // 权限检查
+        if (!projectService.hasAccess(projectId, userId)) {
+            return AjaxResult.error("无权访问该项目");
+        }
+        
+        sourceDocumentService.reorder(projectId, request);
+        return AjaxResult.success("调整顺序成功");
+    }
+}

+ 36 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/request/AddSourceDocumentRequest.java

@@ -0,0 +1,36 @@
+package com.lingyue.extract.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 添加来源文档请求
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "添加来源文档请求")
+public class AddSourceDocumentRequest {
+    
+    @NotBlank(message = "文档ID不能为空")
+    @Schema(description = "关联的 Document ID", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String documentId;
+    
+    @Size(max = 100, message = "别名最多100字符")
+    @Schema(description = "文档别名,如'可研批复'")
+    private String alias;
+    
+    @Schema(description = "文档类型: pdf/docx/xlsx")
+    private String docType;
+    
+    @Schema(description = "显示顺序(不填则追加到最后)")
+    private Integer displayOrder;
+    
+    @Schema(description = "元数据")
+    private Map<String, Object> metadata;
+}

+ 24 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/request/BatchAddSourceDocumentsRequest.java

@@ -0,0 +1,24 @@
+package com.lingyue.extract.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 批量添加来源文档请求
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "批量添加来源文档请求")
+public class BatchAddSourceDocumentsRequest {
+    
+    @NotEmpty(message = "文档列表不能为空")
+    @Valid
+    @Schema(description = "来源文档列表", requiredMode = Schema.RequiredMode.REQUIRED)
+    private List<AddSourceDocumentRequest> documents;
+}

+ 31 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/request/CreateProjectRequest.java

@@ -0,0 +1,31 @@
+package com.lingyue.extract.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 创建项目请求
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "创建项目请求")
+public class CreateProjectRequest {
+    
+    @NotBlank(message = "项目名称不能为空")
+    @Size(max = 200, message = "项目名称最多200字符")
+    @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED)
+    private String name;
+    
+    @Size(max = 2000, message = "项目描述最多2000字符")
+    @Schema(description = "项目描述")
+    private String description;
+    
+    @Schema(description = "项目配置")
+    private Map<String, Object> config;
+}

+ 22 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/request/ReorderSourceDocumentsRequest.java

@@ -0,0 +1,22 @@
+package com.lingyue.extract.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 调整来源文档顺序请求
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "调整来源文档顺序请求")
+public class ReorderSourceDocumentsRequest {
+    
+    @NotEmpty(message = "顺序列表不能为空")
+    @Schema(description = "按顺序排列的来源文档ID列表", requiredMode = Schema.RequiredMode.REQUIRED)
+    private List<String> orderedIds;
+}

+ 32 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/request/UpdateProjectRequest.java

@@ -0,0 +1,32 @@
+package com.lingyue.extract.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 更新项目请求
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "更新项目请求")
+public class UpdateProjectRequest {
+    
+    @Size(max = 200, message = "项目名称最多200字符")
+    @Schema(description = "项目名称")
+    private String name;
+    
+    @Size(max = 2000, message = "项目描述最多2000字符")
+    @Schema(description = "项目描述")
+    private String description;
+    
+    @Schema(description = "状态: draft-草稿, extracting-提取中, completed-已完成, archived-已归档")
+    private String status;
+    
+    @Schema(description = "项目配置")
+    private Map<String, Object> config;
+}

+ 31 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/request/UpdateSourceDocumentRequest.java

@@ -0,0 +1,31 @@
+package com.lingyue.extract.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * 更新来源文档请求
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "更新来源文档请求")
+public class UpdateSourceDocumentRequest {
+    
+    @Size(max = 100, message = "别名最多100字符")
+    @Schema(description = "文档别名")
+    private String alias;
+    
+    @Schema(description = "文档类型: pdf/docx/xlsx")
+    private String docType;
+    
+    @Schema(description = "显示顺序")
+    private Integer displayOrder;
+    
+    @Schema(description = "元数据")
+    private Map<String, Object> metadata;
+}

+ 74 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/response/ProjectDetailResponse.java

@@ -0,0 +1,74 @@
+package com.lingyue.extract.dto.response;
+
+import com.lingyue.extract.entity.Project;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 项目详情响应
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "项目详情响应")
+public class ProjectDetailResponse {
+    
+    @Schema(description = "项目ID")
+    private String id;
+    
+    @Schema(description = "用户ID")
+    private String userId;
+    
+    @Schema(description = "项目名称")
+    private String name;
+    
+    @Schema(description = "项目描述")
+    private String description;
+    
+    @Schema(description = "状态")
+    private String status;
+    
+    @Schema(description = "项目配置")
+    private Map<String, Object> config;
+    
+    @Schema(description = "创建时间")
+    private Date createTime;
+    
+    @Schema(description = "更新时间")
+    private Date updateTime;
+    
+    // ==================== 关联数据 ====================
+    
+    @Schema(description = "来源文档列表")
+    private List<SourceDocumentResponse> sourceDocuments;
+    
+    @Schema(description = "来源文档数量")
+    private Integer documentCount;
+    
+    @Schema(description = "规则数量")
+    private Integer ruleCount;
+    
+    @Schema(description = "已完成规则数量")
+    private Integer completedRuleCount;
+    
+    /**
+     * 从实体转换
+     */
+    public static ProjectDetailResponse fromEntity(Project project) {
+        ProjectDetailResponse response = new ProjectDetailResponse();
+        response.setId(project.getId());
+        response.setUserId(project.getUserId());
+        response.setName(project.getName());
+        response.setDescription(project.getDescription());
+        response.setStatus(project.getStatus());
+        response.setConfig(project.getConfig());
+        response.setCreateTime(project.getCreateTime());
+        response.setUpdateTime(project.getUpdateTime());
+        return response;
+    }
+}

+ 62 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/response/ProjectListResponse.java

@@ -0,0 +1,62 @@
+package com.lingyue.extract.dto.response;
+
+import com.lingyue.extract.entity.Project;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 项目列表项响应
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "项目列表项响应")
+public class ProjectListResponse {
+    
+    @Schema(description = "项目ID")
+    private String id;
+    
+    @Schema(description = "项目名称")
+    private String name;
+    
+    @Schema(description = "项目描述")
+    private String description;
+    
+    @Schema(description = "状态")
+    private String status;
+    
+    @Schema(description = "创建时间")
+    private Date createTime;
+    
+    @Schema(description = "更新时间")
+    private Date updateTime;
+    
+    // ==================== 统计信息 ====================
+    
+    @Schema(description = "来源文档数量")
+    private Integer documentCount;
+    
+    @Schema(description = "规则数量")
+    private Integer ruleCount;
+    
+    @Schema(description = "提取进度(百分比)")
+    private Integer progress;
+    
+    /**
+     * 从实体转换
+     */
+    public static ProjectListResponse fromEntity(Project project) {
+        ProjectListResponse response = new ProjectListResponse();
+        response.setId(project.getId());
+        response.setName(project.getName());
+        response.setDescription(project.getDescription());
+        response.setStatus(project.getStatus());
+        response.setCreateTime(project.getCreateTime());
+        response.setUpdateTime(project.getUpdateTime());
+        return response;
+    }
+}

+ 70 - 0
backend/extract-service/src/main/java/com/lingyue/extract/dto/response/SourceDocumentResponse.java

@@ -0,0 +1,70 @@
+package com.lingyue.extract.dto.response;
+
+import com.lingyue.extract.entity.SourceDocument;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 来源文档响应
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Data
+@Schema(description = "来源文档响应")
+public class SourceDocumentResponse {
+    
+    @Schema(description = "来源文档ID")
+    private String id;
+    
+    @Schema(description = "项目ID")
+    private String projectId;
+    
+    @Schema(description = "关联的 Document ID")
+    private String documentId;
+    
+    @Schema(description = "文档别名")
+    private String alias;
+    
+    @Schema(description = "文档类型")
+    private String docType;
+    
+    @Schema(description = "显示顺序")
+    private Integer displayOrder;
+    
+    @Schema(description = "元数据")
+    private Map<String, Object> metadata;
+    
+    @Schema(description = "创建时间")
+    private Date createTime;
+    
+    // ==================== 关联的 Document 信息 ====================
+    
+    @Schema(description = "原始文档名称")
+    private String originalFileName;
+    
+    @Schema(description = "文档解析状态")
+    private String parseStatus;
+    
+    @Schema(description = "文档大小(字节)")
+    private Long fileSize;
+    
+    /**
+     * 从实体转换
+     */
+    public static SourceDocumentResponse fromEntity(SourceDocument sourceDocument) {
+        SourceDocumentResponse response = new SourceDocumentResponse();
+        response.setId(sourceDocument.getId());
+        response.setProjectId(sourceDocument.getProjectId());
+        response.setDocumentId(sourceDocument.getDocumentId());
+        response.setAlias(sourceDocument.getAlias());
+        response.setDocType(sourceDocument.getDocType());
+        response.setDisplayOrder(sourceDocument.getDisplayOrder());
+        response.setMetadata(sourceDocument.getMetadata());
+        response.setCreateTime(sourceDocument.getCreateTime());
+        return response;
+    }
+}

+ 285 - 0
backend/extract-service/src/main/java/com/lingyue/extract/service/ProjectService.java

@@ -0,0 +1,285 @@
+package com.lingyue.extract.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.lingyue.extract.dto.request.CreateProjectRequest;
+import com.lingyue.extract.dto.request.UpdateProjectRequest;
+import com.lingyue.extract.dto.response.ProjectDetailResponse;
+import com.lingyue.extract.dto.response.ProjectListResponse;
+import com.lingyue.extract.dto.response.SourceDocumentResponse;
+import com.lingyue.extract.entity.Project;
+import com.lingyue.extract.repository.ExtractResultRepository;
+import com.lingyue.extract.repository.ExtractRuleRepository;
+import com.lingyue.extract.repository.ProjectRepository;
+import com.lingyue.extract.repository.SourceDocumentRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * 项目服务
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ProjectService {
+    
+    private final ProjectRepository projectRepository;
+    private final SourceDocumentRepository sourceDocumentRepository;
+    private final ExtractRuleRepository extractRuleRepository;
+    private final ExtractResultRepository extractResultRepository;
+    
+    /**
+     * 创建项目
+     */
+    @Transactional
+    public Project create(String userId, CreateProjectRequest request) {
+        Project project = new Project();
+        project.setId(UUID.randomUUID().toString());
+        project.setUserId(userId);
+        project.setName(request.getName());
+        project.setDescription(request.getDescription());
+        project.setStatus(Project.STATUS_DRAFT);
+        project.setConfig(request.getConfig());
+        project.setCreateTime(new Date());
+        project.setCreateBy(userId);
+        
+        projectRepository.insert(project);
+        log.info("创建项目成功: projectId={}, name={}", project.getId(), project.getName());
+        return project;
+    }
+    
+    /**
+     * 获取项目详情
+     */
+    public Project getById(String id) {
+        return projectRepository.selectById(id);
+    }
+    
+    /**
+     * 获取项目详情(含统计信息)
+     */
+    public ProjectDetailResponse getProjectDetail(String id) {
+        Project project = projectRepository.selectById(id);
+        if (project == null) {
+            return null;
+        }
+        
+        ProjectDetailResponse response = ProjectDetailResponse.fromEntity(project);
+        
+        // 统计来源文档数量
+        int docCount = sourceDocumentRepository.countByProjectId(id);
+        response.setDocumentCount(docCount);
+        
+        // 统计规则数量
+        int ruleCount = extractRuleRepository.countByProjectId(id);
+        response.setRuleCount(ruleCount);
+        
+        // 统计已完成规则数量
+        List<Map<String, Object>> statusCounts = extractRuleRepository.countByProjectIdGroupByStatus(id);
+        int completedCount = 0;
+        for (Map<String, Object> item : statusCounts) {
+            String status = (String) item.get("status");
+            if ("extracted".equals(status) || "confirmed".equals(status)) {
+                completedCount += ((Number) item.get("count")).intValue();
+            }
+        }
+        response.setCompletedRuleCount(completedCount);
+        
+        return response;
+    }
+    
+    /**
+     * 获取项目详情(含来源文档列表)
+     */
+    public ProjectDetailResponse getProjectWithDocuments(String id, SourceDocumentService sourceDocumentService) {
+        ProjectDetailResponse response = getProjectDetail(id);
+        if (response == null) {
+            return null;
+        }
+        
+        // 获取来源文档列表
+        List<SourceDocumentResponse> docs = sourceDocumentService.listByProjectId(id);
+        response.setSourceDocuments(docs);
+        
+        return response;
+    }
+    
+    /**
+     * 分页查询用户的项目
+     */
+    public Page<ProjectListResponse> listByUserId(String userId, int pageNum, int pageSize) {
+        Page<Project> page = new Page<>(pageNum, pageSize);
+        
+        LambdaQueryWrapper<Project> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Project::getUserId, userId)
+               .orderByDesc(Project::getCreateTime);
+        
+        Page<Project> resultPage = projectRepository.selectPage(page, wrapper);
+        
+        // 转换为响应对象
+        Page<ProjectListResponse> responsePage = new Page<>();
+        responsePage.setCurrent(resultPage.getCurrent());
+        responsePage.setSize(resultPage.getSize());
+        responsePage.setTotal(resultPage.getTotal());
+        responsePage.setPages(resultPage.getPages());
+        
+        List<ProjectListResponse> records = resultPage.getRecords().stream()
+                .map(project -> {
+                    ProjectListResponse resp = ProjectListResponse.fromEntity(project);
+                    // 统计来源文档和规则数量
+                    resp.setDocumentCount(sourceDocumentRepository.countByProjectId(project.getId()));
+                    resp.setRuleCount(extractRuleRepository.countByProjectId(project.getId()));
+                    // 计算进度
+                    resp.setProgress(calculateProgress(project.getId()));
+                    return resp;
+                })
+                .collect(Collectors.toList());
+        responsePage.setRecords(records);
+        
+        return responsePage;
+    }
+    
+    /**
+     * 按状态查询用户的项目
+     */
+    public List<ProjectListResponse> listByUserIdAndStatus(String userId, String status) {
+        List<Project> projects = projectRepository.findByUserIdAndStatus(userId, status);
+        return projects.stream()
+                .map(ProjectListResponse::fromEntity)
+                .collect(Collectors.toList());
+    }
+    
+    /**
+     * 更新项目
+     */
+    @Transactional
+    public Project update(String id, UpdateProjectRequest request, String operatorId) {
+        Project project = projectRepository.selectById(id);
+        if (project == null) {
+            return null;
+        }
+        
+        if (request.getName() != null) {
+            project.setName(request.getName());
+        }
+        if (request.getDescription() != null) {
+            project.setDescription(request.getDescription());
+        }
+        if (request.getStatus() != null) {
+            project.setStatus(request.getStatus());
+        }
+        if (request.getConfig() != null) {
+            project.setConfig(request.getConfig());
+        }
+        
+        project.setUpdateTime(new Date());
+        project.setUpdateBy(operatorId);
+        
+        projectRepository.updateById(project);
+        log.info("更新项目成功: projectId={}", id);
+        return project;
+    }
+    
+    /**
+     * 删除项目(级联删除关联数据)
+     */
+    @Transactional
+    public boolean delete(String id) {
+        Project project = projectRepository.selectById(id);
+        if (project == null) {
+            return false;
+        }
+        
+        log.info("开始删除项目及关联数据: projectId={}", id);
+        
+        // 1. 删除提取结果
+        int resultCount = extractResultRepository.deleteByProjectId(id);
+        log.debug("删除提取结果: {} 条", resultCount);
+        
+        // 2. 删除提取规则
+        int ruleCount = extractRuleRepository.deleteByProjectId(id);
+        log.debug("删除提取规则: {} 条", ruleCount);
+        
+        // 3. 删除来源文档
+        int docCount = sourceDocumentRepository.deleteByProjectId(id);
+        log.debug("删除来源文档: {} 条", docCount);
+        
+        // 4. 删除项目
+        projectRepository.deleteById(id);
+        
+        log.info("删除项目完成: projectId={}, 删除规则={}, 删除结果={}, 删除文档={}", 
+                 id, ruleCount, resultCount, docCount);
+        return true;
+    }
+    
+    /**
+     * 归档项目
+     */
+    @Transactional
+    public Project archive(String id, String operatorId) {
+        Project project = projectRepository.selectById(id);
+        if (project == null) {
+            return null;
+        }
+        
+        project.setStatus(Project.STATUS_ARCHIVED);
+        project.setUpdateTime(new Date());
+        project.setUpdateBy(operatorId);
+        
+        projectRepository.updateById(project);
+        log.info("归档项目成功: projectId={}", id);
+        return project;
+    }
+    
+    /**
+     * 计算项目进度(百分比)
+     */
+    private int calculateProgress(String projectId) {
+        int totalRules = extractRuleRepository.countByProjectId(projectId);
+        if (totalRules == 0) {
+            return 0;
+        }
+        
+        List<Map<String, Object>> statusCounts = extractRuleRepository.countByProjectIdGroupByStatus(projectId);
+        int completedCount = 0;
+        for (Map<String, Object> item : statusCounts) {
+            String status = (String) item.get("status");
+            if ("extracted".equals(status) || "confirmed".equals(status)) {
+                completedCount += ((Number) item.get("count")).intValue();
+            }
+        }
+        
+        return (int) Math.round((double) completedCount / totalRules * 100);
+    }
+    
+    /**
+     * 检查用户是否有权访问项目
+     */
+    public boolean hasAccess(String projectId, String userId) {
+        Project project = projectRepository.selectById(projectId);
+        return project != null && project.getUserId().equals(userId);
+    }
+    
+    /**
+     * 获取用户项目统计
+     */
+    public Map<String, Integer> getProjectStatistics(String userId) {
+        List<Map<String, Object>> counts = projectRepository.countByUserIdGroupByStatus(userId);
+        return counts.stream()
+                .collect(Collectors.toMap(
+                        m -> (String) m.get("status"),
+                        m -> ((Number) m.get("count")).intValue()
+                ));
+    }
+}

+ 255 - 0
backend/extract-service/src/main/java/com/lingyue/extract/service/SourceDocumentService.java

@@ -0,0 +1,255 @@
+package com.lingyue.extract.service;
+
+import com.lingyue.extract.dto.request.AddSourceDocumentRequest;
+import com.lingyue.extract.dto.request.BatchAddSourceDocumentsRequest;
+import com.lingyue.extract.dto.request.ReorderSourceDocumentsRequest;
+import com.lingyue.extract.dto.request.UpdateSourceDocumentRequest;
+import com.lingyue.extract.dto.response.SourceDocumentResponse;
+import com.lingyue.extract.entity.SourceDocument;
+import com.lingyue.extract.repository.ExtractRuleRepository;
+import com.lingyue.extract.repository.SourceDocumentRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 来源文档服务
+ * 
+ * @author lingyue
+ * @since 2026-01-22
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SourceDocumentService {
+    
+    private final SourceDocumentRepository sourceDocumentRepository;
+    private final ExtractRuleRepository extractRuleRepository;
+    
+    /**
+     * 添加来源文档
+     */
+    @Transactional
+    public SourceDocument add(String projectId, AddSourceDocumentRequest request) {
+        SourceDocument doc = new SourceDocument();
+        doc.setId(UUID.randomUUID().toString());
+        doc.setProjectId(projectId);
+        doc.setDocumentId(request.getDocumentId());
+        doc.setAlias(request.getAlias());
+        doc.setDocType(request.getDocType());
+        doc.setMetadata(request.getMetadata());
+        doc.setCreateTime(new Date());
+        
+        // 设置顺序
+        if (request.getDisplayOrder() != null) {
+            doc.setDisplayOrder(request.getDisplayOrder());
+        } else {
+            // 追加到最后
+            int maxOrder = sourceDocumentRepository.getMaxDisplayOrder(projectId);
+            doc.setDisplayOrder(maxOrder + 1);
+        }
+        
+        sourceDocumentRepository.insert(doc);
+        log.info("添加来源文档成功: projectId={}, sourceDocId={}, documentId={}", 
+                 projectId, doc.getId(), doc.getDocumentId());
+        return doc;
+    }
+    
+    /**
+     * 批量添加来源文档
+     */
+    @Transactional
+    public List<SourceDocument> batchAdd(String projectId, BatchAddSourceDocumentsRequest request) {
+        int maxOrder = sourceDocumentRepository.getMaxDisplayOrder(projectId);
+        List<SourceDocument> docs = new ArrayList<>();
+        
+        for (int i = 0; i < request.getDocuments().size(); i++) {
+            AddSourceDocumentRequest docRequest = request.getDocuments().get(i);
+            
+            SourceDocument doc = new SourceDocument();
+            doc.setId(UUID.randomUUID().toString());
+            doc.setProjectId(projectId);
+            doc.setDocumentId(docRequest.getDocumentId());
+            doc.setAlias(docRequest.getAlias());
+            doc.setDocType(docRequest.getDocType());
+            doc.setMetadata(docRequest.getMetadata());
+            doc.setCreateTime(new Date());
+            
+            if (docRequest.getDisplayOrder() != null) {
+                doc.setDisplayOrder(docRequest.getDisplayOrder());
+            } else {
+                doc.setDisplayOrder(maxOrder + i + 1);
+            }
+            
+            sourceDocumentRepository.insert(doc);
+            docs.add(doc);
+        }
+        
+        log.info("批量添加来源文档成功: projectId={}, count={}", projectId, docs.size());
+        return docs;
+    }
+    
+    /**
+     * 获取来源文档详情
+     */
+    public SourceDocument getById(String id) {
+        return sourceDocumentRepository.selectById(id);
+    }
+    
+    /**
+     * 获取来源文档详情响应
+     */
+    public SourceDocumentResponse getSourceDocumentResponse(String id) {
+        SourceDocument doc = sourceDocumentRepository.selectById(id);
+        if (doc == null) {
+            return null;
+        }
+        return SourceDocumentResponse.fromEntity(doc);
+    }
+    
+    /**
+     * 获取项目的来源文档列表
+     */
+    public List<SourceDocumentResponse> listByProjectId(String projectId) {
+        List<SourceDocument> docs = sourceDocumentRepository.findByProjectId(projectId);
+        return docs.stream()
+                .map(SourceDocumentResponse::fromEntity)
+                .collect(Collectors.toList());
+    }
+    
+    /**
+     * 根据 Document ID 查询来源文档
+     */
+    public List<SourceDocument> findByDocumentId(String documentId) {
+        return sourceDocumentRepository.findByDocumentId(documentId);
+    }
+    
+    /**
+     * 根据项目ID和别名查询
+     */
+    public SourceDocument findByProjectIdAndAlias(String projectId, String alias) {
+        return sourceDocumentRepository.findByProjectIdAndAlias(projectId, alias);
+    }
+    
+    /**
+     * 更新来源文档
+     */
+    @Transactional
+    public SourceDocument update(String id, UpdateSourceDocumentRequest request) {
+        SourceDocument doc = sourceDocumentRepository.selectById(id);
+        if (doc == null) {
+            return null;
+        }
+        
+        if (request.getAlias() != null) {
+            doc.setAlias(request.getAlias());
+        }
+        if (request.getDocType() != null) {
+            doc.setDocType(request.getDocType());
+        }
+        if (request.getDisplayOrder() != null) {
+            doc.setDisplayOrder(request.getDisplayOrder());
+        }
+        if (request.getMetadata() != null) {
+            doc.setMetadata(request.getMetadata());
+        }
+        
+        sourceDocumentRepository.updateById(doc);
+        log.info("更新来源文档成功: sourceDocId={}", id);
+        return doc;
+    }
+    
+    /**
+     * 移除来源文档
+     */
+    @Transactional
+    public boolean remove(String id) {
+        SourceDocument doc = sourceDocumentRepository.selectById(id);
+        if (doc == null) {
+            return false;
+        }
+        
+        // 检查是否有关联的规则
+        int ruleCount = extractRuleRepository.findBySourceDocId(id).size();
+        if (ruleCount > 0) {
+            log.warn("来源文档存在关联规则,无法删除: sourceDocId={}, ruleCount={}", id, ruleCount);
+            throw new IllegalStateException("来源文档存在关联规则,请先删除相关规则");
+        }
+        
+        sourceDocumentRepository.deleteById(id);
+        log.info("移除来源文档成功: sourceDocId={}", id);
+        return true;
+    }
+    
+    /**
+     * 强制移除来源文档(同时删除关联规则)
+     */
+    @Transactional
+    public boolean forceRemove(String id) {
+        SourceDocument doc = sourceDocumentRepository.selectById(id);
+        if (doc == null) {
+            return false;
+        }
+        
+        // 删除关联的规则
+        var rules = extractRuleRepository.findBySourceDocId(id);
+        for (var rule : rules) {
+            extractRuleRepository.deleteById(rule.getId());
+        }
+        
+        sourceDocumentRepository.deleteById(id);
+        log.info("强制移除来源文档成功: sourceDocId={}, 删除规则={}", id, rules.size());
+        return true;
+    }
+    
+    /**
+     * 调整文档顺序
+     */
+    @Transactional
+    public void reorder(String projectId, ReorderSourceDocumentsRequest request) {
+        List<String> orderedIds = request.getOrderedIds();
+        
+        for (int i = 0; i < orderedIds.size(); i++) {
+            String docId = orderedIds.get(i);
+            SourceDocument doc = sourceDocumentRepository.selectById(docId);
+            if (doc != null && doc.getProjectId().equals(projectId)) {
+                doc.setDisplayOrder(i + 1);
+                sourceDocumentRepository.updateById(doc);
+            }
+        }
+        
+        log.info("调整来源文档顺序成功: projectId={}, count={}", projectId, orderedIds.size());
+    }
+    
+    /**
+     * 统计项目来源文档数量
+     */
+    public int countByProjectId(String projectId) {
+        return sourceDocumentRepository.countByProjectId(projectId);
+    }
+    
+    /**
+     * 更新来源文档的解析状态(从 metadata 中)
+     */
+    @Transactional
+    public void updateParseStatus(String sourceDocId, String parseStatus) {
+        SourceDocument doc = sourceDocumentRepository.selectById(sourceDocId);
+        if (doc == null) {
+            return;
+        }
+        
+        Map<String, Object> metadata = doc.getMetadata();
+        if (metadata == null) {
+            metadata = new HashMap<>();
+        }
+        metadata.put("parseStatus", parseStatus);
+        doc.setMetadata(metadata);
+        
+        sourceDocumentRepository.updateById(doc);
+        log.debug("更新来源文档解析状态: sourceDocId={}, parseStatus={}", sourceDocId, parseStatus);
+    }
+}