Browse Source

Merge branch 'main-stable' of http://124.222.6.97:3000/sunquan/apelet_tenant into dev-lml

dev-lml
chenchuchuan 2 weeks ago
parent
commit
70cad812e4
  1. 10
      application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java
  2. 67
      application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAccountProviderImpl.java
  3. 106
      application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAuthServiceImpl.java
  4. 198
      application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java
  5. 13
      application-tenant/tenant-admin/src/main/resources/tenant-admin-dev.yml
  6. 42
      common/common-core/src/main/java/apelet/common/core/mcp/auth/config/McpWebMvcConfig.java
  7. 142
      common/common-core/src/main/java/apelet/common/core/mcp/auth/interceptor/McpAuthInterceptor.java
  8. 33
      common/common-core/src/main/java/apelet/common/core/mcp/auth/session/UserSessionContextService.java
  9. 91
      common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountInfo.java
  10. 31
      common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountProvider.java
  11. 56
      common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthResult.java
  12. 40
      common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthService.java
  13. 4
      common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java
  14. 67
      common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java
  15. 4
      common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java
  16. 41
      common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretSpec.java
  17. 44
      common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretStatus.java
  18. 245
      common/common-openapi/src/main/java/apelet/common/openapi/controller/SysUserSecretController.java
  19. 13
      common/common-openapi/src/main/java/apelet/common/openapi/dao/SysUserSecretMapper.java
  20. 98
      common/common-openapi/src/main/java/apelet/common/openapi/model/SysUserSecret.java
  21. 76
      common/common-openapi/src/main/java/apelet/common/openapi/service/SysUserSecretService.java
  22. 322
      common/common-openapi/src/main/java/apelet/common/openapi/service/impl/SysUserSecretServiceImpl.java
  23. 41
      common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java
  24. 1
      common/pom.xml

10
application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java

@ -1,5 +1,6 @@
package apelet.tenantadmin.config; package apelet.tenantadmin.config;
import apelet.common.core.mcp.auth.interceptor.McpAuthInterceptor;
import apelet.common.openapi.config.OpenApiProperties; import apelet.common.openapi.config.OpenApiProperties;
import apelet.common.openapi.interceptor.OpenApiAuthInterceptor; import apelet.common.openapi.interceptor.OpenApiAuthInterceptor;
import apelet.tenantadmin.interceptor.AuthenticationInterceptor; import apelet.tenantadmin.interceptor.AuthenticationInterceptor;
@ -32,7 +33,14 @@ public class InterceptorConfig implements WebMvcConfigurer {
// {prefix}/auth/token 是公开的签发接口。这里一并排除,避免调用方误带 Authorization 头时 // {prefix}/auth/token 是公开的签发接口。这里一并排除,避免调用方误带 Authorization 头时
// 被登录态拦截器抢先拦下(它注册在前,会先执行)。 // 被登录态拦截器抢先拦下(它注册在前,会先执行)。
.excludePathPatterns(openApiPrefix + "/v1/**") .excludePathPatterns(openApiPrefix + "/v1/**")
.excludePathPatterns(openApiPrefix + "/auth/**"); .excludePathPatterns(openApiPrefix + "/auth/**")
// MCP 端点由 core 的 McpAuthInterceptor 独立鉴权(支持登录态与人员密钥两种凭证),
// 该拦截器由 common-core 的 McpWebMvcConfig 在 apelet.mcp.enabled=true 时自动注册。
// 这里必须排除:HandlerInterceptor 没有"我处理完了后续跳过"的机制,若不排除,
// 未带 Authorization 的密钥调用会被登录态拦截器直接 401。
// 注意两条都要写:/** 并不匹配不带尾斜杠的端点本身。
.excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH)
.excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**");
// OpenAPI 对外开放路由鉴权({prefix}/v1/**):校验 accessToken 并把 TokenData 注入请求上下文。 // OpenAPI 对外开放路由鉴权({prefix}/v1/**):校验 accessToken 并把 TokenData 注入请求上下文。
// 注意 HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上; // 注意 HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上;
// common-openapi.operationEnabled=false 时该 bean 不存在,getIfAvailable() 返回 null,跳过注册即可。 // common-openapi.operationEnabled=false 时该 bean 不存在,getIfAvailable() 返回 null,跳过注册即可。

67
application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAccountProviderImpl.java

@ -0,0 +1,67 @@
package apelet.tenantadmin.mcp;
import apelet.common.core.mcp.auth.spi.McpAccountInfo;
import apelet.common.core.mcp.auth.spi.McpAccountProvider;
import apelet.tenantadmin.upms.model.SysUser;
import apelet.tenantadmin.upms.model.constant.SysUserStatus;
import apelet.tenantadmin.upms.model.constant.SysUserType;
import apelet.tenantadmin.upms.service.SysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* MCP 账号信息查询 SPI 的租户端实现。
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Service("mcpAccountProvider")
public class TenantMcpAccountProviderImpl implements McpAccountProvider {
@Autowired
private SysUserService sysUserService;
@Override
public McpAccountInfo getById(Long userId) {
if (userId == null) {
return null;
}
return this.convert(sysUserService.getById(userId));
}
@Override
public List<McpAccountInfo> listByIds(Collection<Long> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyList();
}
return sysUserService.listByIds(userIds).stream().map(this::convert).collect(Collectors.toList());
}
/**
* 把应用侧的账号模型折算成 core 侧的账号信息。
*
* @param user 账号对象。
* @return 账号信息;入参为null时返回null。
*/
private McpAccountInfo convert(SysUser user) {
if (user == null) {
return null;
}
McpAccountInfo info = new McpAccountInfo();
info.setUserId(user.getUserId());
info.setLoginName(user.getLoginName());
info.setShowName(user.getShowName());
info.setDeptId(user.getDeptId());
info.setMobile(user.getMobile());
info.setHeadImageUrl(user.getHeadImageUrl());
// 用户类型/状态枚举属应用侧概念,在这里折算成布尔值交给 core。
info.setIsAdmin(user.getUserType() != null && user.getUserType() == SysUserType.TYPE_ADMIN);
info.setEnabled(user.getUserStatus() == null || user.getUserStatus() != SysUserStatus.STATUS_LOCKED);
return info;
}
}

106
application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAuthServiceImpl.java

