优化注册服务及token管理

This commit is contained in:
2026-08-31 13:44:05 +08:00
parent 6fe61ff094
commit 3f6dabb634
7 changed files with 188 additions and 63 deletions
+29 -9
View File
@@ -21,7 +21,7 @@ def _row_to_dict(row) -> dict:
return {
"service_id": row["service_id"],
"service_name": row["service_name"],
"api_key_prefix": row["api_key_prefix"],
"api_key": row["api_key"],
"description": row["description"],
"status": row["status"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
@@ -59,24 +59,22 @@ async def register_service(req: ServiceCreate, admin: dict = Depends(current_adm
if existing:
raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态")
# 生成 API Key(明文仅此一次返回)
# 生成 API Key
plain = secrets.token_urlsafe(32)
api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
api_key_prefix = plain[:12] + "…"
row = await pool.fetchrow(
"""INSERT INTO mcp_service (service_name, api_key_hash, api_key_prefix, description, created_by)
"""INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING service_id, service_name, api_key_prefix, description, created_at, created_by""",
req.service_name, api_key_hash, api_key_prefix, req.description, admin.get("username", "admin"),
RETURNING service_id, service_name, api_key, description, created_at, created_by""",
req.service_name, plain, api_key_hash, req.description, admin.get("username", "admin"),
)
return {
"api_key": plain, # 明文仅此一次
"api_key": plain,
"service_id": row["service_id"],
"service_name": row["service_name"],
"api_key_prefix": row["api_key_prefix"],
"message": "请立即保存此 API Key,之后无法再次查看。配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量",
"message": "请保存此 API Key,配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量",
}
@@ -101,6 +99,28 @@ async def revoke_service(
return {"success": True, "service_id": service_id, "status": "revoked"}
@router.patch("/{service_id}/enable")
async def enable_service(
service_id: int,
admin: dict = Depends(current_admin),
):
"""启用服务:将已吊销的服务恢复为 active。"""
pool = await get_pool()
row = await pool.fetchrow(
"SELECT service_id, status FROM mcp_service WHERE service_id = $1", service_id
)
if row is None:
raise HTTPException(404, "服务不存在")
if row["status"] == "active":
raise HTTPException(400, "服务已是启用状态")
await pool.execute(
"UPDATE mcp_service SET status = 'active', revoked_at = NULL WHERE service_id = $1",
service_id,
)
return {"success": True, "service_id": service_id, "status": "active"}
@router.delete("/{service_id}")
async def delete_service(
service_id: int,
+34
View File
@@ -85,6 +85,14 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
if not exists:
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
# client_id 唯一校验:同一 client_id 不允许重复签发
existing = await pool.fetchval(
"SELECT 1 FROM mcp_token WHERE client_id = $1 AND status = 'active'",
req.client_id,
)
if existing:
raise HTTPException(400, f"client_id '{req.client_id}' 已存在活跃 Token,请先吊销旧 Token")
# 生成明文 token:仅此一次返回
plain = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(plain.encode()).hexdigest()
@@ -173,6 +181,32 @@ async def revoke_token(
return {"success": True, "token_id": token_id, "status": "revoked"}
@router.put("/{token_id}/enable")
async def enable_token(
token_id: int,
admin: dict = Depends(current_admin),
):
"""启用 token:将已吊销的 token 恢复为 active。"""
pool = await get_pool()
row = await pool.fetchrow(
"SELECT token_id, status FROM mcp_token WHERE token_id = $1", token_id
)
if row is None:
raise HTTPException(404, "token 不存在")
if row["status"] == "active":
raise HTTPException(400, "token 已是启用状态")
await pool.execute(
"UPDATE mcp_token SET status = 'active', revoked_at = NULL, revoke_reason = NULL WHERE token_id = $1",
token_id,
)
await pool.execute(
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'enabled', $2)",
token_id, json.dumps({"by": admin.get("username")}),
)
return {"success": True, "token_id": token_id, "status": "active"}
@router.delete("/{token_id}")
async def delete_token(
token_id: int,
+11 -2
View File
@@ -56,6 +56,11 @@ export async function revokeToken(token_id: number, reason?: string) {
return data;
}
export async function enableToken(token_id: number) {
const { data } = await api.put(`/tokens/${token_id}/enable`);
return data;
}
export async function deleteToken(token_id: number) {
const { data } = await api.delete(`/tokens/${token_id}`);
return data;
@@ -107,7 +112,7 @@ export async function getStats() {
export interface ServiceRow {
service_id: number;
service_name: string;
api_key_prefix: string;
api_key: string;
description: string | null;
status: string;
created_at: string | null;
@@ -125,7 +130,6 @@ export interface ServiceCreateResult {
api_key: string;
service_id: number;
service_name: string;
api_key_prefix: string;
message: string;
}
@@ -144,6 +148,11 @@ export async function revokeService(service_id: number) {
return data;
}
export async function enableService(service_id: number) {
const { data } = await api.patch(`/services/${service_id}/enable`);
return data;
}
export async function deleteService(service_id: number) {
const { data } = await api.delete(`/services/${service_id}`);
return data;
+55 -11
View File
@@ -10,13 +10,14 @@ import {
Typography,
message,
} from 'antd';
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { ColumnsType } from 'antd/es/table';
import {
listServices,
registerService,
revokeService,
enableService,
deleteService,
type ServiceCreateResult,
type ServiceRow,
@@ -63,6 +64,12 @@ export default function Services() {
});
};
const onEnable = async (id: number) => {
await enableService(id);
message.success('已启用');
load();
};
const onDelete = async (id: number) => {
Modal.confirm({
title: '删除此服务?',
@@ -79,6 +86,7 @@ export default function Services() {
};
const onCreate = async () => {
try {
const values = await form.validateFields();
const res = await registerService({
service_name: values.service_name,
@@ -88,11 +96,36 @@ export default function Services() {
setCreateOpen(false);
form.resetFields();
load();
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (detail) {
message.error(detail);
}
}
};
const onCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
message.success('已复制');
});
};
const columns: ColumnsType<ServiceRow> = [
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 120 },
{ title: 'Key 前缀', dataIndex: 'api_key_prefix', key: 'api_key_prefix', width: 140 },
{
title: 'API Key',
dataIndex: 'api_key',
key: 'api_key',
width: 300,
render: (key: string) => (
<Space size="small">
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
{key}
</Typography.Text>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => onCopy(key)} />
</Space>
),
},
{ title: '说明', dataIndex: 'description', key: 'description' },
{
title: '状态',
@@ -118,21 +151,26 @@ export default function Services() {
{
title: '操作',
key: 'action',
width: 140,
width: 180,
render: (_, row: ServiceRow) =>
row.status === 'active' ? (
<Space size="small">
<Button type="link" danger size="small" onClick={() => onRevoke(row.service_id)}>
吊销
</Button>
<Button type="link" size="small" disabled onClick={() => onDelete(row.service_id)}>
<Button type="link" size="small" disabled>
删除
</Button>
</Space>
) : row.status === 'revoked' ? (
<Space size="small">
<Button type="link" size="small" onClick={() => onEnable(row.service_id)}>
启用
</Button>
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}>
删除
</Button>
</Space>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
@@ -181,25 +219,31 @@ export default function Services() {
</Form>
</Modal>
{/* 注册结果:明文 API Key 仅此一次 */}
{/* 注册结果:API Key */}
<Modal
title="API Key 已生成"
open={created !== null}
onCancel={() => setCreated(null)}
footer={<Button type="primary" onClick={() => setCreated(null)}>我已保存</Button>}
footer={<Button type="primary" onClick={() => setCreated(null)}>关闭</Button>}
>
<Typography.Paragraph type="warning">
请立即复制并保存,此 API Key 仅显示一次,之后无法再次查看。
<Typography.Paragraph type="secondary">
配置到 MCP 服务的 <code>MCP_AUTH_API_KEY</code> 环境变量:
</Typography.Paragraph>
<Space.Compact style={{ width: '100%' }}>
<Input.TextArea
rows={2}
value={created?.api_key || ''}
readOnly
style={{ fontFamily: 'monospace' }}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
配置到 MCP 服务的 <code>MCP_AUTH_API_KEY</code> 环境变量。
</Typography.Paragraph>
<Button
type="primary"
icon={<CopyOutlined />}
onClick={() => created?.api_key && onCopy(created.api_key)}
>
复制
</Button>
</Space.Compact>
</Modal>
</div>
);
+22 -3
View File
@@ -19,6 +19,7 @@ import {
listTokens,
listServices,
revokeToken,
enableToken,
deleteToken,
type CreateResult,
type TokenRow,
@@ -78,6 +79,12 @@ export default function Tokens() {
});
};
const onEnable = async (id: number) => {
await enableToken(id);
message.success('已启用');
load();
};
const onDelete = async (id: number) => {
Modal.confirm({
title: '删除此 Token?',
@@ -94,6 +101,7 @@ export default function Tokens() {
};
const onCreate = async () => {
try {
const values = await form.validateFields();
const res = await createToken({
client_id: values.client_id,
@@ -105,11 +113,17 @@ export default function Tokens() {
setCreateOpen(false);
form.resetFields();
load();
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (detail) {
message.error(detail);
}
}
};
const columns: ColumnsType<TokenRow> = [
{ title: '前缀', dataIndex: 'token_prefix', key: 'token_prefix', width: 140 },
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 120 },
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 },
{
title: '范围',
dataIndex: 'service_scope',
@@ -143,21 +157,26 @@ export default function Tokens() {
{
title: '操作',
key: 'action',
width: 140,
width: 180,
render: (_, row: TokenRow) =>
row.status === 'active' ? (
<Space size="small">
<Button type="link" danger size="small" onClick={() => onRevoke(row.token_id)}>
吊销
</Button>
<Button type="link" size="small" disabled onClick={() => onDelete(row.token_id)}>
<Button type="link" size="small" disabled>
删除
</Button>
</Space>
) : row.status === 'revoked' ? (
<Space size="small">
<Button type="link" size="small" onClick={() => onEnable(row.token_id)}>
启用
</Button>
<Button type="link" danger size="small" onClick={() => onDelete(row.token_id)}>
删除
</Button>
</Space>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
+2 -2
View File
@@ -55,8 +55,8 @@ CREATE TABLE IF NOT EXISTS admin_user (
CREATE TABLE IF NOT EXISTS mcp_service (
service_id BIGSERIAL PRIMARY KEY,
service_name VARCHAR(64) UNIQUE NOT NULL, -- erp / crm / ...
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key)
api_key_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
api_key VARCHAR(128) NOT NULL, -- 明文 API Key(管理后台展示用,MCP 服务用此值)
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key),verify-token 校验用
description VARCHAR(200),
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+4 -5
View File
@@ -97,11 +97,10 @@ async def main() -> None:
continue
plain_key = secrets.token_urlsafe(32)
key_hash = hashlib.sha256(plain_key.encode()).hexdigest()
key_prefix = plain_key[:12] + "…"
await conn.execute(
"""INSERT INTO mcp_service (service_name, api_key_hash, api_key_prefix, description, created_by)
"""INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by)
VALUES ($1, $2, $3, $4, 'seed.py')""",
service_name, key_hash, key_prefix, desc,
service_name, plain_key, key_hash, desc,
)
print(f" {service_name}: API Key = {plain_key}")
print(f" → 配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量")
@@ -115,11 +114,11 @@ async def main() -> None:
print(f" {r['token_prefix']:<16} client={r['client_id']:<12} scope={r['service_scope']:<6} status={r['status']}")
svc_rows = await conn.fetch(
"SELECT service_name, api_key_prefix, status FROM mcp_service ORDER BY service_id"
"SELECT service_name, api_key, status FROM mcp_service ORDER BY service_id"
)
print(f"\n[概览] mcp_service 表共 {len(svc_rows)} 条:")
for r in svc_rows:
print(f" {r['service_name']:<8} key_prefix={r['api_key_prefix']:<16} status={r['status']}")
print(f" {r['service_name']:<8} api_key={r['api_key']:<44} status={r['status']}")
await conn.close()
print("\n[完成] MCP 服务 API Key 仅在本次输出,请立即保存到 .env.dev")