调整服务逻辑
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,之后无法再次查看",
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/, ''),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user