diff --git a/README.md b/README.md
index 1e6c522..f7af8aa 100644
--- a/README.md
+++ b/README.md
@@ -68,9 +68,9 @@ Data Agent 反其道而行——**不引入任何向量检索**。语义层把
**前置依赖**:JDK 17+、Maven 3.9+、Node 18+、pnpm 8+、MySQL 8+、Python 3+(需安装 pandas、numpy、scipy)。
```bash
-# 1. 建元数据库并初始化表结构
+# 1. 建元数据库并初始化表结构(sql/ 目录下所有 .sql 文件均需执行)
mysql -u root -p -e "CREATE DATABASE data_agent CHARACTER SET utf8mb4;"
-mysql -u root -p data_agent < sql/data_source.sql
+for f in sql/*.sql; do mysql -u root -p data_agent < "$f"; done
# 2. 启动后端(默认端口 8080)
cd data-agent-backend
diff --git a/data-agent-backend/pom.xml b/data-agent-backend/pom.xml
index 6936bf8..e5a1e53 100644
--- a/data-agent-backend/pom.xml
+++ b/data-agent-backend/pom.xml
@@ -144,6 +144,25 @@
jsqlparser5.0
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java b/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java
index 399f5e2..730a7bc 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/common/ErrorCode.java
@@ -33,6 +33,9 @@ public enum ErrorCode {
/** 请求参数格式或取值非法,但没有更细分的业务错误码。 */
BAD_REQUEST("BAD_REQUEST", HttpStatus.BAD_REQUEST, "Invalid request parameters."),
+ /** 未认证:缺少或无效的凭证(token 缺失/过期/非法/用户已禁用)。 */
+ UNAUTHORIZED("UNAUTHORIZED", HttpStatus.UNAUTHORIZED, "Authentication is required."),
+
/** Bean Validation、绑定校验等字段级参数校验失败。 */
VALIDATION_FAILED("VALIDATION_FAILED", HttpStatus.BAD_REQUEST, "Invalid request parameters."),
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/common/UserContext.java b/data-agent-backend/src/main/java/io/github/malonetalk/common/UserContext.java
new file mode 100644
index 0000000..4065854
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/common/UserContext.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.common;
+
+/**
+ * 当前登录用户的轻量投影,由拦截器在同步请求线程放入 ThreadLocal。
+ *
+ *
仅承载鉴权必要字段(不含 password_hash),供管理/会话等同步 API 取用。Agent 异步链路
+ * (Reactor 弹性线程)拿不到此 ThreadLocal——权限轮次会改为通过 ToolCallContext 显式传 userId。
+ */
+public record UserContext(Integer userId, String username, String displayName) {
+
+ private static final ThreadLocal HOLDER = new ThreadLocal<>();
+
+ public static void set(UserContext context) {
+ HOLDER.set(context);
+ }
+
+ public static UserContext get() {
+ return HOLDER.get();
+ }
+
+ public static void clear() {
+ HOLDER.remove();
+ }
+
+ public static UserContext require() {
+ UserContext context = HOLDER.get();
+ if (context == null) {
+ throw new IllegalStateException("No user context bound to current thread.");
+ }
+ return context;
+ }
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/config/AdminBootstrapRunner.java b/data-agent-backend/src/main/java/io/github/malonetalk/config/AdminBootstrapRunner.java
new file mode 100644
index 0000000..2d06e36
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/config/AdminBootstrapRunner.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.config;
+
+import io.github.malonetalk.entity.SysUser;
+import io.github.malonetalk.mapper.SysUserMapper;
+import io.github.malonetalk.util.PasswordUtil;
+import java.time.LocalDateTime;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+/**
+ * 启动引导:sys_user 为空时创建初始 admin 账号。
+ *
+ *
未配置初始密码时启动失败(fail-closed)——避免无密码 admin 账号被静默创建。
+ * role_id 暂为 0:本轮无任何权限检查,admin 仅作为首个登录账号;
+ * 「不受权限限制」语义随权限轮次 sys_role(id=1) + @AdminOnly 一起生效。
+ */
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class AdminBootstrapRunner implements CommandLineRunner {
+
+ private final SysUserMapper sysUserMapper;
+
+ @Value("${admin.init-password:}")
+ private String adminInitPassword;
+
+ @Override
+ public void run(String... args) {
+ if (sysUserMapper.countAll() > 0) {
+ return;
+ }
+ if (adminInitPassword == null || adminInitPassword.isBlank()) {
+ throw new IllegalStateException(
+ "No user exists and admin.init-password (env ADMIN_INIT_PASSWORD) is not set. "
+ + "Configure it before first startup to bootstrap the admin account.");
+ }
+ LocalDateTime now = LocalDateTime.now();
+ SysUser admin = new SysUser();
+ admin.setUsername("admin");
+ admin.setPasswordHash(PasswordUtil.hash(adminInitPassword));
+ admin.setDisplayName("管理员");
+ admin.setRoleId(0);
+ admin.setIdpType("LOCAL");
+ admin.setIdpUserId(null);
+ admin.setStatus(1);
+ admin.setCreateTime(now);
+ admin.setUpdateTime(now);
+ sysUserMapper.insert(admin);
+ log.info(
+ "Bootstrapped initial admin account (id={}, username=admin). Change its password"
+ + " ASAP.",
+ admin.getId());
+ }
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/config/WebMvcConfig.java b/data-agent-backend/src/main/java/io/github/malonetalk/config/WebMvcConfig.java
new file mode 100644
index 0000000..211f502
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/config/WebMvcConfig.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.config;
+
+import io.github.malonetalk.interceptor.AuthInterceptor;
+import lombok.AllArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/** 注册鉴权拦截器:覆盖 /api/**,仅放行登录接口与 Spring 错误页。 */
+@Configuration
+@AllArgsConstructor
+public class WebMvcConfig implements WebMvcConfigurer {
+
+ private final AuthInterceptor authInterceptor;
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(authInterceptor)
+ .addPathPatterns("/api/**")
+ .excludePathPatterns("/api/auth/login", "/error");
+ }
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/AuthController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/AuthController.java
new file mode 100644
index 0000000..5a9d80a
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/AuthController.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.controller;
+
+import io.github.malonetalk.common.ErrorCode;
+import io.github.malonetalk.common.Result;
+import io.github.malonetalk.common.UserContext;
+import io.github.malonetalk.dto.ChangePasswordRequest;
+import io.github.malonetalk.dto.LoginRequest;
+import io.github.malonetalk.dto.LoginResponse;
+import io.github.malonetalk.dto.UserInfoResponse;
+import io.github.malonetalk.entity.SysUser;
+import io.github.malonetalk.exception.BusinessException;
+import io.github.malonetalk.mapper.SysUserMapper;
+import io.github.malonetalk.util.JwtUtil;
+import io.github.malonetalk.util.PasswordUtil;
+import jakarta.validation.Valid;
+import java.time.LocalDateTime;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 认证接口:登录 / 当前用户 / 改密码。
+ *
+ *
登录失败统一返回 401 + "用户名或密码错误",避免用户名枚举;账号禁用单独提示。
+ */
+@RestController
+@Slf4j
+@AllArgsConstructor
+@RequestMapping("/api/auth")
+public class AuthController {
+
+ private static final String BAD_CREDENTIALS = "用户名或密码错误";
+
+ private final SysUserMapper sysUserMapper;
+ private final JwtUtil jwtUtil;
+
+ @PostMapping("/login")
+ public Result login(@Valid @RequestBody LoginRequest request) {
+ SysUser user = sysUserMapper.selectByUsername(request.username());
+ // 用户不存在、外部身份源(password_hash 为空)、密码不匹配:统一文案,避免枚举用户名。
+ if (user == null
+ || user.getPasswordHash() == null
+ || !PasswordUtil.verify(request.password(), user.getPasswordHash())) {
+ throw BusinessException.of(ErrorCode.UNAUTHORIZED, BAD_CREDENTIALS);
+ }
+ if (user.getStatus() == null || user.getStatus() != 1) {
+ throw BusinessException.of(ErrorCode.UNAUTHORIZED, "账号已禁用,请联系管理员");
+ }
+ String token = jwtUtil.generate(user.getId());
+ UserInfoResponse info =
+ new UserInfoResponse(user.getId(), user.getUsername(), user.getDisplayName());
+ return Result.success(new LoginResponse(token, info));
+ }
+
+ @GetMapping("/me")
+ public Result me() {
+ UserContext context = UserContext.require();
+ return Result.success(
+ new UserInfoResponse(context.userId(), context.username(), context.displayName()));
+ }
+
+ @PostMapping("/change-password")
+ public Result changePassword(@Valid @RequestBody ChangePasswordRequest request) {
+ Integer userId = UserContext.require().userId();
+ SysUser user = sysUserMapper.selectById(userId);
+ if (user == null
+ || user.getPasswordHash() == null
+ || !PasswordUtil.verify(request.oldPassword(), user.getPasswordHash())) {
+ throw BusinessException.of(ErrorCode.BAD_REQUEST, "旧密码不正确");
+ }
+ sysUserMapper.updatePassword(
+ userId, PasswordUtil.hash(request.newPassword()), LocalDateTime.now());
+ return Result.success(true);
+ }
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/ChangePasswordRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ChangePasswordRequest.java
new file mode 100644
index 0000000..c6f3a51
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ChangePasswordRequest.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record ChangePasswordRequest(
+ @NotBlank(message = "oldPassword 不能为空") String oldPassword,
+ @NotBlank(message = "newPassword 不能为空")
+ @Size(min = 6, max = 64, message = "newPassword 长度需在 6-64 之间")
+ String newPassword) {}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginRequest.java
new file mode 100644
index 0000000..3493d14
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginRequest.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record LoginRequest(
+ @NotBlank(message = "username 不能为空") String username,
+ @NotBlank(message = "password 不能为空") String password) {}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginResponse.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginResponse.java
new file mode 100644
index 0000000..22ebeb7
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/LoginResponse.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.dto;
+
+/** 登录成功响应:token + 用户信息(一次往返,前端无需再调 me)。 */
+public record LoginResponse(String token, UserInfoResponse user) {}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserInfoResponse.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserInfoResponse.java
new file mode 100644
index 0000000..6c46a7f
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserInfoResponse.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.dto;
+
+/** 当前用户信息;角色/是否管理员字段随权限轮次补充。 */
+public record UserInfoResponse(Integer userId, String username, String displayName) {}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/entity/SysUser.java b/data-agent-backend/src/main/java/io/github/malonetalk/entity/SysUser.java
new file mode 100644
index 0000000..7f20665
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/entity/SysUser.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.entity;
+
+import java.time.LocalDateTime;
+import lombok.Data;
+
+/** 系统用户。身份源抽象字段(idp_type/idp_user_id)本轮登录仅用 LOCAL,外部身份源对接后置。 */
+@Data
+public class SysUser {
+
+ private Integer id;
+ private String username;
+
+ /** PBKDF2 哈希,格式 pbkdf2$iter$salt$hash;外部身份源用户为空。 */
+ private String passwordHash;
+
+ private String displayName;
+ private Integer roleId;
+ private String idpType;
+ private String idpUserId;
+
+ /** 1=启用 0=禁用。 */
+ private Integer status;
+
+ private LocalDateTime createTime;
+ private LocalDateTime updateTime;
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/interceptor/AuthInterceptor.java b/data-agent-backend/src/main/java/io/github/malonetalk/interceptor/AuthInterceptor.java
new file mode 100644
index 0000000..926cd22
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/interceptor/AuthInterceptor.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.interceptor;
+
+import io.github.malonetalk.common.ErrorCode;
+import io.github.malonetalk.common.UserContext;
+import io.github.malonetalk.exception.BusinessException;
+import io.github.malonetalk.mapper.SysUserMapper;
+import io.github.malonetalk.util.JwtUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+/**
+ * 鉴权拦截器:拦 /api/**,放行 /api/auth/login 与 /error。
+ *
+ *
权限轮次再加 @AdminOnly 与表/列拦截;本轮登录后所有接口行为与现状一致。
+ */
+@Component
+@AllArgsConstructor
+public class AuthInterceptor implements HandlerInterceptor {
+
+ private final JwtUtil jwtUtil;
+ private final SysUserMapper sysUserMapper;
+
+ @Override
+ public boolean preHandle(
+ HttpServletRequest request, HttpServletResponse response, Object handler) {
+ Integer userId = jwtUtil.parseUserId(extractBearer(request));
+ if (userId == null) {
+ throw BusinessException.of(ErrorCode.UNAUTHORIZED, "Missing or invalid token.");
+ }
+ UserContext context = sysUserMapper.selectAuthProjection(userId);
+ if (context == null) {
+ // 用户不存在或 status=0(禁用),均视为未授权。
+ throw BusinessException.of(
+ ErrorCode.UNAUTHORIZED, "Account is disabled or does not exist.");
+ }
+ UserContext.set(context);
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ Object handler,
+ Exception ex) {
+ UserContext.clear();
+ }
+
+ private String extractBearer(HttpServletRequest request) {
+ String header = request.getHeader("Authorization");
+ if (header == null || header.isBlank()) {
+ return null;
+ }
+ String trimmed = header.trim();
+ String prefix = "Bearer ";
+ if (trimmed.length() > prefix.length()
+ && trimmed.regionMatches(true, 0, prefix, 0, prefix.length())) {
+ return trimmed.substring(prefix.length()).trim();
+ }
+ return null;
+ }
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/mapper/SysUserMapper.java b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/SysUserMapper.java
new file mode 100644
index 0000000..ceefded
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/SysUserMapper.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.mapper;
+
+import io.github.malonetalk.common.UserContext;
+import io.github.malonetalk.entity.SysUser;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface SysUserMapper {
+
+ /** 登录校验:按用户名查 LOCAL 账号(含 password_hash)。 */
+ SysUser selectByUsername(@Param("username") String username);
+
+ SysUser selectById(@Param("id") Integer id);
+
+ /** 拦截器每次请求调用:仅取鉴权必要字段,且 status=1 才返回;禁用即时生效。 */
+ UserContext selectAuthProjection(@Param("id") Integer id);
+
+ int insert(SysUser user);
+
+ int updatePassword(
+ @Param("id") Integer id,
+ @Param("passwordHash") String passwordHash,
+ @Param("updateTime") java.time.LocalDateTime updateTime);
+
+ int countAll();
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/util/JwtUtil.java b/data-agent-backend/src/main/java/io/github/malonetalk/util/JwtUtil.java
new file mode 100644
index 0000000..4402bce
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/util/JwtUtil.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.util;
+
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Date;
+import javax.crypto.SecretKey;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * JWT 签发/校验:HS256,唯一用途是承载 userId(权限每次请求现查库,不进 token)。
+ *
+ *
密钥来源环境变量 {@code JWT_SECRET}(须 ≥ 32 字节);未配置或过短时启动期生成随机密钥并告警——
+ * 此时重启会让所有已签发 token 失效,生产环境必须配置固定密钥。
+ * ponytail: 不引入刷新 token / 黑名单,登出 = 前端清 token(JWT 无状态)。
+ */
+@Component
+@Slf4j
+public class JwtUtil {
+
+ private static final int MIN_SECRET_BYTES = 32;
+
+ private final SecretKey key;
+ private final long expirationMillis;
+
+ public JwtUtil(
+ @Value("${jwt.secret:}") String secret,
+ @Value("${jwt.expiration-hours:24}") long expirationHours) {
+ this.key = resolveKey(secret);
+ this.expirationMillis = expirationHours * 3600_000L;
+ }
+
+ /**
+ * 签发 token,subject 为 userId 字符串。
+ */
+ public String generate(Integer userId) {
+ Date now = new Date();
+ return Jwts.builder()
+ .subject(String.valueOf(userId))
+ .issuedAt(now)
+ .expiration(new Date(now.getTime() + expirationMillis))
+ .signWith(key)
+ .compact();
+ }
+
+ /**
+ * 解析 userId;token 非法/过期返回 null,由拦截器据此返回 401。
+ */
+ public Integer parseUserId(String token) {
+ if (token == null || token.isBlank()) {
+ return null;
+ }
+ try {
+ String subject =
+ Jwts.parser()
+ .verifyWith(key)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload()
+ .getSubject();
+ return Integer.valueOf(subject);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static SecretKey resolveKey(String secret) {
+ if (secret != null && secret.getBytes(StandardCharsets.UTF_8).length >= MIN_SECRET_BYTES) {
+ return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ }
+ log.warn(
+ "jwt.secret is missing or shorter than {} bytes; generating an in-memory random"
+ + " key. All tokens will invalidate on restart. Configure JWT_SECRET for"
+ + " production.",
+ MIN_SECRET_BYTES);
+ // ponytail: 直接用随机字节走 hmacShaKeyFor,避开 jjwt 0.12.x secretKeyFor 重载歧义。
+ byte[] randomBytes = new byte[MIN_SECRET_BYTES];
+ new SecureRandom().nextBytes(randomBytes);
+ return Keys.hmacShaKeyFor(randomBytes);
+ }
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/util/PasswordUtil.java b/data-agent-backend/src/main/java/io/github/malonetalk/util/PasswordUtil.java
new file mode 100644
index 0000000..d2aac47
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/util/PasswordUtil.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2026 github.com/MaloneTalk
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ * limitations under the License.
+ */
+package io.github.malonetalk.util;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+
+/**
+ * 密码哈希:JDK 标准库 PBKDF2WithHmacSHA256,零新依赖。
+ *
+ *