api 管理
本章完成应用的 API 访问凭证管理:为应用创建 API 密钥,第三方拿着密钥就能直接调用应用的问答接口,不再依赖登录态。除应用密钥外,也提供账号级密钥的接口。前端沿用官方界面,后端接口、密钥认证与数据表在本项目中一并补齐。
一、功能范围
| 能力 | 状态 | 说明 |
|---|---|---|
| 应用密钥列表 | 已完成 | 按应用归属过滤,密钥原文直接展示,可一键复制 |
| 创建应用密钥 | 已完成 | 服务端生成 application- 前缀加 32 位随机串,默认启用 |
| 启用 / 停用密钥 | 已完成 | 停用后带该密钥的请求立即返回 401,无需等待缓存过期 |
| 删除密钥 | 已完成 | 逻辑删除,同时把 is_active 置为 false |
| 跨域来源配置 | 已完成 | 默认禁止跨域;开启后按来源列表放行,列表为空表示不限制 |
| 账号密钥 | 已完成 | system- 前缀,代表密钥所属账号本身,接口与列表结构同构 |
| 密钥调用应用问答 | 已完成 | 密钥可当 Bearer 令牌用,能开会话并流式问答 |
| 密钥访问范围 | 已完成 | 应用密钥只能访问所属应用:带应用 ID 的跨应用请求返回 403,不带应用 ID 的会话路径由会话归属校验兜底 |
| 归属隔离 | 已完成 | 普通账号看不到、也改不了别人的应用密钥 |
密钥的两级设计各管一件事:应用密钥给第三方集成用,权限只覆盖它所属的那个应用;账号密钥代表账号本身,供账号自己的开放调用使用。两级密钥都只在库里保存密钥本身,不额外保存调用方资料,也不与登录令牌互相转换。
二、后端接口
应用密钥挂在应用路径下,账号密钥挂在 /api/system/api_key 下。控制器只做参数绑定,归属校验、字段合并等业务规则放在服务层:
package nexus.io.mosskb.controller;
import com.alibaba.fastjson2.JSONObject;
import nexus.io.annotation.Delete;
import nexus.io.annotation.Get;
import nexus.io.annotation.Post;
import nexus.io.annotation.Put;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.kb.MossKbApiKeyService;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.json.FastJson2Utils;
/**
* 应用 API 密钥。路径前缀与 ApiApplicationController 相同,靠更具体的路径段匹配,
* 因此带 api_key 的请求不会落到应用分页接口上。
*/
@RequestPath("/api/application")
public class ApiApplicationApiKeyController {
@Get("/{applicationId}/api_key")
public ResultVo list(Long applicationId) {
return Aop.get(MossKbApiKeyService.class).listApplicationKeys(TioRequestContext.getUserIdLong(), applicationId);
}
@Post("/{applicationId}/api_key")
public ResultVo create(Long applicationId) {
return Aop.get(MossKbApiKeyService.class).createApplicationKey(TioRequestContext.getUserIdLong(), applicationId);
}
@Put("/{applicationId}/api_key/{apiKeyId}")
public ResultVo update(Long applicationId, Long apiKeyId, HttpRequest request) {
return Aop.get(MossKbApiKeyService.class).updateApplicationKey(TioRequestContext.getUserIdLong(), applicationId,
apiKeyId, body(request));
}
@Delete("/{applicationId}/api_key/{apiKeyId}")
public ResultVo delete(Long applicationId, Long apiKeyId) {
return Aop.get(MossKbApiKeyService.class).deleteApplicationKey(TioRequestContext.getUserIdLong(), applicationId,
apiKeyId);
}
private JSONObject body(HttpRequest request) {
String bodyString = request.getBodyString();
return StrUtil.isBlank(bodyString) ? new JSONObject() : FastJson2Utils.parseObject(bodyString);
}
}
账号密钥的列表与新增在官方前端里带结尾斜杠(/api/system/api_key/),为了让前端与 curl 共用同一组接口,这两种写法都注册一次,其余方法保持一致:
package nexus.io.mosskb.controller;
import com.alibaba.fastjson2.JSONObject;
import nexus.io.annotation.Delete;
import nexus.io.annotation.Get;
import nexus.io.annotation.Post;
import nexus.io.annotation.Put;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.kb.MossKbApiKeyService;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.json.FastJson2Utils;
/** 账号 API 密钥:代表当前登录账号,列表只返回自己的密钥。 */
@RequestPath("/api/system/api_key")
public class ApiSystemApiKeyController {
@Get("")
public ResultVo list() {
return listKeys();
}
@Get("/")
public ResultVo listWithTrailingSlash() {
return listKeys();
}
@Post("")
public ResultVo create() {
return createKey();
}
@Post("/")
public ResultVo createWithTrailingSlash() {
return createKey();
}
@Put("/{apiKeyId}")
public ResultVo update(Long apiKeyId, HttpRequest request) {
return Aop.get(MossKbApiKeyService.class).updateUserKey(TioRequestContext.getUserIdLong(), apiKeyId, body(request));
}
@Delete("/{apiKeyId}")
public ResultVo delete(Long apiKeyId) {
return Aop.get(MossKbApiKeyService.class).deleteUserKey(TioRequestContext.getUserIdLong(), apiKeyId);
}
private ResultVo listKeys() {
return Aop.get(MossKbApiKeyService.class).listUserKeys(TioRequestContext.getUserIdLong());
}
private ResultVo createKey() {
return Aop.get(MossKbApiKeyService.class).createUserKey(TioRequestContext.getUserIdLong());
}
private JSONObject body(HttpRequest request) {
String bodyString = request.getBodyString();
return StrUtil.isBlank(bodyString) ? new JSONObject() : FastJson2Utils.parseObject(bodyString);
}
}
| 接口 | 行为 |
|---|---|
GET /api/application/{applicationId}/api_key | 应用密钥列表,按创建时间倒序,最多 100 条 |
POST /api/application/{applicationId}/api_key | 创建应用密钥,返回新密钥完整记录 |
PUT /api/application/{applicationId}/api_key/{apiKeyId} | 修改启用状态与跨域设置,未提交的字段保持原值 |
DELETE /api/application/{applicationId}/api_key/{apiKeyId} | 逻辑删除应用密钥 |
GET /api/system/api_key、GET /api/system/api_key/ | 当前账号的密钥列表 |
POST /api/system/api_key、POST /api/system/api_key/ | 创建账号密钥,返回新密钥完整记录 |
PUT /api/system/api_key/{apiKeyId} | 修改账号密钥的启用状态与跨域设置 |
DELETE /api/system/api_key/{apiKeyId} | 逻辑删除账号密钥 |
路径匹配按段解析,同一个地址带结尾斜杠与不带结尾斜杠等价,因此官方前端对账号密钥的 PUT、DELETE 使用 /api/system/api_key/{apiKeyId}/ 也能直接命中;只有纯字面量的列表与新增路径需要成对注册,两种写法都能直接调用。
路由优先级
应用密钥接口与应用分页接口共用 /api/application 前缀,路由按「固定段更多的模板优先」裁决:/api/application/{applicationId}/api_key 的固定段是 api、application、api_key 三段,比 /api/application/{pageNo}/{pageSize} 的两段更具体,带 api_key 的请求稳定命中密钥接口。这一点在接口层面做过验证:GET /api/application/{applicationId}/api_key 返回的是密钥数组,而不是应用分页结果。
职责分工
路径模板、路径变量注入、JSON 返回体与统一响应结构都由框架处理,控制器只需要声明路径、参数与返回值,方法里没有一行解析路径的代码;归属校验写在服务层,认证集中在拦截器。这样做的直接好处是:登录令牌与 API 密钥共用同一套鉴权入口,密钥不需要单独开一条业务通道,后续新增接口也不会漏掉鉴权。SQL 用文本块书写,多行语句在源码里保持数据库里能直接执行的排版,排查问题时可以把整段语句原样贴进客户端。
三、服务层实现
服务层把每条 SQL 都写成 Java 21 的文本块常量("""),参数全部走占位符,不做字符串拼接。应用密钥的每次读写都带 application_id 条件,账号密钥的每次读写都带 user_id 条件,越权请求在数据库层就查不到记录:
package nexus.io.mosskb.service.kb;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.jfinal.kit.Kv;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.mosskb.constant.MossKbTableNames;
import nexus.io.mosskb.service.ApiKeyAuth;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
public class MossKbApiKeyService {
/** 密钥列表最多返回的条数,避免密钥过多时把界面拖慢。 */
private static final int KEY_LIMIT = 100;
/** 单个密钥允许配置的来源数量上限。 */
private static final int ORIGIN_LIMIT = 32;
private static final String LIST_APPLICATION_KEYS = """
select id, secret_key, application_id, user_id, is_active, allow_cross_domain, cross_domain_list, create_time
from moss_kb_application_api_key
where application_id = ?
and deleted = 0
order by create_time desc
limit %d
""".formatted(KEY_LIMIT);
private static final String FIND_APPLICATION_KEY = """
select id, secret_key, application_id, user_id, is_active, allow_cross_domain, cross_domain_list, create_time
from moss_kb_application_api_key
where id = ?
and application_id = ?
and deleted = 0
""";
private static final String UPDATE_APPLICATION_KEY = """
update moss_kb_application_api_key
set is_active = ?,
allow_cross_domain = ?,
cross_domain_list = ?,
update_time = now()
where id = ?
and application_id = ?
and deleted = 0
""";
private static final String DELETE_APPLICATION_KEY = """
update moss_kb_application_api_key
set deleted = 1,
is_active = false,
update_time = now()
where id = ?
and application_id = ?
and deleted = 0
""";
}
列表、创建、修改、删除四个动作都先做一次应用归属校验,再用「主键 + 应用 ID」作为更新条件。修改用「先读后并」的方式处理部分字段:请求里出现的字段覆盖原值,没出现的字段保留原值,因此前端只提交 is_active 时不会顺带清掉跨域配置:
public ResultVo updateApplicationKey(Long userId, Long applicationId, Long apiKeyId, JSONObject input) {
if (!ApplicationAccess.owns(userId, applicationId)) {
return ResultVo.fail("应用不存在或无权访问");
}
Row existing = Db.findFirst(FIND_APPLICATION_KEY, apiKeyId, applicationId);
if (existing == null) {
return ResultVo.fail("密钥不存在");
}
int updated = Db.update(UPDATE_APPLICATION_KEY, enabled(input, existing), crossDomain(input, existing),
(Object) crossDomainList(input, existing), apiKeyId, applicationId);
return updated == 1 ? ResultVo.ok() : ResultVo.fail("密钥不存在");
}
public ResultVo deleteApplicationKey(Long userId, Long applicationId, Long apiKeyId) {
if (!ApplicationAccess.owns(userId, applicationId)) {
return ResultVo.fail("应用不存在或无权访问");
}
int updated = Db.update(DELETE_APPLICATION_KEY, apiKeyId, applicationId);
return updated == 1 ? ResultVo.ok() : ResultVo.fail("密钥不存在");
}
账号密钥的服务方法同名同理,只是把条件换成 user_id;管理员也不例外,账号密钥始终只属于创建它的账号:
public ResultVo listUserKeys(Long userId) {
return ResultVo.ok(toKv(Db.find(LIST_USER_KEYS, userId)));
}
public ResultVo updateUserKey(Long userId, Long apiKeyId, JSONObject input) {
Row existing = Db.findFirst(FIND_USER_KEY, apiKeyId, userId);
if (existing == null) {
return ResultVo.fail("密钥不存在");
}
int updated = Db.update(UPDATE_USER_KEY, enabled(input, existing), crossDomain(input, existing),
(Object) crossDomainList(input, existing), apiKeyId, userId);
return updated == 1 ? ResultVo.ok() : ResultVo.fail("密钥不存在");
}
新建密钥时写入主键、前缀加 32 位随机串、归属与默认值,然后回查一次返回,保证新增与列表返回同一份结构(含 create_time):
/**
* 新密钥默认启用、默认不允许跨域,来源列表为空;返回主键供调用方回查,保证新增与列表返回同一份结构。
*/
private Long insert(String tableName, String prefix, Long userId, Long applicationId) {
Long apiKeyId = SnowflakeIdUtils.id();
Row record = Row.by("id", apiKeyId).set("secret_key", prefix + UUID.randomUUID().toString().replace("-", ""))
.set("user_id", userId).set("is_active", true).set("allow_cross_domain", false)
.set("cross_domain_list", new String[] {});
if (applicationId != null) {
record.set("application_id", applicationId);
}
Db.save(tableName, record);
return apiKeyId;
}
public ResultVo createApplicationKey(Long userId, Long applicationId) {
if (!ApplicationAccess.owns(userId, applicationId)) {
return ResultVo.fail("应用不存在或无权访问");
}
Long apiKeyId = insert(MossKbTableNames.moss_kb_application_api_key, ApiKeyAuth.APPLICATION_PREFIX, userId,
applicationId);
return ResultVo.ok(Db.findFirst(FIND_APPLICATION_KEY, apiKeyId, applicationId).toKv());
}
跨域来源列表做去空、去首尾空白和长度限制,最多 32 条、单条不超过 128 字符,避免把任意文本写进数据库:
private String[] crossDomainList(JSONObject input, Row existing) {
if (input.containsKey("cross_domain_list")) {
return origins(input.getJSONArray("cross_domain_list"));
}
String[] current = existing.get("cross_domain_list");
return current == null ? new String[] {} : current;
}
/** 来源列表做去空、去空白和长度限制,避免把任意文本写进数据库。 */
private String[] origins(JSONArray array) {
List<String> origins = new ArrayList<>();
if (array != null) {
for (int i = 0; i < array.size() && origins.size() < ORIGIN_LIMIT; i++) {
String origin = array.getString(i);
if (StrUtil.isNotBlank(origin) && origin.trim().length() <= 128) {
origins.add(origin.trim());
}
}
}
return origins.toArray(new String[0]);
}
四、密钥认证
密钥是不透明字符串而不是 JWT,所以不能走令牌解析。认证器按前缀区分两把钥匙,先在密钥表里查主体,再交给拦截器裁决访问范围:
package nexus.io.mosskb.service;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
/**
* API 密钥解析。密钥是不透明字符串而不是 JWT,因此不能走令牌校验,需要直接在密钥表里查找。
*/
public final class ApiKeyAuth {
/** 应用密钥前缀。 */
public static final String APPLICATION_PREFIX = "application-";
/** 账号密钥前缀。 */
public static final String USER_PREFIX = "system-";
private static final String FIND_APPLICATION_KEY = """
select k.id, k.application_id, k.user_id, k.is_active, k.allow_cross_domain, k.cross_domain_list
from moss_kb_application_api_key k
join moss_kb_application a on a.id = k.application_id
where k.secret_key = ?
and k.deleted = 0
and a.deleted = 0
""";
private static final String FIND_USER_KEY = """
select id, user_id, is_active, allow_cross_domain, cross_domain_list
from moss_kb_user_api_key
where secret_key = ?
and deleted = 0
""";
public static boolean isApiKey(String token) {
return token != null && (token.startsWith(APPLICATION_PREFIX) || token.startsWith(USER_PREFIX));
}
public static ApiKeyPrincipal resolve(String token) {
if (!isApiKey(token)) {
return null;
}
boolean application = token.startsWith(APPLICATION_PREFIX);
Row record = Db.findFirst(application ? FIND_APPLICATION_KEY : FIND_USER_KEY, token);
if (record == null) {
return null;
}
String[] crossDomainList = record.get("cross_domain_list");
if (crossDomainList == null) {
crossDomainList = new String[] {};
}
return new ApiKeyPrincipal(record.getLong("user_id"), application ? record.getLong("application_id") : null,
Boolean.TRUE.equals(record.getBoolean("is_active")),
Boolean.TRUE.equals(record.getBoolean("allow_cross_domain")), crossDomainList);
}
/**
* 密钥主体:userId 是密钥所属账号,applicationId 只有应用密钥才有。
*/
public record ApiKeyPrincipal(Long userId, Long applicationId, boolean active, boolean allowCrossDomain,
String[] crossDomainList) {
public boolean applicationKey() {
return applicationId != null;
}
}
}
拦截器在原有的令牌校验之前先分流:命中密钥就走进密钥分支,明文令牌不再参与 JWT 校验。密钥分支做四件事——密钥必须启用、所属账号必须启用且未删除、请求路径必须在密钥范围内、来源必须被允许。下面是拦截器的摘录,登录令牌分支只保留分流点,其余与第 25 章一致:
package nexus.io.mosskb.inteceptor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.mosskb.service.ApiKeyAuth;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.common.HttpResponseStatus;
import nexus.io.tio.http.common.RequestLine;
import nexus.io.tio.http.server.intf.HttpRequestInterceptor;
import nexus.io.tio.utils.hutool.StrUtil;
public class MossKbAuthInterceptor implements HttpRequestInterceptor {
/** 带应用 ID 的路径,用于比对密钥的应用范围。 */
private static final Pattern APPLICATION_PATH = Pattern.compile("^/api/application/(\\d+)(/.*)?$");
/** 账号状态与令牌版本,登录令牌分支与密钥分支共用。 */
private static final String ACCOUNT_STATE = """
select role, is_active, deleted, token_version
from moss_kb_user
where id = ?
""";
@Override
public HttpResponse doBeforeHandler(HttpRequest request, RequestLine requestLine, HttpResponse responseFromCache) {
String authorization = request.getHeader("authorization");
String rawToken = authorization == null ? null : authorization.replaceFirst("(?i)^Bearer\\s+", "");
// 密钥是不透明字符串,不进下面的 JWT 校验分支
if (ApiKeyAuth.isApiKey(rawToken)) {
return doBeforeApiKeyHandler(request, requestLine, rawToken);
}
// 登录令牌分支:解析令牌、校验账号状态与令牌版本、按前缀校验管理员权限与资源归属,
// 通过后 TioRequestContext.setUserId(userId) 并 return null;这里只保留分流点,完整实现见第 25 章。
return null;
}
private HttpResponse doBeforeApiKeyHandler(HttpRequest request, RequestLine requestLine, String rawToken) {
ApiKeyAuth.ApiKeyPrincipal principal = ApiKeyAuth.resolve(rawToken);
if (principal == null || !principal.active()) {
return deny(HttpResponseStatus.C401);
}
Row account = Db.findFirst(ACCOUNT_STATE, principal.userId());
if (account == null || !Boolean.TRUE.equals(account.getBoolean("is_active")) || account.getInt("deleted") != 0) {
return deny(HttpResponseStatus.C401);
}
String path = requestLine.getPath();
if (!withinKeyScope(principal, path) || !acceptsCaller(principal, request)) {
return deny(HttpResponseStatus.C403);
}
TioRequestContext.setUserId(principal.userId());
return null;
}
/** 应用密钥只能访问所属应用的接口;账号密钥不额外限制路径。 */
private boolean withinKeyScope(ApiKeyAuth.ApiKeyPrincipal principal, String path) {
if (!principal.applicationKey()) {
return true;
}
if (!path.startsWith("/api/application/")) {
return false;
}
Matcher application = APPLICATION_PATH.matcher(path);
return !application.matches() || principal.applicationId().equals(Long.valueOf(application.group(1)));
}
private HttpResponse deny(HttpResponseStatus status) {
HttpResponse denied = TioRequestContext.getResponse();
denied.setStatus(status);
return denied;
}
// …acceptsCaller 见下文…
}
范围判定用路径里的数字应用 ID 比对,所以 /api/application/{其他应用ID}/api_key 这类请求会被拦下;/api/application/chat_message/{会话ID} 这样的路径本身不带应用 ID,它由业务层的会话归属校验兜底——只有会话的 client_id 本人或管理员才能继续提问。
认证通过后,请求上下文里的账号就是密钥所属的账号,后续会话创建、聊天记录归属、知识库检索都按这个账号执行,业务代码不需要知道这次调用用的是密钥还是登录令牌。
跨域设置只作用于密钥调用,同源请求与服务器之间的调用直接放行,因此不会影响界面自身的请求:
/** 跨域设置只作用于密钥调用:同源请求或未声明来源的调用直接放行。 */
private boolean acceptsCaller(ApiKeyAuth.ApiKeyPrincipal principal, HttpRequest request) {
String origin = request.getOrigin();
if (StrUtil.isBlank(origin)) {
return true;
}
String host = request.getHost();
if (host != null && origin.contains(host)) {
return true;
}
if (!principal.allowCrossDomain()) {
return false;
}
String[] allowed = principal.crossDomainList();
if (allowed == null || allowed.length == 0) {
return true;
}
for (String item : allowed) {
if ("*".equals(item) || origin.equalsIgnoreCase(item)) {
return true;
}
}
return false;
}
五、数据表
两张密钥表结构一致,只差一个归属列,都带审计列与逻辑删除标记:
CREATE TABLE IF NOT EXISTS moss_kb_application_api_key (
id BIGINT PRIMARY KEY,
secret_key VARCHAR(128) NOT NULL,
application_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
allow_cross_domain BOOLEAN NOT NULL DEFAULT FALSE,
cross_domain_list VARCHAR[] NOT NULL DEFAULT '{}',
remark VARCHAR(256),
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT DEFAULT 0,
tenant_id BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS moss_kb_application_api_key_secret_key
ON moss_kb_application_api_key (secret_key);
CREATE INDEX IF NOT EXISTS moss_kb_application_api_key_application_id
ON moss_kb_application_api_key (application_id);
CREATE TABLE IF NOT EXISTS moss_kb_user_api_key (
id BIGINT PRIMARY KEY,
secret_key VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
allow_cross_domain BOOLEAN NOT NULL DEFAULT FALSE,
cross_domain_list VARCHAR[] NOT NULL DEFAULT '{}',
remark VARCHAR(256),
creator VARCHAR(64) DEFAULT '',
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) DEFAULT '',
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT DEFAULT 0,
tenant_id BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS moss_kb_user_api_key_secret_key
ON moss_kb_user_api_key (secret_key);
CREATE INDEX IF NOT EXISTS moss_kb_user_api_key_user_id
ON moss_kb_user_api_key (user_id);
建表脚本放在迁移目录里,可重复执行,并已加入初始化脚本的迁移清单;cross_domain_list 用 VARCHAR[] 直接对应界面上的多行文本,一条来源一行。
字段取值约定:secret_key 为 application- 或 system- 前缀加 32 位十六进制随机串,全局唯一;is_active 控制密钥是否可用;allow_cross_domain 与 cross_domain_list 只在密钥调用时参与判断;deleted 为 1 表示已删除,删除后的密钥立即失效且不再出现在列表里。
六、前端页面
前端的接口封装与后端路由一一对应,应用密钥挂在应用接口文件里,账号密钥单独一份:
import { Result } from '@/request/Result'
import { get, post, del, put } from '@/request/index'
import { type Ref } from 'vue'
const prefix = '/application'
/**
* API_KEY列表
* @param 参数 application_id
*/
const getAPIKey: (application_id: string, loading?: Ref<boolean>) => Promise<Result<any>> = (
application_id,
loading
) => {
return get(`${prefix}/${application_id}/api_key`, undefined, loading)
}
/**
* 新增API_KEY
* @param 参数 application_id
*/
const postAPIKey: (application_id: string, loading?: Ref<boolean>) => Promise<Result<any>> = (
application_id,
loading
) => {
return post(`${prefix}/${application_id}/api_key`, {}, undefined, loading)
}
/**
* 删除API_KEY
* @param 参数 application_id api_key_id
*/
const delAPIKey: (
application_id: String,
api_key_id: String,
loading?: Ref<boolean>
) => Promise<Result<boolean>> = (application_id, api_key_id, loading) => {
return del(`${prefix}/${application_id}/api_key/${api_key_id}`, undefined, undefined, loading)
}
/**
* 修改API_KEY
* data { is_active: boolean }
*/
const putAPIKey: (
application_id: string,
api_key_id: String,
data: any,
loading?: Ref<boolean>
) => Promise<Result<any>> = (application_id, api_key_id, data, loading) => {
return put(`${prefix}/${application_id}/api_key/${api_key_id}`, data, undefined, loading)
}
export default {
getAPIKey,
postAPIKey,
delAPIKey,
putAPIKey
}
import { Result } from '@/request/Result'
import { get, post, del, put } from '@/request/index'
import { type Ref } from 'vue'
const prefix = '/system/api_key'
/**
* API_KEY列表
*/
const getAPIKey: (loading?: Ref<boolean>) => Promise<Result<any>> = () => {
return get(`${prefix}/`)
}
/**
* 新增API_KEY
*/
const postAPIKey: (loading?: Ref<boolean>) => Promise<Result<any>> = (loading) => {
return post(`${prefix}/`, {}, undefined, loading)
}
/**
* 删除API_KEY
* @param 参数 api_key_id
*/
const delAPIKey: (api_key_id: String, loading?: Ref<boolean>) => Promise<Result<boolean>> = (
api_key_id,
loading
) => {
return del(`${prefix}/${api_key_id}/`, undefined, undefined, loading)
}
/**
* 修改API_KEY
* data { is_active: boolean }
*/
const putAPIKey: (
api_key_id: String,
data: any,
loading?: Ref<boolean>
) => Promise<Result<any>> = (api_key_id, data, loading) => {
return put(`${prefix}/${api_key_id}/`, data, undefined, loading)
}
export default {
getAPIKey,
postAPIKey,
delAPIKey,
putAPIKey
}
密钥对话框负责列表、新增、启停与删除,接口返回的 data 直接作为表格数据源,因此后端列表接口返回数组即可:
<template>
<el-dialog
title="API Key"
v-model="dialogVisible"
width="800"
:close-on-click-modal="false"
:close-on-press-escape="false"
align-center
>
<el-button type="primary" class="mb-16" @click="createApiKey">
{{ $t('common.create') }}
</el-button>
<el-table :data="apiKey" class="mb-16" :loading="loading" height="420">
<el-table-column prop="secret_key" label="API Key">
<template #default="{ row }">
<span class="vertical-middle lighter break-all">
{{ row.secret_key }}
</span>
<el-button type="primary" text @click="copyClick(row.secret_key)">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('common.status.label')" width="70">
<template #default="{ row }">
<div @click.stop>
<el-switch size="small" v-model="row.is_active" :before-change="() => changeState(row)" />
</div>
</template>
</el-table-column>
<el-table-column prop="name" :label="$t('common.createDate')" width="170">
<template #default="{ row }">
{{ datetimeFormat(row.create_time) }}
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" align="left" width="90">
<template #default="{ row }">
<span class="mr-4">
<el-tooltip effect="dark" :content="$t('common.setting')" placement="top">
<el-button type="primary" text @click.stop="settingApiKey(row)">
<el-icon><Setting /></el-icon>
</el-button>
</el-tooltip>
</span>
<el-tooltip effect="dark" :content="$t('common.delete')" placement="top">
<el-button type="primary" text @click="deleteApiKey(row)">
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</template>
</el-table-column>
</el-table>
<SettingAPIKeyDialog ref="SettingAPIKeyDialogRef" @refresh="refresh" />
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { copyClick } from '@/utils/clipboard'
import overviewApi from '@/api/application-overview'
import SettingAPIKeyDialog from './SettingAPIKeyDialog.vue'
import { datetimeFormat } from '@/utils/time'
import { MsgSuccess, MsgConfirm } from '@/utils/message'
import { t } from '@/locales'
const route = useRoute()
const {
params: { id }
} = route
const SettingAPIKeyDialogRef = ref()
const dialogVisible = ref<boolean>(false)
const loading = ref(false)
const apiKey = ref<any>(null)
const open = () => {
getApiKeyList()
dialogVisible.value = true
}
function refresh() {
getApiKeyList()
}
function settingApiKey(row: any) {
SettingAPIKeyDialogRef.value.open(row, 'APPLICATION')
}
function deleteApiKey(row: any) {
MsgConfirm(
`${t('views.applicationOverview.appInfo.APIKeyDialog.msgConfirm1')}: ${row.secret_key}?`,
t('views.applicationOverview.appInfo.APIKeyDialog.msgConfirm2'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
confirmButtonClass: 'danger'
}
)
.then(() => {
overviewApi.delAPIKey(id as string, row.id, loading).then(() => {
MsgSuccess(t('common.deleteSuccess'))
getApiKeyList()
})
})
.catch(() => {})
}
function changeState(row: any) {
const obj = {
is_active: !row.is_active
}
overviewApi
.putAPIKey(id as string, row.id, obj, loading)
.then((res) => {
getApiKeyList()
return true
})
.catch(() => {
return false
})
}
function createApiKey() {
overviewApi.postAPIKey(id as string, loading).then((res) => {
getApiKeyList()
})
}
function getApiKeyList() {
overviewApi.getAPIKey(id as string, loading).then((res) => {
apiKey.value = res.data
})
}
defineExpose({ open })
</script>
跨域设置对话框把来源列表按行拼接与拆分,保存时走同一个 PUT 接口,只提交这两个字段:
<template>
<el-dialog :title="$t('common.setting')" v-model="dialogVisible">
<el-form label-position="top" ref="settingFormRef" :model="form">
<el-form-item :label="$t('views.applicationOverview.appInfo.SettingAPIKeyDialog.allowCrossDomainLabel')">
<el-switch size="small" v-model="form.allow_cross_domain"></el-switch>
</el-form-item>
<el-form-item>
<el-input
v-model="form.cross_domain_list"
:placeholder="$t('views.applicationOverview.appInfo.SettingAPIKeyDialog.crossDomainPlaceholder')"
:rows="10"
type="textarea"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click.prevent="dialogVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="submit(settingFormRef)" :loading="loading">
{{ $t('common.save') }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import type { FormInstance } from 'element-plus'
import overviewApi from '@/api/application-overview'
import overviewSystemApi from '@/api/system-api-key'
import { MsgSuccess } from '@/utils/message'
import { t } from '@/locales'
const route = useRoute()
const {
params: { id }
} = route
const emit = defineEmits(['refresh'])
const settingFormRef = ref()
const dialogVisible = ref<boolean>(false)
const loading = ref(false)
const form = ref<any>({
allow_cross_domain: false,
cross_domain_list: ''
})
const APIKeyId = ref('')
const APIType = ref('APPLICATION')
const open = (data: any, type: string) => {
APIKeyId.value = data.id
APIType.value = type
form.value.allow_cross_domain = data.allow_cross_domain
form.value.cross_domain_list = data.cross_domain_list?.length ? data.cross_domain_list?.join('\n') : ''
dialogVisible.value = true
}
const submit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid, fields) => {
if (valid) {
const obj = {
allow_cross_domain: form.value.allow_cross_domain,
cross_domain_list: form.value.cross_domain_list
? form.value.cross_domain_list.split('\n').filter(function (item: string) {
return item !== ''
})
: []
}
const apiCall =
APIType.value === 'APPLICATION'
? overviewApi.putAPIKey(id as string, APIKeyId.value, obj, loading)
: overviewSystemApi.putAPIKey(APIKeyId.value, obj, loading)
apiCall.then((res) => {
emit('refresh')
MsgSuccess(t('common.settingSuccess'))
dialogVisible.value = false
})
}
})
}
defineExpose({ open })
</script>
一个对话框同时服务应用密钥与账号密钥,用 APIType 区分调用哪一份接口封装,因此两级密钥的设置交互完全一致。
七、调用示例
密钥就是 Bearer 令牌。第三方集成时先用应用的 Base URL 与密钥开一个会话,再用会话 ID 提问,问答按 SSE 流式返回:
# 1. 用密钥开一个会话,返回 chat_id
curl -X GET "http://127.0.0.1:10060/api/application/695640157413023744/chat/open" \
-H "Authorization: Bearer ${API_KEY}"
# 2. 用同一个密钥提问,SSE 流式返回
curl -N -X POST "http://127.0.0.1:10060/api/application/chat_message/${CHAT_ID}" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "你好"}'
返回的第一段是过程事件,后面是逐段增量内容:
event:agent_status
data:{"phase":"context","message":"正在读取会话上下文"}
event:agent_status
data:{"phase":"retrieving","round":1,"query":"你好"}
data:{"is_end":false,"chat_id":"695949624775057408","content":"您好","node_type":"ai-chat-node"}
data:{"is_end":false,"chat_id":"695949624775057408","content":"。已收到","node_type":"ai-chat-node"}
密钥的管理动作同样可以直接用 HTTP 调用,便于在脚本里准备环境:
# 创建应用密钥(需登录令牌)
curl -X POST "http://127.0.0.1:10060/api/application/695640157413023744/api_key" \
-H "Authorization: ${TOKEN}" -H "Content-Type: application/json" -d '{}'
# 停用某个密钥
curl -X PUT "http://127.0.0.1:10060/api/application/695640157413023744/api_key/${KEY_ID}" \
-H "Authorization: ${TOKEN}" -H "Content-Type: application/json" -d '{"is_active": false}'
# 打开跨域并按来源放行
curl -X PUT "http://127.0.0.1:10060/api/application/695640157413023744/api_key/${KEY_ID}" \
-H "Authorization: ${TOKEN}" -H "Content-Type: application/json" \
-d '{"allow_cross_domain": true, "cross_domain_list": ["http://127.0.0.1:5678", "https://dataease.io"]}'
应用密钥创建成功后的返回如下,data.secret_key 就是需要保存到第三方系统里的密钥(示例里已省略后半段):
{
"code": 200,
"message": null,
"data": {
"id": "695949215679422464",
"secret_key": "application-be80381b…",
"application_id": "695640157413023744",
"user_id": "1",
"is_active": true,
"allow_cross_domain": false,
"cross_domain_list": [],
"create_time": 1791003223137
}
}
八、界面操作步骤
- 打开
http://localhost:3000/ui/,使用管理员账号登录。 - 在 APP 页打开目标应用,进入 Overview(中文界面为 概览),右侧「API Access Credentials」区块就是密钥入口。

区块里同时给出文档地址与 Base URL:Base URL 是第三方集成时拼接口前缀的地址,形如 /api/application/{应用ID}。
- 点击 API Key 打开密钥对话框,列表展示密钥原文、启用状态与创建时间,行内提供设置与删除两个操作。

- 点击 Create 新建密钥,新密钥出现在列表最上方并默认启用;点密钥右侧的复制图标即可复制。

- 点击行内的齿轮图标打开设置,打开「Allow Cross-Domain Access」并逐行填写允许的来源,保存后立即生效。

- 需要临时停用时拨动该行的状态开关;需要彻底作废时点行尾的删除图标,确认后密钥立即失效。
账号级密钥的入口由前端按角色显示:应用界面右上角头像菜单里的 API Key 项只在 x-pack 角色下渲染,当前部署的账号角色为 ADMIN,因此界面上不显示该入口;接口本身不受角色限制,任何登录账号都能通过 /api/system/api_key 管理自己的密钥,管理员也可以直接调接口核对。
九、验收结果
验收在管理员界面与一个临时账号上完成,实际观测到的行为如下。
创建与落库。 在界面上点击 Create 后列表立即出现新密钥,库中记录为:
secret_key | application_id | user_id | is_active | allow_cross_domain | cross_domain_list | deleted
application-be8038… | 695640157413023744 | 1 | t | t | {http://127.0.0.1:5678,https://dataease.io} | 0
application-a1ae38… | 695640157413023744 | 1 | t | f | {} | 0
密钥形如 application- 加 32 位十六进制随机串,create_time 由数据库默认值写入,界面上的创建时间与之一致。
接口与界面互认。 界面上新建的密钥可以直接用于接口调用;反过来用接口创建的密钥也会立刻出现在界面列表里,两级密钥的列表接口都返回数组。
路由定位准确。 应用密钥接口与应用分页接口共用前缀,同一个地址稳定返回密钥数组,不会命中应用分页接口。
密钥调用问答。 用界面创建的密钥开会话并提问,会话创建返回 200 与 chat_id,问答接口按 SSE 流式返回内容,事件序列正常:
GET /api/application/{应用ID}/chat/open → 200 {"code":200,"data":"695949624775057408"}
POST /api/application/chat_message/{会话ID} → event:agent_status … data:{"content":"您好"} …
停用与删除立即失效。 停用密钥后,同一个密钥再次调用返回 401;删除后同样返回 401,且列表中不再出现该密钥。
访问范围。 应用 A 的密钥访问应用 B 的密钥列表返回 403,访问知识库接口也返回 403:
GET /api/application/{应用B}/api_key → 403
GET /api/dataset/1/20 → 403
跨域按配置放行。 同一个密钥(来源列表为 http://127.0.0.1:5678 与 https://dataease.io)在不同来源下的结果:
不带 Origin → 200
Origin: http://127.0.0.1:5678 → 200
Origin: http://evil.example.com → 403
账号隔离。 临时普通账号访问管理员应用的密钥列表与新增接口返回 403,删除该应用的密钥同样返回 403,这些请求在拦截器按应用归属就被拒绝。用该账号修改或删除管理员的账号密钥(/api/system/api_key/{管理员密钥ID})时,路径不带应用 ID,请求进到服务层后按 user_id 过滤查不到记录,返回业务失败码 400 与提示「密钥不存在」。两次尝试之后管理员的密钥状态与内容都没有被改动,两级密钥列表各自只返回自己的密钥。
账号状态联动。 停用密钥所属账号后,该账号的密钥调用返回 401;重新启用后恢复 200。
十、部署与构建
- 初始化脚本已包含密钥表的迁移,可重复执行;
cross_domain_list使用 PostgreSQL 原生数组类型,不需要额外扩展。 - 后端构建跳过签名即可,例如
mvn -DskipTests -Dgpg.skip=true package;Java 版本为 21,文本块语法可直接使用。 - 前端沿用官方界面,无需额外定制即可对接上述接口;密钥原文只在应用概览的密钥对话框里展示,复制按钮依赖浏览器的剪贴板权限。
十一、已知取舍
- 密钥明文展示与复制沿用官方交互,利于第三方对接;如需更严格的管理,可在展示层改为掩码加一次性复制。
- 应用密钥创建后不再变更归属:应用被逻辑删除后,认证时关联不到应用,密钥随之失效,但记录仍留在库里,需要清理时可以按应用 ID 另行删除这些记录。
- 停用与删除都是即时生效的库内状态,不做本地缓存,因此每次调用多一次按键查表;密钥量级很小,代价可以忽略。
- 账号密钥的界面入口按 x-pack 角色显示,当前部署不显示该入口,符合官方前端的角色约定。