@ -0,0 +1,106 @@
package apelet.tenantadmin.mcp;
import apelet.common.core.constant.AppDeviceType;
import apelet.common.core.mcp.auth.session.UserSessionContextService;
import apelet.common.core.mcp.auth.spi.McpAuthResult;
import apelet.common.core.mcp.auth.spi.McpAuthService;
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.model.SysUserSecret;
import apelet.common.openapi.service.OpenApiAppService;
import apelet.common.openapi.service.SysUserSecretService;
import apelet.tenantadmin.config.ApplicationConfig;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import io.jsonwebtoken.Claims;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* MCP 鉴权 SPI 的租户端实现。
* <p>两条分支的落点一致:都产出一份 {@code TokenData},由拦截器注入请求上下文,下游无感知。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Service("mcpAuthService")
public class TenantMcpAuthServiceImpl implements McpAuthService {
private static final int APP_STATUS_ENABLED = 1;
/**
* 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 OpenApiAppService openApiAppService;
@Autowired
private UserSessionContextService userSessionContextService;
@Override
public String getBearerHeaderName() {
return appConfig.getTokenHeaderKey();
}
@Override
public McpAuthResult authenticateByJwt(String token) {
Claims claims = JwtUtil.parseToken(token, appConfig.getTokenSigningKey());
if (JwtUtil.isNullOrExpired(claims)) {
return McpAuthResult.fail("登录会话已过期或尚未登录,请重新登录!");
}
String sessionId = (String) claims.get(CLAIM_SESSION_ID);
if (StrUtil.isBlank(sessionId)) {
return McpAuthResult.fail("登录会话无效,请重新登录!");
}
RBucket<String> sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId));
if (!sessionData.isExists()) {
return McpAuthResult.fail("登录会话已失效,请重新登录!");
}
TokenData tokenData = JSON.parseObject(sessionData.get(), TokenData.class);
if (tokenData == null) {
return McpAuthResult.fail("登录会话已失效,请重新登录!");
}
tokenData.setToken(token);
return McpAuthResult.ok(tokenData);
}
@Override
public McpAuthResult authenticateByUserSecret(String mobile, String credential) {
ResponseResult<SysUserSecret> authResult = sysUserSecretService.authenticate(mobile, credential);
if (!authResult.isSuccess() || authResult.getData() == null) {
return McpAuthResult.fail(authResult.getErrorMessage());
}
SysUserSecret userSecret = authResult.getData();
String appCode = this.resolveAppCode(userSecret.getAppId());
TokenData tokenData = userSessionContextService.buildAndCacheSession(userSecret.getUserId(), appCode, AppDeviceType.WEB);
return McpAuthResult.ok(tokenData, userSecret.getKeyId());
}
/**
* 由密钥绑定的应用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() != APP_STATUS_ENABLED) {
return null;
}
return app.getAppCode();
}
}

198
application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java

@ -0,0 +1,198 @@
package apelet.tenantadmin.upms.service.impl;
import apelet.common.core.annotation.MyDataSource;
import apelet.common.core.exception.MyRuntimeException;
import apelet.common.core.mcp.auth.session.UserSessionContextService;
import apelet.common.core.object.TokenData;
import apelet.common.core.util.ContextUtil;
import apelet.common.core.util.IpUtil;
import apelet.common.core.util.MyCommonUtil;
import apelet.common.core.util.RedisKeyUtil;
import apelet.common.datafilter.config.DataFilterProperties;
import apelet.common.redis.cache.SessionCacheHelper;
import apelet.tenantadmin.config.ApplicationConfig;
import apelet.tenantadmin.config.DataSourceType;
import apelet.tenantadmin.upms.model.SysUser;
import apelet.tenantadmin.upms.model.SysUserPost;
import apelet.tenantadmin.upms.model.SysUserRole;
import apelet.tenantadmin.upms.model.constant.SysUserType;
import apelet.tenantadmin.upms.service.SysDataPermService;
import apelet.tenantadmin.upms.service.SysPermService;
import apelet.tenantadmin.upms.service.SysPermWhitelistService;
import apelet.tenantadmin.upms.service.SysPostService;
import apelet.tenantadmin.upms.service.SysRoleService;
import apelet.tenantadmin.upms.service.SysUserService;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
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.Service;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 会话上下文构建 SPI 的租户端实现。
* <p>TokenData 的字段来源、会话Id 生成规则、会话缓存写入方式均对齐登录链路的既有做法,
* 保证"用密钥进来的身份"和"用密码登录的身份"在下游完全等价。</p>
* <p>带会话复用:同一账号在 TTL 内重复调用返回同一份会话,避免每个请求都重建
* (一次重建包含若干次查库与多次 Redis 写入)。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Slf4j
@MyDataSource(DataSourceType.TENANT_ADMIN)
@Service("userSessionContextService")
public class UserSessionContextServiceImpl implements UserSessionContextService {
/**
* 会话复用缓存前缀:一个账号在 TTL 内复用同一个会话。
*/
private static final String SESSION_REUSE_KEY_PREFIX = "MCP_SESSION:";
/**
* 会话复用TTL(秒)。刻意短于会话本身的有效期,以限制角色/数据权限变更后的缓存陈旧窗口。
*/
private static final long SESSION_REUSE_TTL_SECONDS = 1800L;
@Autowired
private ApplicationConfig appConfig;
@Autowired
private RedissonClient redissonClient;
@Autowired
private SessionCacheHelper cacheHelper;
@Autowired
private DataFilterProperties dataFilterProperties;
@Autowired
private SysUserService sysUserService;
@Autowired
private SysPostService sysPostService;
@Autowired
private SysRoleService sysRoleService;
@Autowired
private SysDataPermService sysDataPermService;
@Autowired
private SysPermService sysPermService;
@Autowired
private SysPermWhitelistService sysPermWhitelistService;
@Override
public TokenData buildAndCacheSession(Long userId, String appCode, int deviceType) {
RBucket<String> reuseBucket = redissonClient.getBucket(SESSION_REUSE_KEY_PREFIX + userId);
String cachedSessionId = reuseBucket.get();
if (StrUtil.isNotBlank(cachedSessionId)) {
RBucket<String> cachedSession = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(cachedSessionId));
if (cachedSession.isExists()) {
TokenData cachedTokenData = JSON.parseObject(cachedSession.get(), TokenData.class);
if (cachedTokenData != null) {
return cachedTokenData;
}
}
}
SysUser user = sysUserService.getById(userId);
if (user == null) {
throw new MyRuntimeException("构建会话失败,账号不存在!");
}
String sessionId = this.generateSessionId(user.getLoginName(), deviceType);
TokenData tokenData = this.buildTokenData(user, sessionId, deviceType, appCode);
this.putSessionContext(tokenData, user);
reuseBucket.set(sessionId, SESSION_REUSE_TTL_SECONDS, TimeUnit.SECONDS);
return tokenData;
}
@Override
public void removeSessionContext(String sessionId) {
if (StrUtil.isBlank(sessionId)) {
return;
}
redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)).deleteAsync();
redissonClient.getBucket(RedisKeyUtil.makeSessionWhiteListPermKey(sessionId)).deleteAsync();
redissonClient.getKeys().deleteByPatternAsync(RedisKeyUtil.getSessionMenuPermPrefix(sessionId) + "*");
sysDataPermService.removeDataPermCache(sessionId);
sysPermService.removeUserSysPermCache(sessionId);
cacheHelper.removeAllSessionCache(sessionId);
}
/**
* 生成会话Id,规则与登录链路保持一致:登录名 + 设备类型 + UUID。
*
* @param loginName 登录名。
* @param deviceType 设备类型。
* @return 会话Id。
*/
private String generateSessionId(String loginName, int deviceType) {
return loginName + "_" + deviceType + "_" + MyCommonUtil.generateUuid();
}
/**
* 依据账号信息组装 TokenData(含部门/岗位/角色)。
*
* @param user 账号对象。
* @param sessionId 会话Id。
* @param deviceType 设备类型。
* @param appCode 第三方开放应用编码,非开放调用时传null。
* @return 组装好的 TokenData。
*/
private TokenData buildTokenData(SysUser user, String sessionId, int deviceType, String appCode) {
TokenData tokenData = new TokenData();
tokenData.setSessionId(sessionId);
tokenData.setUserId(user.getUserId());
tokenData.setDeptId(user.getDeptId());
tokenData.setLoginName(user.getLoginName());
tokenData.setShowName(user.getShowName());
tokenData.setIsAdmin(user.getUserType().equals(SysUserType.TYPE_ADMIN));
tokenData.setLoginIp(IpUtil.getRemoteIpAddress(ContextUtil.getHttpRequest()));
tokenData.setLoginTime(new Date());
tokenData.setDeviceType(deviceType);
tokenData.setHeadImageUrl(user.getHeadImageUrl());
tokenData.setAppCode(appCode);
List<SysUserPost> userPostList = sysPostService.getSysUserPostListByUserId(user.getUserId());
if (CollUtil.isNotEmpty(userPostList)) {
Set<Long> deptPostIdSet = userPostList.stream().map(SysUserPost::getDeptPostId).collect(Collectors.toSet());
tokenData.setDeptPostIds(StrUtil.join(",", deptPostIdSet));
Set<Long> postIdSet = userPostList.stream().map(SysUserPost::getPostId).collect(Collectors.toSet());
tokenData.setPostIds(StrUtil.join(",", postIdSet));
}
// 2026-07-28 角色用户关联表,增加组织,导致角色id 可能为空
List<SysUserRole> userRoleList = sysRoleService.getSysUserRoleListByUserId(user.getUserId());
if (CollUtil.isNotEmpty(userRoleList)) {
Set<Long> userRoleIdSet = userRoleList.stream().map(SysUserRole::getRoleId).filter(Objects::nonNull).collect(Collectors.toSet());
tokenData.setRoleIds(StrUtil.join(",", userRoleIdSet));
}
return tokenData;
}
/**
* 写入会话上下文缓存。
* <p>包含:SESSIONID(会话)、DATA_PERM(数据权限)、WHITELISTPERM(白名单URL)。</p>
* <p><b>不写 PERM(URL权限集合)</b>:MCP 路径已从登录态拦截器排除、不做"菜单×URL"越权校验;
* 登录链路若复用本方法需另行写入 PERM。</p>
*
* @param tokenData 已组装好的 TokenData。
* @param user 账号对象。
*/
private void putSessionContext(TokenData tokenData, SysUser user) {
String sessionId = tokenData.getSessionId();
long expiredSeconds = appConfig.getSessionExpiredSeconds();
RBucket<String> sessionBucket = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId));
sessionBucket.set(JSON.toJSONString(tokenData, SerializerFeature.WriteNonStringValueAsString));
sessionBucket.expire(expiredSeconds, TimeUnit.SECONDS);
// 数据权限缓存必须有:在线表单的 processDataPerm 读不到它会直接抛异常。
sysDataPermService.putDataPermCache(sessionId, user.getUserId(), user.getDeptId());
// 白名单URL缓存用于 verifyMenuPerm 的兜底比对,同样在读不到时抛异常。
if (BooleanUtil.isTrue(dataFilterProperties.getEnableMenuPermVerify())) {
RBucket<String> whitelistBucket = redissonClient.getBucket(RedisKeyUtil.makeSessionWhiteListPermKey(sessionId));
whitelistBucket.set(JSON.toJSONString(sysPermWhitelistService.getWhitelistPermList()));
whitelistBucket.expire(expiredSeconds, TimeUnit.SECONDS);
}
}
}

