diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 84e48cc..4ed3ce8 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -197,6 +197,26 @@ claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \ --header "Authorization: Bearer <测试ak>" ``` +## Open API(服务间接口,/api/open/**) + +供其他系统在服务端到服务端场景下获取用户 Token。鉴权为静态服务间密钥(header `X-Open-Key`,dev 明文配置在 `application-dev.yml` 的 `app.open.api-key`;配置为空 = 接口关闭)。 + +**`POST /api/open/token/ensure`** —— 按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create): + +```bash +curl -X POST http://localhost:8088/api/open/token/ensure \ + -H 'Content-Type: application/json' \ + -H 'X-Open-Key: <服务间密钥>' \ + -d '{"mobile":"13800001111","scopeCode":"operation"}' +``` + +- 入参:`mobile`、`email` 至少一个(手机号优先);`scopeCode` 必填。人员只认企业账号(enterprise_type 0/1),查不到 → 404。 +- 已存在有效 Token → `{"created":false,"token":null,"keyPrefix":"xqak_xxx…",...}`(服务端只存哈希,**明文不可回吐**,丢了只能后台重新签发)。 +- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文,仅此一次)",...}`。 +- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api)。 + +**签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。 + ## codegen(run-once) 表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`;svc `agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。 diff --git a/xiaoqu-agent-service/front/src/App.vue b/xiaoqu-agent-service/front/src/App.vue index ae4a5ba..ff50594 100644 --- a/xiaoqu-agent-service/front/src/App.vue +++ b/xiaoqu-agent-service/front/src/App.vue @@ -26,8 +26,8 @@ const audits = ref([]) const toolFilter = reactive({ category: '', kind: '', keyword: '' }) const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' }) const tokenDialog = ref(false) -const tokenForm = reactive({ id: null, memo: '', scopeIds: [], expireTime: null }) -const issuedPlaintext = ref('') +const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null }) +const issuedTokens = ref([]) const scopeDialog = ref(false) const scopeForm = reactive({ id: null, name: '', code: '', memo: '' }) const scopeToolsDialog = ref(false) @@ -106,24 +106,26 @@ async function selectUser(user) { function openToken(token = null) { Object.assign(tokenForm, token ? { - id: token.id, memo: token.memo || '', scopeIds: [...token.scopeIds], expireTime: token.expireTime || null, - } : { id: null, memo: '', scopeIds: [], expireTime: null }) + id: token.id, memo: token.memo || '', scopeIds: [], scopeId: token.scopeIds[0] ?? null, + expireTime: token.expireTime || null, + } : { id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null }) tokenDialog.value = true } async function saveToken() { - if (!tokenForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域') + const scopeIds = tokenForm.id ? (tokenForm.scopeId ? [tokenForm.scopeId] : []) : tokenForm.scopeIds + if (!scopeIds.length) return ElMessage.warning('至少选择一个作用域') await run(async () => { - const body = JSON.stringify({ memo: tokenForm.memo, scopeIds: tokenForm.scopeIds, expireTime: tokenForm.expireTime }) + const body = JSON.stringify({ memo: tokenForm.memo, scopeIds, expireTime: tokenForm.expireTime }) if (tokenForm.id) await api(`/tokens/${tokenForm.id}`, { method: 'PUT', body }) else { - const issued = await api(`/users/${selectedUser.value.id}/tokens`, { method: 'POST', body }) - issuedPlaintext.value = issued.plaintext + // 一作用域一 Token:选 N 个作用域返回 N 把明文 + issuedTokens.value = await api(`/users/${selectedUser.value.id}/tokens`, { method: 'POST', body }) } tokenDialog.value = false tokens.value = await api(`/users/${selectedUser.value.id}/tokens`) await loadUsers() - ElMessage.success(tokenForm.id ? 'Token 已更新' : 'Token 已签发') + ElMessage.success(tokenForm.id ? 'Token 已更新' : `已签发 ${issuedTokens.value.length} 把 Token`) }).catch(() => {}) } @@ -206,7 +208,10 @@ async function loadAudit() { audits.value = await api(`/audit${qs(auditFilter)}` function date(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' } function expiry(value) { return value ? date(value) : '永久' } function enterprise(value) { return ({ 1: '代理', 2: '物业' })[value] || `类型 ${value ?? '未配置'}` } -async function copyToken() { await navigator.clipboard.writeText(issuedPlaintext.value); ElMessage.success('已复制') } +async function copyText(value) { await navigator.clipboard.writeText(value); ElMessage.success('已复制') } +async function copyAllTokens() { + await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n')) +} onMounted(async () => { try { @@ -388,7 +393,15 @@ onMounted(async () => { {{ selectedUser?.name }} - {{ scope.name }} + + + {{ scope.name }} + + + {{ scope.name }} + +
{{ tokenForm.id ? '一把 Token 只绑一个作用域' : '一作用域一 Token:选几个作用域就签发几把' }}
+
@@ -410,9 +423,13 @@ onMounted(async () => { - - - {{ issuedPlaintext }} - + + +
+ {{ item.token.scopeNames.join('/') }} + {{ item.plaintext }} + 复制 +
+
diff --git a/xiaoqu-agent-service/front/src/style.css b/xiaoqu-agent-service/front/src/style.css index 1244039..13675b3 100644 --- a/xiaoqu-agent-service/front/src/style.css +++ b/xiaoqu-agent-service/front/src/style.css @@ -60,4 +60,6 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; } .tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; } .tool-group small { margin-top: 3px; color: #909399; } .token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; } +.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; } +.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; } @media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java index b21e816..333a7e1 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java @@ -130,8 +130,8 @@ public class AdminConsoleController { } @PostMapping("/users/{id}/tokens") - public AdminTokenService.IssuedToken issue(@PathVariable Long id, @RequestBody TokenRequest body, - HttpSession session) { + public List issue(@PathVariable Long id, @RequestBody TokenRequest body, + HttpSession session) { if (body == null) bad("请求内容不能为空"); return tokenService.issue(id, body.memo(), body.scopeIds(), body.expireTime(), adminId(session)); } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java index a2230f7..f20544e 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java @@ -34,12 +34,23 @@ public class AdminTokenService { this.scopeMapper = scopeMapper; } + /** + * 签发 Token:一作用域一 Token。选 N 个作用域生成 N 把,每把独立绑定单个作用域, + * 便于独立吊销、独立审计,明文逐把返回(仅本次可见)。 + */ @Transactional(transactionManager = "svcTransactionManager") - public IssuedToken issue(Long userId, String memo, List scopeIds, Date expireTime, Long adminId) { + public List issue(Long userId, String memo, List scopeIds, Date expireTime, Long adminId) { User user = userMapper.selectById(userId); if (user == null || Integer.valueOf(1).equals(user.getSoftDelete())) bad("人员不存在"); validateMemo(memo); validateScopes(scopeIds); + return scopeIds.stream().distinct() + .map(scopeId -> issueSingle(userId, memo, scopeId, expireTime, adminId)) + .toList(); + } + + /** 签发一把绑定单个作用域的 Token;人员/作用域合法性由调用方保证。 */ + public IssuedToken issueSingle(Long userId, String memo, Long scopeId, Date expireTime, Long creatorId) { String plaintext = "xqak_" + AdminAuthController.randomHex(24); AgentApiKey token = new AgentApiKey(); token.setUserId(userId); @@ -48,14 +59,32 @@ public class AdminTokenService { token.setMemo(trim(memo, 255)); token.setStatus((byte) 1); token.setExpireTime(expireTime); - token.setCreateId(adminId); - token.setModifyId(adminId); + token.setCreateId(creatorId); + token.setModifyId(creatorId); token.setSoftDelete((byte) 0); tokenMapper.insert(token); - replaceScopes(token.getId(), scopeIds, adminId); + replaceScopes(token.getId(), List.of(scopeId), creatorId); return new IssuedToken(plaintext, view(token)); } + /** 查找某人员在某作用域下的有效 Token(正常状态且未过期),取最新一把;无则 null。 */ + public AgentApiKey findActiveToken(Long userId, Long scopeId) { + List tokenIds = relationMapper.selectList(new LambdaQueryWrapper() + .eq(TokenScope::getScopeId, scopeId).eq(TokenScope::getSoftDelete, 0)) + .stream().map(TokenScope::getTokenId).toList(); + if (tokenIds.isEmpty()) return null; + return tokenMapper.selectList(new LambdaQueryWrapper() + .in(AgentApiKey::getId, tokenIds) + .eq(AgentApiKey::getUserId, userId) + .eq(AgentApiKey::getStatus, 1) + .eq(AgentApiKey::getSoftDelete, 0) + .and(w -> w.isNull(AgentApiKey::getExpireTime) + .or().gt(AgentApiKey::getExpireTime, new Date())) + .orderByDesc(AgentApiKey::getCreateTime) + .last("LIMIT 1")) + .stream().findFirst().orElse(null); + } + @Transactional(transactionManager = "svcTransactionManager") public void update(Long tokenId, String memo, List scopeIds, Date expireTime, Long adminId) { AgentApiKey current = activeToken(tokenId); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java index d678af2..34111ab 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/common/AuditService.java @@ -66,7 +66,7 @@ public class AuditService { } /** - * client_type:X-Client-Type 头优先;否则 User-Agent 含 kimi→mcp-kimi, + * client_type:/api/open/** → open-api;X-Client-Type 头其次;否则 User-Agent 含 kimi→mcp-kimi, * 含 claude→mcp-claude,含 curl→cli,其余 unknown。 */ private static String clientType() { @@ -74,6 +74,9 @@ public class AuditService { if (request == null) { return "unknown"; } + if (request.getRequestURI() != null && request.getRequestURI().startsWith("/api/open/")) { + return "open-api"; + } String explicit = request.getHeader("X-Client-Type"); if (explicit != null && !explicit.isBlank()) { return explicit; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenKeyFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenKeyFilter.java new file mode 100644 index 0000000..8ef74ef --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenKeyFilter.java @@ -0,0 +1,55 @@ +package me.iiv.agent.open; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * /api/open 通道服务间鉴权:X-Open-Key 静态密钥(配置 app.open.api-key)。 + * 与 ak 体系、后台 session 体系完全分离;配置为空 = 接口关闭。 + */ +@Component +public class OpenKeyFilter extends OncePerRequestFilter { + + static final String OPEN_KEY_HEADER = "X-Open-Key"; + + @Value("${app.open.api-key:}") + private String apiKey; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/api/open/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + response.setHeader("Cache-Control", "no-store"); + if (apiKey == null || apiKey.isBlank()) { + error(response, 403, "Open API 未启用"); + return; + } + String provided = request.getHeader(OPEN_KEY_HEADER); + if (provided == null || !MessageDigest.isEqual( + apiKey.getBytes(StandardCharsets.UTF_8), provided.getBytes(StandardCharsets.UTF_8))) { + error(response, 401, "服务间密钥无效"); + return; + } + chain.doFilter(request, response); + } + + private static void error(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json"); + response.getWriter().write("{\"message\":\"" + message + "\"}"); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java new file mode 100644 index 0000000..049f38b --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java @@ -0,0 +1,35 @@ +package me.iiv.agent.open; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Date; + +/** + * 对外服务间接口(/api/open/**),X-Open-Key 鉴权,见 OpenKeyFilter。 + */ +@RestController +@RequestMapping("/api/open") +public class OpenTokenController { + + private final OpenTokenService openTokenService; + + public OpenTokenController(OpenTokenService openTokenService) { + this.openTokenService = openTokenService; + } + + /** + * 确保某人员在某作用域下有有效 Token:存在则返回元信息(created=false,无明文), + * 不存在则自动创建永久 Token 并返回明文(created=true,仅此一次可见)。 + */ + @PostMapping("/token/ensure") + public EnsureResult ensure(@RequestBody EnsureRequest body) { + return openTokenService.ensure(body); + } + + public record EnsureRequest(String mobile, String email, String scopeCode) {} + public record EnsureResult(boolean created, String token, String keyPrefix, Date expireTime, + Long userId, String userName, String scopeCode) {} +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java new file mode 100644 index 0000000..f9fd985 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java @@ -0,0 +1,120 @@ +package me.iiv.agent.open; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.admin.AdminTokenService; +import me.iiv.agent.common.AuditService; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.security.AuthContext; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Date; + +/** + * Open API:按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create)。 + * 存在有效 Token 只回元信息(服务端只存哈希,明文不可回吐); + * 不存在则自动创建永久 Token,明文仅本次返回。 + */ +@Service +public class OpenTokenService { + + /** 系统签发(Open API 自动创建)的 createId 约定值 */ + static final long SYSTEM_CREATOR_ID = 0L; + static final String AUDIT_TOOL = "open.token.ensure"; + + private final UserMapper userMapper; + private final AgentScopeMapper scopeMapper; + private final AdminTokenService tokenService; + private final AuditService auditService; + + public OpenTokenService(UserMapper userMapper, AgentScopeMapper scopeMapper, + AdminTokenService tokenService, AuditService auditService) { + this.userMapper = userMapper; + this.scopeMapper = scopeMapper; + this.tokenService = tokenService; + this.auditService = auditService; + } + + @Transactional(transactionManager = "svcTransactionManager") + public OpenTokenController.EnsureResult ensure(OpenTokenController.EnsureRequest body) { + long start = System.currentTimeMillis(); + if (body == null) bad(HttpStatus.BAD_REQUEST, "请求内容不能为空"); + if (!StringUtils.hasText(body.mobile()) && !StringUtils.hasText(body.email())) { + bad(HttpStatus.BAD_REQUEST, "手机号和邮箱至少传一个"); + } + if (!StringUtils.hasText(body.scopeCode())) bad(HttpStatus.BAD_REQUEST, "作用域编码不能为空"); + + AgentScope scope = scopeMapper.selectOne(new LambdaQueryWrapper() + .eq(AgentScope::getCode, body.scopeCode().trim()).eq(AgentScope::getSoftDelete, 0)); + if (scope == null || !Byte.valueOf((byte) 1).equals(scope.getStatus())) { + bad(HttpStatus.BAD_REQUEST, "作用域不存在或已停用"); + } + + User user = findUser(body.mobile(), body.email()); + if (user == null) { + audit(null, null, body, "error", "人员不存在或非企业账号", start); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "人员不存在或非企业账号"); + } + + AgentApiKey existing = tokenService.findActiveToken(user.getId(), scope.getId()); + if (existing != null) { + audit(user, existing, body, "success", "已存在有效 Token,返回元信息", start); + return new OpenTokenController.EnsureResult(false, null, existing.getKeyPrefix(), + existing.getExpireTime(), user.getId(), user.getName(), scope.getCode()); + } + + AdminTokenService.IssuedToken issued = tokenService.issueSingle(user.getId(), + "open-api 自动签发(" + scope.getCode() + ")", scope.getId(), null, SYSTEM_CREATOR_ID); + AgentApiKey created = new AgentApiKey(); + created.setId(issued.token().id()); + audit(user, created, body, "success", "自动创建永久 Token", start); + return new OpenTokenController.EnsureResult(true, issued.plaintext(), issued.token().keyPrefix(), + null, user.getId(), user.getName(), scope.getCode()); + } + + /** 手机号优先、邮箱兜底;只认企业账号(enterprise_type 0/1,历史 NULL 保留),与后台用户列表同口径。 */ + private User findUser(String mobile, String email) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(User::getSoftDelete, 0) + .and(w -> w.ne(User::getEnterpriseType, 2).or().isNull(User::getEnterpriseType)); + if (StringUtils.hasText(mobile)) { + query.eq(User::getMobile, mobile.trim()); + } else { + query.eq(User::getEmail, email.trim()); + } + return userMapper.selectList(query.orderByDesc(User::getId).last("LIMIT 1")) + .stream().findFirst().orElse(null); + } + + private void audit(User user, AgentApiKey token, OpenTokenController.EnsureRequest body, + String status, String message, long start) { + String digest = "mobile=" + maskMobile(body.mobile()) + " email=" + maskEmail(body.email()) + + " scopeCode=" + body.scopeCode(); + auditService.record(new AuthContext(token, user), AUDIT_TOOL, digest, + status, message, System.currentTimeMillis() - start); + } + + private static String maskMobile(String mobile) { + if (!StringUtils.hasText(mobile)) return "-"; + String value = mobile.trim(); + return value.length() < 7 ? "***" : value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + + private static String maskEmail(String email) { + if (!StringUtils.hasText(email)) return "-"; + String value = email.trim(); + int at = value.indexOf('@'); + return at <= 1 ? "***" + value.substring(at) : value.charAt(0) + "***" + value.substring(at); + } + + private static void bad(HttpStatus status, String message) { + throw new ResponseStatusException(status, message); + } +} diff --git a/xiaoqu-agent-service/src/main/resources/application-dev.yml b/xiaoqu-agent-service/src/main/resources/application-dev.yml index 3298b4c..70c0a2e 100644 --- a/xiaoqu-agent-service/src/main/resources/application-dev.yml +++ b/xiaoqu-agent-service/src/main/resources/application-dev.yml @@ -2,6 +2,9 @@ # 账号密码明文写在本文件(内网 dev 库,与 xiaoqu-franchisee pom.xml dev profile 同一惯例); # 生产/测试环境不得在配置文件中写明文密码,改用环境变量 app: + open: + # Open API(/api/open/**)服务间静态密钥,dev 明文;调用方放 header X-Open-Key + api-key: xqok_4b44012f12b9fa54d6ec11690e6da57563a75c891df60961 datasource: m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth) driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/xiaoqu-agent-service/src/main/resources/application.yml b/xiaoqu-agent-service/src/main/resources/application.yml index d85049a..8d6bf83 100644 --- a/xiaoqu-agent-service/src/main/resources/application.yml +++ b/xiaoqu-agent-service/src/main/resources/application.yml @@ -14,6 +14,9 @@ app: account: ${ADMIN_BOOTSTRAP_ACCOUNT:} password: ${ADMIN_BOOTSTRAP_PASSWORD:} name: ${ADMIN_BOOTSTRAP_NAME:系统管理员} + open: + # /api/open 服务间静态密钥;为空 = Open API 关闭。dev 明文见 application-dev.yml + api-key: ${OPEN_API_KEY:} spring: application: diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java index 494724c..2352750 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java @@ -3,6 +3,7 @@ package me.iiv.agent.admin; import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.svc.AgentApiKey; import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.entity.svc.TokenScope; import me.iiv.agent.mapper.m0.UserMapper; import me.iiv.agent.mapper.svc.AgentApiKeyMapper; import me.iiv.agent.mapper.svc.AgentScopeMapper; @@ -15,12 +16,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -31,12 +34,21 @@ class AdminTokenServiceTest { @Mock TokenScopeMapper tokenScopeMapper; @Mock AgentScopeMapper scopeMapper; - @Test - void issueReturnsPlaintextOnceAndStoresOnlyHash() { + private User stubUser() { User user = new User(); user.setId(7L); user.setSoftDelete(0); when(userMapper.selectById(7L)).thenReturn(user); + return user; + } + + private AdminTokenService service() { + return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper); + } + + @Test + void issueReturnsPlaintextOnceAndStoresOnlyHash() { + stubUser(); AgentScope scope = new AgentScope(); scope.setId(3L); scope.setStatus((byte) 1); @@ -47,28 +59,52 @@ class AdminTokenServiceTest { return 1; }); - AdminTokenService service = new AdminTokenService( - userMapper, tokenMapper, tokenScopeMapper, scopeMapper); - AdminTokenService.IssuedToken issued = service.issue(7L, "Kimi", List.of(3L), null, 1L); + List issued = service().issue(7L, "Kimi", List.of(3L), null, 1L); + assertEquals(1, issued.size()); ArgumentCaptor captor = ArgumentCaptor.forClass(AgentApiKey.class); org.mockito.Mockito.verify(tokenMapper).insert(captor.capture()); AgentApiKey stored = captor.getValue(); - assertTrue(issued.plaintext().matches("xqak_[0-9a-f]{48}")); - assertEquals(AuthService.sha256Hex(issued.plaintext()), stored.getKeyHash()); - assertNotEquals(issued.plaintext(), stored.getKeyHash()); - assertEquals(11L, issued.token().id()); + assertTrue(issued.get(0).plaintext().matches("xqak_[0-9a-f]{48}")); + assertEquals(AuthService.sha256Hex(issued.get(0).plaintext()), stored.getKeyHash()); + assertNotEquals(issued.get(0).plaintext(), stored.getKeyHash()); + assertEquals(11L, issued.get(0).token().id()); + } + + @Test + void issueWithTwoScopesCreatesTwoTokensEachBoundToOneScope() { + stubUser(); + AgentScope scopeA = new AgentScope(); + scopeA.setId(3L); + scopeA.setStatus((byte) 1); + scopeA.setSoftDelete((byte) 0); + AgentScope scopeB = new AgentScope(); + scopeB.setId(4L); + scopeB.setStatus((byte) 1); + scopeB.setSoftDelete((byte) 0); + when(scopeMapper.selectList(any())).thenReturn(List.of(scopeA, scopeB)); + AtomicLong idSequence = new AtomicLong(10); + when(tokenMapper.insert(any(AgentApiKey.class))).thenAnswer(invocation -> { + invocation.getArgument(0).setId(idSequence.incrementAndGet()); + return 1; + }); + + List issued = service().issue(7L, "Kimi", List.of(3L, 4L), null, 1L); + + assertEquals(2, issued.size()); + assertNotEquals(issued.get(0).plaintext(), issued.get(1).plaintext()); + org.mockito.Mockito.verify(tokenMapper, times(2)).insert(any(AgentApiKey.class)); + // 每把 Token 只绑定一个作用域 + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(TokenScope.class); + org.mockito.Mockito.verify(tokenScopeMapper, times(2)).insert(relationCaptor.capture()); + List relations = relationCaptor.getAllValues(); + assertEquals(List.of(3L, 4L), relations.stream().map(TokenScope::getScopeId).sorted().toList()); } @Test void issueRejectsDuplicateScopesBeforeWriting() { - User user = new User(); - user.setId(7L); - user.setSoftDelete(0); - when(userMapper.selectById(7L)).thenReturn(user); - AdminTokenService service = new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper); - + stubUser(); assertThrows(RuntimeException.class, - () -> service.issue(7L, "Kimi", List.of(3L, 3L), null, 1L)); + () -> service().issue(7L, "Kimi", List.of(3L, 3L), null, 1L)); } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java new file mode 100644 index 0000000..a166b3e --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java @@ -0,0 +1,126 @@ +package me.iiv.agent.open; + +import me.iiv.agent.admin.AdminTokenService; +import me.iiv.agent.common.AuditService; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OpenTokenServiceTest { + + @Mock UserMapper userMapper; + @Mock AgentScopeMapper scopeMapper; + @Mock AdminTokenService tokenService; + @Mock AuditService auditService; + + OpenTokenService service; + AgentScope scope; + User user; + + @BeforeEach + void setUp() { + service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService); + scope = new AgentScope(); + scope.setId(3L); + scope.setCode("operation"); + scope.setStatus((byte) 1); + scope.setSoftDelete((byte) 0); + user = new User(); + user.setId(7L); + user.setName("张三"); + user.setSoftDelete(0); + } + + private void stubScopeAndUser() { + when(scopeMapper.selectOne(any())).thenReturn(scope); + when(userMapper.selectList(any())).thenReturn(List.of(user)); + } + + @Test + void existingActiveTokenReturnsMetadataWithoutPlaintext() { + stubScopeAndUser(); + AgentApiKey existing = new AgentApiKey(); + existing.setId(11L); + existing.setKeyPrefix("xqak_abcd1234"); + when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing); + + OpenTokenController.EnsureResult result = service.ensure( + new OpenTokenController.EnsureRequest("13800001111", null, "operation")); + + assertFalse(result.created()); + assertNull(result.token()); + assertEquals("xqak_abcd1234", result.keyPrefix()); + assertEquals(7L, result.userId()); + org.mockito.Mockito.verify(tokenService, never()) + .issueSingle(anyLong(), anyString(), anyLong(), isNull(), anyLong()); + } + + @Test + void missingTokenCreatesPermanentOneAndReturnsPlaintext() { + stubScopeAndUser(); + when(tokenService.findActiveToken(7L, 3L)).thenReturn(null); + AdminTokenService.TokenView view = new AdminTokenService.TokenView( + 12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1, + null, null, List.of(3L), List.of("运营")); + when(tokenService.issueSingle(eq(7L), anyString(), eq(3L), isNull(), eq(0L))) + .thenReturn(new AdminTokenService.IssuedToken("xqak_" + "a".repeat(48), view)); + + OpenTokenController.EnsureResult result = service.ensure( + new OpenTokenController.EnsureRequest(null, "zhangsan@test.com", "operation")); + + assertTrue(result.created()); + assertTrue(result.token().matches("xqak_[0-9a-f]{48}")); + assertNull(result.expireTime()); + assertEquals("operation", result.scopeCode()); + } + + @Test + void unknownUserRejected() { + when(scopeMapper.selectOne(any())).thenReturn(scope); + when(userMapper.selectList(any())).thenReturn(List.of()); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure( + new OpenTokenController.EnsureRequest("13800001111", null, "operation"))); + assertEquals(404, error.getStatusCode().value()); + } + + @Test + void disabledScopeRejected() { + scope.setStatus((byte) 0); + when(scopeMapper.selectOne(any())).thenReturn(scope); + + assertThrows(ResponseStatusException.class, () -> service.ensure( + new OpenTokenController.EnsureRequest("13800001111", null, "operation"))); + } + + @Test + void requiresMobileOrEmail() { + assertThrows(ResponseStatusException.class, () -> service.ensure( + new OpenTokenController.EnsureRequest(null, null, "operation"))); + } +} diff --git a/xiaoqu-franchisee/doc/agent-mcp-service-design.md b/xiaoqu-franchisee/doc/agent-mcp-service-design.md index 543f4a8..8235a88 100644 --- a/xiaoqu-franchisee/doc/agent-mcp-service-design.md +++ b/xiaoqu-franchisee/doc/agent-mcp-service-design.md @@ -125,7 +125,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌── ### 5.2 Web 管理后台(四个页面) -- **用户与 Token 管理**:人员选自现有 `user` 表,**只列企业账号**(`enterprise_type` 0=企业内部 / 1=代理,历史 NULL 保留),物业账号(=2)不进列表——Token 是给能查代理商/客户数据的人用的;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。 +- **用户与 Token 管理**:人员选自现有 `user` 表,**只列企业账号**(`enterprise_type` 0=企业内部 / 1=代理,历史 NULL 保留),物业账号(=2)不进列表——Token 是给能查代理商/客户数据的人用的;每人名下签发/禁用 token,展示 key 前缀、作用域、最后使用时间;选中人员展示其**数据权限归属**(权限组/isagent、所属代理、企业类型、可见范围说明,只读,便于排查"查不到数据")。**一作用域一 Token**:签发时选 N 个作用域生成 N 把 token(每把独立绑定单作用域,独立吊销/审计,明文逐把展示一次);编辑存量 token 时作用域收敛为单选。 - **工具管理**:工具列表来自服务端注册自动同步(工具名/分类/读写类型页面只读);展示每个工具被哪些作用域引用;「停用」为全局紧急开关。分类(category)在代码注册时由开发者声明(工具的业务域归属是客观属性,不允许页面上修改,避免与代码漂移)。 - **作用域管理**:作用域 CRUD + 勾选工具(工具按 category 分组展示)。 - **审计日志**:按人/token/工具/时间筛选。 @@ -145,6 +145,18 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌── - **配置**:`~/.xq/config.yaml` 存 base-url、多环境 profile(`--env dev|test|prd`)与 ak 明文(本机文件,权限 600),与 mcp.json 明文直写同一风格。 - **服务端配套改动**(仅两点):审计表加 `client_type`(mcp-kimi / mcp-claude / cli,从请求头识别);错误消息面向"人和 agent 都可读"(如权限拒绝时提示应由管理员在哪个作用域开通)。 +### 5.4 Open API(服务间接口) + +供其他业务系统/智能体平台在服务端到服务端场景下获取用户 Token,路径 `/api/open/**`,与 ak 体系、后台 session 体系完全分离。 + +- **鉴权**:静态服务间密钥,header `X-Open-Key`,配置项 `app.open.api-key`(dev 明文写 `application-dev.yml`;配置为空 = 接口关闭)。常量时间比较防时序侧信道。 +- **`POST /api/open/token/ensure`**(get-or-create): + - 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。 + - 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。 + - 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,只回 `keyPrefix`/`expireTime` 等元信息。**明文不可回吐**:服务端只存 SHA-256,这是安全红线,调用方丢了明文只能走后台重新签发。 + - **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文,仅此一次。 + - 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏)。 + ## 6. 技术栈与工程结构 - **JDK 17 + Spring Boot 3.x**(Spring AI MCP starter 的硬性要求) @@ -225,6 +237,9 @@ xiaoqu-agent-service/ - CLI 复用 `/mcp` 端点做 MCP 客户端,不开第二套服务 → 权限逻辑只有一份;Go 单二进制分发,与服务端 Java 栈解耦 - 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖 - 前后端一体打包单 jar → 可逆决定,将来可拆 +- 一作用域一 Token(2026-09-04)→ 收回某能力不需整把吊销;数据模型不变(token_scope 每把只写一行) +- Open API 已存在 Token 只回元信息不回明文 → 服务端只存哈希,明文不可回吐是安全红线 +- Open API 鉴权用配置文件静态密钥(dev)→ 服务间调用方少,落库+轮换留给生产阶段 - 列表类接口一律服务端分页,pageSize/rows 上限 100(超出截断为 100)→ 防止一次性拉全表,MCP 工具与后台 API 同一尺度 ## 11. 开发环境(dev)