From 63cbdf4dca24bd711e34741622265d6a8344f8a0 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 2 Jul 2026 15:42:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=BD=91=E7=AB=99=E8=B4=A6=E5=8F=B7=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E6=A8=A1=E5=9E=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/websites.py | 161 +++++++++++++++++ backend/app/schemas/website.py | 15 ++ backend/app/services/website_client.py | 15 ++ backend/test_sync_upstream_models.py | 241 +++++++++++++++++++++++++ frontend/src/api/index.ts | 11 ++ frontend/src/views/Websites.vue | 105 +++++++++++ 6 files changed, 548 insertions(+) create mode 100644 backend/test_sync_upstream_models.py diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index e530fb0..f7f48c7 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -44,6 +44,8 @@ from app.schemas.website import ( SetConcurrencyRequest, SetConcurrencyItem, SetConcurrencyResponse, + SyncUpstreamModelsItem, + SyncUpstreamModelsResponse, ) from app.services.website_client import Sub2ApiWebsiteClient, _extract_id @@ -1280,6 +1282,165 @@ def set_website_accounts_concurrency( ) +@router.post("/api/websites/{wid}/accounts/sync-upstream-models", response_model=SyncUpstreamModelsResponse) +def sync_website_accounts_upstream_models( + wid: int, + db: Session = Depends(get_db), + _=Depends(get_current_user), +): + """一键同步上游模型""" + website = db.query(Website).filter(Website.id == wid).first() + if not website: + raise HTTPException(404, "website not found") + if website.site_type != "sub2api": + raise HTTPException(400, "only sub2api site supports account model syncing") + + with _client(website) as c: + try: + remote_accounts = c.list_accounts() + except Exception as e: + return SyncUpstreamModelsResponse( + success=False, + message=f"拉取远端账号列表失败: {e}", + items=[] + ) + + if remote_accounts is None: + return SyncUpstreamModelsResponse( + success=False, + message="拉取远端账号列表失败,无法同步上游模型", + items=[] + ) + + remote_map = {} + for acc in remote_accounts: + aid = c.extract_id(acc) + if aid: + remote_map[aid] = acc + + # 1. 查找候选账号:本地 UpstreamGeneratedKey.imported_website_id == wid 且 imported_account_id 非空 + keys = db.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.imported_website_id == wid, + UpstreamGeneratedKey.imported_account_id.isnot(None) + ).all() + + candidates = {} + for key in keys: + aid = key.imported_account_id + if aid not in candidates: + candidates[aid] = { + "account_id": aid, + "db_key": key + } + + if not candidates: + return SyncUpstreamModelsResponse( + success=True, + message="没有找到 SmartUp 导入的有效账号", + items=[] + ) + + items = [] + + for aid, cand in candidates.items(): + # 校验是否存在于远端 + remote_acc = remote_map.get(aid) + if not remote_acc: + items.append(SyncUpstreamModelsItem( + account_id=aid, + account_name=None, + model_count=0, + models=[], + status="skipped", + message="账号在远端已被删除或不存在" + )) + continue + + acc_name = remote_acc.get("name") + + # 校验账号 ID 是否为数字 + try: + int(aid) + except ValueError: + items.append(SyncUpstreamModelsItem( + account_id=aid, + account_name=acc_name, + model_count=0, + models=[], + status="skipped", + message="账号 ID 非数字,跳过同步" + )) + continue + + # 开始同步该账号的模型 + try: + raw_models = c.sync_account_upstream_models(aid) + # 过滤空、去重、排序 + valid_models = sorted(list(set(m.strip() for m in raw_models if m and m.strip()))) + + if not valid_models: + # 如果返回空模型,保守处理为失败,不清空已有的模型白名单配置 + items.append(SyncUpstreamModelsItem( + account_id=aid, + account_name=acc_name, + model_count=0, + models=[], + status="failed", + message="上游同步返回模型列表为空" + )) + continue + + # 构造 model_mapping + model_mapping = {m: m for m in valid_models} + + # 优先读取账号现有的 credentials 并 merge,以防覆盖掉 api_key/base_url 等其他配置 + current_creds = remote_acc.get("credentials") or {} + updated_creds = dict(current_creds) + updated_creds["model_mapping"] = model_mapping + + # 调用更新接口 + c.update_account(aid, {"credentials": updated_creds}) + + items.append(SyncUpstreamModelsItem( + account_id=aid, + account_name=acc_name, + model_count=len(valid_models), + models=valid_models, + status="success", + message=f"成功同步 {len(valid_models)} 个模型" + )) + except Exception as e: + items.append(SyncUpstreamModelsItem( + account_id=aid, + account_name=acc_name, + model_count=0, + models=[], + status="failed", + message=f"同步/保存上游模型失败: {e}" + )) + + success_count = sum(1 for item in items if item.status == "success") + failed_count = sum(1 for item in items if item.status == "failed") + skip_count = sum(1 for item in items if item.status == "skipped") + + success = (failed_count == 0) + msg_parts = [] + if success_count: + msg_parts.append(f"成功 {success_count} 个") + if failed_count: + msg_parts.append(f"失败 {failed_count} 个") + if skip_count: + msg_parts.append(f"跳过 {skip_count} 个") + + message = "同步上游模型执行完毕:" + ",".join(msg_parts) + + return SyncUpstreamModelsResponse( + success=success, + message=message, + items=items + ) + + @router.post("/api/websites/{wid}/groups/organize", response_model=OrganizeGroupsResponse) def organize_website_groups( wid: int, diff --git a/backend/app/schemas/website.py b/backend/app/schemas/website.py index 3c4af20..9a02145 100644 --- a/backend/app/schemas/website.py +++ b/backend/app/schemas/website.py @@ -287,3 +287,18 @@ class SetConcurrencyResponse(BaseModel): success: bool message: str items: list[SetConcurrencyItem] + + +class SyncUpstreamModelsItem(BaseModel): + account_id: str + account_name: Optional[str] = None + model_count: int + models: list[str] = Field(default_factory=list) + status: str # "success" | "skipped" | "failed" + message: str + + +class SyncUpstreamModelsResponse(BaseModel): + success: bool + message: str + items: list[SyncUpstreamModelsItem] diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index bfa4229..6745329 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -312,6 +312,21 @@ class Sub2ApiWebsiteClient: data = _unwrap_data(resp) return data if isinstance(data, dict) else {"value": data} + def sync_account_upstream_models(self, account_id: str, endpoint: str = "/accounts") -> list[str]: + """拉取上游真实支持模型并返回列表。""" + quoted_id = quote(account_id, safe="") + path = f"{endpoint}/{quoted_id}/models/sync-upstream" + resp = self._request("POST", path) + data = _unwrap_data(resp) + if isinstance(data, list): + return [str(m) for m in data if m] + if isinstance(data, dict): + for key in ("models", "items", "data", "list"): + val = data.get(key) + if isinstance(val, list): + return [str(m) for m in val if m] + raise WebsiteError(f"同步上游模型接口返回的数据格式不正确: {resp}") + @staticmethod def _unwrap_list(value: dict) -> list | None: """递归展开嵌套的列表包装:data.items、data.data、items、accounts 等。""" diff --git a/backend/test_sync_upstream_models.py b/backend/test_sync_upstream_models.py new file mode 100644 index 0000000..bea411b --- /dev/null +++ b/backend/test_sync_upstream_models.py @@ -0,0 +1,241 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base +from app.models.upstream import Upstream +from app.models.upstream_key import UpstreamGeneratedKey +from app.models.website import Website +from app.routers.websites import sync_website_accounts_upstream_models +from app.schemas.website import SyncUpstreamModelsResponse + +@pytest.fixture() +def db_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key + Base.metadata.create_all(bind=engine) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +def test_sync_upstream_models_workflow(db_session, monkeypatch): + # 1. 创建网站与上游 + w = Website( + id=1, + name="Sub2Api site", + site_type="sub2api", + base_url="http://sub2api", + api_prefix="api/v1", + auth_type="bearer", + auth_config_json='{"token": "tok1"}' + ) + db_session.add(w) + + up = Upstream(id=10, name="Upstream A", base_url="http://upstream-a") + db_session.add(up) + db_session.commit() + + # 2. 插入本地导入的 Key 记录 + # k1: 属于本站,且在远端正常存在的账号 + k1 = UpstreamGeneratedKey( + id=101, + upstream_id=up.id, + group_id="g1", + key_name="key-101", + key_value="val-101", + status="active", + imported_website_id=w.id, + imported_account_id="1001", + ) + # k2: 属于本站,但账号 ID 无法转为数字 + k2 = UpstreamGeneratedKey( + id=102, + upstream_id=up.id, + group_id="g1", + key_name="key-102", + key_value="val-102", + status="active", + imported_website_id=w.id, + imported_account_id="abc", + ) + # k3: 属于其他站的账号 + k3 = UpstreamGeneratedKey( + id=103, + upstream_id=up.id, + group_id="g1", + key_name="key-103", + key_value="val-103", + status="active", + imported_website_id=999, + imported_account_id="1003", + ) + # k4: 属于本站,但远端已被删除的账号 + k4 = UpstreamGeneratedKey( + id=104, + upstream_id=up.id, + group_id="g1", + key_name="key-104", + key_value="val-104", + status="active", + imported_website_id=w.id, + imported_account_id="1004", + ) + # k5: 属于本站,但同步接口返回空模型(应当标记为 failed,且不覆盖) + k5 = UpstreamGeneratedKey( + id=105, + upstream_id=up.id, + group_id="g1", + key_name="key-105", + key_value="val-105", + status="active", + imported_website_id=w.id, + imported_account_id="1005", + ) + # k6: 属于本站,但同步接口抛错(应当标记为 failed,且不覆盖) + k6 = UpstreamGeneratedKey( + id=106, + upstream_id=up.id, + group_id="g1", + key_name="key-106", + key_value="val-106", + status="active", + imported_website_id=w.id, + imported_account_id="1006", + ) + db_session.add_all([k1, k2, k3, k4, k5, k6]) + db_session.commit() + + # Mock Sub2Api 客户端 + sync_calls = [] + update_calls = [] + closed_count = 0 + + class MockClient: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + nonlocal closed_count + closed_count += 1 + return False + + def list_accounts(self): + return [ + {"id": 1001, "name": "acc-1001", "credentials": {"api_key": "k1", "model_mapping": {"old": "old"}}}, + {"id": "abc", "name": "acc-abc", "credentials": {}}, + {"id": 1005, "name": "acc-1005", "credentials": {"api_key": "k5"}}, + {"id": 1006, "name": "acc-1006", "credentials": {"api_key": "k6"}}, + ] + + def extract_id(self, val): + return str(val.get("id")) + + def sync_account_upstream_models(self, account_id): + sync_calls.append(account_id) + if account_id == "1001": + return ["gpt-4", "gpt-3.5-turbo", "", "gpt-4 "] + elif account_id == "1005": + return [] + elif account_id == "1006": + raise Exception("Network Error") + return [] + + def update_account(self, account_id, body): + update_calls.append((account_id, body)) + + monkeypatch.setattr("app.routers.websites._client", lambda row: MockClient()) + + res = sync_website_accounts_upstream_models(wid=w.id, db=db_session) + + # 验证总体结果 + assert res.success is False # 存在 failed 账号,success 应该为 False + assert "成功 1 个" in res.message + assert "失败 2 个" in res.message + assert "跳过 2 个" in res.message + + items = res.items + assert len(items) == 5 + + # 1001 成功 + item_1001 = next(i for i in items if i.account_id == "1001") + assert item_1001.status == "success" + assert item_1001.model_count == 2 + assert item_1001.models == ["gpt-3.5-turbo", "gpt-4"] + + # 1001 的 update 应当 merge credentials + assert len(update_calls) == 1 + assert update_calls[0][0] == "1001" + assert update_calls[0][1]["credentials"]["api_key"] == "k1" + assert update_calls[0][1]["credentials"]["model_mapping"] == { + "gpt-3.5-turbo": "gpt-3.5-turbo", + "gpt-4": "gpt-4" + } + + # abc 跳过 + item_abc = next(i for i in items if i.account_id == "abc") + assert item_abc.status == "skipped" + assert "非数字" in item_abc.message + + # 1004 跳过 + item_1004 = next(i for i in items if i.account_id == "1004") + assert item_1004.status == "skipped" + assert "不存在" in item_1004.message + + # 1005 失败 (空模型) + item_1005 = next(i for i in items if i.account_id == "1005") + assert item_1005.status == "failed" + assert "为空" in item_1005.message + + # 1006 失败 (抛错) + item_1006 = next(i for i in items if i.account_id == "1006") + assert item_1006.status == "failed" + assert "Network Error" in item_1006.message + + assert closed_count == 1 + + +def test_sync_upstream_models_list_accounts_none(db_session, monkeypatch): + w = Website( + id=1, + name="Sub2Api site", + site_type="sub2api", + base_url="http://sub2api", + api_prefix="api/v1", + auth_type="bearer", + auth_config_json='{"token": "tok1"}' + ) + db_session.add(w) + db_session.commit() + + closed_count = 0 + + class MockClientNone: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + nonlocal closed_count + closed_count += 1 + return False + + def list_accounts(self): + return None + + monkeypatch.setattr("app.routers.websites._client", lambda row: MockClientNone()) + + res = sync_website_accounts_upstream_models(wid=w.id, db=db_session) + + assert res.success is False + assert "拉取远端账号列表失败" in res.message + assert len(res.items) == 0 + assert closed_count == 1 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3cf2711..0013f53 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -352,6 +352,15 @@ export interface SetConcurrencyItem { message: string } +export interface SyncUpstreamModelsItem { + account_id: string + account_name: string | null + model_count: number + models: string[] + status: string + message: string +} + export const websitesApi = { list: () => api.get('/api/websites'), create: (data: WebsiteForm) => api.post('/api/websites', data), @@ -385,6 +394,8 @@ export const websitesApi = { api.post<{ success: boolean; message: string; items: CleanupInvalidAccountsItem[] }>(`/api/websites/${id}/accounts/cleanup-invalid/execute`), setConcurrency: (id: number, data: { concurrency: number }) => api.post<{ success: boolean; message: string; items: SetConcurrencyItem[] }>(`/api/websites/${id}/accounts/set-concurrency`, data), + syncUpstreamModels: (id: number) => + api.post<{ success: boolean; message: string; items: SyncUpstreamModelsItem[] }>(`/api/websites/${id}/accounts/sync-upstream-models`), listBindings: () => api.get('/api/group-bindings'), createBinding: (data: GroupBindingForm) => api.post('/api/group-bindings', data), updateBinding: (id: number, data: Partial) => api.put(`/api/group-bindings/${id}`, data), diff --git a/frontend/src/views/Websites.vue b/frontend/src/views/Websites.vue index 475f77b..a822254 100644 --- a/frontend/src/views/Websites.vue +++ b/frontend/src/views/Websites.vue @@ -142,6 +142,15 @@ > 设置账号并发 + + 同步上游模型 + 新增绑定
@@ -931,6 +940,62 @@
+ + + +
+
+ {{ syncModelsMessage }} +
+ + + + + + + + + + + + +
+ + +
@@ -957,6 +1022,7 @@ import { type OrganizeGroupsItem, type CleanupInvalidAccountsItem, type SetConcurrencyItem, + type SyncUpstreamModelsItem, } from '@/api' const websites = ref<(WebsiteData & { _testing?: boolean })[]>([]) @@ -1119,6 +1185,11 @@ const targetConcurrency = ref(100) const concurrencyMessage = ref('') const concurrencyResults = ref([]) +const syncModelsDialog = ref(false) +const syncModelsExecuting = ref(false) +const syncModelsMessage = ref('') +const syncModelsResults = ref([]) + const upstreamGroupOptions = computed(() => { const rows: Array<{ key: string; label: string; rate: string | number; source: BindingSourceGroup }> = [] for (const upstream of upstreams.value) { @@ -1866,6 +1937,40 @@ async function submitSetConcurrency() { } } +async function triggerSyncUpstreamModels() { + if (!selectedWebsite.value) return + try { + await ElMessageBox.confirm( + '确认开始同步上游模型?此操作将仅拉取当前网站中由 SmartUp 导入账号的上游支持模型,并自动更新写入对应的模型白名单/映射配置。', + '确认同步上游模型', + { type: 'warning' } + ) + } catch { + return + } + + syncModelsResults.value = [] + syncModelsMessage.value = '' + syncModelsExecuting.value = true + syncModelsDialog.value = true + + try { + const res = await websitesApi.syncUpstreamModels(selectedWebsite.value.id) + syncModelsMessage.value = res.data.message + syncModelsResults.value = res.data.items + if (res.data.success) { + ElMessage.success('同步完成') + } else { + ElMessage.warning(res.data.message || '部分账号同步模型失败') + } + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '同步上游模型失败') + syncModelsDialog.value = false + } finally { + syncModelsExecuting.value = false + } +} + async function toggleBinding(row: GroupBindingData) { try { await websitesApi.updateBinding(row.id, { enabled: row.enabled })