13
application-tenant/tenant-admin/src/main/resources/tenant-admin-dev.yml

@ -7,6 +7,19 @@ logging:
server: server:
port: 8075 port: 8075
apelet:
mcp:
# 是否启用 MCP 端点(/tenantadmin/mcp)。MCP 端点、工具装配与鉴权拦截器都定义在 common-core,
# 默认关闭:若不显式开启,任何引入 common-core 的应用都会暴露一个 MCP 端点。
enabled: true
# 人员密钥的承载应用编码(必填):所有人员密钥共用该应用,
# 它决定密钥能调哪些开放接口;该应用的 appSecret 严禁对外分发。
app-code: AIMCP
# 管理类工具名称前缀(逗号分隔),仅管理员可调用;用于防止人员密钥提权
admin-only-tool-prefixes: openapi_app_,openapi_config_
# 存量配置:请求上下文缺失时 MCP 工具使用的兜底用户Id
default-user-id: 0
application: application:
# Jwt令牌加密的签名值。该值的长度要超过10个字符(过短会报错)。 # Jwt令牌加密的签名值。该值的长度要超过10个字符(过短会报错)。
tokenSigningKey: tenantadmin-signing-key-signing-key tokenSigningKey: tenantadmin-signing-key-signing-key

42
common/common-core/src/main/java/apelet/common/core/mcp/auth/config/McpWebMvcConfig.java

@ -0,0 +1,42 @@
package apelet.common.core.mcp.auth.config;
import apelet.common.core.mcp.auth.interceptor.McpAuthInterceptor;
import apelet.common.core.mcp.auth.spi.McpAuthService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* MCP 端点鉴权的自动装配。
* <p>MCP 端点本身在 core,鉴权也应随端点一起生效,否则引入了 core 的应用会暴露一个未鉴权端点。
* 因此拦截器在这里自动注册,应用无需再写配置;只需要提供 {@link McpAuthService} 实现。</p>
* <p><b>开关</b>:仅当 {@code apelet.mcp.enabled=true} 时生效。默认关闭,避免引入 core 的应用
* (如同步服务、鉴权中心等)无意间暴露 MCP 端点。</p>
* <p><b>应用侧仍需做一件事</b>:若应用有自己的登录态拦截器注册在 {@code /tenantadmin/**} 上,
* 必须把 {@code /tenantadmin/mcp} 与 {@code /tenantadmin/mcp/**} 排除,否则未带登录令牌的密钥调用
* 会被登录态拦截器抢先拒绝。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Configuration
@ConditionalOnProperty(name = "apelet.mcp.enabled", havingValue = "true")
public class McpWebMvcConfig implements WebMvcConfigurer {
private final ObjectProvider<McpAuthService> mcpAuthServiceProvider;
public McpWebMvcConfig(ObjectProvider<McpAuthService> mcpAuthServiceProvider) {
this.mcpAuthServiceProvider = mcpAuthServiceProvider;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上。
// getIfAvailable() 允许应用暂时不实现 SPI:此时拦截器仍会注册,但所有请求一律 401(fail-closed)。
registry.addInterceptor(new McpAuthInterceptor(mcpAuthServiceProvider.getIfAvailable()))
.addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH)
.addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**");
}
}

142
common/common-core/src/main/java/apelet/common/core/mcp/auth/interceptor/McpAuthInterceptor.java

@ -0,0 +1,142 @@
package apelet.common.core.mcp.auth.interceptor;
import apelet.common.core.constant.ApplicationConstant;
import apelet.common.core.constant.ErrorCodeEnum;
import apelet.common.core.mcp.auth.spi.McpAuthResult;
import apelet.common.core.mcp.auth.spi.McpAuthService;
import apelet.common.core.object.ResponseResult;
import apelet.common.core.object.TokenData;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
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.HttpServletResponse;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.IOException;
import java.io.PrintWriter;
/**
* MCP 端点({@code /tenantadmin/mcp})的鉴权拦截器。
* <p>支持两种调用方身份,按顺序判定:</p>
* <ol>
* <li>{@code Authorization: Bearer <jwt>} —— 登录态,兼容以登录 token 连接 MCP 的现有调用方;</li>
* <li>人员标识头 + 人员密钥头 —— 人员级凭证,锁定到具体账号(头名称由 {@link McpAuthService} 约定)。</li>
* </ol>
* <p>两者都带时以 Bearer 优先,避免调用方主动降级到密钥分支。</p>
* <p>凭证校验全部委托给 {@link McpAuthService}:JWT 需要应用的签名密钥,密钥需要账号表与应用表,
* 这些都属于应用侧能力,core 不直接依赖。</p>
* <p><b>本拦截器由 {@code McpWebMvcConfig} 自动注册</b>,应用只需提供 {@link McpAuthService} 实现。
* 若应用未实现该 SPI,所有请求一律 401(fail-closed)。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Slf4j
public class McpAuthInterceptor implements HandlerInterceptor {
/**
* MCP 端点路径。该路径同时被 {@code McpStreamableController} 使用,两边必须保持一致。
*/
public static final String MCP_ENDPOINT_PATH = "/tenantadmin/mcp";
/**
* 人员钥匙凭证请求头的默认名称;实际名称由 {@link McpAuthService} 约定,此处仅用于读取。
*/
public static final String HEADER_MCP_USER = "X-Mcp-User";
public static final String HEADER_MCP_SECRET = "X-Mcp-Secret";
/**
* 登录态请求头兜底名称(MCP 规范要求使用 Authorization)。
*/
private static final String DEFAULT_BEARER_HEADER = "Authorization";
private final McpAuthService mcpAuthService;
public McpAuthInterceptor(McpAuthService mcpAuthService) {
this.mcpAuthService = mcpAuthService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (mcpAuthService == null) {
log.error("MCP 端点鉴权未配置:应用未提供 McpAuthService 实现。");
this.outputUnauthorized(response, "MCP 鉴权未配置,请联系管理员!");
return false;
}
String bearerHeader = StrUtil.blankToDefault(mcpAuthService.getBearerHeaderName(), DEFAULT_BEARER_HEADER);
String authorization = request.getHeader(bearerHeader);
McpAuthResult authResult;
if (StrUtil.isNotBlank(authorization)) {
authResult = mcpAuthService.authenticateByJwt(authorization);
} 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, "缺少调用凭证,请提供登录令牌或人员密钥!");
return false;
}
authResult = mcpAuthService.authenticateByUserSecret(mobile, credential);
}
if (authResult == null || !authResult.isSuccess() || authResult.getTokenData() == null) {
String message = authResult == null ? "鉴权失败!" : authResult.getErrorMessage();
log.warn("MCP 鉴权失败,keyId={}, 原因={}", authResult == null ? null : authResult.getKeyId(), message);
this.outputUnauthorized(response, message);
return false;
}
TokenData.addToRequest(authResult.getTokenData());
// 在线表单引擎需要 MenuId 头来选取数据权限规则分支;MCP 无菜单上下文,统一按"通用规则"处理。
this.wrapRequestWithMenuId(request);
return true;
}
/**
* 用 HttpServletRequestWrapper 覆盖 MenuId 请求头。
* <p>拦截器无法修改已传入的 request 对象,只能包装后替换 RequestContextHolder 中的引用,
* 使下游通过 ContextUtil.getHttpRequest().getHeader("MenuId") 能取到值。</p>
*
* @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);
}
}
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();
}
}

33
common/common-core/src/main/java/apelet/common/core/mcp/auth/session/UserSessionContextService.java

@ -0,0 +1,33 @@
package apelet.common.core.mcp.auth.session;
import apelet.common.core.object.TokenData;
/**
* 会话上下文构建 SPI。
* <p>登录成功后需要写入 Redis 的那组缓存(会话、数据权限、白名单等)依赖应用的账号/角色/权限体系,
* core 不能直接实现,故只定义契约。</p>
* <p>实现方应自带会话复用:同一账号在有效期内重复调用应返回同一份会话,避免每个请求都重建
* (一次重建包含若干次查库与多次 Redis 写入)。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public interface UserSessionContextService {
/**
* 为指定账号构建会话上下文并写入缓存。
*
* @param userId 用户Id。
* @param appCode 第三方开放应用编码,非开放调用时传null。
* @param deviceType 设备类型。
* @return 可直接注入请求上下文的 TokenData。
*/
TokenData buildAndCacheSession(Long userId, String appCode, int deviceType);
/**
* 清理指定会话的全部上下文缓存。
*
* @param sessionId 会话Id。
*/
void removeSessionContext(String sessionId);
}

