diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/annotation/AdminOnly.java b/data-agent-backend/src/main/java/io/github/malonetalk/annotation/AdminOnly.java
new file mode 100644
index 0000000..2af3280
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/annotation/AdminOnly.java
@@ -0,0 +1,33 @@
+/*
+ * 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.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标记需要管理员权限的 Controller 方法或类。
+ *
+ *
{@code roleId} 用于 @AdminOnly 权限判定:1=管理员,其他值=普通用户。
*/
-public record UserContext(Integer userId, String username, String displayName) {
+public record UserContext(Integer userId, String username, String displayName, Integer roleId) {
private static final ThreadLocal HOLDER = new ThreadLocal<>();
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
index 2d06e36..a6d1a6c 100644
--- 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
@@ -62,7 +62,7 @@ public void run(String... args) {
admin.setUsername("admin");
admin.setPasswordHash(PasswordUtil.hash(adminInitPassword));
admin.setDisplayName("管理员");
- admin.setRoleId(0);
+ admin.setRoleId(1); // 管理员角色,对应 @AdminOnly 权限判定
admin.setIdpType("LOCAL");
admin.setIdpUserId(null);
admin.setStatus(1);
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/DatasourceController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/DatasourceController.java
index 280bb1f..da9b162 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/DatasourceController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/DatasourceController.java
@@ -18,6 +18,7 @@
package io.github.malonetalk.controller;
import io.github.malonetalk.agent.datasource.DataSourceType;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.ErrorCode;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.convertor.DatasourceConverter;
@@ -59,6 +60,7 @@ public Result findById(@PathVariable Integer id) {
return Result.success(datasourceConverter.toResponse(requireDatasource(id)));
}
+ @AdminOnly
@PostMapping
public Result save(@Valid @RequestBody DatasourceRequest request) {
DataSourceType type = requireDatasourceType(request.type());
@@ -70,6 +72,7 @@ public Result save(@Valid @RequestBody DatasourceRequest request) {
return Result.success();
}
+ @AdminOnly
@PutMapping("/{id}")
public Result update(
@PathVariable Integer id, @Valid @RequestBody DatasourceRequest request) {
@@ -91,6 +94,7 @@ public Result update(
return Result.success(true);
}
+ @AdminOnly
@DeleteMapping("/{id}")
public Result deleteById(@PathVariable Integer id) {
requireDatasource(id);
@@ -117,6 +121,7 @@ public Result> findByType(@PathVariable String type) {
return Result.success(list);
}
+ @AdminOnly
@PutMapping("/{id}/activate")
public Result activate(@PathVariable Integer id) {
requireDatasource(id);
@@ -126,6 +131,7 @@ public Result activate(@PathVariable Integer id) {
return Result.success(true);
}
+ @AdminOnly
@PutMapping("/{id}/deactivate")
public Result deactivate(@PathVariable Integer id) {
requireDatasource(id);
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/DomainController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/DomainController.java
index 851f1d6..a57d728 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/DomainController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/DomainController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.ErrorCode;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.DomainCreateRequest;
@@ -39,6 +40,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@RestController
@RequestMapping("/api/domains")
@RequiredArgsConstructor
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/McpServerController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/McpServerController.java
index c609024..bf75edf 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/McpServerController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/McpServerController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.ErrorCode;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.convertor.McpServerConverter;
@@ -38,6 +39,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@RestController
@AllArgsConstructor
@RequestMapping("/api/mcp-server")
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/MetricController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/MetricController.java
index efa2008..a6fbd77 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/MetricController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/MetricController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.convertor.MetricConverter;
import io.github.malonetalk.dto.MetricRequest;
@@ -38,6 +39,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@Slf4j
@RestController
@AllArgsConstructor
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/ReportController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/ReportController.java
index b023ae6..9f64ddf 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/ReportController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/ReportController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.ReportPageQuery;
import io.github.malonetalk.dto.ReportResponse;
@@ -43,6 +44,7 @@ public Result> findReports(@Valid ReportPageQuery q
return Result.success(reportService.getReportPage(query));
}
+ @AdminOnly
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id) {
RequestAssert.requireNonNegative(id, "id must be non-negative.");
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/SysUserController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/SysUserController.java
new file mode 100644
index 0000000..d61f8e6
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/SysUserController.java
@@ -0,0 +1,89 @@
+/*
+ * 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.annotation.AdminOnly;
+import io.github.malonetalk.common.Result;
+import io.github.malonetalk.dto.ResetPasswordRequest;
+import io.github.malonetalk.dto.UserCreateRequest;
+import io.github.malonetalk.dto.UserResponse;
+import io.github.malonetalk.dto.UserUpdateRequest;
+import io.github.malonetalk.service.SysUserService;
+import jakarta.validation.Valid;
+import java.util.List;
+import lombok.AllArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 用户管理 CRUD(权限轮次再加 @AdminOnly;本轮登录后即可用)。
+ *
+ *
username 唯一性由 Service 层保证;password 不允许通过 update 接口修改(需调用重置密码)。
+ */
+@AdminOnly
+@RestController
+@AllArgsConstructor
+@RequestMapping("/api/sys/user")
+@Validated
+public class SysUserController {
+
+ private final SysUserService sysUserService;
+
+ @GetMapping
+ public Result> listAll() {
+ return Result.success(sysUserService.listAll());
+ }
+
+ @PostMapping
+ public Result create(@Valid @RequestBody UserCreateRequest request) {
+ return Result.success(sysUserService.create(request));
+ }
+
+ @PutMapping("/{id}")
+ public Result update(
+ @PathVariable Integer id, @Valid @RequestBody UserUpdateRequest request) {
+ return Result.success(sysUserService.update(id, request));
+ }
+
+ /** 管理员重置用户密码(不需旧密码)。 */
+ @PutMapping("/{id}/password")
+ public Result resetPassword(
+ @PathVariable Integer id, @Valid @RequestBody ResetPasswordRequest request) {
+ sysUserService.resetPassword(id, request.newPassword());
+ return Result.success(true);
+ }
+
+ /** 启 / 停用户。 */
+ @PutMapping("/{id}/status")
+ public Result updateStatus(
+ @PathVariable Integer id,
+ @RequestParam
+ @jakarta.validation.constraints.Min(0)
+ @jakarta.validation.constraints.Max(1)
+ Integer status) {
+ sysUserService.updateStatus(id, status);
+ return Result.success(true);
+ }
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableColumnSemanticController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableColumnSemanticController.java
index 8de1af8..23be576 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableColumnSemanticController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableColumnSemanticController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.pagination.PageResponse;
import io.github.malonetalk.dto.semantic.BatchResetColumnSemanticRequest;
@@ -39,6 +40,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@RestController
@Validated
@RequestMapping("/api/semantic/tables/columns/{tableName}")
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationSemanticController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationSemanticController.java
index f86ff42..b34f5fd 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationSemanticController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationSemanticController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.pagination.PageResponse;
import io.github.malonetalk.dto.semantic.BatchDeleteLogicalTableRelationRequest;
@@ -42,6 +43,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@RestController
@Validated
@RequestMapping("/api/semantic/tables/relations/{tableName}")
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationWorkspaceController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationWorkspaceController.java
index c2827a7..4d1ef04 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationWorkspaceController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableRelationWorkspaceController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.semantic.RelationWorkspacePageQuery;
import io.github.malonetalk.dto.semantic.RelationWorkspaceResponse;
@@ -27,6 +28,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@RestController
@RequestMapping("/api/semantic/tables/relations/workspace")
@RequiredArgsConstructor
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticController.java
index 0a524e3..19cfc7d 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.pagination.PageResponse;
import io.github.malonetalk.dto.semantic.BatchResetTableSemanticRequest;
@@ -39,6 +40,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@RestController
@Validated
@RequestMapping("/api/semantic/tables")
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticSyncController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticSyncController.java
index e155adb..ded0dca 100644
--- a/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticSyncController.java
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/TableSemanticSyncController.java
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.controller;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.pagination.PageResponse;
import io.github.malonetalk.dto.semantic.PhysicalTableCandidatePageQuery;
@@ -33,6 +34,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+@AdminOnly
@RestController
@RequestMapping("/api/semantic/tables/sync")
@RequiredArgsConstructor
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/ResetPasswordRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ResetPasswordRequest.java
new file mode 100644
index 0000000..3af0a82
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/ResetPasswordRequest.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;
+
+/** 管理员重置用户密码(不需旧密码);区别于 {@link ChangePasswordRequest}(用户自己改,需验旧密码)。 */
+public record ResetPasswordRequest(
+ @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/UserCreateRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserCreateRequest.java
new file mode 100644
index 0000000..a9f08bb
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserCreateRequest.java
@@ -0,0 +1,32 @@
+/*
+ * 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.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+public record UserCreateRequest(
+ @NotBlank(message = "username 不能为空") String username,
+ @NotBlank(message = "password 不能为空")
+ @Size(min = 6, max = 64, message = "password 长度需在 6-64 之间")
+ String password,
+ @NotBlank(message = "displayName 不能为空") String displayName,
+ @NotNull @Min(0) @Max(1) Integer roleId) {}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserResponse.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserResponse.java
new file mode 100644
index 0000000..300bdd7
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserResponse.java
@@ -0,0 +1,28 @@
+/*
+ * 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 java.time.LocalDateTime;
+
+public record UserResponse(
+ Integer id,
+ String username,
+ String displayName,
+ Integer roleId,
+ Integer status,
+ LocalDateTime createTime) {}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserUpdateRequest.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserUpdateRequest.java
new file mode 100644
index 0000000..4bb3412
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/UserUpdateRequest.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.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+
+/** 更新用户。roleId 为 null 表示不修改角色。 */
+public record UserUpdateRequest(
+ @NotBlank(message = "displayName 不能为空") String displayName,
+ @Min(0) @Max(1) Integer roleId) {}
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
index 926cd22..b86327a 100644
--- 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
@@ -17,6 +17,7 @@
*/
package io.github.malonetalk.interceptor;
+import io.github.malonetalk.annotation.AdminOnly;
import io.github.malonetalk.common.ErrorCode;
import io.github.malonetalk.common.UserContext;
import io.github.malonetalk.exception.BusinessException;
@@ -26,6 +27,7 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
+import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
/**
@@ -35,12 +37,15 @@
* → 放入 UserContext。token 缺失/过期/非法 或 用户被禁用 一律抛 {@link ErrorCode#UNAUTHORIZED},
* 由 GlobalExceptionHandler 统一输出 401。每次查库保证禁用即时生效。
*
- *
权限轮次再加 @AdminOnly 与表/列拦截;本轮登录后所有接口行为与现状一致。
+ *
方法/类上有 {@link AdminOnly} 且当前用户 role_id != 1 时返回
+ * {@link ErrorCode#FORBIDDEN} (403)。表/列拦截、会话隔离 = 后续轮次。
*/
@Component
@AllArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
+ private static final int ADMIN_ROLE_ID = 1;
+
private final JwtUtil jwtUtil;
private final SysUserMapper sysUserMapper;
@@ -58,6 +63,12 @@ public boolean preHandle(
ErrorCode.UNAUTHORIZED, "Account is disabled or does not exist.");
}
UserContext.set(context);
+
+ if (handler instanceof HandlerMethod handlerMethod && isAdminRequired(handlerMethod)) {
+ if (context.roleId() == null || context.roleId() != ADMIN_ROLE_ID) {
+ throw BusinessException.of(ErrorCode.FORBIDDEN, "需要管理员权限");
+ }
+ }
return true;
}
@@ -70,6 +81,15 @@ public void afterCompletion(
UserContext.clear();
}
+ /** 方法或所在类上有 @AdminOnly 注解时要求管理员权限;方法级注解覆盖类级。 */
+ private boolean isAdminRequired(HandlerMethod handlerMethod) {
+ AdminOnly methodAnnotation = handlerMethod.getMethodAnnotation(AdminOnly.class);
+ if (methodAnnotation != null) {
+ return true;
+ }
+ return handlerMethod.getBeanType().isAnnotationPresent(AdminOnly.class);
+ }
+
private String extractBearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null || header.isBlank()) {
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
index ceefded..77f03f8 100644
--- 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
@@ -19,6 +19,7 @@
import io.github.malonetalk.common.UserContext;
import io.github.malonetalk.entity.SysUser;
+import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -41,4 +42,10 @@ int updatePassword(
@Param("updateTime") java.time.LocalDateTime updateTime);
int countAll();
+
+ List selectAll();
+
+ int update(SysUser user);
+
+ int updateStatus(@Param("id") Integer id, @Param("status") Integer status);
}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/SysUserService.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/SysUserService.java
new file mode 100644
index 0000000..4b2fe4e
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/SysUserService.java
@@ -0,0 +1,36 @@
+/*
+ * 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.service;
+
+import io.github.malonetalk.dto.UserCreateRequest;
+import io.github.malonetalk.dto.UserResponse;
+import io.github.malonetalk.dto.UserUpdateRequest;
+import java.util.List;
+
+public interface SysUserService {
+
+ List listAll();
+
+ UserResponse create(UserCreateRequest request);
+
+ UserResponse update(Integer id, UserUpdateRequest request);
+
+ void resetPassword(Integer id, String newPassword);
+
+ void updateStatus(Integer id, Integer status);
+}
diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/SysUserServiceImpl.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/SysUserServiceImpl.java
new file mode 100644
index 0000000..c2aff40
--- /dev/null
+++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/SysUserServiceImpl.java
@@ -0,0 +1,109 @@
+/*
+ * 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.service;
+
+import io.github.malonetalk.common.ErrorCode;
+import io.github.malonetalk.dto.UserCreateRequest;
+import io.github.malonetalk.dto.UserResponse;
+import io.github.malonetalk.dto.UserUpdateRequest;
+import io.github.malonetalk.entity.SysUser;
+import io.github.malonetalk.exception.BusinessException;
+import io.github.malonetalk.mapper.SysUserMapper;
+import io.github.malonetalk.util.PasswordUtil;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.AllArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@AllArgsConstructor
+public class SysUserServiceImpl implements SysUserService {
+
+ private final SysUserMapper sysUserMapper;
+
+ @Override
+ public List listAll() {
+ return sysUserMapper.selectAll().stream().map(this::toResponse).toList();
+ }
+
+ @Override
+ public UserResponse create(UserCreateRequest request) {
+ SysUser existing = sysUserMapper.selectByUsername(request.username());
+ if (existing != null) {
+ throw BusinessException.of(
+ ErrorCode.DATA_CONFLICT, "用户名 '" + request.username() + "' 已存在");
+ }
+ LocalDateTime now = LocalDateTime.now();
+ SysUser user = new SysUser();
+ user.setUsername(request.username());
+ user.setPasswordHash(PasswordUtil.hash(request.password()));
+ user.setDisplayName(request.displayName());
+ user.setRoleId(request.roleId());
+ user.setIdpType("LOCAL");
+ user.setStatus(1);
+ user.setCreateTime(now);
+ user.setUpdateTime(now);
+ sysUserMapper.insert(user);
+ return toResponse(user);
+ }
+
+ @Override
+ public UserResponse update(Integer id, UserUpdateRequest request) {
+ SysUser user = requireUser(id);
+ user.setDisplayName(request.displayName());
+ if (request.roleId() != null) {
+ user.setRoleId(request.roleId());
+ }
+ user.setUpdateTime(LocalDateTime.now());
+ sysUserMapper.update(user);
+ return toResponse(user);
+ }
+
+ @Override
+ public void resetPassword(Integer id, String newPassword) {
+ SysUser user = requireUser(id);
+ if (user.getPasswordHash() == null) {
+ throw BusinessException.of(ErrorCode.BAD_REQUEST, "外部身份源用户无法重置密码");
+ }
+ sysUserMapper.updatePassword(id, PasswordUtil.hash(newPassword), LocalDateTime.now());
+ }
+
+ @Override
+ public void updateStatus(Integer id, Integer status) {
+ requireUser(id);
+ sysUserMapper.updateStatus(id, status);
+ }
+
+ private SysUser requireUser(Integer id) {
+ SysUser user = sysUserMapper.selectById(id);
+ if (user == null) {
+ throw BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "用户不存在");
+ }
+ return user;
+ }
+
+ private UserResponse toResponse(SysUser user) {
+ return new UserResponse(
+ user.getId(),
+ user.getUsername(),
+ user.getDisplayName(),
+ user.getRoleId(),
+ user.getStatus(),
+ user.getCreateTime());
+ }
+}
diff --git a/data-agent-backend/src/main/resources/application.properties b/data-agent-backend/src/main/resources/application.properties
index dccb8e1..084e531 100644
--- a/data-agent-backend/src/main/resources/application.properties
+++ b/data-agent-backend/src/main/resources/application.properties
@@ -27,3 +27,10 @@ io.github.malonetalk.model.base-url=
io.github.malonetalk.model.api-key=${IO_GITHUB_MALONETALK_MODEL_API_KEY:}
spring.config.import=classpath:skill.properties
+
+# Auth Configuration (login round): all env-driven, no secrets committed.
+# JWT secret must be >= 32 bytes in production; blank => in-memory random key (dev only, tokens invalidated on restart).
+jwt.secret=${JWT_SECRET:}
+jwt.expiration-hours=${JWT_EXPIRATION_HOURS:24}
+# Initial admin password for first startup when sys_user is empty; fail-closed if unset.
+admin.init-password=${ADMIN_INIT_PASSWORD:}
diff --git a/data-agent-backend/src/main/resources/mapper/SysUserMapper.xml b/data-agent-backend/src/main/resources/mapper/SysUserMapper.xml
index f43caa3..58c25d1 100644
--- a/data-agent-backend/src/main/resources/mapper/SysUserMapper.xml
+++ b/data-agent-backend/src/main/resources/mapper/SysUserMapper.xml
@@ -20,6 +20,7 @@
+
@@ -33,7 +34,7 @@
@@ -57,4 +58,22 @@
SELECT COUNT(*) FROM sys_user
+
+
+
+ UPDATE sys_user
+
+ display_name = #{displayName},
+ role_id = #{roleId},
+ update_time = #{updateTime},
+
+ WHERE id = #{id}
+
+
+
+ UPDATE sys_user SET status = #{status}, update_time = NOW() WHERE id = #{id}
+
+
diff --git a/data-agent-frontend/src/api/sysUser.ts b/data-agent-frontend/src/api/sysUser.ts
new file mode 100644
index 0000000..17e9b5f
--- /dev/null
+++ b/data-agent-frontend/src/api/sysUser.ts
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+import request from './request';
+
+export interface UserResponse {
+ id: number;
+ username: string;
+ displayName: string;
+ roleId: number; // 1=管理员 0=普通用户
+ status: number; // 1=启用 0=禁用
+ createTime: string;
+}
+
+export interface UserCreateRequest {
+ username: string;
+ password: string;
+ displayName: string;
+ roleId: number;
+}
+
+export interface UserUpdateRequest {
+ displayName: string;
+ roleId: number | null;
+}
+
+type ApiResult = { code: number; message: string; data: T };
+
+export function listUsers() {
+ return request.get>('/sys/user').then(res => res.data.data);
+}
+
+export function createUser(payload: UserCreateRequest) {
+ return request.post>('/sys/user', payload).then(res => res.data.data);
+}
+
+export function updateUser(id: number, payload: UserUpdateRequest) {
+ return request
+ .put>(`/sys/user/${id}`, payload)
+ .then(res => res.data.data);
+}
+
+export function resetPassword(id: number, newPassword: string) {
+ return request
+ .put>(`/sys/user/${id}/password`, { newPassword })
+ .then(res => res.data.data);
+}
+
+export function updateStatus(id: number, status: number) {
+ return request
+ .put>(`/sys/user/${id}/status`, null, {
+ params: { status },
+ })
+ .then(res => res.data.data);
+}
diff --git a/data-agent-frontend/src/components/layout/AppSidebar.vue b/data-agent-frontend/src/components/layout/AppSidebar.vue
index 5bf2dec..8f5d6b2 100644
--- a/data-agent-frontend/src/components/layout/AppSidebar.vue
+++ b/data-agent-frontend/src/components/layout/AppSidebar.vue
@@ -30,6 +30,7 @@
{ path: '/semantic', title: '语义管理', icon: 'Collection' },
{ path: '/report', title: '报告管理', icon: 'Document' },
{ path: '/metric', title: '指标口径管理', icon: 'DataLine' },
+ { path: '/sys-user', title: '用户管理', icon: 'User' },
];
const activeMenu = computed(() => route.path);
diff --git a/data-agent-frontend/src/router/index.ts b/data-agent-frontend/src/router/index.ts
index fe2b92d..7264f69 100644
--- a/data-agent-frontend/src/router/index.ts
+++ b/data-agent-frontend/src/router/index.ts
@@ -67,6 +67,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/metric/MetricManage.vue'),
meta: { title: '指标口径管理' },
},
+ {
+ path: '/sys-user',
+ name: 'UserManage',
+ component: () => import('@/views/sys-user/UserManage.vue'),
+ meta: { title: '用户管理' },
+ },
];
const router = createRouter({
diff --git a/data-agent-frontend/src/views/sys-user/UserManage.vue b/data-agent-frontend/src/views/sys-user/UserManage.vue
new file mode 100644
index 0000000..f44979b
--- /dev/null
+++ b/data-agent-frontend/src/views/sys-user/UserManage.vue
@@ -0,0 +1,291 @@
+
+
+
+
+
+