feat: 实现一键同步网站账号上游模型功能

This commit is contained in:
liumangmang
2026-07-02 15:43:00 +08:00
parent c70b68d814
commit 63cbdf4dca
6 changed files with 548 additions and 0 deletions
+161
View File
@@ -44,6 +44,8 @@ from app.schemas.website import (
SetConcurrencyRequest, SetConcurrencyRequest,
SetConcurrencyItem, SetConcurrencyItem,
SetConcurrencyResponse, SetConcurrencyResponse,
SyncUpstreamModelsItem,
SyncUpstreamModelsResponse,
) )
from app.services.website_client import Sub2ApiWebsiteClient, _extract_id 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) @router.post("/api/websites/{wid}/groups/organize", response_model=OrganizeGroupsResponse)
def organize_website_groups( def organize_website_groups(
wid: int, wid: int,
+15
View File
@@ -287,3 +287,18 @@ class SetConcurrencyResponse(BaseModel):
success: bool success: bool
message: str message: str
items: list[SetConcurrencyItem] 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]
+15
View File
@@ -312,6 +312,21 @@ class Sub2ApiWebsiteClient:
data = _unwrap_data(resp) data = _unwrap_data(resp)
return data if isinstance(data, dict) else {"value": data} 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 @staticmethod
def _unwrap_list(value: dict) -> list | None: def _unwrap_list(value: dict) -> list | None:
"""递归展开嵌套的列表包装:data.items、data.data、items、accounts 等。""" """递归展开嵌套的列表包装:data.items、data.data、items、accounts 等。"""
+241
View File
@@ -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
+11
View File
@@ -352,6 +352,15 @@ export interface SetConcurrencyItem {
message: string message: string
} }
export interface SyncUpstreamModelsItem {
account_id: string
account_name: string | null
model_count: number
models: string[]
status: string
message: string
}
export const websitesApi = { export const websitesApi = {
list: () => api.get<WebsiteData[]>('/api/websites'), list: () => api.get<WebsiteData[]>('/api/websites'),
create: (data: WebsiteForm) => api.post<WebsiteData>('/api/websites', data), create: (data: WebsiteForm) => api.post<WebsiteData>('/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`), api.post<{ success: boolean; message: string; items: CleanupInvalidAccountsItem[] }>(`/api/websites/${id}/accounts/cleanup-invalid/execute`),
setConcurrency: (id: number, data: { concurrency: number }) => setConcurrency: (id: number, data: { concurrency: number }) =>
api.post<{ success: boolean; message: string; items: SetConcurrencyItem[] }>(`/api/websites/${id}/accounts/set-concurrency`, data), 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<GroupBindingData[]>('/api/group-bindings'), listBindings: () => api.get<GroupBindingData[]>('/api/group-bindings'),
createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data), createBinding: (data: GroupBindingForm) => api.post<GroupBindingData>('/api/group-bindings', data),
updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data), updateBinding: (id: number, data: Partial<GroupBindingForm>) => api.put<GroupBindingData>(`/api/group-bindings/${id}`, data),
+105
View File
@@ -142,6 +142,15 @@
> >
设置账号并发 设置账号并发
</el-button> </el-button>
<el-button
size="small"
text
:disabled="!selectedWebsite"
@click="triggerSyncUpstreamModels"
title="一键同步当前网站已导入账号的上游支持模型"
>
同步上游模型
</el-button>
<el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button> <el-button size="small" text :disabled="websites.length === 0" @click="openBindingCreate(selectedWebsite || websites[0])">新增绑定</el-button>
</div> </div>
<div class="binding-list" v-loading="bindingLoading"> <div class="binding-list" v-loading="bindingLoading">
@@ -931,6 +940,62 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 同步上游模型结果弹窗 -->
<el-dialog
v-model="syncModelsDialog"
title="一键同步上游模型结果"
width="850px"
destroy-on-close
>
<div v-loading="syncModelsExecuting">
<div v-if="syncModelsMessage" style="margin-bottom: 15px; font-weight: bold; color: var(--el-color-primary);">
{{ syncModelsMessage }}
</div>
<el-table :data="syncModelsResults" border stripe size="small" style="width: 100%; max-height: 400px; overflow-y: auto;">
<el-table-column prop="account_id" label="账号 ID" width="150" />
<el-table-column prop="account_name" label="账号名" min-width="180" show-overflow-tooltip />
<el-table-column prop="model_count" label="同步模型数" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.models && row.models.length > 0"
class="box-item"
effect="dark"
placement="top"
>
<template #content>
<div style="max-width: 300px; word-break: break-all;">
{{ row.models.join(', ') }}
</div>
</template>
<el-tag type="info" size="small" style="cursor: pointer;">
{{ row.model_count }}
</el-tag>
</el-tooltip>
<span v-else class="text-muted">0 </span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 'success'" type="success" size="small">成功</el-tag>
<el-tag v-else-if="row.status === 'skipped'" type="warning" size="small">跳过</el-tag>
<el-tag v-else-if="row.status === 'failed'" type="danger" size="small">失败</el-tag>
<el-tag v-else type="info" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明/原因" min-width="200" show-overflow-tooltip />
</el-table>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="syncModelsDialog = false" :disabled="syncModelsExecuting">
关闭
</el-button>
</div>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -957,6 +1022,7 @@ import {
type OrganizeGroupsItem, type OrganizeGroupsItem,
type CleanupInvalidAccountsItem, type CleanupInvalidAccountsItem,
type SetConcurrencyItem, type SetConcurrencyItem,
type SyncUpstreamModelsItem,
} from '@/api' } from '@/api'
const websites = ref<(WebsiteData & { _testing?: boolean })[]>([]) const websites = ref<(WebsiteData & { _testing?: boolean })[]>([])
@@ -1119,6 +1185,11 @@ const targetConcurrency = ref(100)
const concurrencyMessage = ref('') const concurrencyMessage = ref('')
const concurrencyResults = ref<SetConcurrencyItem[]>([]) const concurrencyResults = ref<SetConcurrencyItem[]>([])
const syncModelsDialog = ref(false)
const syncModelsExecuting = ref(false)
const syncModelsMessage = ref('')
const syncModelsResults = ref<SyncUpstreamModelsItem[]>([])
const upstreamGroupOptions = computed(() => { const upstreamGroupOptions = computed(() => {
const rows: Array<{ key: string; label: string; rate: string | number; source: BindingSourceGroup }> = [] const rows: Array<{ key: string; label: string; rate: string | number; source: BindingSourceGroup }> = []
for (const upstream of upstreams.value) { 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) { async function toggleBinding(row: GroupBindingData) {
try { try {
await websitesApi.updateBinding(row.id, { enabled: row.enabled }) await websitesApi.updateBinding(row.id, { enabled: row.enabled })