Răsfoiți Sursa

feat: 实现数据源服务(DataSourceService)

数据源是节点集合的抽象,支持绑定 GraphNode 和 DocumentElement

新增功能:
- DataSource 实体增强:新增 valueType/aggregateType/separator 字段
- 节点引用结构:支持混合引用 graph_node 和 document_element
- DataSourceService:CRUD 和核心 getValue 取值方法
- DataSourceController:REST API 接口
- 支持文本聚合(first/last/concat/sum/avg/list)
- 支持批量取值(batch-value)

API 接口:
- POST /api/v1/datasource - 创建数据源
- GET /api/v1/datasource/{id} - 获取数据源
- GET /api/v1/datasource/{id}/value - 获取数据源值(核心接口)
- POST /api/v1/datasource/batch-value - 批量获取值
- PUT /api/v1/datasource/{id}/refs - 更新节点引用

数据库迁移:
- V2026_01_21_03__enhance_data_sources.sql
何文松 1 lună în urmă
părinte
comite
2e8d716369

+ 4 - 0
backend/auth-service/src/main/java/com/lingyue/auth/config/SecurityConfig.java

@@ -59,6 +59,10 @@ public class SecurityConfig {
                             .requestMatchers("/api/graph/**", "/api/text-storage/**").permitAll()
                             // 文件访问接口(开发阶段暂时开放)
                             .requestMatchers("/api/v1/files/**").permitAll()
+                            // 数据源接口(开发阶段暂时开放)
+                            .requestMatchers("/api/v1/datasource/**").permitAll()
+                            // 模板接口(开发阶段暂时开放)
+                            .requestMatchers("/api/v1/template/**").permitAll()
                             // 静态资源,可匿名访问
                             .requestMatchers(HttpMethod.GET,
                                              "/",

+ 7 - 0
backend/graph-service/pom.xml

@@ -63,6 +63,13 @@
             <artifactId>common</artifactId>
         </dependency>
         
+        <!-- Document Service (用于 DocumentElement 实体) -->
+        <dependency>
+            <groupId>com.lingyue</groupId>
+            <artifactId>document-service</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        
         <!-- Lombok -->
         <dependency>
             <groupId>org.projectlombok</groupId>

+ 175 - 0
backend/graph-service/src/main/java/com/lingyue/graph/controller/DataSourceController.java

@@ -0,0 +1,175 @@
+package com.lingyue.graph.controller;
+
+import com.lingyue.common.domain.AjaxResult;
+import com.lingyue.graph.dto.*;
+import com.lingyue.graph.entity.DataSource;
+import com.lingyue.graph.service.DataSourceService;
+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-21
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/datasource")
+@RequiredArgsConstructor
+@Tag(name = "数据源管理", description = "数据源 CRUD 和取值接口")
+public class DataSourceController {
+    
+    private final DataSourceService dataSourceService;
+    
+    // 临时用户ID,实际应从安全上下文获取
+    private static final String DEFAULT_USER_ID = "default-user";
+    
+    /**
+     * 创建数据源
+     */
+    @PostMapping
+    @Operation(summary = "创建数据源", description = "创建新的数据源并绑定节点")
+    public AjaxResult<DataSource> create(
+            @RequestBody @Valid CreateDataSourceRequest request,
+            @RequestHeader(value = "X-User-Id", required = false) String userId) {
+        
+        String actualUserId = userId != null ? userId : DEFAULT_USER_ID;
+        DataSource dataSource = dataSourceService.create(actualUserId, request);
+        return AjaxResult.success(dataSource);
+    }
+    
+    /**
+     * 获取数据源
+     */
+    @GetMapping("/{id}")
+    @Operation(summary = "获取数据源", description = "根据ID获取数据源详情")
+    public AjaxResult<DataSource> getById(
+            @PathVariable @Parameter(description = "数据源ID") String id) {
+        
+        DataSource dataSource = dataSourceService.getById(id);
+        if (dataSource == null) {
+            return AjaxResult.error("数据源不存在: " + id);
+        }
+        return AjaxResult.success(dataSource);
+    }
+    
+    /**
+     * 按文档查询数据源
+     */
+    @GetMapping("/document/{documentId}")
+    @Operation(summary = "按文档查询", description = "获取指定文档的所有数据源")
+    public AjaxResult<List<DataSource>> getByDocumentId(
+            @PathVariable @Parameter(description = "文档ID") String documentId) {
+        
+        List<DataSource> dataSources = dataSourceService.getByDocumentId(documentId);
+        return AjaxResult.success(dataSources);
+    }
+    
+    /**
+     * 按用户查询数据源
+     */
+    @GetMapping("/user/{userId}")
+    @Operation(summary = "按用户查询", description = "获取指定用户的所有数据源")
+    public AjaxResult<List<DataSource>> getByUserId(
+            @PathVariable @Parameter(description = "用户ID") String userId) {
+        
+        List<DataSource> dataSources = dataSourceService.getByUserId(userId);
+        return AjaxResult.success(dataSources);
+    }
+    
+    /**
+     * 按用户和类型查询
+     */
+    @GetMapping("/user/{userId}/type/{type}")
+    @Operation(summary = "按用户和类型查询", description = "获取指定用户指定类型的数据源")
+    public AjaxResult<List<DataSource>> getByUserIdAndType(
+            @PathVariable @Parameter(description = "用户ID") String userId,
+            @PathVariable @Parameter(description = "数据源类型") String type) {
+        
+        List<DataSource> dataSources = dataSourceService.getByUserIdAndType(userId, type);
+        return AjaxResult.success(dataSources);
+    }
+    
+    /**
+     * 更新数据源
+     */
+    @PutMapping("/{id}")
+    @Operation(summary = "更新数据源", description = "更新数据源基本信息")
+    public AjaxResult<DataSource> update(
+            @PathVariable @Parameter(description = "数据源ID") String id,
+            @RequestBody UpdateDataSourceRequest request) {
+        
+        try {
+            DataSource dataSource = dataSourceService.update(id, request);
+            return AjaxResult.success(dataSource);
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+    
+    /**
+     * 更新节点引用
+     */
+    @PutMapping("/{id}/refs")
+    @Operation(summary = "更新节点引用", description = "更新数据源绑定的节点引用(支持替换/追加/移除)")
+    public AjaxResult<DataSource> updateRefs(
+            @PathVariable @Parameter(description = "数据源ID") String id,
+            @RequestBody UpdateRefsRequest request) {
+        
+        try {
+            DataSource dataSource = dataSourceService.updateRefs(id, request);
+            return AjaxResult.success(dataSource);
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+    
+    /**
+     * 删除数据源
+     */
+    @DeleteMapping("/{id}")
+    @Operation(summary = "删除数据源", description = "删除指定数据源")
+    public AjaxResult<Void> delete(
+            @PathVariable @Parameter(description = "数据源ID") String id) {
+        
+        dataSourceService.delete(id);
+        return AjaxResult.success();
+    }
+    
+    /**
+     * 获取数据源值(核心接口)
+     */
+    @GetMapping("/{id}/value")
+    @Operation(summary = "获取数据源值", description = "从绑定的节点中提取并聚合值")
+    public AjaxResult<DataSourceValue> getValue(
+            @PathVariable @Parameter(description = "数据源ID") String id) {
+        
+        try {
+            DataSourceValue value = dataSourceService.getValue(id);
+            return AjaxResult.success(value);
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+    
+    /**
+     * 批量获取数据源值
+     */
+    @PostMapping("/batch-value")
+    @Operation(summary = "批量获取数据源值", description = "批量获取多个数据源的值,用于模板渲染")
+    public AjaxResult<Map<String, DataSourceValue>> batchGetValue(
+            @RequestBody BatchValueRequest request) {
+        
+        Map<String, DataSourceValue> values = dataSourceService.batchGetValue(request.getDataSourceIds());
+        return AjaxResult.success(values);
+    }
+}

+ 24 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/BatchValueRequest.java

@@ -0,0 +1,24 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 批量获取数据源值请求
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "批量获取数据源值请求")
+public class BatchValueRequest {
+    
+    @Schema(description = "数据源ID列表")
+    private List<String> dataSourceIds;
+}

+ 52 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/CreateDataSourceRequest.java

@@ -0,0 +1,52 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 创建数据源请求
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "创建数据源请求")
+public class CreateDataSourceRequest {
+    
+    @NotBlank(message = "数据源名称不能为空")
+    @Schema(description = "数据源名称", required = true)
+    private String name;
+    
+    @NotBlank(message = "数据源类型不能为空")
+    @Schema(description = "数据源类型", example = "text/table/image", required = true)
+    private String type;
+    
+    @Schema(description = "文档ID")
+    private String documentId;
+    
+    @Schema(description = "来源类型", example = "file/manual/rule_result")
+    private String sourceType = "manual";
+    
+    @Schema(description = "值类型", example = "text/image/table/mixed")
+    private String valueType = "text";
+    
+    @Schema(description = "聚合方式", example = "first/last/concat/sum/avg/list")
+    private String aggregateType = "first";
+    
+    @Schema(description = "聚合分隔符")
+    private String separator = "";
+    
+    @Schema(description = "节点引用列表")
+    private NodeRefs nodeIds;
+    
+    @Schema(description = "数据源配置")
+    private Object config;
+    
+    @Schema(description = "元数据")
+    private Object metadata;
+}

+ 112 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/DataSourceValue.java

@@ -0,0 +1,112 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 数据源取值返回
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "数据源取值返回")
+public class DataSourceValue {
+    
+    public static final String TYPE_TEXT = "text";
+    public static final String TYPE_IMAGE = "image";
+    public static final String TYPE_TABLE = "table";
+    public static final String TYPE_MIXED = "mixed";
+    
+    @Schema(description = "数据源ID")
+    private String dataSourceId;
+    
+    @Schema(description = "数据源名称")
+    private String dataSourceName;
+    
+    @Schema(description = "值类型", example = "text/image/table/mixed")
+    private String type;
+    
+    @Schema(description = "文本值(聚合后)")
+    private String textValue;
+    
+    @Schema(description = "图片URL")
+    private String imageUrl;
+    
+    @Schema(description = "图片路径")
+    private String imagePath;
+    
+    @Schema(description = "表格数据")
+    private List<List<Map<String, Object>>> tableData;
+    
+    @Schema(description = "原始节点值列表")
+    private List<NodeValueItem> items;
+    
+    @Schema(description = "节点数量")
+    private Integer nodeCount;
+    
+    /**
+     * 创建空值
+     */
+    public static DataSourceValue empty(String dataSourceId, String dataSourceName) {
+        return DataSourceValue.builder()
+                .dataSourceId(dataSourceId)
+                .dataSourceName(dataSourceName)
+                .type(TYPE_TEXT)
+                .textValue("")
+                .nodeCount(0)
+                .build();
+    }
+    
+    /**
+     * 创建文本值
+     */
+    public static DataSourceValue text(String dataSourceId, String dataSourceName, String value, List<NodeValueItem> items) {
+        return DataSourceValue.builder()
+                .dataSourceId(dataSourceId)
+                .dataSourceName(dataSourceName)
+                .type(TYPE_TEXT)
+                .textValue(value)
+                .items(items)
+                .nodeCount(items != null ? items.size() : 0)
+                .build();
+    }
+    
+    /**
+     * 创建图片值
+     */
+    public static DataSourceValue image(String dataSourceId, String dataSourceName, String imageUrl, String imagePath, List<NodeValueItem> items) {
+        return DataSourceValue.builder()
+                .dataSourceId(dataSourceId)
+                .dataSourceName(dataSourceName)
+                .type(TYPE_IMAGE)
+                .imageUrl(imageUrl)
+                .imagePath(imagePath)
+                .items(items)
+                .nodeCount(items != null ? items.size() : 0)
+                .build();
+    }
+    
+    /**
+     * 创建表格值
+     */
+    public static DataSourceValue table(String dataSourceId, String dataSourceName, List<List<Map<String, Object>>> tableData, List<NodeValueItem> items) {
+        return DataSourceValue.builder()
+                .dataSourceId(dataSourceId)
+                .dataSourceName(dataSourceName)
+                .type(TYPE_TABLE)
+                .tableData(tableData)
+                .items(items)
+                .nodeCount(items != null ? items.size() : 0)
+                .build();
+    }
+}

+ 51 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/NodeRef.java

@@ -0,0 +1,51 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 节点引用
+ * 用于数据源绑定不同类型的节点
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "节点引用")
+public class NodeRef {
+    
+    public static final String TYPE_GRAPH_NODE = "graph_node";
+    public static final String TYPE_DOCUMENT_ELEMENT = "document_element";
+    
+    @Schema(description = "节点类型", example = "graph_node/document_element")
+    private String type;
+    
+    @Schema(description = "节点ID")
+    private String id;
+    
+    /**
+     * 创建图节点引用
+     */
+    public static NodeRef graphNode(String id) {
+        return new NodeRef(TYPE_GRAPH_NODE, id);
+    }
+    
+    /**
+     * 创建文档元素引用
+     */
+    public static NodeRef documentElement(String id) {
+        return new NodeRef(TYPE_DOCUMENT_ELEMENT, id);
+    }
+    
+    public boolean isGraphNode() {
+        return TYPE_GRAPH_NODE.equals(type);
+    }
+    
+    public boolean isDocumentElement() {
+        return TYPE_DOCUMENT_ELEMENT.equals(type);
+    }
+}

+ 82 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/NodeRefs.java

@@ -0,0 +1,82 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 节点引用列表
+ * 数据源的 nodeIds 字段结构
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "节点引用列表")
+public class NodeRefs {
+    
+    @Schema(description = "节点引用数组")
+    private List<NodeRef> refs = new ArrayList<>();
+    
+    /**
+     * 添加图节点引用
+     */
+    public NodeRefs addGraphNode(String id) {
+        refs.add(NodeRef.graphNode(id));
+        return this;
+    }
+    
+    /**
+     * 添加文档元素引用
+     */
+    public NodeRefs addDocumentElement(String id) {
+        refs.add(NodeRef.documentElement(id));
+        return this;
+    }
+    
+    /**
+     * 获取所有图节点ID
+     */
+    public List<String> getGraphNodeIds() {
+        List<String> ids = new ArrayList<>();
+        for (NodeRef ref : refs) {
+            if (ref.isGraphNode()) {
+                ids.add(ref.getId());
+            }
+        }
+        return ids;
+    }
+    
+    /**
+     * 获取所有文档元素ID
+     */
+    public List<String> getDocumentElementIds() {
+        List<String> ids = new ArrayList<>();
+        for (NodeRef ref : refs) {
+            if (ref.isDocumentElement()) {
+                ids.add(ref.getId());
+            }
+        }
+        return ids;
+    }
+    
+    /**
+     * 是否为空
+     */
+    public boolean isEmpty() {
+        return refs == null || refs.isEmpty();
+    }
+    
+    /**
+     * 节点数量
+     */
+    public int size() {
+        return refs == null ? 0 : refs.size();
+    }
+}

+ 92 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/NodeValueItem.java

@@ -0,0 +1,92 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 节点值项
+ * 表示从单个节点获取的值
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "节点值项")
+public class NodeValueItem {
+    
+    @Schema(description = "节点引用类型", example = "graph_node/document_element")
+    private String refType;
+    
+    @Schema(description = "节点ID")
+    private String nodeId;
+    
+    @Schema(description = "值类型", example = "text/image/table")
+    private String valueType;
+    
+    @Schema(description = "文本值")
+    private String textValue;
+    
+    @Schema(description = "图片URL")
+    private String imageUrl;
+    
+    @Schema(description = "图片路径")
+    private String imagePath;
+    
+    @Schema(description = "表格数据")
+    private List<List<Map<String, Object>>> tableData;
+    
+    @Schema(description = "原始节点名称")
+    private String nodeName;
+    
+    @Schema(description = "原始节点类型")
+    private String nodeType;
+    
+    /**
+     * 创建文本值项
+     */
+    public static NodeValueItem text(String refType, String nodeId, String value, String nodeName) {
+        return NodeValueItem.builder()
+                .refType(refType)
+                .nodeId(nodeId)
+                .valueType("text")
+                .textValue(value)
+                .nodeName(nodeName)
+                .build();
+    }
+    
+    /**
+     * 创建图片值项
+     */
+    public static NodeValueItem image(String refType, String nodeId, String imageUrl, String imagePath, String nodeName) {
+        return NodeValueItem.builder()
+                .refType(refType)
+                .nodeId(nodeId)
+                .valueType("image")
+                .imageUrl(imageUrl)
+                .imagePath(imagePath)
+                .nodeName(nodeName)
+                .build();
+    }
+    
+    /**
+     * 创建表格值项
+     */
+    public static NodeValueItem table(String refType, String nodeId, List<List<Map<String, Object>>> tableData, String nodeName) {
+        return NodeValueItem.builder()
+                .refType(refType)
+                .nodeId(nodeId)
+                .valueType("table")
+                .tableData(tableData)
+                .nodeName(nodeName)
+                .build();
+    }
+}

+ 43 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/UpdateDataSourceRequest.java

@@ -0,0 +1,43 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 更新数据源请求
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "更新数据源请求")
+public class UpdateDataSourceRequest {
+    
+    @Schema(description = "数据源名称")
+    private String name;
+    
+    @Schema(description = "数据源类型", example = "text/table/image")
+    private String type;
+    
+    @Schema(description = "来源类型", example = "file/manual/rule_result")
+    private String sourceType;
+    
+    @Schema(description = "值类型", example = "text/image/table/mixed")
+    private String valueType;
+    
+    @Schema(description = "聚合方式", example = "first/last/concat/sum/avg/list")
+    private String aggregateType;
+    
+    @Schema(description = "聚合分隔符")
+    private String separator;
+    
+    @Schema(description = "数据源配置")
+    private Object config;
+    
+    @Schema(description = "元数据")
+    private Object metadata;
+}

+ 54 - 0
backend/graph-service/src/main/java/com/lingyue/graph/dto/UpdateRefsRequest.java

@@ -0,0 +1,54 @@
+package com.lingyue.graph.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 更新节点引用请求
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "更新节点引用请求")
+public class UpdateRefsRequest {
+    
+    @Schema(description = "更新模式", example = "replace/append/remove")
+    private String mode = "replace";
+    
+    @Schema(description = "节点引用列表")
+    private List<NodeRef> refs;
+    
+    /**
+     * 替换模式 - 完全替换现有引用
+     */
+    public static final String MODE_REPLACE = "replace";
+    
+    /**
+     * 追加模式 - 追加到现有引用
+     */
+    public static final String MODE_APPEND = "append";
+    
+    /**
+     * 移除模式 - 从现有引用中移除
+     */
+    public static final String MODE_REMOVE = "remove";
+    
+    public boolean isReplace() {
+        return MODE_REPLACE.equals(mode);
+    }
+    
+    public boolean isAppend() {
+        return MODE_APPEND.equals(mode);
+    }
+    
+    public boolean isRemove() {
+        return MODE_REMOVE.equals(mode);
+    }
+}