91
common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountInfo.java

@ -0,0 +1,91 @@
package apelet.common.core.mcp.auth.spi;
/**
* MCP 鉴权所需的账号信息。
* <p>只暴露构建 {@code TokenData} 与做手机号比对所必需的字段,不暴露密码等敏感信息。
* {@code isAdmin} 由应用侧按自己的用户类型枚举计算后填入,避免 core 依赖具体应用的枚举。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public class McpAccountInfo {
private Long userId;
private String loginName;
private String showName;
private Long deptId;
private String mobile;
private String headImageUrl;
/**
* 是否管理员。应用侧按自己的用户类型判定后填入。
*/
private Boolean isAdmin;
/**
* 账号当前是否可用(未锁定/未停用)。应用侧按自己的用户状态判定后填入。
*/
private Boolean enabled;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getLoginName() {
return loginName;
}
public void setLoginName(String loginName) {
this.loginName = loginName;
}
public String getShowName() {
return showName;
}
public void setShowName(String showName) {
this.showName = showName;
}
public Long getDeptId() {
return deptId;
}
public void setDeptId(Long deptId) {
this.deptId = deptId;
}
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getHeadImageUrl() {
return headImageUrl;
}
public void setHeadImageUrl(String headImageUrl) {
this.headImageUrl = headImageUrl;
}
public Boolean getIsAdmin() {
return isAdmin;
}
public void setIsAdmin(Boolean isAdmin) {
this.isAdmin = isAdmin;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
}

31
common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountProvider.java

@ -0,0 +1,31 @@
package apelet.common.core.mcp.auth.spi;
import java.util.Collection;
import java.util.List;
/**
* MCP 账号信息查询 SPI。
* <p>人员密钥的校验需要读账号的手机号与状态,密钥列表需要展示账号姓名,但账号模型属于应用侧,
* core 与 common 模块都不能直接依赖,因此由应用实现本接口注入。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public interface McpAccountProvider {
/**
* 按用户Id查询 MCP 鉴权所需的账号信息。
*
* @param userId 用户Id。
* @return 账号信息;不存在时返回null。
*/
McpAccountInfo getById(Long userId);
/**
* 按用户Id批量查询账号信息,用于列表展示补全。
*
* @param userIds 用户Id集合。
* @return 账号信息列表;入参为空时返回空列表。
*/
List<McpAccountInfo> listByIds(Collection<Long> userIds);
}

56
common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthResult.java

@ -0,0 +1,56 @@
package apelet.common.core.mcp.auth.spi;
import apelet.common.core.object.TokenData;
/**
* MCP 鉴权结果。
* <p>成功时携带已注入请求上下文所需的 {@code TokenData};失败时携带可直接返回给调用方的错误信息。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public class McpAuthResult {
private boolean success;
private TokenData tokenData;
/**
* 通过人员密钥鉴权时的 keyId,仅用于日志审计;登录态分支为null。
*/
private String keyId;
private String errorMessage;
public static McpAuthResult ok(TokenData tokenData) {
return ok(tokenData, null);
}
public static McpAuthResult ok(TokenData tokenData, String keyId) {
McpAuthResult result = new McpAuthResult();
result.success = true;
result.tokenData = tokenData;
result.keyId = keyId;
return result;
}
public static McpAuthResult fail(String errorMessage) {
McpAuthResult result = new McpAuthResult();
result.success = false;
result.errorMessage = errorMessage;
return result;
}
public boolean isSuccess() {
return success;
}
public TokenData getTokenData() {
return tokenData;
}
public String getKeyId() {
return keyId;
}
public String getErrorMessage() {
return errorMessage;
}
}

40
common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthService.java

@ -0,0 +1,40 @@
package apelet.common.core.mcp.auth.spi;
/**
* MCP 端点鉴权 SPI。
* <p>MCP 端点在 core({@code McpStreamableController}),但两种凭证的校验都依赖应用侧能力:
* 登录态 JWT 需要应用的签名密钥与会话存储,人员密钥需要账号表与应用表。
* 因此 core 只定义契约,由应用实现。</p>
* <p><b>实现方必须 fail-closed</b>:任何无法识别的凭证都要返回失败,不能放行。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public interface McpAuthService {
/**
* 登录态 JWT 所在的请求头名称。
* <p>MCP 规范要求使用 {@code Authorization};由应用返回自己的配置值,避免 core 硬编码。
* 应用未配置时返回 null,拦截器按 {@code Authorization} 兜底。</p>
*
* @return 请求头名称。
*/
String getBearerHeaderName();
/**
* 校验登录态 JWT 并取回会话中的身份。
*
* @param token 请求头中的完整取值(含 Bearer 前缀)。
* @return 鉴权结果。
*/
McpAuthResult authenticateByJwt(String token);
/**
* 校验"手机号 + 人员密钥"。
*
* @param mobile 调用方声明的手机号。
* @param credential 完整凭证,格式为 "keyId.secret"。
* @return 鉴权结果。
*/
McpAuthResult authenticateByUserSecret(String mobile, String credential);
}

4
common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java

