调整服务逻辑

This commit is contained in:
2026-09-02 14:43:25 +08:00
parent 8c62274e45
commit c74bb0cd6a
9 changed files with 219 additions and 40 deletions
+14 -4
View File
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/services", tags=["services"])
class ServiceCreate(BaseModel):
service_name: str # erp / crm / ...
service_url: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp)
description: str | None = None
@@ -22,6 +23,7 @@ def _row_to_dict(row) -> dict:
return {
"service_id": row["service_id"],
"service_name": row["service_name"],
"service_url": row["service_url"],
"api_key": row["api_key"],
"description": row["description"],
"status": row["status"],
@@ -60,15 +62,23 @@ async def register_service(req: ServiceCreate, admin: dict = Depends(current_adm
if existing:
raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态")
# MCP 服务地址唯一性校验
url_existing = await pool.fetchval(
"SELECT 1 FROM mcp_service WHERE service_url = $1",
req.service_url.strip(),
)
if url_existing:
raise HTTPException(400, f"MCP 服务地址 {req.service_url} 已被其他服务使用")
# 生成 API Key
plain = secrets.token_urlsafe(32)
api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
row = await pool.fetchrow(
"""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, description, created_at, created_by""",
req.service_name, plain, api_key_hash, req.description, admin.get("username", "admin"),
"""INSERT INTO mcp_service (service_name, service_url, api_key, api_key_hash, description, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING service_id, service_name, service_url, api_key, description, created_at, created_by""",
req.service_name, req.service_url.strip(), plain, api_key_hash, req.description, admin.get("username", "admin"),
)
return {
+16 -13
View File
@@ -17,7 +17,7 @@ router = APIRouter(prefix="/api/tokens", tags=["tokens"])
class TokenCreate(BaseModel):
client_id: str
service_scope: str
service_scope: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp),后端反查服务名入库
description: str | None = None
expires_at: datetime | None = None # null = 永不过期
@@ -38,6 +38,7 @@ def _row_to_dict(row) -> dict:
"token_plain": row["token_plain"],
"client_id": row["client_id"],
"service_scope": row["service_scope"],
"service_url": row["service_url"],
"status": row["status"],
"expires_at": row["expires_at"].isoformat() if row["expires_at"] else None,
"description": row["description"],
@@ -78,21 +79,22 @@ async def list_tokens(
async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
pool = await get_pool()
# 动态校验 service_scope:必须是已注册的 active 服务
exists = await pool.fetchval(
"SELECT 1 FROM mcp_service WHERE service_name = $1 AND status = 'active'",
req.service_scope,
# 动态校验 service_scope:传入 MCP 服务地址,反查服务名(必须是已注册的 active 服务)
svc_row = await pool.fetchrow(
"SELECT service_name, service_url FROM mcp_service WHERE service_url = $1 AND status = 'active'",
req.service_scope.strip(),
)
if not exists:
if svc_row is None:
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
service_name = svc_row["service_name"]
# client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发
existing = await pool.fetchval(
"SELECT 1 FROM mcp_token WHERE client_id = $1 AND service_scope = $2 AND status = 'active'",
req.client_id, req.service_scope,
req.client_id, service_name,
)
if existing:
raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{req.service_scope}' 的活跃 Token,请先吊销旧 Token")
raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{service_name}' 的活跃 Token,请先吊销旧 Token")
# 生成明文 token:仅此一次返回
plain = secrets.token_urlsafe(32)
@@ -100,12 +102,12 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
token_prefix = plain[:12] + "…"
row = await pool.fetchrow(
"""INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, status,
"""INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, service_url, status,
expires_at, description, created_by)
VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)
RETURNING token_id, token_prefix, client_id, service_scope, status,
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
RETURNING token_id, token_prefix, client_id, service_scope, service_url, status,
expires_at, description, created_at, created_by""",
token_hash, plain, token_prefix, req.client_id, req.service_scope,
token_hash, plain, token_prefix, req.client_id, service_name, svc_row["service_url"],
req.expires_at, req.description, admin.get("username", "admin"),
)
@@ -113,7 +115,7 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
await pool.execute(
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'issued', $2)",
row["token_id"],
json.dumps({"client_id": req.client_id, "service_scope": req.service_scope}),
json.dumps({"client_id": req.client_id, "service_scope": service_name, "service_url": svc_row["service_url"]}),
)
return {
@@ -122,6 +124,7 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
"token_prefix": row["token_prefix"],
"client_id": row["client_id"],
"service_scope": row["service_scope"],
"service_url": row["service_url"],
"message": "请立即保存此 token,之后无法再次查看",
}
+3
View File
@@ -6,6 +6,7 @@ export interface TokenRow {
token_plain: string | null;
client_id: string;
service_scope: string;
service_url: string | null;
status: string;
expires_at: string | null;
description: string | null;
@@ -108,6 +109,7 @@ export async function getStats() {
export interface ServiceRow {
service_id: number;
service_name: string;
service_url: string;
api_key: string;
description: string | null;
status: string;
@@ -119,6 +121,7 @@ export interface ServiceRow {
export interface ServiceCreate {
service_name: string;
service_url: string;
description?: string;
}
+44 -12
View File
@@ -96,15 +96,24 @@ export default function Services() {
};
const onCreate = async () => {
const values = await form.validateFields();
const res = await registerService({
service_name: values.service_name,
description: values.description,
});
setCreated(res);
setCreateOpen(false);
form.resetFields();
load();
try {
const values = await form.validateFields();
const res = await registerService({
service_name: values.service_name,
service_url: values.service_url,
description: values.description,
});
setCreated(res);
setCreateOpen(false);
form.resetFields();
load();
} catch (e: any) {
if (e?.response?.data?.detail) {
message.error(e.response.data.detail);
} else if (!e?.errorFields) {
message.error('注册失败');
}
}
};
const onCopy = (text: string) => {
@@ -114,12 +123,11 @@ export default function Services() {
};
const columns: ColumnsType<ServiceRow> = [
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 240 },
{
title: 'API Key',
dataIndex: 'api_key',
key: 'api_key',
width: 300,
width: 400,
render: (key: string) => (
<Space size="small">
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
@@ -129,7 +137,20 @@ export default function Services() {
</Space>
),
},
{ title: '说明', dataIndex: 'description', key: 'description' },
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 150, ellipsis: true },
{
title: 'MCP服务地址',
dataIndex: 'service_url',
key: 'service_url',
width: 280,
ellipsis: true,
render: (u: string) => (
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
{u}
</Typography.Text>
),
},
{ title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
{
title: '状态',
dataIndex: 'status',
@@ -149,12 +170,14 @@ export default function Services() {
dataIndex: 'last_used_at',
key: 'last_used_at',
width: 160,
ellipsis: true,
render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'),
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
render: (_, row: ServiceRow) =>
row.status === 'active' ? (
<Space size="small">
@@ -197,6 +220,8 @@ export default function Services() {
rowKey="service_id"
loading={loading}
pagination={{ pageSize: 15 }}
tableLayout="fixed"
scroll={{ x: 1540 }}
/>
{/* 注册服务表单 */}
@@ -216,6 +241,13 @@ export default function Services() {
>
<Input placeholder="如 erp / crm" />
</Form.Item>
<Form.Item
name="service_url"
label="MCP 服务地址"
rules={[{ required: true, message: '请输入 MCP 服务地址' }]}
>
<Input placeholder="如 http://10.100.154.100:8001/mcp" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} placeholder="服务用途说明" />
</Form.Item>
+89 -9
View File
@@ -38,8 +38,25 @@ export default function Tokens() {
const [createOpen, setCreateOpen] = useState(false);
const [created, setCreated] = useState<CreateResult | null>(null);
const [services, setServices] = useState<ServiceRow[]>([]);
const [jsonRow, setJsonRow] = useState<TokenRow | null>(null);
const [form] = Form.useForm();
const buildRegisterJson = (row: TokenRow): string => {
const json = {
mcpServers: {
[row.service_scope]: {
type: 'streamable-http',
url: row.service_url || '',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${row.token_plain || ''}`,
},
},
},
};
return JSON.stringify(json, null, 2);
};
const load = async () => {
setLoading(true);
try {
@@ -110,10 +127,26 @@ export default function Tokens() {
});
};
const onCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
const onCopy = async (text: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// 非 HTTPS 环境降级方案:临时 textarea + execCommand
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (!ok) throw new Error('copy failed');
}
message.success('已复制');
});
} catch {
message.error('复制失败,请手动选择文本复制');
}
};
const onCreate = async () => {
@@ -141,14 +174,26 @@ export default function Tokens() {
};
const columns: ColumnsType<TokenRow> = [
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 },
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 150, ellipsis: true },
{
title: '范围',
title: '服务标识',
dataIndex: 'service_scope',
key: 'service_scope',
width: 80,
width: 180,
render: (s: string) => <Tag color="blue">{s}</Tag>,
},
{
title: 'MCP服务地址',
dataIndex: 'service_url',
key: 'service_url',
width: 280,
ellipsis: true,
render: (u: string | null) => (
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
{u || '-'}
</Typography.Text>
),
},
{
title: 'Token',
dataIndex: 'token_plain',
@@ -181,19 +226,21 @@ export default function Tokens() {
width: 160,
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '永不过期'),
},
{ title: '说明', dataIndex: 'description', key: 'description' },
{ title: '说明', dataIndex: 'description', key: 'description', width: 200, ellipsis: true },
{
title: '最近调用',
dataIndex: 'last_used_at',
key: 'last_used_at',
width: 160,
ellipsis: true,
render: (t: string, row: TokenRow) =>
t ? `${dayjs(t).format('MM-DD HH:mm')} (${row.last_used_svc || '-'})` : '-',
},
{
title: '操作',
key: 'action',
width: 180,
width: 240,
fixed: 'right',
render: (_, row: TokenRow) =>
row.status === 'active' ? (
<Space size="small">
@@ -203,6 +250,9 @@ export default function Tokens() {
<Button type="link" size="small" disabled>
删除
</Button>
<Button type="link" size="small" onClick={() => setJsonRow(row)}>
注册JSON
</Button>
</Space>
) : row.status === 'revoked' ? (
<Space size="small">
@@ -236,6 +286,8 @@ export default function Tokens() {
rowKey="token_id"
loading={loading}
pagination={{ pageSize: 15 }}
tableLayout="fixed"
scroll={{ x: 1810 }}
/>
{/* 签发表单 */}
@@ -263,7 +315,7 @@ export default function Tokens() {
<Select
placeholder="选择 MCP 服务"
options={services.map((s) => ({
value: s.service_name,
value: s.service_url,
label: s.service_name.toUpperCase(),
}))}
/>
@@ -297,6 +349,34 @@ export default function Tokens() {
{created?.message}
</Typography.Paragraph>
</Modal>
{/* 注册 JSON 查看结果 */}
<Modal
title={`注册 JSON — ${jsonRow?.client_id || ''}`}
open={jsonRow !== null}
onCancel={() => setJsonRow(null)}
footer={
<Space>
<Button
type="primary"
icon={<CopyOutlined />}
onClick={() => {
if (jsonRow) onCopy(buildRegisterJson(jsonRow));
setJsonRow(null);
}}
>
复制并关闭
</Button>
</Space>
}
>
<Input.TextArea
rows={12}
value={jsonRow ? buildRegisterJson(jsonRow) : ''}
readOnly
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Modal>
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
proxy: {
// 生产部署在 /mcp-admin 子路径下,dev 代理路径也需带前缀
'/mcp-admin/api': {
target: 'https://ai-workshop.digiwincloud.com.cn/mcp-auth-api',
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/mcp-admin/, ''),
},
+3 -1
View File
@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS mcp_token (
token_plain VARCHAR(128), -- 明文 Token(管理后台二次复制用)
token_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
client_id VARCHAR(64) NOT NULL, -- 调用方标识(如 trae / partner-a)
service_scope VARCHAR(32) NOT NULL, -- 'erp' | 'crm'
service_scope VARCHAR(32) NOT NULL, -- 'erp' | 'crm'(服务标识)
service_url VARCHAR(255), -- MCP 服务地址(如 http://10.100.154.100:8001/mcp)
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked/expired
expires_at TIMESTAMPTZ, -- null = 永不过期
description VARCHAR(200), -- 用途说明
@@ -56,6 +57,7 @@ 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 / ...
service_url VARCHAR(255) UNIQUE NOT NULL, -- MCP 服务地址(如 http://10.100.154.100:8001/mcp),唯一
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),
+33
View File
@@ -0,0 +1,33 @@
-- ============================================================
-- mcp_service 表升级脚本:添加 MCP 服务地址字段(唯一约束)
-- 适用:已部署的 mcp_auth 库(幂等可重跑)
-- ============================================================
-- 1. 添加 service_url 列(允许 NULL,便于已有数据平滑过渡)
ALTER TABLE mcp_service
ADD COLUMN IF NOT EXISTS service_url VARCHAR(255);
-- 2. 为已有记录回填 service_url(按服务名推断默认地址,可按实际环境修改)
UPDATE mcp_service
SET service_url = 'http://10.100.154.100:' || (CASE service_name
WHEN 'erp' THEN '8001'
WHEN 'crm' THEN '8002'
ELSE '8000' END) || '/mcp'
WHERE service_url IS NULL;
-- 3. 设置为 NOT NULL
ALTER TABLE mcp_service
ALTER COLUMN service_url SET NOT NULL;
-- 4. 添加唯一约束(幂等:先检查约束是否存在)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uk_mcp_service_url'
AND conrelid = 'mcp_service'::regclass
) THEN
ALTER TABLE mcp_service
ADD CONSTRAINT uk_mcp_service_url UNIQUE (service_url);
END IF;
END $$;
@@ -0,0 +1,16 @@
-- ============================================================
-- mcp_token 表升级脚本:添加 MCP 服务地址字段(签发时与服务标识一同保存)
-- 适用:已部署的 mcp_auth 库(幂等可重跑)
-- ============================================================
-- 1. 添加 service_url 列(可空,历史数据无地址信息)
ALTER TABLE mcp_token
ADD COLUMN IF NOT EXISTS service_url VARCHAR(255);
-- 2. 为已有记录按 service_scope 回填默认地址(可按实际环境修改)
UPDATE mcp_token
SET service_url = 'http://10.100.154.100:' || (CASE service_scope
WHEN 'erp' THEN '8001'
WHEN 'crm' THEN '8002'
ELSE '8000' END) || '/mcp'
WHERE service_url IS NULL;