+ 12 - 0
backend/graph-service/src/main/java/com/lingyue/graph/entity/DataSource.java

@@ -52,4 +52,16 @@ public class DataSource extends SimpleModel {
     @Schema(description = "元数据")
     @TableField(value = "metadata", typeHandler = PostgreSqlJsonbTypeHandler.class)
     private Object metadata;
+    
+    @Schema(description = "值类型", example = "text/image/table/mixed")
+    @TableField("value_type")
+    private String valueType = "text";
+    
+    @Schema(description = "聚合方式", example = "first/last/concat/sum/avg/list")
+    @TableField("aggregate_type")
+    private String aggregateType = "first";
+    
+    @Schema(description = "聚合分隔符(concat时使用)")
+    @TableField("separator")
+    private String separator = "";
 }

+ 437 - 0
backend/graph-service/src/main/java/com/lingyue/graph/service/DataSourceService.java

@@ -0,0 +1,437 @@
+package com.lingyue.graph.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.lingyue.document.entity.DocumentElement;
+import com.lingyue.document.repository.DocumentElementRepository;
+import com.lingyue.graph.dto.*;
+import com.lingyue.graph.entity.DataSource;
+import com.lingyue.graph.entity.GraphNode;
+import com.lingyue.graph.repository.DataSourceRepository;
+import com.lingyue.graph.repository.GraphNodeRepository;
+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;
+
+/**
+ * 数据源服务
+ * 管理数据源的 CRUD 和值获取
+ * 
+ * @author lingyue
+ * @since 2026-01-21
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DataSourceService {
+    
+    private final DataSourceRepository dataSourceRepository;
+    private final GraphNodeRepository graphNodeRepository;
+    private final DocumentElementRepository documentElementRepository;
+    private final ObjectMapper objectMapper;
+    
+    // ==================== CRUD 操作 ====================
+    
+    /**
+     * 创建数据源
+     */
+    @Transactional
+    public DataSource create(String userId, CreateDataSourceRequest request) {
+        DataSource dataSource = new DataSource();
+        dataSource.setUserId(userId);
+        dataSource.setName(request.getName());
+        dataSource.setType(request.getType());
+        dataSource.setDocumentId(request.getDocumentId());
+        dataSource.setSourceType(request.getSourceType() != null ? request.getSourceType() : "manual");
+        dataSource.setValueType(request.getValueType() != null ? request.getValueType() : "text");
+        dataSource.setAggregateType(request.getAggregateType() != null ? request.getAggregateType() : "first");
+        dataSource.setSeparator(request.getSeparator() != null ? request.getSeparator() : "");
+        dataSource.setNodeIds(request.getNodeIds());
+        dataSource.setConfig(request.getConfig());
+        dataSource.setMetadata(request.getMetadata());
+        
+        dataSourceRepository.insert(dataSource);
+        log.info("创建数据源: id={}, name={}, type={}", dataSource.getId(), dataSource.getName(), dataSource.getType());
+        return dataSource;
+    }
+    
+    /**
+     * 获取数据源
+     */
+    public DataSource getById(String id) {
+        return dataSourceRepository.selectById(id);
+    }
+    
+    /**
+     * 按文档ID查询
+     */
+    public List<DataSource> getByDocumentId(String documentId) {
+        return dataSourceRepository.findByDocumentId(documentId);
+    }
+    
+    /**
+     * 按用户ID查询
+     */
+    public List<DataSource> getByUserId(String userId) {
+        return dataSourceRepository.findByUserId(userId);
+    }
+    
+    /**
+     * 按类型查询
+     */
+    public List<DataSource> getByUserIdAndType(String userId, String type) {
+        return dataSourceRepository.findByUserIdAndType(userId, type);
+    }
+    
+    /**
+     * 更新数据源
+     */
+    @Transactional
+    public DataSource update(String id, UpdateDataSourceRequest request) {
+        DataSource dataSource = dataSourceRepository.selectById(id);
+        if (dataSource == null) {
+            throw new IllegalArgumentException("数据源不存在: " + id);
+        }
+        
+        if (request.getName() != null) {
+            dataSource.setName(request.getName());
+        }
+        if (request.getType() != null) {
+            dataSource.setType(request.getType());
+        }
+        if (request.getSourceType() != null) {
+            dataSource.setSourceType(request.getSourceType());
+        }
+        if (request.getValueType() != null) {
+            dataSource.setValueType(request.getValueType());
+        }
+        if (request.getAggregateType() != null) {
+            dataSource.setAggregateType(request.getAggregateType());
+        }
+        if (request.getSeparator() != null) {
+            dataSource.setSeparator(request.getSeparator());
+        }
+        if (request.getConfig() != null) {
+            dataSource.setConfig(request.getConfig());
+        }
+        if (request.getMetadata() != null) {
+            dataSource.setMetadata(request.getMetadata());
+        }
+        
+        dataSourceRepository.updateById(dataSource);
+        log.info("更新数据源: id={}", id);
+        return dataSource;
+    }
+    
+    /**
+     * 更新节点引用
+     */
+    @Transactional
+    public DataSource updateRefs(String id, UpdateRefsRequest request) {
+        DataSource dataSource = dataSourceRepository.selectById(id);
+        if (dataSource == null) {
+            throw new IllegalArgumentException("数据源不存在: " + id);
+        }
+        
+        NodeRefs currentRefs = parseNodeRefs(dataSource.getNodeIds());
+        List<NodeRef> newRefs = request.getRefs() != null ? request.getRefs() : new ArrayList<>();
+        
+        if (request.isReplace()) {
+            // 替换模式
+            currentRefs.setRefs(newRefs);
+        } else if (request.isAppend()) {
+            // 追加模式
+            currentRefs.getRefs().addAll(newRefs);
+        } else if (request.isRemove()) {
+            // 移除模式
+            Set<String> idsToRemove = newRefs.stream()
+                    .map(NodeRef::getId)
+                    .collect(Collectors.toSet());
+            currentRefs.setRefs(
+                    currentRefs.getRefs().stream()
+                            .filter(ref -> !idsToRemove.contains(ref.getId()))
+                            .collect(Collectors.toList())
+            );
+        }
+        
+        dataSource.setNodeIds(currentRefs);
+        dataSourceRepository.updateById(dataSource);
+        log.info("更新数据源引用: id={}, mode={}, count={}", id, request.getMode(), currentRefs.size());
+        return dataSource;
+    }
+    
+    /**
+     * 删除数据源
+     */
+    @Transactional
+    public void delete(String id) {
+        dataSourceRepository.deleteById(id);
+        log.info("删除数据源: id={}", id);
+    }
+    
+    // ==================== 核心取值逻辑 ====================
+    
+    /**
+     * 获取数据源的值
+     * 核心方法:从绑定的节点中提取并聚合值
+     */
+    public DataSourceValue getValue(String dataSourceId) {
+        DataSource dataSource = dataSourceRepository.selectById(dataSourceId);
+        if (dataSource == null) {
+            throw new IllegalArgumentException("数据源不存在: " + dataSourceId);
+        }
+        return getValue(dataSource);
+    }
+    
+    /**
+     * 获取数据源的值
+     */
+    public DataSourceValue getValue(DataSource dataSource) {
+        NodeRefs refs = parseNodeRefs(dataSource.getNodeIds());
+        
+        if (refs.isEmpty()) {
+            return DataSourceValue.empty(dataSource.getId(), dataSource.getName());
+        }
+        
+        // 获取所有节点值
+        List<NodeValueItem> items = fetchNodeValues(refs);
+        
+        if (items.isEmpty()) {
+            return DataSourceValue.empty(dataSource.getId(), dataSource.getName());
+        }
+        
+        // 根据值类型和聚合方式生成返回值
+        return aggregateValues(dataSource, items);
+    }
+    
+    /**
+     * 批量获取数据源值
+     */
+    public Map<String, DataSourceValue> batchGetValue(List<String> dataSourceIds) {
+        Map<String, DataSourceValue> result = new LinkedHashMap<>();
+        
+        if (dataSourceIds == null || dataSourceIds.isEmpty()) {
+            return result;
+        }
+        
+        // 批量查询数据源
+        List<DataSource> dataSources = dataSourceRepository.selectBatchIds(dataSourceIds);
+        
+        for (DataSource dataSource : dataSources) {
+            try {
+                DataSourceValue value = getValue(dataSource);
+                result.put(dataSource.getId(), value);
+            } catch (Exception e) {
+                log.error("获取数据源值失败: id={}, error={}", dataSource.getId(), e.getMessage());
+                result.put(dataSource.getId(), DataSourceValue.empty(dataSource.getId(), dataSource.getName()));
+            }
+        }
+        
+        return result;
+    }
+    
+    // ==================== 私有方法 ====================
+    
+    /**
+     * 解析节点引用
+     */
+    private NodeRefs parseNodeRefs(Object nodeIds) {
+        if (nodeIds == null) {
+            return new NodeRefs();
+        }
+        
+        try {
+            if (nodeIds instanceof NodeRefs) {
+                return (NodeRefs) nodeIds;
+            }
+            if (nodeIds instanceof Map) {
+                return objectMapper.convertValue(nodeIds, NodeRefs.class);
+            }
+            if (nodeIds instanceof String) {
+                return objectMapper.readValue((String) nodeIds, NodeRefs.class);
+            }
+            return objectMapper.convertValue(nodeIds, NodeRefs.class);
+        } catch (Exception e) {
+            log.warn("解析节点引用失败: {}", e.getMessage());
+            return new NodeRefs();
+        }
+    }
+    
+    /**
+     * 获取所有节点的值
+     */
+    private List<NodeValueItem> fetchNodeValues(NodeRefs refs) {
+        List<NodeValueItem> items = new ArrayList<>();
+        
+        // 获取图节点
+        List<String> graphNodeIds = refs.getGraphNodeIds();
+        if (!graphNodeIds.isEmpty()) {
+            List<GraphNode> graphNodes = graphNodeRepository.selectBatchIds(graphNodeIds);
+            for (GraphNode node : graphNodes) {
+                items.add(createValueItemFromGraphNode(node));
+            }
+        }
+        
+        // 获取文档元素
+        List<String> elementIds = refs.getDocumentElementIds();
+        if (!elementIds.isEmpty()) {
+            List<DocumentElement> elements = documentElementRepository.selectBatchIds(elementIds);
+            for (DocumentElement element : elements) {
+                items.add(createValueItemFromElement(element));
+            }
+        }
+        
+        return items;
+    }
+    
+    /**
+     * 从图节点创建值项
+     */
+    private NodeValueItem createValueItemFromGraphNode(GraphNode node) {
+        return NodeValueItem.text(
+                NodeRef.TYPE_GRAPH_NODE,
+                node.getId(),
+                node.getValue(),
+                node.getName()
+        );
+    }
+    
+    /**
+     * 从文档元素创建值项
+     */
+    @SuppressWarnings("unchecked")
+    private NodeValueItem createValueItemFromElement(DocumentElement element) {
+        String elementType = element.getElementType();
+        
+        if ("image".equals(elementType)) {
+            return NodeValueItem.image(
+                    NodeRef.TYPE_DOCUMENT_ELEMENT,
+                    element.getId(),
+                    element.getImageUrl(),
+                    element.getImagePath(),
+                    element.getImageAlt()
+            );
+        } else if ("table".equals(elementType)) {
+            return NodeValueItem.table(
+                    NodeRef.TYPE_DOCUMENT_ELEMENT,
+                    element.getId(),
+                    element.getTableData(),
+                    "表格 #" + element.getTableIndex()
+            );
+        } else {
+            // 文本类型(paragraph, heading 等)
+            return NodeValueItem.text(
+                    NodeRef.TYPE_DOCUMENT_ELEMENT,
+                    element.getId(),
+                    element.getContent(),
+                    elementType
+            );
+        }
+    }
+    
+    /**
+     * 聚合值
+     */
+    @SuppressWarnings("unchecked")
+    private DataSourceValue aggregateValues(DataSource dataSource, List<NodeValueItem> items) {
+        String valueType = dataSource.getValueType();
+        String aggregateType = dataSource.getAggregateType();
+        String separator = dataSource.getSeparator() != null ? dataSource.getSeparator() : "";
+        
+        // 图片类型
+        if ("image".equals(valueType)) {
+            NodeValueItem firstImage = items.stream()
+                    .filter(item -> "image".equals(item.getValueType()))
+                    .findFirst()
+                    .orElse(null);
+            
+            if (firstImage != null) {
+                return DataSourceValue.image(
+                        dataSource.getId(),
+                        dataSource.getName(),
+                        firstImage.getImageUrl(),
+                        firstImage.getImagePath(),
+                        items
+                );
+            }
+            return DataSourceValue.empty(dataSource.getId(), dataSource.getName());
+        }
+        
+        // 表格类型
+        if ("table".equals(valueType)) {
+            NodeValueItem firstTable = items.stream()
+                    .filter(item -> "table".equals(item.getValueType()))
+                    .findFirst()
+                    .orElse(null);
+            
+            if (firstTable != null) {
+                return DataSourceValue.table(
+                        dataSource.getId(),
+                        dataSource.getName(),
+                        firstTable.getTableData(),
+                        items
+                );
+            }
+            return DataSourceValue.empty(dataSource.getId(), dataSource.getName());
+        }
+        
+        // 文本类型 - 需要聚合
+        List<String> textValues = items.stream()
+                .filter(item -> "text".equals(item.getValueType()) && item.getTextValue() != null)
+                .map(NodeValueItem::getTextValue)
+                .collect(Collectors.toList());
+        
+        String aggregatedText;
+        switch (aggregateType) {
+            case "first":
+                aggregatedText = textValues.isEmpty() ? "" : textValues.get(0);
+                break;
+            case "last":
+                aggregatedText = textValues.isEmpty() ? "" : textValues.get(textValues.size() - 1);
+                break;
+            case "concat":
+                aggregatedText = String.join(separator, textValues);
+                break;
+            case "sum":
+                aggregatedText = String.valueOf(textValues.stream()
+                        .mapToDouble(this::parseDouble)
+                        .sum());
+                break;
+            case "avg":
+                aggregatedText = String.valueOf(textValues.stream()
+                        .mapToDouble(this::parseDouble)
+                        .average()
+                        .orElse(0.0));
+                break;
+            case "list":
+            default:
+                // list 模式返回所有项
+                return DataSourceValue.builder()
+                        .dataSourceId(dataSource.getId())
+                        .dataSourceName(dataSource.getName())
+                        .type(DataSourceValue.TYPE_TEXT)
+                        .textValue(String.join(separator.isEmpty() ? "\n" : separator, textValues))
+                        .items(items)
+                        .nodeCount(items.size())
+                        .build();
+        }
+        
+        return DataSourceValue.text(dataSource.getId(), dataSource.getName(), aggregatedText, items);
+    }
+    
+    /**
+     * 安全解析数字
+     */
+    private double parseDouble(String value) {
+        try {
+            return Double.parseDouble(value.replaceAll("[^0-9.\\-]", ""));
+        } catch (Exception e) {
+            return 0.0;
+        }
+    }
+}