@ -7,6 +7,7 @@ import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.RouterFunction;
@ -20,11 +21,14 @@ import java.util.List;
* 该 SDK 不提供 listTools(),故注册时同步收集一份工具清单供调试接口读取(见 McpToolScanner.getTools())。 * 该 SDK 不提供 listTools(),故注册时同步收集一份工具清单供调试接口读取(见 McpToolScanner.getTools())。
* 双传输并存:SSE(/mcp/sse + /mcp/message)+ Streamable HTTP(/mcp 单端点)。 * 双传输并存:SSE(/mcp/sse + /mcp/message)+ Streamable HTTP(/mcp 单端点)。
* 工具由 McpToolScanner 启动时扫描 @McpToolClass 自动生成并统一注入。 * 工具由 McpToolScanner 启动时扫描 @McpToolClass 自动生成并统一注入。
* <p>仅在 {@code apelet.mcp.enabled=true} 时装配:本类位于 common-core,引入 core 的应用不应
* 无条件获得 MCP 端点。</p>
* *
* @author chenchuchuan * @author chenchuchuan
* @date 2026-08-27 * @date 2026-08-27
*/ */
@Configuration @Configuration
@ConditionalOnProperty(name = "apelet.mcp.enabled", havingValue = "true")
public class McpToolConfig { public class McpToolConfig {
/** /**

67
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}") @Value("${apelet.mcp.default-user-id:0}")
private Long defaultUserId; private Long defaultUserId;
/** /**
* 管理类工具的名称前缀(逗号分隔),命中者仅允许管理员调用。默认覆盖 OpenAPI 应用/接口配置管理工具。
*/
@Value("${apelet.mcp.admin-only-tool-prefixes:openapi_app_,openapi_config_}")
private String adminOnlyToolPrefixes;
/**
* 已组装好的工具注册项(供 spec.tools 批量注入)。 * 已组装好的工具注册项(供 spec.tools 批量注入)。
*/ */
@Getter @Getter
@ -326,9 +331,18 @@ public class McpToolScanner {
*/ */
private McpSchema.CallToolResult invoke(Object bean, Method method, List<McpToolArgInfo> argInfos, Map<String, Object> args) { private McpSchema.CallToolResult invoke(Object bean, Method method, List<McpToolArgInfo> argInfos, Map<String, Object> args) {
try { try {
//todo 这里要求带上token MCP 无 HTTP 登录态,注入默认用户 TokenData,避免服务内 takeFromRequest() 拿到 null 抛 NPE。 // 优先复用当前请求上下文中的 TokenData —— HTTP 调用链路上,拦截器已完成鉴权并注入真实身份,
TokenData tokenData = new TokenData(); // 这里若再造一个空对象会把真实身份覆盖掉,导致工具以匿名身份执行、下游数据权限取不到会话。
tokenData.setUserId(this.defaultUserId); // 只有在确实没有请求上下文时(如未经 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, () -> { return this.withRequestContext(tokenData, () -> {
Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount()); Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount());
Object result = method.invoke(bean, invokeArgs); 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;
}
/**
* 工具级授权:管理类工具仅允许管理员调用。
* <p>存在这条约束的原因:HTTP 调用链路上,MCP 端点已从登录态拦截器排除、不再做"菜单×URL"越权校验,
* 若不在工具层补一道,任何能通过端点鉴权的身份(含人员密钥)都能调用应用/接口配置等管理类工具,
* 进而读出应用明文密钥、提权为应用级凭证。</p>
* <p>兜底 TokenData(匿名身份)的 isAdmin 为 null,同样会被拒绝。</p>
*
* @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, * 在指定 TokenData 的请求上下文下执行动作:当前线程没有请求上下文时挂动态代理的 request/response,
* 执行完恢复原样。 * 执行完恢复原样。
*/ */

4
common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java

@ -2,6 +2,7 @@ package apelet.common.core.mcp.controller;
import apelet.common.core.mcp.transport.StreamableHttpServerTransportProvider; import apelet.common.core.mcp.transport.StreamableHttpServerTransportProvider;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
@ -17,12 +18,15 @@ import java.nio.charset.StandardCharsets;
* Streamable HTTP 单端点(POST /tenantadmin/mcp)。 * Streamable HTTP 单端点(POST /tenantadmin/mcp)。
* 直接读写 HttpServletRequest/Response,序列化由 provider 完成并原样写入, * 直接读写 HttpServletRequest/Response,序列化由 provider 完成并原样写入,
* 不经过 Spring 消息转换器(应用 ObjectMapper 会丢 @JsonTypeInfo 的 type 判别符 / 双重编码 JsonNode)。 * 不经过 Spring 消息转换器(应用 ObjectMapper 会丢 @JsonTypeInfo 的 type 判别符 / 双重编码 JsonNode)。
* <p>仅在 {@code apelet.mcp.enabled=true} 时暴露:本类位于 common-core,任何引入 core 的应用都会被
* @ComponentScan 扫到,若无条件暴露就等于凭空多出一个未鉴权端点。</p>
* *
* @author chenchuchuan * @author chenchuchuan
* @date 2026-08-28 * @date 2026-08-28
*/ */
@RestController @RestController
@RequestMapping("/tenantadmin/mcp") @RequestMapping("/tenantadmin/mcp")
@ConditionalOnProperty(name = "apelet.mcp.enabled", havingValue = "true")
public class McpStreamableController { public class McpStreamableController {
@Autowired @Autowired

41
common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretSpec.java

@ -0,0 +1,41 @@
package apelet.common.openapi.constant;
/**
* 人员密钥的凭证规格常量。
* <p><b>本类中的参数是安全参数,不是运维参数,不要改成配置项。</b>
* 理由:secret 只存 SHA-256 摘要(不是 BCrypt 这类慢哈希,无法用计算代价补偿短口令),
* 长度就是唯一的强度来源;keyId 的长度还受数据库列宽约束。改这里等于改安全强度,
* 必须走代码评审而不是改配置。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public final class SysUserSecretSpec {
/**
* keyId 与 secret 的字符集:大小写字母 + 数字,共 62 个字符。
* <p>不用特殊符号是为了便于复制粘贴、避免转义与手输歧义。
* <b>调小字符集等于按比例削减熵</b>,例如去掉大写字母会让 32 位的熵从约 190 bit 降到约 165 bit。</p>
*/
public static final String RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
/**
* keyId 长度。
* <p><b>受存储约束</b>:`xy_sys_user_secret.key_id` 为 `varchar(32)`,调大必须同步修改建表脚本,
* 否则插入会被截断或直接报错。</p>
*/
public static final int KEY_ID_LENGTH = 32;
/**
* secret 长度。
* <p><b>这是安全强度本身</b>。32 位 × 字符集 62 ⇒ 约 190 bit 熵,远超需要。
* 若降到 8 位则只有约 48 bit,在摘要泄漏的前提下可被离线爆破。</p>
*/
public static final int SECRET_LENGTH = 32;
/**
* 私有构造函数,明确标识该常量类的作用。
*/
private SysUserSecretSpec() {
}
}

44
common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretStatus.java

@ -0,0 +1,44 @@
package apelet.common.openapi.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<Object, String> DICT_MAP = new HashMap<>(2);
static {
DICT_MAP.put(STATUS_DISABLED, "已停用");
DICT_MAP.put(STATUS_ENABLED, "启用中");
}
/**
* 判断参数是否为当前常量字典的合法值。
*
* @param value 待验证的参数值。
* @return 合法返回true,否则false。
*/
public static boolean isValid(Integer value) {
return value != null && DICT_MAP.containsKey(value);
}
/**
* 私有构造函数,明确标识该常量类的作用。
*/
private SysUserSecretStatus() {
}
}

245
common/common-openapi/src/main/java/apelet/common/openapi/controller/SysUserSecretController.java

@ -0,0 +1,245 @@
package apelet.common.openapi.controller;
import apelet.common.core.annotation.MyRequestBody;
import apelet.common.core.constant.ErrorCodeEnum;
import apelet.common.core.mcp.auth.spi.McpAccountInfo;
import apelet.common.core.mcp.auth.spi.McpAccountProvider;
import apelet.common.core.object.MyPageData;
import apelet.common.core.object.MyPageParam;
import apelet.common.core.object.ResponseResult;
import apelet.common.core.object.TokenData;
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.common.openapi.constant.SysUserSecretStatus;
import apelet.common.openapi.model.SysUserSecret;
import apelet.common.openapi.service.SysUserSecretService;
import apelet.common.openapi.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.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 用户对外密钥管理接口。
* <p><b>签发是自助的</b>:密钥只能签发给当前登录人自己(userId 取自登录态),不需要管理员代发,
* 明文也不经过第三人。绑定的 OpenAPI 应用取自配置项 {@code apelet.mcp.app-code}。</p>
* <p><b>归属校验</b>:非管理员只能看到、操作自己的密钥。这层校验是必需的——自助签发意味着这些接口
* 会对普通登录用户开放,而 {@code resetSecret} 会返回明文新密钥,若不校验归属,
* 任何人都能通过传入他人的记录Id拿到他人的凭证。</p>
* <p>响应中永远不返回 secretHash;明文 secret 只在签发/轮换的响应里出现一次,
* 且对应操作日志显式关闭了响应体记录(saveResponse=false),避免明文密钥落到日志。</p>
* <p>账号姓名/手机号属于应用侧数据,通过 {@link McpAccountProvider} SPI 补全,
* 本模块不依赖具体应用的账号模型。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Tag(name = "对外密钥管理")
@Slf4j
@RestController
@RequestMapping("${common-openapi.urlPrefix:/openapi}/userSecret")
public class SysUserSecretController {
@Autowired
private SysUserSecretService sysUserSecretService;
@Autowired(required = false)
private McpAccountProvider mcpAccountProvider;
/**
* 对外密钥分页列表。非管理员只返回自己的密钥。
*
* @param sysUserSecretFilter 过滤条件(userId/appId/status)。
* @param pageParam 分页参数。
* @return 应答结果对象,包含分页后的密钥列表。
*/
@PostMapping("/list")
public ResponseResult<MyPageData<SysUserSecret>> list(
@MyRequestBody SysUserSecret sysUserSecretFilter,
@MyRequestBody MyPageParam pageParam) {
TokenData tokenData = TokenData.takeFromRequest();
if (tokenData == null || tokenData.getUserId() == null) {
return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN);
}
SysUserSecret filter = sysUserSecretFilter == null ? new SysUserSecret() : sysUserSecretFilter;
if (!Boolean.TRUE.equals(tokenData.getIsAdmin())) {
// 非管理员只能看自己的,忽略其传入的 userId 条件。
filter.setUserId(tokenData.getUserId());
}
if (pageParam != null) {
PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize());
}
List<SysUserSecret> secretList = sysUserSecretService.getSysUserSecretList(filter);
long totalCount = 0L;
if (secretList instanceof Page) {
totalCount = ((Page<SysUserSecret>) secretList).getTotal();
}
this.fillAccountInfo(secretList);
secretList.forEach(this::maskForResponse);
return ResponseResult.success(MyPageUtil.makeResponseData(secretList, totalCount));
}
/**
* 查看指定密钥的详情。非管理员只能查看自己的。不返回 secretHash。
*
* @param userSecretId 密钥记录主键Id。
* @return 应答结果对象,包含密钥详情。
*/
@PostMapping("/view")
public ResponseResult<SysUserSecret> 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, "对外密钥不存在,请刷新后重试!");
}
if (!this.isSelfOrAdmin(userSecret)) {
return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION);
}
this.fillAccountInfo(Collections.singletonList(userSecret));
this.maskForResponse(userSecret);
return ResponseResult.success(userSecret);
}
/**
* 为当前登录人签发一把对外密钥。明文只在本响应中出现一次,之后无法再查。
* <p><b>不能标注 {@code @NoAuthInterface}</b>:本方法依赖 {@code TokenData.takeFromRequest()} 取当前用户,
* 而该方法在没有请求属性时会回退去解析请求头 {@code tokenData} 的 JSON,客户端可以伪造整个身份。
* 普通用户要能调本接口,正确做法是把本 URL 加入权限白名单(见建表脚本),
* 白名单只跳过 URL 权限校验、不跳过登录校验。</p>
*
* @param expireTime 过期时间字符串,留空表示永不过期。
* @return 应答结果对象,包含 keyId、明文 secret 与完整凭证。
*/
@OperationLog(type = SysOperationLogType.ADD, saveResponse = false)
@PostMapping("/issue")
public ResponseResult<SysUserSecretIssueVo> issue(@MyRequestBody String expireTime) {
TokenData tokenData = TokenData.takeFromRequest();
if (tokenData == null || tokenData.getUserId() == null) {
return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN);
}
Date expireDate = null;
if (StrUtil.isNotBlank(expireTime)) {
expireDate = DateUtil.parse(expireTime);
}
return ResponseResult.success(sysUserSecretService.issue(tokenData.getUserId(), expireDate));
}
/**
* 轮换指定密钥的口令:keyId 不变,旧 secret 立即失效。明文只在本响应中出现一次。
* <p>非管理员只能轮换自己的密钥——该接口会返回明文,归属校验不可省略。</p>
*
* @param userSecretId 密钥记录主键Id。
* @return 应答结果对象,包含新的明文 secret 与完整凭证。
*/
@OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false)
@PostMapping("/resetSecret")
public ResponseResult<SysUserSecretIssueVo> resetSecret(@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, "对外密钥不存在,请刷新后重试!");
}
if (!this.isSelfOrAdmin(userSecret)) {
return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION);
}
return ResponseResult.success(sysUserSecretService.resetSecret(userSecretId));
}
/**
* 启用/停用指定密钥。停用后该密钥的调用一律拒绝。非管理员只能操作自己的密钥。
*
* @param userSecretId 密钥记录主键Id。
* @param status 目标状态(1: 启用 0: 停用)。
* @return 应答结果对象。
*/
@OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false)
@PostMapping("/updateStatus")
public ResponseResult<Void> 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);
}
SysUserSecret userSecret = sysUserSecretService.getById(userSecretId);
if (userSecret == null) {
return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!");
}
if (!this.isSelfOrAdmin(userSecret)) {
return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION);
}
if (!sysUserSecretService.updateStatus(userSecretId, status)) {
return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!");
}
return ResponseResult.success();
}
/**
* 补全账号展示字段。密钥表本身不含账号信息,通过 SPI 批量补齐。
*
* @param secretList 密钥列表。
*/
private void fillAccountInfo(List<SysUserSecret> secretList) {
if (secretList == null || secretList.isEmpty() || mcpAccountProvider == null) {
return;
}
Set<Long> userIdSet = secretList.stream().map(SysUserSecret::getUserId).filter(Objects::nonNull).collect(Collectors.toSet());
if (userIdSet.isEmpty()) {
return;
}
Map<Long, McpAccountInfo> accountMap = mcpAccountProvider.listByIds(userIdSet).stream()
.collect(Collectors.toMap(McpAccountInfo::getUserId, Function.identity(), (a, b) -> a));
for (SysUserSecret userSecret : secretList) {
McpAccountInfo account = accountMap.get(userSecret.getUserId());
if (account != null) {
userSecret.setLoginName(account.getLoginName());
userSecret.setShowName(account.getShowName());
userSecret.setMobile(account.getMobile());
}
}
}
/**
* 判断当前登录人是否有权操作该密钥:管理员放行,否则要求是该密钥的绑定账号。
*
* @param userSecret 目标密钥记录。
* @return 有权操作返回true,否则false。
*/
private boolean isSelfOrAdmin(SysUserSecret userSecret) {
TokenData tokenData = TokenData.takeFromRequest();
if (tokenData == null || tokenData.getUserId() == null) {
return false;
}
if (Boolean.TRUE.equals(tokenData.getIsAdmin())) {
return true;
}
return Objects.equals(tokenData.getUserId(), userSecret.getUserId());
}
/**
* 抹掉不应对外返回的字段:密钥摘要一律清空,手机号脱敏。
*
* @param userSecret 待处理的对象。
*/
private void maskForResponse(SysUserSecret userSecret) {
userSecret.setSecretHash(null);
if (StrUtil.isNotBlank(userSecret.getMobile())) {
userSecret.setMobile(StrUtil.hide(userSecret.getMobile(), 3, 7));
}
}
}

