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] =?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