From 3a31d185a478753541505d37404a5bed9db0ceb3 Mon Sep 17 00:00:00 2001 From: SmartUp Developer Date: Sun, 24 May 2026 23:18:40 +0800 Subject: [PATCH] fix: reuse upstream keys for account import --- backend/app/routers/upstreams.py | 42 ++++++++-- backend/app/schemas/upstream.py | 1 + backend/app/services/scheduler.py | 20 ++++- backend/test_upstream_key_sync.py | 123 ++++++++++++++++++++++++++++++ frontend/src/api/index.ts | 1 + frontend/src/views/Websites.vue | 93 +++++++++++++++++----- 6 files changed, 253 insertions(+), 27 deletions(-) diff --git a/backend/app/routers/upstreams.py b/backend/app/routers/upstreams.py index 8b75f2d..a866ebe 100644 --- a/backend/app/routers/upstreams.py +++ b/backend/app/routers/upstreams.py @@ -5,7 +5,7 @@ import json import logging import re from datetime import datetime, timezone -from typing import List +from typing import Any, List logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ from app.schemas.upstream import ( GeneratedUpstreamKeyResponse, UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult ) -from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot, mask_secret +from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot, mask_secret, _extract_key_value from app.services.snapshot_service import diff_snapshots from app.services import scheduler as sched_svc from app.services import webhook_service @@ -65,6 +65,7 @@ def _key_response(row: UpstreamGeneratedKey, include_value: bool = False) -> Gen imported_at=row.imported_at, created_at=row.created_at, updated_at=row.updated_at, + has_key_value=bool(row.key_value), ) @@ -78,6 +79,17 @@ def _mask_auth_config(auth_type: str, cfg: dict) -> dict: return masked +def _extract_plaintext_key(payload: dict[str, Any] | None) -> str: + if not isinstance(payload, dict): + return "" + key_value = _extract_key_value(payload) + if not key_value: + return "" + if "*" in key_value: + return "" + return key_value + + def _to_response(u: Upstream) -> UpstreamResponse: cfg = json.loads(u.auth_config_json or "{}") return UpstreamResponse( @@ -164,9 +176,21 @@ def _ensure_group_key( except Exception: existing = None if existing: + key_id = str(existing.get("id") or "") + key_value = _extract_plaintext_key(existing) + masked = mask_secret(key_value) if key_value else (existing.get("masked_key") or existing.get("key") or "") + row.key_id = key_id or row.key_id + if key_value: + row.key_value = key_value + row.masked_key = masked + elif masked: + row.masked_key = str(masked) + row.raw_json = json.dumps(existing, ensure_ascii=False) row.status = "exists" row.updated_at = datetime.now(timezone.utc) + db.add(row) db.commit() + db.refresh(row) return _key_response(row, include_value=False) # 远端不存在,需要重新创建 row.status = "replaced" @@ -175,10 +199,16 @@ def _ensure_group_key( existing = client.find_smartup_group_key(gid, stable_name, prefix) if existing: key_id = str(existing.get("id") or "") - masked = existing.get("masked_key") or existing.get("key") or "" + key_value = _extract_plaintext_key(existing) + masked = mask_secret(key_value) if key_value else (existing.get("masked_key") or existing.get("key") or "") if row: row.key_id = key_id or row.key_id - row.masked_key = masked or row.masked_key + if key_value: + row.key_value = key_value + row.masked_key = masked + elif masked: + row.masked_key = str(masked) + row.raw_json = json.dumps(existing, ensure_ascii=False) row.status = "exists" row.updated_at = datetime.now(timezone.utc) else: @@ -188,13 +218,13 @@ def _ensure_group_key( group_name=gname, key_id=key_id or None, key_name=stable_name, - key_value="", + key_value=key_value or "", masked_key=masked, raw_json=json.dumps(existing, ensure_ascii=False), managed_prefix=prefix, status="exists", ) - db.add(row) + db.add(row) db.commit() db.refresh(row) return _key_response(row, include_value=False) diff --git a/backend/app/schemas/upstream.py b/backend/app/schemas/upstream.py index 776a76a..059e75b 100644 --- a/backend/app/schemas/upstream.py +++ b/backend/app/schemas/upstream.py @@ -118,6 +118,7 @@ class GeneratedUpstreamKeyResponse(BaseModel): imported_at: Optional[datetime] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None + has_key_value: bool = False class GenerateKeysByGroupsResponse(BaseModel): diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 5bab0e7..8de6254 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -223,13 +223,25 @@ def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at: for row in key_rows: # 1. 分组已不在当前快照中 → 删除本地记录 if row.group_id not in active_group_ids: - db.delete(row) - logger.info("removed key %s (group %s no longer in snapshot)", row.id, row.group_id) + if row.imported_website_id and row.imported_account_id: + row.status = "orphaned" + row.error = "来源分组已不存在" + row.updated_at = captured_at + logger.info("marked key %s orphaned (group %s no longer in snapshot)", row.id, row.group_id) + else: + db.delete(row) + logger.info("removed key %s (group %s no longer in snapshot)", row.id, row.group_id) continue # 2. 远端查询成功但 key_id 不在列表中 → 删除本地记录 if row.key_id and remote_key_ids is not None and row.key_id not in remote_key_ids: - db.delete(row) - logger.info("removed key %s (key_id %s gone from remote)", row.id, row.key_id) + if row.imported_website_id and row.imported_account_id: + row.status = "orphaned" + row.error = "远端 Key 已不存在" + row.updated_at = captured_at + logger.info("marked key %s orphaned (key_id %s gone from remote)", row.id, row.key_id) + else: + db.delete(row) + logger.info("removed key %s (key_id %s gone from remote)", row.id, row.key_id) continue # 3. 更新同步时间戳(仅当查询成功且 Key 仍在远端时) if remote_key_ids is not None and row.key_id in remote_key_ids: diff --git a/backend/test_upstream_key_sync.py b/backend/test_upstream_key_sync.py index a5f883f..b99ef63 100644 --- a/backend/test_upstream_key_sync.py +++ b/backend/test_upstream_key_sync.py @@ -262,6 +262,64 @@ def test_ensure_group_key_reuses_old_record(db_session, monkeypatch): assert rows[0].key_value == "sk-new-value" +def test_ensure_group_key_backfills_plaintext_from_remote_existing_key(db_session): + """远端已存在的 SmartUp Key 如果列表接口返回明文,应补写到本地 key_value。""" + from app.routers.upstreams import _ensure_group_key + from app.models.upstream_key import UpstreamGeneratedKey + from app.schemas.upstream import GenerateKeysByGroupsRequest + from app.services.upstream_client import mask_secret + + upstream = Upstream(name="Test", base_url="http://local", api_prefix="/api/v1", + auth_type="bearer", auth_config_json="{}", + groups_endpoint="/groups", rate_endpoint="/rates") + db_session.add(upstream) + db_session.commit() + db_session.refresh(upstream) + + db_session.add(UpstreamGeneratedKey( + upstream_id=upstream.id, + group_id="vip", + group_name="VIP", + key_name="SmartUp-Test-vip", + key_value="", + masked_key="sk-old-masked", + key_id="remote-123", + managed_prefix="SmartUp", + )) + db_session.commit() + + class MockClient: + def find_smartup_group_key(self, gid, name, prefix): + return { + "id": "remote-123", + "name": "SmartUp-Test-vip", + "key": "sk-remote-plain-value-1234567890abcdef", + "masked_key": "sk-re************cdef", + } + + def create_api_key(self, *args, **kwargs): + raise AssertionError("create_api_key should not be called when remote key exists") + + group = {"id": "vip", "name": "VIP", "rate_multiplier": 1} + body = GenerateKeysByGroupsRequest( + group_ids=["vip"], + name_prefix="SmartUp", + quota=0, + endpoint="/keys", + ) + result = _ensure_group_key(db_session, MockClient(), upstream, group, "SmartUp", body) + + assert result.status == "exists" + assert result.has_key_value is True + row = db_session.query(UpstreamGeneratedKey).filter( + UpstreamGeneratedKey.upstream_id == upstream.id, + UpstreamGeneratedKey.group_id == "vip", + ).one() + assert row.key_value == "sk-remote-plain-value-1234567890abcdef" + assert row.masked_key == mask_secret(row.key_value) + assert row.status == "exists" + + def test_sync_removes_remote_key_when_list_empty(db_session, monkeypatch): """同步函数在远端返回空列表时应删除本地 key_id 对应的记录。""" from app.services import scheduler as sched_mod @@ -310,6 +368,71 @@ def test_sync_removes_remote_key_when_list_empty(db_session, monkeypatch): assert len(remaining) == 0, f"expected 0 after sync with empty remote, got {len(remaining)}" +def test_sync_marks_imported_key_orphaned_when_remote_key_missing(db_session, monkeypatch): + """已导入账号管理的 Key 远端消失时保留本地行,避免丢失目标账号关联。""" + from app.services import scheduler as sched_mod + from app.models.upstream_key import UpstreamGeneratedKey + from app.services.upstream_client import UpstreamClient + + website = Website( + name="Target", + site_type="sub2api", + base_url="http://target.local", + api_prefix="/api/v1/admin", + auth_type="api_key", + auth_config_json="{}", + groups_endpoint="/groups", + group_update_endpoint="/groups/{id}", + ) + upstream = Upstream(name="Test", base_url="http://local", api_prefix="/api/v1", + auth_type="bearer", auth_config_json="{}", + groups_endpoint="/groups", rate_endpoint="/rates") + db_session.add_all([website, upstream]) + db_session.commit() + db_session.refresh(website) + db_session.refresh(upstream) + + db_session.add(UpstreamGeneratedKey( + upstream_id=upstream.id, + group_id="vip", + group_name="VIP", + key_name="SmartUp-Test-vip", + key_value="sk-vip", + managed_prefix="SmartUp", + key_id="remote-key-id", + imported_website_id=website.id, + imported_account_id="account-101", + )) + db_session.commit() + + monkeypatch.setattr(UpstreamClient, "list_api_keys", lambda self, **kw: []) + monkeypatch.setattr(UpstreamClient, "login", lambda self: None) + monkeypatch.setattr(UpstreamClient, "close", lambda self: None) + monkeypatch.setattr(UpstreamClient, "__enter__", lambda self: self) + monkeypatch.setattr(UpstreamClient, "__exit__", lambda self, *a: None) + monkeypatch.setattr(sched_mod, "SessionLocal", lambda: db_session) + original_close = db_session.close + monkeypatch.setattr(db_session, "close", lambda: None) + + snapshot = { + "upstream_id": upstream.id, + "groups": {"vip": {"group_id": "vip", "rate": "1"}}, + "captured_at": datetime.now(timezone.utc).isoformat(), + } + + captured_at = datetime.now(timezone.utc) + sched_mod._sync_upstream_keys(upstream.id, snapshot, captured_at) + + monkeypatch.setattr(db_session, "close", original_close) + remaining = db_session.query(UpstreamGeneratedKey).all() + assert len(remaining) == 1 + row = remaining[0] + assert row.status == "orphaned" + assert row.imported_website_id == website.id + assert row.imported_account_id == "account-101" + assert row.error == "远端 Key 已不存在" + + def test_migration_function_integration(monkeypatch): """直接调用 _migrate_upstream_generated_keys() 验证列新增和索引创建。""" from app.database import _migrate_upstream_generated_keys, engine as real_engine diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 62d289b..2a850ab 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -123,6 +123,7 @@ export interface GeneratedUpstreamKey { imported_account_id: string | null imported_at: string | null created_at: string | null + has_key_value: boolean } export interface GenerateKeysByGroupsForm { diff --git a/frontend/src/views/Websites.vue b/frontend/src/views/Websites.vue index 817d509..58e9779 100644 --- a/frontend/src/views/Websites.vue +++ b/frontend/src/views/Websites.vue @@ -98,6 +98,16 @@
我的网站分组
{{ selectedWebsite?.name || '请选择网站' }}
+ + + 拉取分组 @@ -116,11 +126,24 @@
-
分组绑定
+
+
分组绑定
+
{{ selectedWebsite?.name || '请选择网站' }}
+
+ + + 新增绑定
-
+
{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}
@@ -135,7 +158,7 @@
-
暂无绑定
+
暂无绑定
@@ -331,7 +354,7 @@ 刷新导入状态 - 已校验 {{ importSyncStatus.total }} 个,清除 {{ importSyncStatus.cleared }} 个失效标记 + 已校验已导入标记 {{ importSyncStatus.total }} 个,清除 {{ importSyncStatus.cleared }} 个失效标记 @@ -366,6 +389,9 @@ 全选可导入 Key 清空
+
+ 总计 {{ importGeneratedKeys.length }} 个,现可导入 {{ importableGeneratedKeys.length }} 个,已导入 {{ importedGeneratedKeyCount }} 个,无明文 {{ missingPlaintextKeyCount }} 个 +
snapshotsByUpstream.value[importGroups function isImportableGeneratedKey(item: GeneratedUpstreamKey) { return item.id !== null + && item.has_key_value && item.status !== 'failed' && !(item.imported_website_id === importAccountsForm.value.website_id && item.imported_account_id) } @@ -581,6 +608,21 @@ const importableGeneratedKeys = computed(() => importGeneratedKeys.value.filter(isImportableGeneratedKey), ) +const importedGeneratedKeyCount = computed(() => + importGeneratedKeys.value.filter((item) => + item.imported_website_id === importAccountsForm.value.website_id && Boolean(item.imported_account_id), + ).length, +) + +const missingPlaintextKeyCount = computed(() => + importGeneratedKeys.value.filter((item) => !item.has_key_value).length, +) + +const selectedWebsiteBindings = computed(() => { + if (!selectedWebsite.value) return [] + return bindings.value.filter((binding) => binding.website_id === selectedWebsite.value?.id) +}) + const selectedAccountGroups = computed(() => { const selected = new Set(importAccountsForm.value.upstream_key_ids) const rows = importableGeneratedKeys.value.filter((item) => item.id !== null && selected.has(item.id)) @@ -605,6 +647,14 @@ async function loadWebsites() { } } +async function onSelectedWebsiteChange(value: number | string) { + const nextId = Number(value) + const next = websites.value.find((site) => site.id === nextId) + if (!next) return + selectedWebsite.value = next + await loadWebsiteGroups() +} + async function loadUpstreamGroups() { const upstreamRes = await upstreamsApi.list() upstreams.value = upstreamRes.data @@ -682,12 +732,13 @@ async function loadLogs() { async function syncImportStatus() { const websiteId = importAccountsForm.value.website_id const upstreamId = importAccountsForm.value.upstream_id - if (!websiteId || !upstreamId) return + if (!websiteId || !upstreamId) return false syncingImportStatus.value = true + let reloaded = false try { const res = await websitesApi.syncImportedUpstreamKeys(websiteId, { upstream_id: upstreamId }) // 校验请求完成时表单未切换 - if (importAccountsForm.value.website_id !== websiteId || importAccountsForm.value.upstream_id !== upstreamId) return + if (importAccountsForm.value.website_id !== websiteId || importAccountsForm.value.upstream_id !== upstreamId) return false const items = res.data.items importSyncStatus.value = { total: items.length, @@ -699,12 +750,14 @@ async function syncImportStatus() { } if (importAccountsForm.value.upstream_id === upstreamId) { await loadImportGeneratedKeys(upstreamId) + reloaded = true } } catch (e: any) { ElMessage.error(e.response?.data?.detail || '同步导入状态失败') } finally { syncingImportStatus.value = false } + return reloaded } async function loadImportGeneratedKeys(upstreamId: number) { @@ -977,30 +1030,26 @@ async function openImportAccounts(site?: WebsiteData | null) { } importAccountResults.value = [] importSyncStatus.value = null - await Promise.all([ - loadImportTargetGroups(importAccountsForm.value.website_id), - loadImportGeneratedKeys(importAccountsForm.value.upstream_id), - ]) + await loadImportTargetGroups(importAccountsForm.value.website_id) // 打开弹窗后自动同步导入状态(校验远端账号是否仍存在) - await syncImportStatus() - await loadImportGeneratedKeys(importAccountsForm.value.upstream_id) + const reloaded = await syncImportStatus() + if (!reloaded) await loadImportGeneratedKeys(importAccountsForm.value.upstream_id) importAccountsDialog.value = true } async function onImportAccountWebsiteChange(value: number) { importAccountsForm.value.target_group_map = {} await loadImportTargetGroups(value) - await syncImportStatus() - await loadImportGeneratedKeys(importAccountsForm.value.upstream_id) + const reloaded = await syncImportStatus() + if (!reloaded) await loadImportGeneratedKeys(importAccountsForm.value.upstream_id) } async function onImportAccountUpstreamChange(value: number) { importAccountsForm.value.upstream_key_ids = [] importAccountsForm.value.target_group_map = {} importAccountResults.value = [] - await loadImportGeneratedKeys(value) - await syncImportStatus() - await loadImportGeneratedKeys(value) + const reloaded = await syncImportStatus() + if (!reloaded) await loadImportGeneratedKeys(value) } function onPlatformModeChange(value: string) { @@ -1199,6 +1248,11 @@ onMounted(loadAll) color: var(--el-color-danger); } +.site-switcher { + width: 180px; + flex-shrink: 0; +} + .binding-actions { display: flex; align-items: center; @@ -1216,6 +1270,11 @@ onMounted(loadAll) --el-button-border-color: var(--el-color-danger-light-5, #fbc4c4); } .binding-list { min-height: 120px; } +.import-stats { + margin: 0 0 8px; + color: var(--text-muted); + font-size: 12px; +} .binding-item { display: flex; align-items: center;