13
common/common-openapi/src/main/java/apelet/common/openapi/dao/SysUserSecretMapper.java

@ -0,0 +1,13 @@
package apelet.common.openapi.dao;
import apelet.common.core.base.dao.BaseDaoMapper;
import apelet.common.openapi.model.SysUserSecret;
/**
* 用户对外密钥数据访问操作接口。
*
* @author chenchuchuan
* @date 2026-10-14
*/
public interface SysUserSecretMapper extends BaseDaoMapper<SysUserSecret> {
}

98
common/common-openapi/src/main/java/apelet/common/openapi/model/SysUserSecret.java

@ -0,0 +1,98 @@
package apelet.common.openapi.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;
/**
* 用户对外密钥实体对象。
* <p>一条记录绑定唯一的 (userId, appId):userId 决定"你是谁",appId 决定"你能调哪些开放接口"。
* keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要,对外凭证形态为 "keyId.secret"。</p>
*
* @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;
}

76
common/common-openapi/src/main/java/apelet/common/openapi/service/SysUserSecretService.java

@ -0,0 +1,76 @@
package apelet.common.openapi.service;
import apelet.common.core.base.service.IBaseService;
import apelet.common.core.object.ResponseResult;
import apelet.common.openapi.model.SysUserSecret;
import apelet.common.openapi.vo.SysUserSecretIssueVo;
import java.util.Date;
import java.util.List;
/**
* 用户对外密钥数据服务接口。
* <p>对外凭证形态为 "keyId.secret":keyId 明文存储用于定位,secret 只存 SHA-256 摘要。
* 校验时由 keyId 定位记录、再由手机号比对确认调用方身份,密钥持有者无法代表他人。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
public interface SysUserSecretService extends IBaseService<SysUserSecret, Long> {
/**
* 凭证中 keyId 与 secret 的分隔符。
*/
String CREDENTIAL_SEPARATOR = ".";
/**
* 获取用户对外密钥列表。
* <p>只按密钥表自身的字段过滤(用户Id/应用Id/状态),不关联账号表——账号信息由调用方按需补充。
* 排序固定为创建时间倒序,不接受外部排序串,避免拼接SQL。</p>
*
* @param filter 过滤对象。
* @return 过滤后的对外密钥列表。
*/
List<SysUserSecret> getSysUserSecretList(SysUserSecret filter);
/**
* 为指定账号签发一把对外密钥,明文 secret 只在返回值中出现一次。
* <p><b>全生命周期只允许签发一次</b>:该账号已有记录(无论启用/停用/过期)时直接拒绝,
* 更换密钥需由管理员重置。</p>
* <p>绑定的 OpenAPI 应用取自配置项 {@code apelet.mcp.app-code}:所有人员密钥共用一个承载应用,
* 由该应用在"开放接口配置"里的授权范围决定密钥能调哪些接口。</p>
*
* @param userId 绑定的用户Id。
* @param expireTime 过期时间,null表示永不过期。
* @return 签发结果,含明文 secret。
*/
SysUserSecretIssueVo issue(Long userId, Date expireTime);
/**
* 轮换密钥口令:keyId 保持不变,旧 secret 立即失效。
*
* @param userSecretId 密钥记录主键Id。
* @return 轮换结果,含新的明文 secret;记录不存在时返回null。
*/
SysUserSecretIssueVo resetSecret(Long userSecretId);
/**
* 启用/停用指定密钥。
*
* @param userSecretId 密钥记录主键Id。
* @param status 目标状态。
* @return 更新成功返回true,否则false。
*/
boolean updateStatus(Long userSecretId, Integer status);
/**
* 校验"手机号 + 密钥"。
* <p>校验顺序:keyId 定位 → 状态与有效期 → secret 摘要比对 → 手机号与绑定账号比对。
* "密钥不存在"与"secret 不匹配"返回同一错误信息,不泄露凭证存在性。</p>
*
* @param mobile 调用方声明的手机号。
* @param credential 完整凭证,格式为 keyId.secret。
* @return 校验通过时 data 为对应的密钥记录,否则返回错误。
*/
ResponseResult<SysUserSecret> authenticate(String mobile, String credential);
}

