From c10afd0cbec9ff6966821ce4590bbe34f59c5aa8 Mon Sep 17 00:00:00 2001
From: chenchuchuan <13554600537@163.com>
Date: Fri, 16 Oct 2026 16:31:59 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat(mcp):=20=E6=B7=BB=E5=8A=A0MCP=E5=B7=A5?=
=?UTF-8?q?=E5=85=B7=E7=AE=A1=E7=90=86=E5=91=98=E6=9D=83=E9=99=90=E6=8E=A7?=
=?UTF-8?q?=E5=88=B6=E4=B8=8E=E7=AB=AF=E7=82=B9=E9=89=B4=E6=9D=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 McpToolScanner 中增加管理类工具前缀配置,命中前缀的工具仅允许管理员调用
- 调用时优先复用请求上下文中的 TokenData,避免覆盖真实身份导致匿名执行
- 新增 buildDefaultTokenData 方法作为无请求上下文时的兜底
- 实现 isToolAllowed 与 isAdminOnlyTool 方法进行工具级授权判断
- 在 InterceptorConfig 中注册 McpAuthInterceptor,独立处理 MCP 端点鉴权
- 将 MCP 端点路径从主登录态拦截器中排除,避免密钥调用被 401 拦截
- common-botp 模块依赖版本更新,common-core 版本改为引用变量
- 在 common 父模块中注册 common-botp 子模块
---
.../tenantadmin/config/InterceptorConfig.java | 15 ++++-
common/common-botp/pom.xml | 6 +-
.../common/core/mcp/config/McpToolScanner.java | 67 +++++++++++++++++++++-
common/pom.xml | 2 +
4 files changed, 83 insertions(+), 7 deletions(-)
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java
index 248604b..8e20f05 100644
--- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java
+++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java
@@ -3,6 +3,7 @@ package apelet.tenantadmin.config;
import apelet.common.openapi.config.OpenApiProperties;
import apelet.common.openapi.interceptor.OpenApiAuthInterceptor;
import apelet.tenantadmin.interceptor.AuthenticationInterceptor;
+import apelet.tenantadmin.interceptor.McpAuthInterceptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
@@ -22,6 +23,8 @@ public class InterceptorConfig implements WebMvcConfigurer {
private ObjectProvider openApiAuthInterceptorProvider;
@Autowired
private OpenApiProperties openApiProperties;
+ @Autowired
+ private McpAuthInterceptor mcpAuthInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
@@ -32,7 +35,13 @@ public class InterceptorConfig implements WebMvcConfigurer {
// {prefix}/auth/token 是公开的签发接口。这里一并排除,避免调用方误带 Authorization 头时
// 被登录态拦截器抢先拦下(它注册在前,会先执行)。
.excludePathPatterns(openApiPrefix + "/v1/**")
- .excludePathPatterns(openApiPrefix + "/auth/**");
+ .excludePathPatterns(openApiPrefix + "/auth/**")
+ // MCP 端点由 McpAuthInterceptor 独立鉴权,支持登录态 JWT 与人员密钥两种凭证。
+ // 必须在这里排除:HandlerInterceptor 没有"我处理完了后续跳过"的机制,若不排除,
+ // 未带 Authorization 的密钥调用会被登录态拦截器直接 401。
+ // 注意两条都要写:/** 并不匹配不带尾斜杠的端点本身。
+ .excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH)
+ .excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**");
// OpenAPI 对外开放路由鉴权({prefix}/v1/**):校验 accessToken 并把 TokenData 注入请求上下文。
// 注意 HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上;
// common-openapi.operationEnabled=false 时该 bean 不存在,getIfAvailable() 返回 null,跳过注册即可。
@@ -40,5 +49,9 @@ public class InterceptorConfig implements WebMvcConfigurer {
if (openApiAuthInterceptor != null) {
registry.addInterceptor(openApiAuthInterceptor).addPathPatterns(openApiPrefix + "/v1/**");
}
+ // MCP 端点鉴权(/tenantadmin/mcp 与 /tenantadmin/mcp/**)。
+ registry.addInterceptor(mcpAuthInterceptor)
+ .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH)
+ .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**");
}
}
diff --git a/common/common-botp/pom.xml b/common/common-botp/pom.xml
index 1fc7b12..f121380 100644
--- a/common/common-botp/pom.xml
+++ b/common/common-botp/pom.xml
@@ -17,17 +17,17 @@
apelet
common-core
- 1.0.0
+ ${common-core.version}
apelet
common-generator
- 1.0.3
+ 1.0.5
apelet
common-online
- 1.0.3
+ 1.0.6
compile
diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java
index eaa15b8..63c995b 100644
--- a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java
+++ b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java
@@ -56,6 +56,11 @@ public class McpToolScanner {
@Value("${apelet.mcp.default-user-id:0}")
private Long defaultUserId;
/**
+ * 管理类工具的名称前缀(逗号分隔),命中者仅允许管理员调用。默认覆盖 OpenAPI 应用/接口配置管理工具。
+ */
+ @Value("${apelet.mcp.admin-only-tool-prefixes:openapi_app_,openapi_config_}")
+ private String adminOnlyToolPrefixes;
+ /**
* 已组装好的工具注册项(供 spec.tools 批量注入)。
*/
@Getter
@@ -326,9 +331,18 @@ public class McpToolScanner {
*/
private McpSchema.CallToolResult invoke(Object bean, Method method, List argInfos, Map args) {
try {
- //todo 这里要求带上token MCP 无 HTTP 登录态,注入默认用户 TokenData,避免服务内 takeFromRequest() 拿到 null 抛 NPE。
- TokenData tokenData = new TokenData();
- tokenData.setUserId(this.defaultUserId);
+ // 优先复用当前请求上下文中的 TokenData —— HTTP 调用链路上,拦截器已完成鉴权并注入真实身份,
+ // 这里若再造一个空对象会把真实身份覆盖掉,导致工具以匿名身份执行、下游数据权限取不到会话。
+ // 只有在确实没有请求上下文时(如未经 HTTP 链路的直调),才回落默认用户,避免 takeFromRequest() 拿到 null 抛 NPE。
+ TokenData existing = TokenData.takeFromRequest();
+ TokenData tokenData = existing != null && existing.getUserId() != null ? existing : this.buildDefaultTokenData();
+ McpToolClass ann = method.getAnnotation(McpToolClass.class);
+ String toolName = ann == null ? method.getName() : ann.name();
+ if (!this.isToolAllowed(toolName, tokenData)) {
+ log.warn("MCP 工具 [{}] 调用被拒:当前身份无权调用管理类工具", toolName);
+ return new McpSchema.CallToolResult(Collections.singletonList(
+ new McpSchema.TextContent("当前身份无权调用该工具!管理类工具仅允许管理员调用。")), true);
+ }
return this.withRequestContext(tokenData, () -> {
Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount());
Object result = method.invoke(bean, invokeArgs);
@@ -343,6 +357,53 @@ public class McpToolScanner {
}
/**
+ * 构造兜底 TokenData:仅在请求上下文缺失时使用,保证服务内 {@code takeFromRequest()} 不返回 null。
+ *
+ * @return 仅带默认用户Id的 TokenData。
+ */
+ private TokenData buildDefaultTokenData() {
+ TokenData tokenData = new TokenData();
+ tokenData.setUserId(this.defaultUserId);
+ return tokenData;
+ }
+
+ /**
+ * 工具级授权:管理类工具仅允许管理员调用。
+ * 存在这条约束的原因:HTTP 调用链路上,MCP 端点已从登录态拦截器排除、不再做"菜单×URL"越权校验,
+ * 若不在工具层补一道,任何能通过端点鉴权的身份(含人员密钥)都能调用应用/接口配置等管理类工具,
+ * 进而读出应用明文密钥、提权为应用级凭证。
+ * 兜底 TokenData(匿名身份)的 isAdmin 为 null,同样会被拒绝。
+ *
+ * @param toolName 工具名称。
+ * @param tokenData 当前调用身份。
+ * @return 允许调用返回true,否则false。
+ */
+ private boolean isToolAllowed(String toolName, TokenData tokenData) {
+ if (!this.isAdminOnlyTool(toolName)) {
+ return true;
+ }
+ return Boolean.TRUE.equals(tokenData.getIsAdmin());
+ }
+
+ /**
+ * 判断工具是否属于"管理类",按配置的前缀匹配。
+ *
+ * @param toolName 工具名称。
+ * @return 属于管理类返回true,否则false。
+ */
+ private boolean isAdminOnlyTool(String toolName) {
+ if (StrUtil.isBlank(this.adminOnlyToolPrefixes) || StrUtil.isBlank(toolName)) {
+ return false;
+ }
+ for (String prefix : StrUtil.split(this.adminOnlyToolPrefixes, ',')) {
+ if (StrUtil.isNotBlank(prefix) && toolName.startsWith(prefix.trim())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
* 在指定 TokenData 的请求上下文下执行动作:当前线程没有请求上下文时挂动态代理的 request/response,
* 执行完恢复原样。
*/
diff --git a/common/pom.xml b/common/pom.xml
index d79f21d..a2c592f 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -24,6 +24,8 @@
common-openapi
common-core
+
+ common-botp
From 99694e8d20f4417200717ac57ab40a3a1b3dd1e0 Mon Sep 17 00:00:00 2001
From: chenchuchuan <13554600537@163.com>
Date: Fri, 16 Oct 2026 16:32:14 +0800
Subject: [PATCH 2/4] =?UTF-8?q?feat(mcp):=20=E6=B7=BB=E5=8A=A0MCP=E7=AB=AF?=
=?UTF-8?q?=E7=82=B9=E4=BA=BA=E5=91=98=E5=AF=86=E9=92=A5=E9=89=B4=E6=9D=83?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增McpAuthInterceptor拦截器,支持JWT和人员密钥两种鉴权方式
- 新增SysUserSecret实体及对应的Mapper、Service、Controller完整CRUD
- 实现密钥签发、轮换、启用/停用管理接口
- 密钥口令仅存SHA-256摘要,明文只在签发/轮换响应中出现一次
- 引入会话复用机制,避免密钥分支每次请求重建会话上下文
- 响应中自动脱敏secretHash和手机号字段
---
.../interceptor/McpAuthInterceptor.java | 262 +++++++++++++++++++++
.../upms/controller/SysUserSecretController.java | 171 ++++++++++++++
.../tenantadmin/upms/dao/SysUserSecretMapper.java | 25 ++
.../upms/dao/mapper/SysUserSecretMapper.xml | 66 ++++++
.../tenantadmin/upms/model/SysUserSecret.java | 98 ++++++++
.../upms/model/constant/SysUserSecretStatus.java | 44 ++++
.../upms/service/SysUserSecretService.java | 72 ++++++
.../upms/service/UserSessionContextService.java | 53 +++++
.../service/impl/SysUserSecretServiceImpl.java | 225 ++++++++++++++++++
.../impl/UserSessionContextServiceImpl.java | 134 +++++++++++
.../tenantadmin/upms/vo/SysUserSecretIssueVo.java | 47 ++++
11 files changed, 1197 insertions(+)
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java
create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java
new file mode 100644
index 0000000..b74bfee
--- /dev/null
+++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java
@@ -0,0 +1,262 @@
+package apelet.tenantadmin.interceptor;
+
+import apelet.common.core.constant.AppDeviceType;
+import apelet.common.core.constant.ApplicationConstant;
+import apelet.common.core.constant.ErrorCodeEnum;
+import apelet.common.core.object.ResponseResult;
+import apelet.common.core.object.TokenData;
+import apelet.common.core.util.JwtUtil;
+import apelet.common.core.util.RedisKeyUtil;
+import apelet.common.openapi.model.OpenApiApp;
+import apelet.common.openapi.service.OpenApiAppService;
+import apelet.tenantadmin.config.ApplicationConfig;
+import apelet.tenantadmin.upms.model.SysUser;
+import apelet.tenantadmin.upms.model.SysUserSecret;
+import apelet.tenantadmin.upms.service.SysUserSecretService;
+import apelet.tenantadmin.upms.service.SysUserService;
+import apelet.tenantadmin.upms.service.UserSessionContextService;
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSON;
+import io.jsonwebtoken.Claims;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RBucket;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * MCP 端点({@code /tenantadmin/mcp})的鉴权拦截器。
+ * 支持两种调用方身份,按顺序判定:
+ *
+ * - {@code Authorization: Bearer } —— 登录态,兼容现有调用方;
+ * - {@code X-Mcp-User} + {@code X-Mcp-Secret} —— 人员级密钥,格式为 "keyId.secret",锁定到具体账号。
+ *
+ * 两者都带时以 Bearer 优先,避免调用方主动降级到密钥分支。
+ * 校验通过后统一注入 {@link TokenData},下游(在线表单、数据权限、流程)对来源无感知。
+ * 注意:本拦截器注册后,{@code /tenantadmin/mcp} 已从 AuthenticationInterceptor 的路径中排除,
+ * 因此 MCP 路径不做"菜单×URL"越权校验;数据权限(DATA_PERM)仍照常生效。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+@Slf4j
+@Component
+public class McpAuthInterceptor implements HandlerInterceptor {
+
+ /**
+ * MCP 端点路径。注意该路径硬编码在 common-core 的 McpStreamableController 上,两边需保持一致。
+ */
+ public static final String MCP_ENDPOINT_PATH = "/tenantadmin/mcp";
+
+ /**
+ * 人员手机号请求头。
+ */
+ public static final String HEADER_MCP_USER = "X-Mcp-User";
+ /**
+ * 人员密钥请求头,值为 "keyId.secret"。
+ */
+ public static final String HEADER_MCP_SECRET = "X-Mcp-Secret";
+
+ /**
+ * 会话复用缓存前缀:一个账号在 TTL 内复用同一个会话,避免每个请求都重建会话上下文(含查库与多次Redis写入)。
+ */
+ private static final String MCP_SESSION_KEY_PREFIX = "MCP_SESSION:";
+ /**
+ * 会话复用TTL(秒)。刻意短于会话本身的有效期,以限制角色/数据权限变更后的缓存陈旧窗口。
+ */
+ private static final long MCP_SESSION_TTL_SECONDS = 1800L;
+ /**
+ * JWT 中承载会话Id的 claim 名称,需与登录链路保持一致。
+ */
+ private static final String CLAIM_SESSION_ID = "sessionId";
+
+ @Autowired
+ private ApplicationConfig appConfig;
+ @Autowired
+ private RedissonClient redissonClient;
+ @Autowired
+ private SysUserSecretService sysUserSecretService;
+ @Autowired
+ private SysUserService sysUserService;
+ @Autowired
+ private OpenApiAppService openApiAppService;
+ @Autowired
+ private UserSessionContextService userSessionContextService;
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+ String authorization = request.getHeader(appConfig.getTokenHeaderKey());
+ TokenData tokenData = null;
+ if (StrUtil.isNotBlank(authorization)) {
+ tokenData = this.authenticateByJwt(authorization);
+ if (tokenData == null) {
+ this.outputUnauthorized(response, "登录会话已过期或尚未登录,请重新登录!");
+ return false;
+ }
+ } else {
+ String mobile = request.getHeader(HEADER_MCP_USER);
+ String credential = request.getHeader(HEADER_MCP_SECRET);
+ if (StrUtil.isBlank(mobile) && StrUtil.isBlank(credential)) {
+ this.outputUnauthorized(response, "缺少调用凭证,请提供 Authorization 或人员密钥!");
+ return false;
+ }
+ ResponseResult authResult = sysUserSecretService.authenticate(mobile, credential);
+ if (!authResult.isSuccess()) {
+ log.warn("MCP 密钥鉴权失败,keyId={}, mobile={}, 原因={}", this.maskKeyId(credential), mobile, authResult.getErrorMessage());
+ this.outputUnauthorized(response, authResult.getErrorMessage());
+ return false;
+ }
+ SysUser user = sysUserService.getById(authResult.getData().getUserId());
+ if (user == null) {
+ this.outputUnauthorized(response, "凭证无效!");
+ return false;
+ }
+ tokenData = this.resolveKeyBranchTokenData(user, authResult.getData());
+ }
+ TokenData.addToRequest(tokenData);
+ // 在线表单引擎需要 MenuId 头来选取数据权限规则分支;MCP 无菜单上下文,统一按"通用规则"处理。
+ this.wrapRequestWithMenuId(request);
+ return true;
+ }
+
+ /**
+ * 校验登录态 JWT 并取回会话中的 TokenData。口径与 AuthenticationInterceptor 保持一致。
+ *
+ * @param token 请求头中的 JWT(含 Bearer 前缀)。
+ * @return 校验通过返回 TokenData,否则返回null。
+ */
+ private TokenData authenticateByJwt(String token) {
+ Claims claims = JwtUtil.parseToken(token, appConfig.getTokenSigningKey());
+ if (JwtUtil.isNullOrExpired(claims)) {
+ return null;
+ }
+ String sessionId = (String) claims.get(CLAIM_SESSION_ID);
+ if (StrUtil.isBlank(sessionId)) {
+ return null;
+ }
+ RBucket sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId));
+ if (!sessionData.isExists()) {
+ return null;
+ }
+ TokenData tokenData = JSON.parseObject(sessionData.get(), TokenData.class);
+ if (tokenData != null) {
+ tokenData.setToken(token);
+ }
+ return tokenData;
+ }
+
+ /**
+ * 解决密钥分支的 TokenData:优先复用该账号已有的 MCP 会话,避免每个请求都重建会话上下文。
+ *
+ * @param user 密钥绑定的账号。
+ * @param userSecret 已通过校验的密钥记录。
+ * @return 可直接注入请求上下文的 TokenData。
+ */
+ private TokenData resolveKeyBranchTokenData(SysUser user, SysUserSecret userSecret) {
+ RBucket sessionIdBucket = redissonClient.getBucket(MCP_SESSION_KEY_PREFIX + user.getUserId());
+ String sessionId = sessionIdBucket.get();
+ if (StrUtil.isNotBlank(sessionId)) {
+ RBucket sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId));
+ if (sessionData.isExists()) {
+ TokenData cached = JSON.parseObject(sessionData.get(), TokenData.class);
+ if (cached != null) {
+ return cached;
+ }
+ }
+ }
+ String appCode = this.resolveAppCode(userSecret.getAppId());
+ String newSessionId = userSessionContextService.generateSessionId(user.getLoginName(), AppDeviceType.WEB);
+ TokenData tokenData = userSessionContextService.buildTokenData(user, newSessionId, AppDeviceType.WEB, appCode);
+ userSessionContextService.putSessionContext(tokenData, user);
+ sessionIdBucket.set(newSessionId, MCP_SESSION_TTL_SECONDS, TimeUnit.SECONDS);
+ return tokenData;
+ }
+
+ /**
+ * 由密钥绑定的应用Id取回应用编码,供下游的开放接口授权校验使用。
+ *
+ * @param appId 应用Id。
+ * @return 应用编码;应用不存在或已停用时返回null,由下游给出"无权调用"的可读错误。
+ */
+ private String resolveAppCode(Long appId) {
+ if (appId == null) {
+ return null;
+ }
+ OpenApiApp app = openApiAppService.getById(appId);
+ if (app == null || app.getStatus() == null || app.getStatus() != 1) {
+ log.warn("MCP 密钥绑定的应用不存在或已停用,appId={}", appId);
+ return null;
+ }
+ return app.getAppCode();
+ }
+
+ /**
+ * 用 HttpServletRequestWrapper 覆盖 MenuId 请求头。
+ * 拦截器无法修改已传入的 request 对象,只能包装后替换 RequestContextHolder 中的引用,
+ * 使下游通过 ContextUtil.getHttpRequest().getHeader("MenuId") 能取到值。
+ *
+ * @param request 当前请求。
+ */
+ private void wrapRequestWithMenuId(HttpServletRequest request) {
+ if (StrUtil.isNotBlank(request.getHeader(ApplicationConstant.HTTP_HEADER_MENU_ID))) {
+ return;
+ }
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ if (attributes == null) {
+ return;
+ }
+ RequestContextHolder.setRequestAttributes(
+ new ServletRequestAttributes(new MenuIdHeaderRequestWrapper(request), attributes.getResponse()));
+ }
+
+ /**
+ * 仅覆盖 MenuId 请求头,其余行为全部委托给原请求(含 attribute 读写,TokenData 依赖它)。
+ */
+ private static class MenuIdHeaderRequestWrapper extends HttpServletRequestWrapper {
+
+ MenuIdHeaderRequestWrapper(HttpServletRequest request) {
+ super(request);
+ }
+
+ @Override
+ public String getHeader(String name) {
+ if (ApplicationConstant.HTTP_HEADER_MENU_ID.equalsIgnoreCase(name)) {
+ return ApplicationConstant.DATA_PERM_ALL_MENU_ID;
+ }
+ return super.getHeader(name);
+ }
+ }
+
+ /**
+ * 日志中只保留 keyId,避免密钥口令落入日志。
+ *
+ * @param credential 完整凭证(keyId.secret)。
+ * @return 仅含 keyId 的字符串。
+ */
+ private String maskKeyId(String credential) {
+ if (StrUtil.isBlank(credential)) {
+ return "";
+ }
+ return StrUtil.subBefore(credential, SysUserSecretService.CREDENTIAL_SEPARATOR, false);
+ }
+
+ private void outputUnauthorized(HttpServletResponse response, String message) throws IOException {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setHeader("WWW-Authenticate", "Bearer");
+ response.setContentType("application/json; charset=utf-8");
+ PrintWriter out = response.getWriter();
+ out.print(JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, message)));
+ out.flush();
+ out.close();
+ }
+}
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java
new file mode 100644
index 0000000..27e4cf7
--- /dev/null
+++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java
@@ -0,0 +1,171 @@
+package apelet.tenantadmin.upms.controller;
+
+import apelet.common.core.annotation.MyRequestBody;
+import apelet.common.core.constant.ErrorCodeEnum;
+import apelet.common.core.object.MyOrderParam;
+import apelet.common.core.object.MyPageData;
+import apelet.common.core.object.MyPageParam;
+import apelet.common.core.object.ResponseResult;
+import apelet.common.core.util.MyCommonUtil;
+import apelet.common.core.util.MyPageUtil;
+import apelet.common.log.annotation.OperationLog;
+import apelet.common.log.model.constant.SysOperationLogType;
+import apelet.tenantadmin.upms.model.SysUserSecret;
+import apelet.tenantadmin.upms.model.constant.SysUserSecretStatus;
+import apelet.tenantadmin.upms.service.SysUserSecretService;
+import apelet.tenantadmin.upms.vo.SysUserSecretIssueVo;
+import cn.hutool.core.date.DateUtil;
+import cn.hutool.core.util.StrUtil;
+import com.github.pagehelper.Page;
+import com.github.pagehelper.page.PageMethod;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 用户对外密钥管理接口。
+ * 密钥是"一人一把、锁定到具体账号"的长期凭证,签发与停用属于提权操作,
+ * 接口本身依赖登录态 + URL 权限控制,仅管理员角色应被授予。
+ * 响应中永远不返回 secretHash;明文 secret 只在签发/轮换的响应里出现一次,
+ * 且对应操作日志显式关闭了响应体记录(saveResponse=false),避免明文密钥落到日志。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+@Tag(name = "对外密钥管理")
+@Slf4j
+@RestController
+@RequestMapping("/tenantadmin/upms/sysUserSecret")
+public class SysUserSecretController {
+
+ @Autowired
+ private SysUserSecretService sysUserSecretService;
+
+ /**
+ * 对外密钥分页列表。
+ *
+ * @param sysUserSecretFilter 过滤条件(支持 loginName/mobile 模糊、userId/appId/status 精确)。
+ * @param orderParam 排序参数。
+ * @param pageParam 分页参数。
+ * @return 应答结果对象,包含分页后的密钥列表。
+ */
+ @PostMapping("/list")
+ public ResponseResult> list(
+ @MyRequestBody SysUserSecret sysUserSecretFilter,
+ @MyRequestBody MyOrderParam orderParam,
+ @MyRequestBody MyPageParam pageParam) {
+ if (pageParam != null) {
+ PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
+ }
+ String orderBy = MyOrderParam.buildOrderBy(orderParam, SysUserSecret.class);
+ List secretList = sysUserSecretService.getSysUserSecretList(sysUserSecretFilter, orderBy);
+ long totalCount = 0L;
+ if (secretList instanceof Page) {
+ totalCount = ((Page) secretList).getTotal();
+ }
+ secretList.forEach(this::maskForResponse);
+ return ResponseResult.success(MyPageUtil.makeResponseData(secretList, totalCount));
+ }
+
+ /**
+ * 查看指定密钥的详情。不返回 secretHash。
+ *
+ * @param userSecretId 密钥记录主键Id。
+ * @return 应答结果对象,包含密钥详情。
+ */
+ @PostMapping("/view")
+ public ResponseResult view(@MyRequestBody Long userSecretId) {
+ if (MyCommonUtil.existBlankArgument(userSecretId)) {
+ return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
+ }
+ SysUserSecret userSecret = sysUserSecretService.getById(userSecretId);
+ if (userSecret == null) {
+ return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!");
+ }
+ this.maskForResponse(userSecret);
+ return ResponseResult.success(userSecret);
+ }
+
+ /**
+ * 为指定账号签发一把对外密钥。明文只在本响应中出现一次,之后无法再查。
+ *
+ * @param userId 绑定的账号Id。
+ * @param appId 绑定的OpenAPI应用Id(决定该密钥可调用的开放接口范围)。
+ * @param expireTime 过期时间字符串,留空表示永不过期。
+ * @return 应答结果对象,包含 keyId、明文 secret 与完整凭证。
+ */
+ @OperationLog(type = SysOperationLogType.ADD, saveResponse = false)
+ @PostMapping("/issue")
+ public ResponseResult issue(
+ @MyRequestBody Long userId,
+ @MyRequestBody Long appId,
+ @MyRequestBody String expireTime) {
+ if (MyCommonUtil.existBlankArgument(userId, appId)) {
+ return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
+ }
+ Date expireDate = null;
+ if (StrUtil.isNotBlank(expireTime)) {
+ expireDate = DateUtil.parse(expireTime);
+ }
+ return ResponseResult.success(sysUserSecretService.issue(userId, appId, expireDate));
+ }
+
+ /**
+ * 轮换指定密钥的口令:keyId 不变,旧 secret 立即失效。明文只在本响应中出现一次。
+ *
+ * @param userSecretId 密钥记录主键Id。
+ * @return 应答结果对象,包含新的明文 secret 与完整凭证。
+ */
+ @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false)
+ @PostMapping("/resetSecret")
+ public ResponseResult resetSecret(@MyRequestBody Long userSecretId) {
+ if (MyCommonUtil.existBlankArgument(userSecretId)) {
+ return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
+ }
+ SysUserSecretIssueVo issueVo = sysUserSecretService.resetSecret(userSecretId);
+ if (issueVo == null) {
+ return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!");
+ }
+ return ResponseResult.success(issueVo);
+ }
+
+ /**
+ * 启用/停用指定密钥。停用后该密钥的调用一律拒绝。
+ *
+ * @param userSecretId 密钥记录主键Id。
+ * @param status 目标状态(1: 启用 0: 停用)。
+ * @return 应答结果对象。
+ */
+ @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false)
+ @PostMapping("/updateStatus")
+ public ResponseResult updateStatus(@MyRequestBody Long userSecretId, @MyRequestBody Integer status) {
+ if (MyCommonUtil.existBlankArgument(userSecretId, status)) {
+ return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
+ }
+ if (!SysUserSecretStatus.isValid(status)) {
+ return ResponseResult.error(ErrorCodeEnum.INVALID_STATUS_ARGUMENT);
+ }
+ if (!sysUserSecretService.updateStatus(userSecretId, status)) {
+ return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!");
+ }
+ return ResponseResult.success();
+ }
+
+ /**
+ * 抹掉不应对外返回的字段:密钥摘要一律清空,手机号脱敏。
+ *
+ * @param userSecret 待处理的对象。
+ */
+ private void maskForResponse(SysUserSecret userSecret) {
+ userSecret.setSecretHash(null);
+ if (StrUtil.isNotBlank(userSecret.getMobile())) {
+ userSecret.setMobile(StrUtil.hide(userSecret.getMobile(), 3, 7));
+ }
+ }
+}
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java
new file mode 100644
index 0000000..852b7fb
--- /dev/null
+++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java
@@ -0,0 +1,25 @@
+package apelet.tenantadmin.upms.dao;
+
+import apelet.common.core.base.dao.BaseDaoMapper;
+import apelet.tenantadmin.upms.model.SysUserSecret;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 用户对外密钥数据访问操作接口。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+public interface SysUserSecretMapper extends BaseDaoMapper {
+
+ /**
+ * 获取用户对外密钥列表(带账号信息)。
+ *
+ * @param sysUserSecretFilter 过滤对象。
+ * @param orderBy 排序字符串。
+ * @return 过滤后的对外密钥列表。
+ */
+ List getSysUserSecretList(@Param("sysUserSecretFilter") SysUserSecret sysUserSecretFilter, @Param("orderBy") String orderBy);
+}
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml
new file mode 100644
index 0000000..5e8cb57
--- /dev/null
+++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AND xy_sys_user_secret.user_id = #{sysUserSecretFilter.userId}
+
+
+ AND xy_sys_user_secret.app_id = #{sysUserSecretFilter.appId}
+
+
+ AND xy_sys_user_secret.status = #{sysUserSecretFilter.status}
+
+
+
+ AND IFNULL(xy_sys_user.login_name, '') LIKE #{safeLoginName}
+
+
+
+ AND IFNULL(xy_sys_user.mobile, '') LIKE #{safeMobile}
+
+
+ AND xy_sys_user_secret.deleted_flag = ${@apelet.common.core.constant.GlobalDeletedFlag@NORMAL}
+
+
+
+
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java
new file mode 100644
index 0000000..3489e8b
--- /dev/null
+++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java
@@ -0,0 +1,98 @@
+package apelet.tenantadmin.upms.model;
+
+import apelet.common.core.base.model.BaseModel;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.Date;
+
+/**
+ * 用户对外密钥实体对象。
+ * 一条记录绑定唯一的 (userId, appId):userId 决定"你是谁",appId 决定"你能调哪些开放接口"。
+ * keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要,对外凭证形态为 "keyId.secret"。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName(value = "xy_sys_user_secret")
+public class SysUserSecret extends BaseModel {
+
+ /**
+ * 主键Id。
+ */
+ @TableId(value = "user_secret_id")
+ private Long userSecretId;
+
+ /**
+ * 对外可见的密钥标识(明文,用于定位与审计)。
+ */
+ @TableField(value = "key_id")
+ private String keyId;
+
+ /**
+ * 绑定的用户Id。
+ */
+ @TableField(value = "user_id")
+ private Long userId;
+
+ /**
+ * 绑定的OpenAPI应用Id(决定该密钥可调用的开放接口范围)。
+ */
+ @TableField(value = "app_id")
+ private Long appId;
+
+ /**
+ * 密钥口令的SHA-256十六进制摘要。
+ */
+ @TableField(value = "secret_hash")
+ private String secretHash;
+
+ /**
+ * 状态(1: 启用 0: 停用)。
+ */
+ @TableField(value = "status")
+ private Integer status;
+
+ /**
+ * 过期时间(NULL表示永不过期)。
+ */
+ @TableField(value = "expire_time")
+ private Date expireTime;
+
+ /**
+ * 最后一次使用时间。
+ */
+ @TableField(value = "last_used_time")
+ private Date lastUsedTime;
+
+ /**
+ * 逻辑删除标记字段(1: 正常 -1: 已删除)。
+ */
+ @TableLogic
+ @TableField(value = "deleted_flag")
+ private Integer deletedFlag;
+
+ /**
+ * 绑定的账号(非数据库字段,仅用于列表展示)。
+ */
+ @TableField(exist = false)
+ private String loginName;
+
+ /**
+ * 绑定的账号显示名(非数据库字段,仅用于列表展示)。
+ */
+ @TableField(exist = false)
+ private String showName;
+
+ /**
+ * 绑定的账号手机号(非数据库字段,仅用于列表展示,需脱敏)。
+ */
+ @TableField(exist = false)
+ private String mobile;
+}
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java
new file mode 100644
index 0000000..215505b
--- /dev/null
+++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java
@@ -0,0 +1,44 @@
+package apelet.tenantadmin.upms.model.constant;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 用户对外密钥状态常量对象。
+ *
+ * @author chenchuchuan
+ * @date 2026-10-14
+ */
+public final class SysUserSecretStatus {
+
+ /**
+ * 已停用。
+ */
+ public static final int STATUS_DISABLED = 0;
+ /**
+ * 启用中。
+ */
+ public static final int STATUS_ENABLED = 1;
+
+ private static final Map
+ * 账号信息(手机号/是否可用)依赖应用的账号体系,通过 {@link McpAccountProvider} SPI 获取;
+ * 承载应用信息则直接使用本模块的 {@link OpenApiAppService}。
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Slf4j
-@MyDataSource(DataSourceType.TENANT_ADMIN)
@Service("sysUserSecretService")
public class SysUserSecretServiceImpl extends BaseService implements SysUserSecretService {
- /**
- * 生成 keyId 与 secret 用的字符集。只使用大小写字母和数字,便于复制粘贴。
- */
- private static final String RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
- private static final int KEY_ID_LENGTH = 32;
- private static final int SECRET_LENGTH = 32;
+ private static final int APP_STATUS_ENABLED = 1;
+
/**
* 密钥属于长期凭证,必须使用密码学安全的随机源。
*/
@@ -60,9 +58,15 @@ public class SysUserSecretServiceImpl extends BaseService i
@Autowired
private SysUserSecretMapper sysUserSecretMapper;
@Autowired
- private SysUserService sysUserService;
- @Autowired
- private IdGeneratorWrapper idGenerator;
+ private OpenApiAppService openApiAppService;
+ @Autowired(required = false)
+ private McpAccountProvider mcpAccountProvider;
+
+ /**
+ * 人员密钥的承载应用编码。所有人员密钥共用一个 OpenAPI 应用,由它承载"能调哪些开放接口"的授权关系。
+ */
+ @Value("${apelet.mcp.app-code:}")
+ private String mcpAppCode;
/**
* 返回主对象的Mapper对象。
@@ -75,30 +79,40 @@ public class SysUserSecretServiceImpl extends BaseService i
}
@Override
- public List getSysUserSecretList(SysUserSecret filter, String orderBy) {
- return sysUserSecretMapper.getSysUserSecretList(filter, orderBy);
+ public List getSysUserSecretList(SysUserSecret filter) {
+ SysUserSecret condition = new SysUserSecret();
+ if (filter != null) {
+ condition.setUserId(filter.getUserId());
+ condition.setAppId(filter.getAppId());
+ condition.setStatus(filter.getStatus());
+ }
+ QueryWrapper wrapper = new QueryWrapper<>(condition);
+ wrapper.orderByDesc("create_time");
+ return this.list(wrapper);
}
@Override
- public SysUserSecretIssueVo issue(Long userId, Long appId, Date expireTime) {
- SysUser user = sysUserService.getById(userId);
- if (user == null) {
+ public SysUserSecretIssueVo issue(Long userId, Date expireTime) {
+ McpAccountInfo account = this.getAccount(userId);
+ if (account == null) {
throw new MyRuntimeException("签发失败,指定的用户不存在!");
}
- if (StrUtil.isBlank(user.getMobile())) {
+ if (StrUtil.isBlank(account.getMobile())) {
throw new MyRuntimeException("签发失败,该账号未维护手机号,无法用于外部调用身份比对!");
}
- String keyId = this.randomString(KEY_ID_LENGTH);
- String secret = this.randomString(SECRET_LENGTH);
+ this.assertNoSecretIssued(userId);
+ Long appId = this.resolveMcpAppId();
+ String keyId = this.randomString(SysUserSecretSpec.KEY_ID_LENGTH);
+ String secret = this.randomString(SysUserSecretSpec.SECRET_LENGTH);
SysUserSecret userSecret = new SysUserSecret();
- userSecret.setUserSecretId(idGenerator.nextLongId());
+ userSecret.setUserSecretId(IdUtil.getSnowflakeNextId());
userSecret.setKeyId(keyId);
userSecret.setUserId(userId);
userSecret.setAppId(appId);
userSecret.setSecretHash(this.sha256Hex(secret));
userSecret.setStatus(SysUserSecretStatus.STATUS_ENABLED);
userSecret.setExpireTime(expireTime);
- MyModelUtil.fillCommonsForInsert(userSecret);
+ this.fillCommonsWithCurrentUser(userSecret, true);
userSecret.setDeletedFlag(GlobalDeletedFlag.NORMAL);
sysUserSecretMapper.insert(userSecret);
return this.buildIssueVo(userSecret, secret);
@@ -110,11 +124,11 @@ public class SysUserSecretServiceImpl extends BaseService i
if (original == null) {
return null;
}
- String secret = this.randomString(SECRET_LENGTH);
+ String secret = this.randomString(SysUserSecretSpec.SECRET_LENGTH);
SysUserSecret updating = new SysUserSecret();
updating.setUserSecretId(userSecretId);
updating.setSecretHash(this.sha256Hex(secret));
- MyModelUtil.fillCommonsForUpdate(updating, original);
+ this.fillCommonsWithCurrentUser(updating, false);
sysUserSecretMapper.updateById(updating);
return this.buildIssueVo(original, secret);
}
@@ -131,10 +145,26 @@ public class SysUserSecretServiceImpl extends BaseService i
SysUserSecret updating = new SysUserSecret();
updating.setUserSecretId(userSecretId);
updating.setStatus(status);
- MyModelUtil.fillCommonsForUpdate(updating, original);
+ this.fillCommonsWithCurrentUser(updating, false);
return sysUserSecretMapper.updateById(updating) == 1;
}
+ /**
+ * 校验该账号是否已经生成过对外密钥。
+ * 全生命周期只允许生成一次:不论已有记录是启用、停用还是已过期,都拒绝再次生成。
+ * 需要更换密钥时由管理员执行重置(轮换 secret,keyId 不变),或由管理员停用后另行处理。
+ * 这样"密钥 ⇄ 人"始终一一对应,吊销、轮换、审计都不会有歧义。
+ *
+ * @param userId 用户Id。
+ */
+ private void assertNoSecretIssued(Long userId) {
+ SysUserSecret condition = new SysUserSecret();
+ condition.setUserId(userId);
+ if (!this.list(new QueryWrapper<>(condition)).isEmpty()) {
+ throw new MyRuntimeException("该账号已生成过对外密钥,不可重复生成;如需重置请联系管理员!");
+ }
+ }
+
@Override
public ResponseResult authenticate(String mobile, String credential) {
if (StrUtil.hasBlank(mobile, credential)) {
@@ -164,23 +194,74 @@ public class SysUserSecretServiceImpl extends BaseService i
if (userSecret.getExpireTime() != null && userSecret.getExpireTime().before(new Date())) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证已过期,请联系管理员!");
}
- SysUser user = sysUserService.getById(userSecret.getUserId());
- if (user == null) {
+ McpAccountInfo account = this.getAccount(userSecret.getUserId());
+ if (account == null) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!");
}
- if (user.getUserStatus() != null && user.getUserStatus() == SysUserStatus.STATUS_LOCKED) {
- return ResponseResult.error(ErrorCodeEnum.INVALID_USER_STATUS, "登录失败,用户账号被锁定!");
+ if (Boolean.FALSE.equals(account.getEnabled())) {
+ return ResponseResult.error(ErrorCodeEnum.INVALID_USER_STATUS, "账号不可用,请联系管理员!");
}
- if (!Objects.equals(mobile, user.getMobile())) {
+ if (!Objects.equals(mobile, account.getMobile())) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证与用户不匹配!");
}
- userSecret.setLoginName(user.getLoginName());
- userSecret.setShowName(user.getShowName());
+ userSecret.setLoginName(account.getLoginName());
+ userSecret.setShowName(account.getShowName());
this.touchLastUsedTime(userSecret.getUserSecretId());
return ResponseResult.success(userSecret);
}
/**
+ * 解析人员密钥的承载应用Id。
+ * 所有人员密钥共用一个 OpenAPI 应用(配置项 {@code apelet.mcp.app-code}):该应用本身不对任何调用方分发
+ * appSecret,只用于承载"这批密钥能调哪些开放接口"的授权关系。
+ *
+ * @return 承载应用的主键Id。
+ */
+ private Long resolveMcpAppId() {
+ if (StrUtil.isBlank(mcpAppCode)) {
+ throw new MyRuntimeException("签发失败,未配置 MCP 承载应用编码(apelet.mcp.app-code)!");
+ }
+ OpenApiApp appFilter = new OpenApiApp();
+ appFilter.setAppCode(mcpAppCode.trim());
+ OpenApiApp app = openApiAppService.getOne(new QueryWrapper<>(appFilter));
+ if (app == null || app.getStatus() == null || app.getStatus() != APP_STATUS_ENABLED) {
+ throw new MyRuntimeException("签发失败,配置的 MCP 承载应用不存在或已停用,appCode=" + mcpAppCode + "!");
+ }
+ return app.getId();
+ }
+
+ /**
+ * 按用户Id取账号信息。
+ *
+ * @param userId 用户Id。
+ * @return 账号信息;未提供 SPI 实现或账号不存在时返回null。
+ */
+ private McpAccountInfo getAccount(Long userId) {
+ if (mcpAccountProvider == null || userId == null) {
+ return null;
+ }
+ return mcpAccountProvider.getById(userId);
+ }
+
+ /**
+ * 填充审计字段。应用侧可能没有登录态(如直调工具),因此不依赖 TokenData 一定存在。
+ *
+ * @param data 实体对象。
+ * @param isInsert 是否为新增。
+ */
+ private void fillCommonsWithCurrentUser(SysUserSecret data, boolean isInsert) {
+ Date now = new Date();
+ TokenData tokenData = TokenData.takeFromRequest();
+ Long currentUserId = tokenData == null ? null : tokenData.getUserId();
+ if (isInsert) {
+ data.setCreateTime(now);
+ data.setCreateUserId(currentUserId);
+ }
+ data.setUpdateTime(now);
+ data.setUpdateUserId(currentUserId);
+ }
+
+ /**
* 更新最后一次使用时间。该动作不参与鉴权结果,失败只记日志。
*
* @param userSecretId 密钥记录主键Id。
@@ -206,14 +287,30 @@ public class SysUserSecretServiceImpl extends BaseService i
return result;
}
+ /**
+ * 生成指定长度的随机串,字符集见 {@link SysUserSecretSpec#RANDOM_ALPHABET}。
+ * 使用 {@link SecureRandom} 而非普通随机数:密钥是长期凭证,可预测的随机源等于没有随机。
+ * 长度等规格是安全参数,集中在 {@link SysUserSecretSpec},不接配置项。
+ *
+ * @param length 目标长度。
+ * @return 随机串。
+ */
private String randomString(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
- sb.append(RANDOM_ALPHABET.charAt(SECURE_RANDOM.nextInt(RANDOM_ALPHABET.length())));
+ sb.append(SysUserSecretSpec.RANDOM_ALPHABET.charAt(SECURE_RANDOM.nextInt(SysUserSecretSpec.RANDOM_ALPHABET.length())));
}
return sb.toString();
}
+ /**
+ * 计算 secret 的 SHA-256 十六进制摘要。
+ * 刻意不用 BCrypt:该摘要每个请求都要参与校验,BCrypt 单次约百毫秒不可接受;
+ * 32 位随机串的熵足够,无需靠慢哈希抵抗爆破。
+ *
+ * @param text 待摘要的明文。
+ * @return 64 位十六进制摘要。
+ */
private String sha256Hex(String text) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java b/common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java
similarity index 66%
rename from application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java
rename to common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java
index a215cc1..67583dc 100644
--- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java
+++ b/common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java
@@ -1,6 +1,5 @@
-package apelet.tenantadmin.upms.vo;
+package apelet.common.openapi.vo;
-import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
@@ -18,30 +17,25 @@ public class SysUserSecretIssueVo {
/**
* 主键Id。
*/
- @Schema(description = "主键Id")
private Long userSecretId;
/**
* 密钥标识(明文,可用于定位与审计)。
*/
- @Schema(description = "密钥标识")
private String keyId;
/**
* 密钥口令明文,仅本次响应可见。
*/
- @Schema(description = "密钥口令明文,仅本次响应可见,请妥善保存")
private String secret;
/**
* 完整凭证(keyId + "." + secret),可直接交给调用方配置。
*/
- @Schema(description = "完整凭证,格式为 keyId.secret")
private String credential;
/**
* 过期时间(null表示永不过期)。
*/
- @Schema(description = "过期时间,null表示永不过期")
private Date expireTime;
}
From 080f3ce61b122f2a9a4d7b23fdcbe3ea205dace0 Mon Sep 17 00:00:00 2001
From: chenchuchuan <13554600537@163.com>
Date: Tue, 20 Oct 2026 10:28:31 +0800
Subject: [PATCH 4/4] =?UTF-8?q?refactor(common):=20=E7=A7=BB=E9=99=A4commo?=
=?UTF-8?q?n-botp=E6=A8=A1=E5=9D=97=E5=BC=95=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 从common/pom.xml中删除了common-botp模块的声明
- 该模块已从项目结构中移除
---
common/pom.xml | 1 -
1 file changed, 1 deletion(-)
diff --git a/common/pom.xml b/common/pom.xml
index cd7c4aa..a2c592f 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -15,7 +15,6 @@
common-flow
common-flow-online
common-online
- common-botp
common-tenant
common-tenant-online