+ 5 - 2
backend/sql/supplement_tables.sql

@@ -39,10 +39,13 @@ CREATE TABLE IF NOT EXISTS data_sources (
     document_id VARCHAR(36) REFERENCES documents(id) ON DELETE SET NULL,
     name VARCHAR(255) NOT NULL,
     type VARCHAR(50) NOT NULL, -- table/text/image
-    source_type VARCHAR(50) NOT NULL, -- file/manual/rule_result
-    node_ids TEXT[] DEFAULT '{}', -- 关联的节点ID数组(VARCHAR数组
+    source_type VARCHAR(50) NOT NULL DEFAULT 'manual', -- file/manual/rule_result
+    node_ids JSONB DEFAULT '{"refs": []}', -- 节点引用列表(支持 graph_node 和 document_element
     config JSONB DEFAULT '{}', -- 数据源配置
     metadata JSONB DEFAULT '{}',
+    value_type VARCHAR(20) DEFAULT 'text', -- 值类型: text/image/table/mixed
+    aggregate_type VARCHAR(20) DEFAULT 'first', -- 聚合方式: first/last/concat/sum/avg/list
+    separator VARCHAR(50) DEFAULT '', -- 聚合分隔符(concat时使用)
     create_by VARCHAR(36),
     create_by_name VARCHAR(100),
     create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

+ 50 - 0
database/migrations/V2026_01_21_03__enhance_data_sources.sql

@@ -0,0 +1,50 @@
+-- 数据源表增强
+-- 新增 value_type, aggregate_type, separator 字段
+-- 修改 node_ids 为 JSONB 类型以支持混合节点引用
+
+-- 添加新字段
+ALTER TABLE data_sources 
+ADD COLUMN IF NOT EXISTS value_type VARCHAR(20) DEFAULT 'text';
+
+ALTER TABLE data_sources 
+ADD COLUMN IF NOT EXISTS aggregate_type VARCHAR(20) DEFAULT 'first';
+
+ALTER TABLE data_sources 
+ADD COLUMN IF NOT EXISTS separator VARCHAR(50) DEFAULT '';
+
+-- 添加字段注释
+COMMENT ON COLUMN data_sources.value_type IS '值类型: text/image/table/mixed';
+COMMENT ON COLUMN data_sources.aggregate_type IS '聚合方式: first/last/concat/sum/avg/list';
+COMMENT ON COLUMN data_sources.separator IS '聚合分隔符(concat时使用)';
+
+-- 检查并修改 node_ids 类型(如果是 TEXT[] 则转为 JSONB)
+-- 注意:如果已经是 JSONB 类型,此语句会失败,这是预期行为
+DO $$
+BEGIN
+    -- 检查当前类型
+    IF EXISTS (
+        SELECT 1 
+        FROM information_schema.columns 
+        WHERE table_name = 'data_sources' 
+        AND column_name = 'node_ids' 
+        AND data_type = 'ARRAY'
+    ) THEN
+        -- 如果是数组类型,转换为 JSONB
+        ALTER TABLE data_sources 
+        ALTER COLUMN node_ids TYPE JSONB 
+        USING CASE 
+            WHEN node_ids IS NULL THEN '{"refs": []}'::JSONB
+            ELSE jsonb_build_object('refs', 
+                (SELECT jsonb_agg(jsonb_build_object('type', 'graph_node', 'id', elem))
+                 FROM unnest(node_ids::text[]) AS elem)
+            )
+        END;
+        RAISE NOTICE 'Converted node_ids from TEXT[] to JSONB';
+    ELSE
+        RAISE NOTICE 'node_ids is already JSONB or compatible type';
+    END IF;
+END $$;
+
+-- 设置默认值
+ALTER TABLE data_sources 
+ALTER COLUMN node_ids SET DEFAULT '{"refs": []}'::JSONB;

+ 385 - 0
test/test_datasource_api.sh

@@ -0,0 +1,385 @@
+#!/bin/bash
+
+# ============================================
+# 数据源 API 测试脚本
+# ============================================
+# 测试数据源的 CRUD 和取值功能
+# 使用方法: ./test_datasource_api.sh [host] [port]
+# 示例: ./test_datasource_api.sh localhost 5232
+# ============================================
+
+# 配置参数
+HOST=${1:-localhost}
+PORT=${2:-5232}
+BASE_URL="http://${HOST}:${PORT}"
+DATASOURCE_URL="${BASE_URL}/api/v1/datasource"
+
+# 颜色定义
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+# 输出函数
+print_header() {
+    echo -e "\n${BLUE}============================================${NC}"
+    echo -e "${BLUE}$1${NC}"
+    echo -e "${BLUE}============================================${NC}"
+}
+
+print_step() {
+    echo -e "\n${CYAN}>>> $1${NC}"
+}
+
+print_success() {
+    echo -e "${GREEN}✓ $1${NC}"
+}
+
+print_error() {
+    echo -e "${RED}✗ $1${NC}"
+}
+
+print_info() {
+    echo -e "${YELLOW}ℹ $1${NC}"
+}
+
+# 变量存储
+DATASOURCE_ID=""
+DATASOURCE_ID_2=""
+
+# ============================================
+# 测试函数
+# ============================================
+
+test_create_text_datasource() {
+    print_step "创建文本类型数据源"
+    
+    RESPONSE=$(curl -s -X POST "${DATASOURCE_URL}" \
+        -H "Content-Type: application/json" \
+        -H "X-User-Id: test-user-001" \
+        -d '{
+            "name": "项目名称",
+            "type": "text",
+            "documentId": "test-doc-001",
+            "sourceType": "manual",
+            "valueType": "text",
+            "aggregateType": "first",
+            "nodeIds": {
+                "refs": [
+                    {"type": "graph_node", "id": "node-001"}
+                ]
+            },
+            "metadata": {"description": "测试数据源"}
+        }')
+    
+    echo "Response: $RESPONSE"
+    
+    # 提取 ID
+    DATASOURCE_ID=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
+    
+    if [ -n "$DATASOURCE_ID" ]; then
+        print_success "创建成功,ID: $DATASOURCE_ID"
+    else
+        print_error "创建失败"
+        return 1
+    fi
+}
+
+test_create_image_datasource() {
+    print_step "创建图片类型数据源"
+    
+    RESPONSE=$(curl -s -X POST "${DATASOURCE_URL}" \
+        -H "Content-Type: application/json" \
+        -H "X-User-Id: test-user-001" \
+        -d '{
+            "name": "公司LOGO",
+            "type": "image",
+            "documentId": "test-doc-001",
+            "sourceType": "file",
+            "valueType": "image",
+            "nodeIds": {
+                "refs": [
+                    {"type": "document_element", "id": "element-001"}
+                ]
+            }
+        }')
+    
+    echo "Response: $RESPONSE"
+    
+    DATASOURCE_ID_2=$(echo "$RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
+    
+    if [ -n "$DATASOURCE_ID_2" ]; then
+        print_success "创建成功,ID: $DATASOURCE_ID_2"
+    else
+        print_error "创建失败"
+        return 1
+    fi
+}
+
+test_get_datasource() {
+    print_step "获取数据源详情"
+    
+    if [ -z "$DATASOURCE_ID" ]; then
+        print_error "没有可用的数据源ID"
+        return 1
+    fi
+    
+    RESPONSE=$(curl -s -X GET "${DATASOURCE_URL}/${DATASOURCE_ID}")
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "获取成功"
+    else
+        print_error "获取失败"
+        return 1
+    fi
+}
+
+test_get_by_document() {
+    print_step "按文档ID查询数据源"
+    
+    RESPONSE=$(curl -s -X GET "${DATASOURCE_URL}/document/test-doc-001")
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "查询成功"
+    else
+        print_error "查询失败"
+        return 1
+    fi
+}
+
+test_get_by_user() {
+    print_step "按用户ID查询数据源"
+    
+    RESPONSE=$(curl -s -X GET "${DATASOURCE_URL}/user/test-user-001")
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "查询成功"
+    else
+        print_error "查询失败"
+        return 1
+    fi
+}
+
+test_update_datasource() {
+    print_step "更新数据源"
+    
+    if [ -z "$DATASOURCE_ID" ]; then
+        print_error "没有可用的数据源ID"
+        return 1
+    fi
+    
+    RESPONSE=$(curl -s -X PUT "${DATASOURCE_URL}/${DATASOURCE_ID}" \
+        -H "Content-Type: application/json" \
+        -d '{
+            "name": "项目名称(已更新)",
+            "aggregateType": "concat",
+            "separator": ", "
+        }')
+    
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "更新成功"
+    else
+        print_error "更新失败"
+        return 1
+    fi
+}
+
+test_update_refs() {
+    print_step "更新节点引用"
+    
+    if [ -z "$DATASOURCE_ID" ]; then
+        print_error "没有可用的数据源ID"
+        return 1
+    fi
+    
+    # 追加模式
+    RESPONSE=$(curl -s -X PUT "${DATASOURCE_URL}/${DATASOURCE_ID}/refs" \
+        -H "Content-Type: application/json" \
+        -d '{
+            "mode": "append",
+            "refs": [
+                {"type": "graph_node", "id": "node-002"},
+                {"type": "document_element", "id": "element-002"}
+            ]
+        }')
+    
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "追加引用成功"
+    else
+        print_error "追加引用失败"
+        return 1
+    fi
+}
+
+test_get_value() {
+    print_step "获取数据源值"
+    
+    if [ -z "$DATASOURCE_ID" ]; then
+        print_error "没有可用的数据源ID"
+        return 1
+    fi
+    
+    RESPONSE=$(curl -s -X GET "${DATASOURCE_URL}/${DATASOURCE_ID}/value")
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "获取值成功"
+    else
+        print_error "获取值失败"
+        return 1
+    fi
+}
+
+test_batch_get_value() {
+    print_step "批量获取数据源值"
+    
+    if [ -z "$DATASOURCE_ID" ] || [ -z "$DATASOURCE_ID_2" ]; then
+        print_error "没有足够的数据源ID"
+        return 1
+    fi
+    
+    RESPONSE=$(curl -s -X POST "${DATASOURCE_URL}/batch-value" \
+        -H "Content-Type: application/json" \
+        -d "{
+            \"dataSourceIds\": [\"${DATASOURCE_ID}\", \"${DATASOURCE_ID_2}\"]
+        }")
+    
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "批量获取值成功"
+    else
+        print_error "批量获取值失败"
+        return 1
+    fi
+}
+
+test_delete_datasource() {
+    print_step "删除数据源"
+    
+    if [ -z "$DATASOURCE_ID" ]; then
+        print_error "没有可用的数据源ID"
+        return 1
+    fi
+    
+    RESPONSE=$(curl -s -X DELETE "${DATASOURCE_URL}/${DATASOURCE_ID}")
+    echo "Response: $RESPONSE"
+    
+    if echo "$RESPONSE" | grep -q '"code":200'; then
+        print_success "删除成功"
+    else
+        print_error "删除失败"
+        return 1
+    fi
+    
+    # 删除第二个
+    if [ -n "$DATASOURCE_ID_2" ]; then
+        curl -s -X DELETE "${DATASOURCE_URL}/${DATASOURCE_ID_2}" > /dev/null
+        print_success "删除第二个数据源"
+    fi
+}
+
+# ============================================
+# 主流程
+# ============================================
+
+show_help() {
+    echo "数据源 API 测试脚本"
+    echo ""
+    echo "用法: $0 [选项] [host] [port]"
+    echo ""
+    echo "选项:"
+    echo "  -h, --help     显示帮助信息"
+    echo "  -a, --all      运行所有测试(默认)"
+    echo "  -c, --create   仅测试创建"
+    echo "  -r, --read     仅测试查询"
+    echo "  -u, --update   仅测试更新"
+    echo "  -v, --value    仅测试取值"
+    echo "  -d, --delete   仅测试删除"
+    echo ""
+    echo "示例:"
+    echo "  $0                    # 默认测试 localhost:5232"
+    echo "  $0 192.168.1.100 8080 # 指定 host 和 port"
+    echo "  $0 -c                 # 仅测试创建"
+}
+
+run_all_tests() {
+    print_header "数据源 API 测试开始"
+    print_info "目标地址: ${BASE_URL}"
+    
+    echo ""
+    print_header "1. 创建测试"
+    test_create_text_datasource
+    test_create_image_datasource
+    
+    echo ""
+    print_header "2. 查询测试"
+    test_get_datasource
+    test_get_by_document
+    test_get_by_user
+    
+    echo ""
+    print_header "3. 更新测试"
+    test_update_datasource
+    test_update_refs
+    
+    echo ""
+    print_header "4. 取值测试"
+    test_get_value
+    test_batch_get_value
+    
+    echo ""
+    print_header "5. 删除测试"
+    test_delete_datasource
+    
+    echo ""
+    print_header "测试完成"
+}
+
+# 解析命令行参数
+case "$1" in
+    -h|--help)
+        show_help
+        exit 0
+        ;;
+    -a|--all)
+        shift
+        HOST=${1:-localhost}
+        PORT=${2:-5232}
+        BASE_URL="http://${HOST}:${PORT}"
+        DATASOURCE_URL="${BASE_URL}/api/v1/datasource"
+        run_all_tests
+        ;;
+    -c|--create)
+        shift
+        HOST=${1:-localhost}
+        PORT=${2:-5232}
+        BASE_URL="http://${HOST}:${PORT}"
+        DATASOURCE_URL="${BASE_URL}/api/v1/datasource"
+        print_header "创建测试"
+        test_create_text_datasource
+        test_create_image_datasource
+        ;;
+    -v|--value)
+        shift
+        HOST=${1:-localhost}
+        PORT=${2:-5232}
+        BASE_URL="http://${HOST}:${PORT}"
+        DATASOURCE_URL="${BASE_URL}/api/v1/datasource"
+        print_header "取值测试"
+        test_create_text_datasource
+        test_get_value
+        ;;
+    *)
+        run_all_tests
+        ;;
+esac