322
common/common-openapi/src/main/java/apelet/common/openapi/service/impl/SysUserSecretServiceImpl.java

@ -0,0 +1,322 @@
package apelet.common.openapi.service.impl;
import apelet.common.core.base.dao.BaseDaoMapper;
import apelet.common.core.base.service.BaseService;
import apelet.common.core.constant.ErrorCodeEnum;
import apelet.common.core.constant.GlobalDeletedFlag;
import apelet.common.core.exception.MyRuntimeException;
import apelet.common.core.mcp.auth.spi.McpAccountInfo;
import apelet.common.core.mcp.auth.spi.McpAccountProvider;
import apelet.common.core.object.ResponseResult;
import apelet.common.core.object.TokenData;
import apelet.common.openapi.constant.SysUserSecretSpec;
import apelet.common.openapi.constant.SysUserSecretStatus;
import apelet.common.openapi.dao.SysUserSecretMapper;
import apelet.common.openapi.model.OpenApiApp;
import apelet.common.openapi.model.SysUserSecret;
import apelet.common.openapi.service.OpenApiAppService;
import apelet.common.openapi.service.SysUserSecretService;
import apelet.common.openapi.vo.SysUserSecretIssueVo;
import cn.hutool.core.util.HexUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Date;
import java.util.List;
import java.util.Objects;
/**
* 用户对外密钥数据服务类。
* <p>对外凭证形态为 "keyId.secret":keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要。
* 定位永远走 keyId(唯一索引),手机号只做比对、不参与定位——因为账号表的手机号不一定有唯一约束。</p>
* <p>账号信息(手机号/是否可用)依赖应用的账号体系,通过 {@link McpAccountProvider} SPI 获取;
* 承载应用信息则直接使用本模块的 {@link OpenApiAppService}。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Slf4j
@Service("sysUserSecretService")
public class SysUserSecretServiceImpl extends BaseService<SysUserSecret, Long> implements SysUserSecretService {
private static final int APP_STATUS_ENABLED = 1;
/**
* 密钥属于长期凭证,必须使用密码学安全的随机源。
*/
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
@Autowired
private SysUserSecretMapper sysUserSecretMapper;
@Autowired
private OpenApiAppService openApiAppService;
@Autowired(required = false)
private McpAccountProvider mcpAccountProvider;
/**
* 人员密钥的承载应用编码。所有人员密钥共用一个 OpenAPI 应用,由它承载"能调哪些开放接口"的授权关系。
*/
@Value("${apelet.mcp.app-code:}")
private String mcpAppCode;
/**
* 返回主对象的Mapper对象。
*
* @return 主对象的Mapper对象。
*/
@Override
protected BaseDaoMapper<SysUserSecret> mapper() {
return sysUserSecretMapper;
}
@Override
public List<SysUserSecret> getSysUserSecretList(SysUserSecret filter) {
SysUserSecret condition = new SysUserSecret();
if (filter != null) {
condition.setUserId(filter.getUserId());
condition.setAppId(filter.getAppId());
condition.setStatus(filter.getStatus());
}
QueryWrapper<SysUserSecret> wrapper = new QueryWrapper<>(condition);
wrapper.orderByDesc("create_time");
return this.list(wrapper);
}
@Override
public SysUserSecretIssueVo issue(Long userId, Date expireTime) {
McpAccountInfo account = this.getAccount(userId);
if (account == null) {
throw new MyRuntimeException("签发失败,指定的用户不存在!");
}
if (StrUtil.isBlank(account.getMobile())) {
throw new MyRuntimeException("签发失败,该账号未维护手机号,无法用于外部调用身份比对!");
}
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(IdUtil.getSnowflakeNextId());
userSecret.setKeyId(keyId);
userSecret.setUserId(userId);
userSecret.setAppId(appId);
userSecret.setSecretHash(this.sha256Hex(secret));
userSecret.setStatus(SysUserSecretStatus.STATUS_ENABLED);
userSecret.setExpireTime(expireTime);
this.fillCommonsWithCurrentUser(userSecret, true);
userSecret.setDeletedFlag(GlobalDeletedFlag.NORMAL);
sysUserSecretMapper.insert(userSecret);
return this.buildIssueVo(userSecret, secret);
}
@Override
public SysUserSecretIssueVo resetSecret(Long userSecretId) {
SysUserSecret original = this.getById(userSecretId);
if (original == null) {
return null;
}
String secret = this.randomString(SysUserSecretSpec.SECRET_LENGTH);
SysUserSecret updating = new SysUserSecret();
updating.setUserSecretId(userSecretId);
updating.setSecretHash(this.sha256Hex(secret));
this.fillCommonsWithCurrentUser(updating, false);
sysUserSecretMapper.updateById(updating);
return this.buildIssueVo(original, secret);
}
@Override
public boolean updateStatus(Long userSecretId, Integer status) {
if (!SysUserSecretStatus.isValid(status)) {
throw new MyRuntimeException("停用失败,无效的状态参数值!");
}
SysUserSecret original = this.getById(userSecretId);
if (original == null) {
return false;
}
SysUserSecret updating = new SysUserSecret();
updating.setUserSecretId(userSecretId);
updating.setStatus(status);
this.fillCommonsWithCurrentUser(updating, false);
return sysUserSecretMapper.updateById(updating) == 1;
}
/**
* 校验该账号是否已经生成过对外密钥。
* <p><b>全生命周期只允许生成一次</b>:不论已有记录是启用、停用还是已过期,都拒绝再次生成。
* 需要更换密钥时由管理员执行重置(轮换 secret,keyId 不变),或由管理员停用后另行处理。</p>
* <p>这样"密钥 ⇄ 人"始终一一对应,吊销、轮换、审计都不会有歧义。</p>
*
* @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<SysUserSecret> authenticate(String mobile, String credential) {
if (StrUtil.hasBlank(mobile, credential)) {
return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "凭证缺失,请提供手机号与密钥!");
}
int separatorIndex = credential.indexOf(CREDENTIAL_SEPARATOR);
if (separatorIndex <= 0 || separatorIndex == credential.length() - 1) {
// 与"密钥无效"保持同一个提示,不泄露凭证格式信息。
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!");
}
String keyId = credential.substring(0, separatorIndex);
String secret = credential.substring(separatorIndex + 1);
SysUserSecret keyFilter = new SysUserSecret();
keyFilter.setKeyId(keyId);
SysUserSecret userSecret = this.getOne(new QueryWrapper<>(keyFilter));
if (userSecret == null) {
// 密钥不存在与密钥口令错误返回同一提示,避免暴露凭证是否存在。
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!");
}
String expectedHash = userSecret.getSecretHash();
if (StrUtil.isBlank(expectedHash) || !MessageDigest.isEqual(expectedHash.getBytes(StandardCharsets.UTF_8), this.sha256Hex(secret).getBytes(StandardCharsets.UTF_8))) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!");
}
if (!Objects.equals(userSecret.getStatus(), SysUserSecretStatus.STATUS_ENABLED)) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证已停用,请联系管理员!");
}
if (userSecret.getExpireTime() != null && userSecret.getExpireTime().before(new Date())) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证已过期,请联系管理员!");
}
McpAccountInfo account = this.getAccount(userSecret.getUserId());
if (account == null) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!");
}
if (Boolean.FALSE.equals(account.getEnabled())) {
return ResponseResult.error(ErrorCodeEnum.INVALID_USER_STATUS, "账号不可用,请联系管理员!");
}
if (!Objects.equals(mobile, account.getMobile())) {
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证与用户不匹配!");
}
userSecret.setLoginName(account.getLoginName());
userSecret.setShowName(account.getShowName());
this.touchLastUsedTime(userSecret.getUserSecretId());
return ResponseResult.success(userSecret);
}
/**
* 解析人员密钥的承载应用Id。
* <p>所有人员密钥共用一个 OpenAPI 应用(配置项 {@code apelet.mcp.app-code}):该应用本身不对任何调用方分发
* appSecret,只用于承载"这批密钥能调哪些开放接口"的授权关系。</p>
*
* @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。
*/
private void touchLastUsedTime(Long userSecretId) {
try {
SysUserSecret updating = new SysUserSecret();
updating.setUserSecretId(userSecretId);
updating.setLastUsedTime(new Date());
sysUserSecretMapper.updateById(updating);
} catch (Exception e) {
log.warn("更新对外密钥最后使用时间失败,userSecretId={}", userSecretId, e);
}
}
private SysUserSecretIssueVo buildIssueVo(SysUserSecret userSecret, String secret) {
SysUserSecretIssueVo result = new SysUserSecretIssueVo();
result.setUserSecretId(userSecret.getUserSecretId());
result.setKeyId(userSecret.getKeyId());
result.setSecret(secret);
result.setCredential(userSecret.getKeyId() + CREDENTIAL_SEPARATOR + secret);
result.setExpireTime(userSecret.getExpireTime());
return result;
}
/**
* 生成指定长度的随机串,字符集见 {@link SysUserSecretSpec#RANDOM_ALPHABET}。
* <p>使用 {@link SecureRandom} 而非普通随机数:密钥是长期凭证,可预测的随机源等于没有随机。
* 长度等规格是安全参数,集中在 {@link SysUserSecretSpec},不接配置项。</p>
*
* @param length 目标长度。
* @return 随机串。
*/
private String randomString(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(SysUserSecretSpec.RANDOM_ALPHABET.charAt(SECURE_RANDOM.nextInt(SysUserSecretSpec.RANDOM_ALPHABET.length())));
}
return sb.toString();
}
/**
* 计算 secret 的 SHA-256 十六进制摘要。
* <p>刻意不用 BCrypt:该摘要每个请求都要参与校验,BCrypt 单次约百毫秒不可接受;
* 32 位随机串的熵足够,无需靠慢哈希抵抗爆破。</p>
*
* @param text 待摘要的明文。
* @return 64 位十六进制摘要。
*/
private String sha256Hex(String text) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexUtil.encodeHexStr(digest.digest(text.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new MyRuntimeException("当前运行环境不支持 SHA-256 摘要算法!");
}
}
}

41
common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java

@ -0,0 +1,41 @@
package apelet.common.openapi.vo;
import lombok.Data;
import java.util.Date;
/**
* 用户对外密钥签发结果对象。
* <p>明文 secret 只在签发/轮换的响应中出现一次,之后任何接口都查不到。</p>
*
* @author chenchuchuan
* @date 2026-10-14
*/
@Data
public class SysUserSecretIssueVo {
/**
* 主键Id。
*/
private Long userSecretId;
/**
* 密钥标识(明文,可用于定位与审计)。
*/
private String keyId;
/**
* 密钥口令明文,仅本次响应可见。
*/
private String secret;
/**
* 完整凭证(keyId + "." + secret),可直接交给调用方配置。
*/
private String credential;
/**
* 过期时间(null表示永不过期)。
*/
private Date expireTime;
}

1
common/pom.xml

@ -15,7 +15,6 @@
<module>common-flow</module> <module>common-flow</module>
<module>common-flow-online</module> <module>common-flow-online</module>
<module>common-online</module> <module>common-online</module>
<module>common-botp</module>
<module>common-tenant</module> <module>common-tenant</module>
<module>common-tenant-online</module> <module>common-tenant-online</module>

Loading…
Cancel
Save