overrides = new HashMap<>();
+ }
+}
diff --git a/back/src/main/java/com/linkwork/config/ImageBuildConfig.java b/back/src/main/java/com/linkwork/config/ImageBuildConfig.java
new file mode 100644
index 0000000..148f548
--- /dev/null
+++ b/back/src/main/java/com/linkwork/config/ImageBuildConfig.java
@@ -0,0 +1,142 @@
+package com.linkwork.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 镜像构建配置
+ *
+ * 设计说明:
+ * - 仅构建 Agent 镜像,Runner 由运行时 agent 启动
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "image-build")
+public class ImageBuildConfig {
+
+ /**
+ * 是否启用镜像构建
+ */
+ private boolean enabled = false;
+
+ /**
+ * 是否推送镜像到仓库(K8s 模式)
+ * 设置为 false 时只构建不推送,适用于测试环境
+ */
+ private boolean pushEnabled = false;
+
+ /**
+ * 镜像拉取策略
+ * - Always: 总是拉取(默认,需要镜像在仓库中)
+ * - IfNotPresent: 本地有则不拉取(适合本地构建 + 单节点/共享 Docker)
+ * - Never: 从不拉取(要求镜像必须已在节点上)
+ */
+ private String imagePullPolicy = "IfNotPresent";
+
+ /**
+ * K8s 拉取私有镜像的 Secret 名称
+ * 需要在 K8s 中预先创建,或由服务自动创建
+ */
+ private String imagePullSecret = "robot-registry-secret";
+
+ /**
+ * Docker 连接配置
+ * 默认使用 unix socket: unix:///var/run/docker.sock
+ */
+ private String dockerHost = "unix:///var/run/docker.sock";
+
+ /**
+ * 默认 Agent 基础镜像(K8s 模式构建使用内网 Harbor)
+ */
+ private String defaultAgentBaseImage = "10.30.107.146/robot/rockylinux9-agent@sha256:b49d75f52f6b3c55bbf90427f0df0e97bc8e3f3e03727721cafc2c9d775b8975";
+
+ /**
+ * Compose 模式基础镜像(用户本地构建,需要可公开拉取的镜像)
+ */
+ private String composeBaseImage = "rockylinux:9";
+
+ /**
+ * 镜像仓库地址
+ * K8s 模式下构建的镜像会推送到此仓库
+ */
+ private String registry = "";
+
+ /**
+ * 镜像仓库用户名
+ */
+ private String registryUsername = "";
+
+ /**
+ * 镜像仓库密码
+ */
+ private String registryPassword = "";
+
+ /**
+ * 构建脚本路径
+ * 此脚本会在 Dockerfile 中被 COPY 并执行
+ */
+ private String buildScriptPath = "/opt/scripts/build.sh";
+
+ /**
+ * 构建超时时间(秒)
+ */
+ private int buildTimeout = 300;
+
+ /**
+ * 入口点脚本名称
+ */
+ private String entrypointScript = "/entrypoint.sh";
+
+ /**
+ * 构建上下文临时目录
+ */
+ private String buildContextDir = "/tmp/docker-build";
+
+ /**
+ * 是否启用本地镜像自动同步到 Kind 节点(仅 K8s + 未配置镜像仓库时生效)
+ */
+ private boolean autoLoadToKind = true;
+
+ /**
+ * 指定 Kind 集群名;为空时自动发现所有 Kind 集群节点
+ */
+ private String kindClusterName = "";
+
+ /**
+ * Kind 节点镜像导入超时时间(秒)
+ */
+ private int kindLoadTimeout = 600;
+
+ /**
+ * 是否启用本地镜像定期清理
+ */
+ private boolean localCleanupEnabled = true;
+
+ /**
+ * 本地构建镜像保留小时数(超过后尝试删除,运行中镜像会跳过)
+ */
+ private int localImageRetentionHours = 24;
+
+ /**
+ * 本地镜像清理 Cron(默认每小时第 40 分钟)
+ */
+ private String localCleanupCron = "0 40 * * * *";
+
+ /**
+ * 是否在 Kind 节点执行未使用镜像 prune
+ */
+ private boolean kindPruneEnabled = true;
+
+ /**
+ * SDK 源码在镜像中的目标路径
+ * 从项目内置 build-assets/sdk-source/ 目录拷贝
+ */
+ private String sdkSourcePath = "/opt/linkwork-agent-build/sdk-source";
+
+ /**
+ * zzd 二进制文件在镜像中的目标路径
+ * 从项目内置 build-assets/zzd-binaries/ 目录拷贝
+ */
+ private String zzdBinariesPath = "/opt/linkwork-agent-build/zzd-binaries";
+}
diff --git a/back/src/main/java/com/linkwork/config/JacksonConfig.java b/back/src/main/java/com/linkwork/config/JacksonConfig.java
new file mode 100644
index 0000000..bed1b91
--- /dev/null
+++ b/back/src/main/java/com/linkwork/config/JacksonConfig.java
@@ -0,0 +1,40 @@
+package com.linkwork.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.boot.autoconfigure.jackson.JacksonProperties;
+import org.springframework.boot.jackson.JsonComponentModule;
+import org.springframework.boot.jackson.JsonMixinModule;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+
+@Configuration
+public class JacksonConfig {
+
+ @Bean
+ public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
+ return builder -> builder
+ .modulesToInstall(JavaTimeModule.class)
+ .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ }
+
+ /**
+ * MCP starter 可能在自动配置阶段提前注册一个裸 ObjectMapper,导致 Web 层缺少 JavaTime 支持。
+ * 这里显式提供主 ObjectMapper,确保 MVC 与业务注入都使用支持 LocalDateTime 的配置。
+ */
+ @Bean
+ @Primary
+ public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder, JacksonProperties properties) {
+ builder.modules(new JsonComponentModule(), new JsonMixinModule(), new JavaTimeModule());
+ ObjectMapper objectMapper = builder.build();
+ objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ if (properties.getTimeZone() != null) {
+ objectMapper.setTimeZone(properties.getTimeZone());
+ }
+ return objectMapper;
+ }
+}
diff --git a/back/src/main/java/com/linkwork/config/KubernetesConfig.java b/back/src/main/java/com/linkwork/config/KubernetesConfig.java
new file mode 100644
index 0000000..19b2b8e
--- /dev/null
+++ b/back/src/main/java/com/linkwork/config/KubernetesConfig.java
@@ -0,0 +1,47 @@
+package com.linkwork.config;
+
+import io.fabric8.kubernetes.client.Config;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Kubernetes 客户端配置
+ */
+@Configuration
+@Slf4j
+public class KubernetesConfig {
+
+ private final EnvConfig envConfig;
+
+ public KubernetesConfig(EnvConfig envConfig) {
+ this.envConfig = envConfig;
+ }
+
+ @Bean
+ public KubernetesClient kubernetesClient() {
+ String kubeconfigPath = envConfig.getCluster().getKubeconfigPath();
+
+ if (kubeconfigPath != null && !kubeconfigPath.isBlank()) {
+ log.info("Loading kubeconfig from: {}", kubeconfigPath);
+ try {
+ String kubeconfigContent = Files.readString(Path.of(kubeconfigPath));
+ Config config = Config.fromKubeconfig(kubeconfigContent);
+ return new KubernetesClientBuilder().withConfig(config).build();
+ } catch (IOException e) {
+ log.error("Failed to load kubeconfig from {}: {}", kubeconfigPath, e.getMessage());
+ throw new RuntimeException("Failed to load kubeconfig", e);
+ }
+ }
+
+ // 使用默认配置(从环境变量或默认路径)
+ log.info("Using default Kubernetes configuration");
+ return new KubernetesClientBuilder().build();
+ }
+}
diff --git a/back/src/main/java/com/linkwork/config/MemoryConfig.java b/back/src/main/java/com/linkwork/config/MemoryConfig.java
new file mode 100644
index 0000000..72bf8c4
--- /dev/null
+++ b/back/src/main/java/com/linkwork/config/MemoryConfig.java
@@ -0,0 +1,48 @@
+package com.linkwork.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "memory")
+public class MemoryConfig {
+
+ private boolean enabled = true;
+ private Milvus milvus = new Milvus();
+ private Embedding embedding = new Embedding();
+ private Index index = new Index();
+ private String ossMountPath = "/data/oss";
+
+ @Data
+ public static class Milvus {
+ private String uri = "http://milvus:19530";
+ private String token = "";
+ }
+
+ @Data
+ public static class Embedding {
+ private String model = "text-embedding-3-small";
+ private int dimension = 1536;
+ }
+
+ @Data
+ public static class Index {
+ private int maxChunkSize = 1500;
+ private int overlapLines = 2;
+ private String queueKey = "memory:index:jobs";
+ }
+
+ public String collectionName(String workstationId, String userId) {
+ return "memory_" + sanitize(workstationId) + "_" + sanitize(userId);
+ }
+
+ public String userCollectionName(String userId) {
+ return "memory_user_" + sanitize(userId);
+ }
+
+ private static String sanitize(String s) {
+ return s.replaceAll("[^a-zA-Z0-9_]", "_");
+ }
+}
diff --git a/back/src/main/java/com/linkwork/config/MyBatisPlusConfig.java b/back/src/main/java/com/linkwork/config/MyBatisPlusConfig.java
new file mode 100644
index 0000000..1eeea89
--- /dev/null
+++ b/back/src/main/java/com/linkwork/config/MyBatisPlusConfig.java
@@ -0,0 +1,47 @@
+package com.linkwork.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.apache.ibatis.reflection.MetaObject;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.LocalDateTime;
+
+/**
+ * MyBatis Plus 配置
+ */
+@Configuration
+public class MyBatisPlusConfig {
+
+ /**
+ * 分页插件
+ */
+ @Bean
+ public MybatisPlusInterceptor mybatisPlusInterceptor() {
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+ return interceptor;
+ }
+
+ /**
+ * 自动填充处理器
+ */
+ @Bean
+ public MetaObjectHandler metaObjectHandler() {
+ return new MetaObjectHandler() {
+ @Override
+ public void insertFill(MetaObject metaObject) {
+ this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
+ this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
+ }
+
+ @Override
+ public void updateFill(MetaObject metaObject) {
+ this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
+ }
+ };
+ }
+}
diff --git a/back/src/main/java/com/linkwork/config/NfsStorageConfig.java b/back/src/main/java/com/linkwork/config/NfsStorageConfig.java
new file mode 100644
index 0000000..a8e88cc
--- /dev/null
+++ b/back/src/main/java/com/linkwork/config/NfsStorageConfig.java
@@ -0,0 +1,40 @@
+package com.linkwork.config;
+
+import jakarta.annotation.PostConstruct;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@Slf4j
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "nfs.storage")
+public class NfsStorageConfig {
+
+ /** NFS 本地挂载根路径,后续更换 NFS 服务器只需重新 mount + 改此值 */
+ private String basePath = "/mnt/oss/robot-agent-files";
+
+ /** 后端文件下载 API 的 URL 前缀 */
+ private String downloadBaseUrl = "/api/v1/files";
+
+ /** 任务产出物下载 API 的 URL 前缀 */
+ private String taskOutputBaseUrl = "/api/v1/task-outputs";
+
+ @PostConstruct
+ public void validate() {
+ Path base = Path.of(basePath);
+ if (Files.isDirectory(base)) {
+ log.info("NFS storage configured: basePath={}", basePath);
+ } else {
+ log.warn("NFS storage basePath does not exist or is not a directory: {}", basePath);
+ }
+ }
+
+ public Path resolve(String relativePath) {
+ return Path.of(basePath).resolve(relativePath);
+ }
+}
diff --git a/back/src/main/java/com/linkwork/config/WebSocketConfig.java b/back/src/main/java/com/linkwork/config/WebSocketConfig.java
new file mode 100644
index 0000000..e64acc6
--- /dev/null
+++ b/back/src/main/java/com/linkwork/config/WebSocketConfig.java
@@ -0,0 +1,24 @@
+package com.linkwork.config;
+
+import com.linkwork.websocket.TaskWebSocketHandler;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+@Configuration
+@EnableWebSocket
+public class WebSocketConfig implements WebSocketConfigurer {
+
+ private final TaskWebSocketHandler taskWebSocketHandler;
+
+ public WebSocketConfig(TaskWebSocketHandler taskWebSocketHandler) {
+ this.taskWebSocketHandler = taskWebSocketHandler;
+ }
+
+ @Override
+ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
+ registry.addHandler(taskWebSocketHandler, "/api/v1/ws", "/ws", "/ws/")
+ .setAllowedOrigins("*");
+ }
+}
diff --git a/back/src/main/java/com/linkwork/context/UserContext.java b/back/src/main/java/com/linkwork/context/UserContext.java
new file mode 100644
index 0000000..9dad7bc
--- /dev/null
+++ b/back/src/main/java/com/linkwork/context/UserContext.java
@@ -0,0 +1,61 @@
+package com.linkwork.context;
+
+/**
+ * 用户上下文(ThreadLocal)
+ *
+ * 替代现有的 X-User-Id Header 和 Mock 硬编码。
+ * 由 JwtAuthFilter 在请求进入时设置,请求结束时清除。
+ * 用户信息全部来自 JWT payload,不查数据库。
+ */
+public final class UserContext {
+
+ private static final ThreadLocal HOLDER = new ThreadLocal<>();
+
+ private UserContext() {
+ }
+
+ /**
+ * 设置当前用户(由 Filter 调用)
+ */
+ public static void set(UserInfo userInfo) {
+ HOLDER.set(userInfo);
+ }
+
+ /**
+ * 获取当前用户(完整信息)
+ */
+ public static UserInfo get() {
+ return HOLDER.get();
+ }
+
+ /**
+ * 获取当前用户 ID
+ */
+ public static String getCurrentUserId() {
+ UserInfo info = HOLDER.get();
+ return info != null ? info.getUserId() : null;
+ }
+
+ /**
+ * 获取当前用户姓名
+ */
+ public static String getCurrentUserName() {
+ UserInfo info = HOLDER.get();
+ return info != null ? info.getName() : null;
+ }
+
+ /**
+ * 获取当前用户邮箱
+ */
+ public static String getCurrentEmail() {
+ UserInfo info = HOLDER.get();
+ return info != null ? info.getEmail() : null;
+ }
+
+ /**
+ * 清除当前用户(由 Filter 在 finally 中调用,防止内存泄漏)
+ */
+ public static void clear() {
+ HOLDER.remove();
+ }
+}
diff --git a/back/src/main/java/com/linkwork/context/UserInfo.java b/back/src/main/java/com/linkwork/context/UserInfo.java
new file mode 100644
index 0000000..0efae09
--- /dev/null
+++ b/back/src/main/java/com/linkwork/context/UserInfo.java
@@ -0,0 +1,36 @@
+package com.linkwork.context;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 当前登录用户信息(从 JWT payload 解析,不查数据库)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UserInfo {
+
+ /** 唯一用户标识 */
+ private String userId;
+
+ /** 姓名 */
+ private String name;
+
+ /** 邮箱 */
+ private String email;
+
+ /** 工号 */
+ private String workId;
+
+ /** 头像 URL */
+ private String avatarUrl;
+
+ /** 权限列表 */
+ private List permissions;
+}
diff --git a/back/src/main/java/com/linkwork/controller/ApprovalController.java b/back/src/main/java/com/linkwork/controller/ApprovalController.java
new file mode 100644
index 0000000..df15528
--- /dev/null
+++ b/back/src/main/java/com/linkwork/controller/ApprovalController.java
@@ -0,0 +1,116 @@
+package com.linkwork.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.linkwork.common.ApiResponse;
+import com.linkwork.common.ClientIpResolver;
+import com.linkwork.context.UserContext;
+import com.linkwork.model.entity.Approval;
+import com.linkwork.service.ApprovalService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.util.StringUtils;
+
+import java.util.HashMap;
+import jakarta.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 审批控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/approvals")
+@CrossOrigin(origins = "*")
+@RequiredArgsConstructor
+public class ApprovalController {
+
+ private final ApprovalService approvalService;
+
+ /**
+ * 获取审批列表
+ * GET /api/v1/approvals
+ */
+ @GetMapping
+ public ApiResponse