diff --git a/.gitignore b/.gitignore
index dcf5d0d..32778df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -190,5 +190,3 @@ Thumbs.db
# Milvus
deployment/docker/milvus/volumes/
-# Local documentation
-docs/knowledge-graph/
diff --git a/README.md b/README.md
index b303c7e..6b79952 100644
--- a/README.md
+++ b/README.md
@@ -110,9 +110,9 @@ Thank you for your interest in this project! We warmly welcome contributions fro
bug reports, suggesting new features, or directly participating in code development, all forms of help make the project
better.
-• 📮 [GitHub Issues](../../issues): Submit bugs or feature suggestions.
+• 📮 [GitHub Issues](https://github.com/ModelEngine-Group/DataMate/issues): Submit bugs or feature suggestions.
-• 🔧 [GitHub Pull Requests](../../pulls): Contribute code improvements.
+• 🔧 [GitHub Pull Requests](https://github.com/ModelEngine-Group/DataMate/pulls): Contribute code improvements.
## 📄 License
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java
index 7585062..fe4f372 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/exception/KnowledgeGraphErrorCode.java
@@ -22,7 +22,8 @@ public enum KnowledgeGraphErrorCode implements ErrorCode {
SYNC_FAILED("knowledge_graph.0009", "数据同步失败"),
EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"),
SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"),
- INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码");
+ INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码"),
+ UNAUTHORIZED_INTERNAL_CALL("knowledge_graph.0013", "内部调用未授权:X-Internal-Token 校验失败");
private final String code;
private final String message;
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java
index 650286b..fe0b954 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/neo4j/KnowledgeGraphProperties.java
@@ -25,6 +25,24 @@ public class KnowledgeGraphProperties {
/** 同步相关配置 */
private Sync sync = new Sync();
+ /** 安全相关配置 */
+ private Security security = new Security();
+
+ @Data
+ public static class Security {
+
+ /** 内部服务调用 Token,用于校验 sync 端点的 X-Internal-Token 请求头 */
+ private String internalToken;
+
+ /**
+ * 是否跳过内部 Token 校验(默认 false,即 fail-closed)。
+ *
+ * 仅允许在 dev/test 环境显式设置为 true 以跳过校验。
+ * 生产环境必须保持 false 并配置 {@code internal-token}。
+ */
+ private boolean skipTokenCheck = false;
+ }
+
@Data
public static class Sync {
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenInterceptor.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenInterceptor.java
new file mode 100644
index 0000000..8a427f8
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenInterceptor.java
@@ -0,0 +1,74 @@
+package com.datamate.knowledgegraph.infrastructure.security;
+
+import com.datamate.common.infrastructure.common.Response;
+import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode;
+import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.io.IOException;
+
+/**
+ * 内部服务调用 Token 校验拦截器。
+ *
+ * 验证 {@code X-Internal-Token} 请求头,保护 sync 端点仅供内部服务/定时任务调用。
+ *
+ * 安全策略(fail-closed):
+ *
+ * - Token 未配置且 {@code skip-token-check=false}(默认)时,直接拒绝请求
+ * - 仅当 dev/test 环境显式设置 {@code skip-token-check=true} 时,才跳过校验
+ *
+ */
+@Component
+@RequiredArgsConstructor
+public class InternalTokenInterceptor implements HandlerInterceptor {
+
+ private static final Logger log = LoggerFactory.getLogger(InternalTokenInterceptor.class);
+ private static final String HEADER_INTERNAL_TOKEN = "X-Internal-Token";
+
+ private final KnowledgeGraphProperties properties;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
+ throws IOException {
+ KnowledgeGraphProperties.Security security = properties.getSecurity();
+ String configuredToken = security.getInternalToken();
+
+ if (!StringUtils.hasText(configuredToken)) {
+ if (security.isSkipTokenCheck()) {
+ log.warn("内部调用 Token 未配置且 skip-token-check=true,跳过校验(仅限 dev/test 环境)。");
+ return true;
+ }
+ log.error("内部调用 Token 未配置且 skip-token-check=false(fail-closed),拒绝请求。"
+ + "请设置 KG_INTERNAL_TOKEN 环境变量或在 dev/test 环境启用 skip-token-check。");
+ writeErrorResponse(response);
+ return false;
+ }
+
+ String requestToken = request.getHeader(HEADER_INTERNAL_TOKEN);
+
+ if (!configuredToken.equals(requestToken)) {
+ writeErrorResponse(response);
+ return false;
+ }
+
+ return true;
+ }
+
+ private void writeErrorResponse(HttpServletResponse response) throws IOException {
+ Response> errorBody = Response.error(KnowledgeGraphErrorCode.UNAUTHORIZED_INTERNAL_CALL);
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding("UTF-8");
+ response.getWriter().write(objectMapper.writeValueAsString(errorBody));
+ }
+}
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenWebMvcConfigurer.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenWebMvcConfigurer.java
new file mode 100644
index 0000000..ec7456c
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenWebMvcConfigurer.java
@@ -0,0 +1,22 @@
+package com.datamate.knowledgegraph.infrastructure.security;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 注册 {@link InternalTokenInterceptor},仅拦截 sync 端点。
+ */
+@Configuration
+@RequiredArgsConstructor
+public class InternalTokenWebMvcConfigurer implements WebMvcConfigurer {
+
+ private final InternalTokenInterceptor internalTokenInterceptor;
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(internalTokenInterceptor)
+ .addPathPatterns("/knowledge-graph/*/sync/**");
+ }
+}
diff --git a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java
index 0d19fd0..291bfef 100644
--- a/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java
+++ b/backend/services/knowledge-graph-service/src/main/java/com/datamate/knowledgegraph/interfaces/rest/GraphSyncController.java
@@ -23,10 +23,13 @@ import java.util.List;
* 提供手动触发 MySQL → Neo4j 同步的 REST 端点。
* 生产环境中也可通过定时任务自动触发。
*
- * 安全说明:本接口仅供内部服务调用(API Gateway / 定时任务),
- * 外部请求必须经 API Gateway 鉴权后转发。
- * 生产环境建议通过 mTLS 或内部 JWT 进一步加固服务间认证。
- * 当前通过 {@code X-Internal-Token} 请求头进行简单的内部调用校验。
+ * 安全架构:
+ *
+ * - 外部请求 → API Gateway (JWT 校验) → X-User-* headers → 后端服务
+ * - 内部调用 → X-Internal-Token header → {@code InternalTokenInterceptor} 校验 → sync 端点
+ *
+ * Token 校验由 {@code InternalTokenInterceptor} 拦截器统一实现,
+ * 对 {@code /knowledge-graph/{graphId}/sync/} 路径前缀自动生效。
*/
@RestController
@RequestMapping("/knowledge-graph/{graphId}/sync")
diff --git a/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml b/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml
index 4a02bc6..45dcfe3 100644
--- a/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml
+++ b/backend/services/knowledge-graph-service/src/main/resources/application-knowledgegraph.yml
@@ -23,6 +23,14 @@ datamate:
max-nodes-per-query: ${KG_MAX_NODES:500}
# 批量导入批次大小
import-batch-size: ${KG_IMPORT_BATCH_SIZE:100}
+ # 安全配置
+ security:
+ # 内部服务调用 Token(用于 sync 端点的 X-Internal-Token 校验)
+ # 生产环境务必通过 KG_INTERNAL_TOKEN 环境变量设置,否则 sync 端点将拒绝所有请求(fail-closed)
+ internal-token: ${KG_INTERNAL_TOKEN:}
+ # 是否跳过 Token 校验(默认 false = fail-closed)
+ # 仅在 dev/test 环境显式设置为 true 以跳过校验
+ skip-token-check: ${KG_SKIP_TOKEN_CHECK:false}
# MySQL → Neo4j 同步配置
sync:
# 数据管理服务地址
diff --git a/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenInterceptorTest.java b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenInterceptorTest.java
new file mode 100644
index 0000000..6124dcf
--- /dev/null
+++ b/backend/services/knowledge-graph-service/src/test/java/com/datamate/knowledgegraph/infrastructure/security/InternalTokenInterceptorTest.java
@@ -0,0 +1,152 @@
+package com.datamate.knowledgegraph.infrastructure.security;
+
+import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ExtendWith(MockitoExtension.class)
+class InternalTokenInterceptorTest {
+
+ private static final String VALID_TOKEN = "test-secret-token";
+
+ private KnowledgeGraphProperties properties;
+ private InternalTokenInterceptor interceptor;
+
+ @BeforeEach
+ void setUp() {
+ properties = new KnowledgeGraphProperties();
+ interceptor = new InternalTokenInterceptor(properties, new ObjectMapper());
+ }
+
+ // -----------------------------------------------------------------------
+ // fail-closed:Token 未配置 + skipTokenCheck=false → 拒绝
+ // -----------------------------------------------------------------------
+
+ @Test
+ void tokenNotConfigured_skipFalse_rejects() throws Exception {
+ properties.getSecurity().setInternalToken(null);
+ properties.getSecurity().setSkipTokenCheck(false);
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean result = interceptor.preHandle(request, response, new Object());
+
+ assertThat(result).isFalse();
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
+ assertThat(response.getContentAsString()).contains("knowledge_graph.0013");
+ }
+
+ @Test
+ void tokenEmpty_skipFalse_rejects() throws Exception {
+ properties.getSecurity().setInternalToken("");
+ properties.getSecurity().setSkipTokenCheck(false);
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean result = interceptor.preHandle(request, response, new Object());
+
+ assertThat(result).isFalse();
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ // -----------------------------------------------------------------------
+ // dev/test 放行:Token 未配置 + skipTokenCheck=true → 放行
+ // -----------------------------------------------------------------------
+
+ @Test
+ void tokenNotConfigured_skipTrue_allows() throws Exception {
+ properties.getSecurity().setInternalToken(null);
+ properties.getSecurity().setSkipTokenCheck(true);
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean result = interceptor.preHandle(request, response, new Object());
+
+ assertThat(result).isTrue();
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
+ }
+
+ // -----------------------------------------------------------------------
+ // 正常校验:Token 已配置 + 请求头匹配 → 放行
+ // -----------------------------------------------------------------------
+
+ @Test
+ void validToken_allows() throws Exception {
+ properties.getSecurity().setInternalToken(VALID_TOKEN);
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.addHeader("X-Internal-Token", VALID_TOKEN);
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean result = interceptor.preHandle(request, response, new Object());
+
+ assertThat(result).isTrue();
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
+ }
+
+ // -----------------------------------------------------------------------
+ // 401:Token 已配置 + 请求头不匹配 → 拒绝
+ // -----------------------------------------------------------------------
+
+ @Test
+ void invalidToken_rejects() throws Exception {
+ properties.getSecurity().setInternalToken(VALID_TOKEN);
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.addHeader("X-Internal-Token", "wrong-token");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean result = interceptor.preHandle(request, response, new Object());
+
+ assertThat(result).isFalse();
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
+ assertThat(response.getContentType()).startsWith("application/json");
+ assertThat(response.getContentAsString()).contains("knowledge_graph.0013");
+ }
+
+ @Test
+ void missingTokenHeader_rejects() throws Exception {
+ properties.getSecurity().setInternalToken(VALID_TOKEN);
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ // No X-Internal-Token header
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean result = interceptor.preHandle(request, response, new Object());
+
+ assertThat(result).isFalse();
+ assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ // -----------------------------------------------------------------------
+ // 错误响应格式:应使用 Response 体系
+ // -----------------------------------------------------------------------
+
+ @Test
+ void errorResponse_usesResponseFormat() throws Exception {
+ properties.getSecurity().setInternalToken(VALID_TOKEN);
+
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.addHeader("X-Internal-Token", "wrong");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ interceptor.preHandle(request, response, new Object());
+
+ String body = response.getContentAsString();
+ assertThat(body).contains("\"code\"");
+ assertThat(body).contains("\"message\"");
+ // Response.error() 包含 data 字段(值为 null)
+ assertThat(body).contains("\"data\"");
+ }
+}
diff --git a/backend/services/main-application/src/main/java/com/datamate/main/config/SecurityConfig.java b/backend/services/main-application/src/main/java/com/datamate/main/config/SecurityConfig.java
index 8e04562..8ff5821 100644
--- a/backend/services/main-application/src/main/java/com/datamate/main/config/SecurityConfig.java
+++ b/backend/services/main-application/src/main/java/com/datamate/main/config/SecurityConfig.java
@@ -7,8 +7,14 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.security.web.SecurityFilterChain;
/**
- * 安全配置 - 暂时禁用所有认证
- * 开发阶段使用,生产环境需要启用认证
+ * Spring Security 配置。
+ *
+ * 安全架构采用双层防护:
+ *
+ * - Gateway 层:API Gateway 负责 JWT 校验,通过后透传 X-User-* headers 到后端服务
+ * - 服务层:内部 sync 端点通过 {@code InternalTokenInterceptor} 校验 X-Internal-Token
+ *
+ * 当前 SecurityFilterChain 配置为 permitAll,HTTP 级别的访问控制由 Gateway 和业务拦截器共同完成。
*/
@Configuration
@EnableWebSecurity
diff --git a/backend/services/main-application/src/main/resources/application.yml b/backend/services/main-application/src/main/resources/application.yml
index 227f995..db84873 100644
--- a/backend/services/main-application/src/main/resources/application.yml
+++ b/backend/services/main-application/src/main/resources/application.yml
@@ -3,12 +3,6 @@ spring:
application:
name: datamate
- # 暂时排除Spring Security自动配置(开发阶段使用)
- autoconfigure:
- exclude:
- - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
- - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
-
# 数据源配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver