调整服务逻辑
This commit is contained in:
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/services", tags=["services"])
|
|||||||
|
|
||||||
class ServiceCreate(BaseModel):
|
class ServiceCreate(BaseModel):
|
||||||
service_name: str # erp / crm / ...
|
service_name: str # erp / crm / ...
|
||||||
|
service_url: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ def _row_to_dict(row) -> dict:
|
|||||||
return {
|
return {
|
||||||
"service_id": row["service_id"],
|
"service_id": row["service_id"],
|
||||||
"service_name": row["service_name"],
|
"service_name": row["service_name"],
|
||||||
|
"service_url": row["service_url"],
|
||||||
"api_key": row["api_key"],
|
"api_key": row["api_key"],
|
||||||
"description": row["description"],
|
"description": row["description"],
|
||||||
"status": row["status"],
|
"status": row["status"],
|
||||||
@@ -60,15 +62,23 @@ async def register_service(req: ServiceCreate, admin: dict = Depends(current_adm
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态")
|
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
|
# 生成 API Key
|
||||||
plain = secrets.token_urlsafe(32)
|
plain = secrets.token_urlsafe(32)
|
||||||
api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
|
api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
|
||||||
|
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
"""INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by)
|
"""INSERT INTO mcp_service (service_name, service_url, api_key, api_key_hash, description, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING service_id, service_name, api_key, description, created_at, created_by""",
|
RETURNING service_id, service_name, service_url, api_key, description, created_at, created_by""",
|
||||||
req.service_name, plain, api_key_hash, req.description, admin.get("username", "admin"),
|
req.service_name, req.service_url.strip(), plain, api_key_hash, req.description, admin.get("username", "admin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ router = APIRouter(prefix="/api/tokens", tags=["tokens"])
|
|||||||
|
|
||||||
class TokenCreate(BaseModel):
|
class TokenCreate(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
service_scope: str
|
service_scope: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp),后端反查服务名入库
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
expires_at: datetime | None = None # null = 永不过期
|
expires_at: datetime | None = None # null = 永不过期
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ def _row_to_dict(row) -> dict:
|
|||||||
"token_plain": row["token_plain"],
|
"token_plain": row["token_plain"],
|
||||||
"client_id": row["client_id"],
|
"client_id": row["client_id"],
|
||||||
"service_scope": row["service_scope"],
|
"service_scope": row["service_scope"],
|
||||||
|
"service_url": row["service_url"],
|
||||||
"status": row["status"],
|
"status": row["status"],
|
||||||
"expires_at": row["expires_at"].isoformat() if row["expires_at"] else None,
|
"expires_at": row["expires_at"].isoformat() if row["expires_at"] else None,
|
||||||
"description": row["description"],
|
"description": row["description"],
|
||||||
@@ -78,21 +79,22 @@ async def list_tokens(
|
|||||||
async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
|
|
||||||
# 动态校验 service_scope:必须是已注册的 active 服务
|
# 动态校验 service_scope:传入 MCP 服务地址,反查服务名(必须是已注册的 active 服务)
|
||||||
exists = await pool.fetchval(
|
svc_row = await pool.fetchrow(
|
||||||
"SELECT 1 FROM mcp_service WHERE service_name = $1 AND status = 'active'",
|
"SELECT service_name, service_url FROM mcp_service WHERE service_url = $1 AND status = 'active'",
|
||||||
req.service_scope,
|
req.service_scope.strip(),
|
||||||
)
|
)
|
||||||
if not exists:
|
if svc_row is None:
|
||||||
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
|
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
|
||||||
|
service_name = svc_row["service_name"]
|
||||||
|
|
||||||
# client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发
|
# client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发
|
||||||
existing = await pool.fetchval(
|
existing = await pool.fetchval(
|
||||||
"SELECT 1 FROM mcp_token WHERE client_id = $1 AND service_scope = $2 AND status = 'active'",
|
"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:
|
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:仅此一次返回
|
# 生成明文 token:仅此一次返回
|
||||||
plain = secrets.token_urlsafe(32)
|
plain = secrets.token_urlsafe(32)
|
||||||
@@ -100,12 +102,12 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
|||||||
token_prefix = plain[:12] + "…"
|
token_prefix = plain[:12] + "…"
|
||||||
|
|
||||||
row = await pool.fetchrow(
|
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)
|
expires_at, description, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
|
||||||
RETURNING token_id, token_prefix, client_id, service_scope, status,
|
RETURNING token_id, token_prefix, client_id, service_scope, service_url, status,
|
||||||
expires_at, description, created_at, created_by""",
|
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"),
|
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(
|
await pool.execute(
|
||||||
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'issued', $2)",
|
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'issued', $2)",
|
||||||
row["token_id"],
|
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 {
|
return {
|
||||||
@@ -122,6 +124,7 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
|||||||
"token_prefix": row["token_prefix"],
|
"token_prefix": row["token_prefix"],
|
||||||
"client_id": row["client_id"],
|
"client_id": row["client_id"],
|
||||||
"service_scope": row["service_scope"],
|
"service_scope": row["service_scope"],
|
||||||
|
"service_url": row["service_url"],
|
||||||
"message": "请立即保存此 token,之后无法再次查看",
|
"message": "请立即保存此 token,之后无法再次查看",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface TokenRow {
|
|||||||
token_plain: string | null;
|
token_plain: string | null;
|
||||||
client_id: string;
|
client_id: string;
|
||||||
service_scope: string;
|
service_scope: string;
|
||||||
|
service_url: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -108,6 +109,7 @@ export async function getStats() {
|
|||||||
export interface ServiceRow {
|
export interface ServiceRow {
|
||||||
service_id: number;
|
service_id: number;
|
||||||
service_name: string;
|
service_name: string;
|
||||||
|
service_url: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -119,6 +121,7 @@ export interface ServiceRow {
|
|||||||
|
|
||||||
export interface ServiceCreate {
|
export interface ServiceCreate {
|
||||||
service_name: string;
|
service_name: string;
|
||||||
|
service_url: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,15 +96,24 @@ export default function Services() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const res = await registerService({
|
const res = await registerService({
|
||||||
service_name: values.service_name,
|
service_name: values.service_name,
|
||||||
|
service_url: values.service_url,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
});
|
});
|
||||||
setCreated(res);
|
setCreated(res);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
load();
|
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) => {
|
const onCopy = (text: string) => {
|
||||||
@@ -114,12 +123,11 @@ export default function Services() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<ServiceRow> = [
|
const columns: ColumnsType<ServiceRow> = [
|
||||||
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 240 },
|
|
||||||
{
|
{
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
dataIndex: 'api_key',
|
dataIndex: 'api_key',
|
||||||
key: 'api_key',
|
key: 'api_key',
|
||||||
width: 300,
|
width: 400,
|
||||||
render: (key: string) => (
|
render: (key: string) => (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
|
||||||
@@ -129,7 +137,20 @@ export default function Services() {
|
|||||||
</Space>
|
</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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -149,12 +170,14 @@ export default function Services() {
|
|||||||
dataIndex: 'last_used_at',
|
dataIndex: 'last_used_at',
|
||||||
key: 'last_used_at',
|
key: 'last_used_at',
|
||||||
width: 160,
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'),
|
render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 180,
|
width: 180,
|
||||||
|
fixed: 'right',
|
||||||
render: (_, row: ServiceRow) =>
|
render: (_, row: ServiceRow) =>
|
||||||
row.status === 'active' ? (
|
row.status === 'active' ? (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
@@ -197,6 +220,8 @@ export default function Services() {
|
|||||||
rowKey="service_id"
|
rowKey="service_id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{ pageSize: 15 }}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1540 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 注册服务表单 */}
|
{/* 注册服务表单 */}
|
||||||
@@ -216,6 +241,13 @@ export default function Services() {
|
|||||||
>
|
>
|
||||||
<Input placeholder="如 erp / crm" />
|
<Input placeholder="如 erp / crm" />
|
||||||
</Form.Item>
|
</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="说明">
|
<Form.Item name="description" label="说明">
|
||||||
<Input.TextArea rows={2} placeholder="服务用途说明" />
|
<Input.TextArea rows={2} placeholder="服务用途说明" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -38,8 +38,25 @@ export default function Tokens() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [created, setCreated] = useState<CreateResult | null>(null);
|
const [created, setCreated] = useState<CreateResult | null>(null);
|
||||||
const [services, setServices] = useState<ServiceRow[]>([]);
|
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||||
|
const [jsonRow, setJsonRow] = useState<TokenRow | null>(null);
|
||||||
const [form] = Form.useForm();
|
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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -110,10 +127,26 @@ export default function Tokens() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopy = (text: string) => {
|
const onCopy = async (text: string) => {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
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('已复制');
|
message.success('已复制');
|
||||||
});
|
} catch {
|
||||||
|
message.error('复制失败,请手动选择文本复制');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
@@ -141,14 +174,26 @@ export default function Tokens() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<TokenRow> = [
|
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',
|
dataIndex: 'service_scope',
|
||||||
key: 'service_scope',
|
key: 'service_scope',
|
||||||
width: 80,
|
width: 180,
|
||||||
render: (s: string) => <Tag color="blue">{s}</Tag>,
|
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',
|
title: 'Token',
|
||||||
dataIndex: 'token_plain',
|
dataIndex: 'token_plain',
|
||||||
@@ -181,19 +226,21 @@ export default function Tokens() {
|
|||||||
width: 160,
|
width: 160,
|
||||||
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '永不过期'),
|
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: '最近调用',
|
title: '最近调用',
|
||||||
dataIndex: 'last_used_at',
|
dataIndex: 'last_used_at',
|
||||||
key: 'last_used_at',
|
key: 'last_used_at',
|
||||||
width: 160,
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
render: (t: string, row: TokenRow) =>
|
render: (t: string, row: TokenRow) =>
|
||||||
t ? `${dayjs(t).format('MM-DD HH:mm')} (${row.last_used_svc || '-'})` : '-',
|
t ? `${dayjs(t).format('MM-DD HH:mm')} (${row.last_used_svc || '-'})` : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 180,
|
width: 240,
|
||||||
|
fixed: 'right',
|
||||||
render: (_, row: TokenRow) =>
|
render: (_, row: TokenRow) =>
|
||||||
row.status === 'active' ? (
|
row.status === 'active' ? (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
@@ -203,6 +250,9 @@ export default function Tokens() {
|
|||||||
<Button type="link" size="small" disabled>
|
<Button type="link" size="small" disabled>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="link" size="small" onClick={() => setJsonRow(row)}>
|
||||||
|
注册JSON
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
) : row.status === 'revoked' ? (
|
) : row.status === 'revoked' ? (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
@@ -236,6 +286,8 @@ export default function Tokens() {
|
|||||||
rowKey="token_id"
|
rowKey="token_id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{ pageSize: 15 }}
|
pagination={{ pageSize: 15 }}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1810 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 签发表单 */}
|
{/* 签发表单 */}
|
||||||
@@ -263,7 +315,7 @@ export default function Tokens() {
|
|||||||
<Select
|
<Select
|
||||||
placeholder="选择 MCP 服务"
|
placeholder="选择 MCP 服务"
|
||||||
options={services.map((s) => ({
|
options={services.map((s) => ({
|
||||||
value: s.service_name,
|
value: s.service_url,
|
||||||
label: s.service_name.toUpperCase(),
|
label: s.service_name.toUpperCase(),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
@@ -297,6 +349,34 @@ export default function Tokens() {
|
|||||||
{created?.message}
|
{created?.message}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
// 生产部署在 /mcp-admin 子路径下,dev 代理路径也需带前缀
|
// 生产部署在 /mcp-admin 子路径下,dev 代理路径也需带前缀
|
||||||
'/mcp-admin/api': {
|
'/mcp-admin/api': {
|
||||||
target: 'https://ai-workshop.digiwincloud.com.cn/mcp-auth-api',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (p) => p.replace(/^\/mcp-admin/, ''),
|
rewrite: (p) => p.replace(/^\/mcp-admin/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS mcp_token (
|
|||||||
token_plain VARCHAR(128), -- 明文 Token(管理后台二次复制用)
|
token_plain VARCHAR(128), -- 明文 Token(管理后台二次复制用)
|
||||||
token_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
|
token_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
|
||||||
client_id VARCHAR(64) NOT NULL, -- 调用方标识(如 trae / partner-a)
|
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
|
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked/expired
|
||||||
expires_at TIMESTAMPTZ, -- null = 永不过期
|
expires_at TIMESTAMPTZ, -- null = 永不过期
|
||||||
description VARCHAR(200), -- 用途说明
|
description VARCHAR(200), -- 用途说明
|
||||||
@@ -56,6 +57,7 @@ CREATE TABLE IF NOT EXISTS admin_user (
|
|||||||
CREATE TABLE IF NOT EXISTS mcp_service (
|
CREATE TABLE IF NOT EXISTS mcp_service (
|
||||||
service_id BIGSERIAL PRIMARY KEY,
|
service_id BIGSERIAL PRIMARY KEY,
|
||||||
service_name VARCHAR(64) UNIQUE NOT NULL, -- erp / crm / ...
|
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 VARCHAR(128) NOT NULL, -- 明文 API Key(管理后台展示用,MCP 服务用此值)
|
||||||
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key),verify-token 校验用
|
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key),verify-token 校验用
|
||||||
description VARCHAR(200),
|
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