feat(token): 一作用域一Token(签发N个作用域生成N把); 新增Open API /api/open/token/ensure(get-or-create, X-Open-Key鉴权, 明文不可回吐)
This commit is contained in:
@@ -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` 对应分包并提交进仓库。
|
||||
|
||||
@@ -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 () => {
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="所属人员"><strong>{{ selectedUser?.name }}</strong></el-form-item>
|
||||
<el-form-item label="用途备注"><el-input v-model="tokenForm.memo" maxlength="255" placeholder="如:Kimi 桌面用" aria-label="Token 用途备注" data-testid="admin-token-memo" /></el-form-item>
|
||||
<el-form-item label="作用域"><el-checkbox-group v-model="tokenForm.scopeIds" data-testid="admin-token-scopes"><el-checkbox v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item label="作用域">
|
||||
<el-radio-group v-if="tokenForm.id" v-model="tokenForm.scopeId" data-testid="admin-token-scopes">
|
||||
<el-radio v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-radio>
|
||||
</el-radio-group>
|
||||
<el-checkbox-group v-else v-model="tokenForm.scopeIds" data-testid="admin-token-scopes">
|
||||
<el-checkbox v-for="scope in scopes.filter(item => item.status === 1)" :key="scope.id" :value="scope.id">{{ scope.name }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<div class="hint" style="margin:4px 0 0">{{ tokenForm.id ? '一把 Token 只绑一个作用域' : '一作用域一 Token:选几个作用域就签发几把' }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间"><el-date-picker v-model="tokenForm.expireTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="留空为永久" aria-label="Token 过期时间" data-testid="admin-token-expiry" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button data-testid="admin-token-cancel" @click="tokenDialog = false">取消</el-button><el-button type="primary" data-testid="admin-token-save" @click="saveToken">确认</el-button></template>
|
||||
@@ -410,9 +423,13 @@ onMounted(async () => {
|
||||
<template #footer><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!issuedPlaintext" title="Token 签发成功" width="560px" :close-on-click-modal="false" @close="issuedPlaintext = ''">
|
||||
<el-alert title="明文只展示这一次,请立即复制并妥善保存。" type="warning" :closable="false" show-icon />
|
||||
<code class="token-secret" data-testid="admin-issued-token">{{ issuedPlaintext }}</code>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-token" @click="copyToken">复制 Token</el-button><el-button data-testid="admin-close-token" @click="issuedPlaintext = ''">我已保存</el-button></template>
|
||||
<el-dialog :model-value="!!issuedTokens.length" title="Token 签发成功" width="600px" :close-on-click-modal="false" @close="issuedTokens = []">
|
||||
<el-alert title="每个作用域一把 Token,明文只展示这一次,请立即复制并妥善保存。" type="warning" :closable="false" show-icon />
|
||||
<div v-for="item in issuedTokens" :key="item.token.id" class="issued-row">
|
||||
<el-tag size="small" effect="plain">{{ item.token.scopeNames.join('/') }}</el-tag>
|
||||
<code class="token-secret" :data-testid="`admin-issued-token-${item.token.id}`">{{ item.plaintext }}</code>
|
||||
<el-button link type="primary" :data-testid="`admin-copy-token-${item.token.id}`" @click="copyText(item.plaintext)">复制</el-button>
|
||||
</div>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-all-tokens" @click="copyAllTokens">全部复制</el-button><el-button data-testid="admin-close-token" @click="issuedTokens = []">我已保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -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); } }
|
||||
|
||||
@@ -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<AdminTokenService.IssuedToken> 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));
|
||||
}
|
||||
|
||||
@@ -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<Long> scopeIds, Date expireTime, Long adminId) {
|
||||
public List<IssuedToken> issue(Long userId, String memo, List<Long> 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<Long> tokenIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
|
||||
.eq(TokenScope::getScopeId, scopeId).eq(TokenScope::getSoftDelete, 0))
|
||||
.stream().map(TokenScope::getTokenId).toList();
|
||||
if (tokenIds.isEmpty()) return null;
|
||||
return tokenMapper.selectList(new LambdaQueryWrapper<AgentApiKey>()
|
||||
.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<Long> scopeIds, Date expireTime, Long adminId) {
|
||||
AgentApiKey current = activeToken(tokenId);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 + "\"}");
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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<AgentScope>()
|
||||
.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<User> query = new LambdaQueryWrapper<User>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<AdminTokenService.IssuedToken> issued = service().issue(7L, "Kimi", List.of(3L), null, 1L);
|
||||
|
||||
assertEquals(1, issued.size());
|
||||
ArgumentCaptor<AgentApiKey> 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.<AgentApiKey>getArgument(0).setId(idSequence.incrementAndGet());
|
||||
return 1;
|
||||
});
|
||||
|
||||
List<AdminTokenService.IssuedToken> 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<TokenScope> relationCaptor = ArgumentCaptor.forClass(TokenScope.class);
|
||||
org.mockito.Mockito.verify(tokenScopeMapper, times(2)).insert(relationCaptor.capture());
|
||||
List<TokenScope> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user