diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 3de0026..e242570 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -462,6 +462,17 @@ def import_groups_from_upstream( ) +SUPPORTED_PLATFORMS = {"openai", "anthropic", "gemini", "antigravity"} + + +def _resolve_platform(text: str, group_snapshot: dict | None, fallback: str = "openai") -> str: + if group_snapshot and isinstance(group_snapshot, dict): + platform = group_snapshot.get("platform") + if platform and str(platform).lower() in SUPPORTED_PLATFORMS: + return str(platform).lower() + return _detect_platform(text, fallback) + + def _detect_platform(text: str, fallback: str = "openai") -> str: """根据 Key 名或分组名关键词判断平台类型。""" lower = text.lower() @@ -496,6 +507,12 @@ def sync_imported_upstream_keys( .all() ) items: list[ImportAccountItem] = [] + # 预载最新快照映射,用于结构化 platform 解析 + upstream_ids = {row.upstream_id for row in rows} + latest_snapshots = {} + for uid in upstream_ids: + latest_snapshots[uid] = latest_rate_map(db, uid) + with _client(website) as c: remote_accounts = None try: @@ -513,7 +530,9 @@ def sync_imported_upstream_keys( remote_ids = None for row in rows: - platform = _detect_platform(f"{row.group_name} {row.group_id} {row.key_name}", "openai") + snap_groups = latest_snapshots.get(row.upstream_id) or {} + group_snap = snap_groups.get(row.group_id) if isinstance(snap_groups, dict) else None + platform = _resolve_platform(f"{row.group_name} {row.group_id} {row.key_name}", group_snap, "openai") if not row.imported_account_id: continue old_account_id = row.imported_account_id @@ -645,12 +664,17 @@ def import_upstream_keys_as_accounts( ) for kid in missing_ids ] - # 预载上游以方便获取名称和 base_url + # 预载上游以方便获取名称 and base_url from app.models.upstream import Upstream as _Up upstream_ids = {row.upstream_id for row in rows} upstreams_db = db.query(_Up).filter(_Up.id.in_(upstream_ids)).all() upstreams_map = {u.id: u for u in upstreams_db} + # 预载最新快照映射,用于结构化 platform 解析 + latest_snapshots: dict[int, dict[str, Any]] = {} + for uid in upstream_ids: + latest_snapshots[uid] = latest_rate_map(db, uid) + # 按倍率自动分配优先级 rate_priority_map: dict[tuple[str, int, str], int] = {} if body.auto_priority_by_rate: @@ -706,8 +730,11 @@ def import_upstream_keys_as_accounts( continue # 先确定平台(失败项也需要记录) if body.platform_mode == "auto": - platform = _detect_platform( + snap_groups = latest_snapshots.get(row.upstream_id) or {} + group_snap = snap_groups.get(row.group_id) if isinstance(snap_groups, dict) else None + platform = _resolve_platform( f"{row.group_name} {row.group_id} {row.key_name}", + group_snap, body.default_platform, ) else: @@ -1644,6 +1671,18 @@ def organize_website_groups( if acc_id: remote_account_map[str(acc_id)] = acc + # 预载所有相关的上游快照映射 + all_upstream_ids = set() + for b in bindings: + for src in binding_sources(b): + _uid = src.get("upstream_id") + if _uid: + all_upstream_ids.add(int(_uid)) + + latest_snapshots: dict[int, dict[str, Any]] = {} + for _uid in all_upstream_ids: + latest_snapshots[_uid] = latest_rate_map(db, _uid) + # 对每个绑定关系遍历其对应的上游 Key 进行整理 for b in bindings: target_group_id = b.target_group_id @@ -1659,14 +1698,10 @@ def organize_website_groups( _u = db.query(Upstream).filter(Upstream.id == uid).first() upstream_name = _u.name if _u else f"#{uid}" + snapshot_groups = latest_snapshots.get(int(uid)) or {} # 来源名称优先取 src.group_name,再 fallback 快照,再 fallback ID source_group_name = src.get("group_name") or "" if not source_group_name: - snapshot_groups = {} - try: - snapshot_groups = latest_rate_map(db, uid) - except Exception: - pass if gid in snapshot_groups: g_data = snapshot_groups[gid] if isinstance(g_data, dict): @@ -1725,8 +1760,10 @@ def organize_website_groups( continue # 检测平台类型 - platform = _detect_platform( + group_snap = snapshot_groups.get(row.group_id) if isinstance(snapshot_groups, dict) else None + platform = _resolve_platform( f"{row.group_name} {row.group_id} {row.key_name}", + group_snap, "openai", ) @@ -1771,11 +1808,31 @@ def organize_website_groups( old_set = {str(g) for g in current_group_ids} new_set = {str(g) for g in new_group_ids} + remote_platform = remote_acc.get("platform") + if remote_platform is not None: + platform_mismatch = (str(remote_platform).lower() != str(platform).lower()) + else: + platform_mismatch = (str(platform).lower() != "openai") + + update_payload = {} if old_set != new_set: - c.update_account(old_account_id, {"group_ids": new_group_ids}) - # 顺便更新下 remote_account_map 中的数据,防止同个账号后续在其他绑定中重复处理 - remote_acc["group_ids"] = new_group_ids - msg = "已存在,已迁移到目标分组" + update_payload["group_ids"] = new_group_ids + if platform_mismatch: + update_payload["platform"] = platform + + if update_payload: + c.update_account(old_account_id, update_payload) + if "group_ids" in update_payload: + remote_acc["group_ids"] = new_group_ids + if "platform" in update_payload: + remote_acc["platform"] = platform + + msg_parts = [] + if old_set != new_set: + msg_parts.append("已迁移到目标分组") + if platform_mismatch: + msg_parts.append(f"平台从 {remote_platform} 修正为 {platform}") + msg = "已存在," + "且".join(msg_parts) else: msg = "已存在且已对齐,已跳过" diff --git a/backend/fix_account_935.py b/backend/fix_account_935.py new file mode 100644 index 0000000..43c189f --- /dev/null +++ b/backend/fix_account_935.py @@ -0,0 +1,36 @@ +import os +import sys +import json + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from app.database import SessionLocal +from app.models.website import Website +from app.routers.websites import _client + +db = SessionLocal() +try: + website = db.query(Website).filter(Website.id == 2).first() + if not website: + print("Error: website wangwang888 (id=2) not found!") + sys.exit(1) + + print(f"Loaded website: {website.name}, URL: {website.base_url}") + + with _client(website) as c: + accounts = c.list_accounts() + acc = next((a for a in accounts if str(c.extract_id(a)) == "935"), None) + if not acc: + print("Error: Account 935 not found on remote site!") + sys.exit(1) + + print(f"Found account 935: {acc.get('name')}, current platform: {acc.get('platform')}") + + res = c.update_account("935", {"platform": "anthropic"}) + print(f"Update response: {res}") + + accounts_new = c.list_accounts() + acc_new = next((a for a in accounts_new if str(c.extract_id(a)) == "935"), None) + print(f"Verified account 935: {acc_new.get('name')}, new platform: {acc_new.get('platform')}") +finally: + db.close() diff --git a/backend/test_organize_groups.py b/backend/test_organize_groups.py index f89401e..a8111b5 100644 --- a/backend/test_organize_groups.py +++ b/backend/test_organize_groups.py @@ -479,3 +479,102 @@ def test_organize_groups_force_alignment(db_session, monkeypatch): # 既然已经一致,不会触发 update_account assert len(updated_accounts) == 0 assert "已存在且已对齐 2" in response_aligned.message + + +def test_organize_groups_corrects_mismatched_platform_instead_of_recreating(db_session, monkeypatch): + """测试一键整理遇到已存在账号平台错配时,只调用 update_account() 修正平台,不创建新账号。""" + from app.models.snapshot import UpstreamRateSnapshot + + w = Website( + name="W1", + site_type="sub2api", + base_url="http://w1", + enabled=True, + auth_config_json="{}", + timeout_seconds=30 + ) + u1 = Upstream(name="U1", base_url="http://u1") + db_session.add_all([w, u1]) + db_session.commit() + db_session.refresh(w) + db_session.refresh(u1) + + # 绑定关系:目标分组 TG1 ↔ G1 + b1 = WebsiteGroupBinding( + website_id=w.id, + target_group_id="TG1", + target_group_name="TG1-Group", + source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "G1"}]), + enabled=True + ) + db_session.add(b1) + + # G1: 已导入,导入的目标分组为 TG1,账号为 ACC-G1 + k1 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G1", + group_name="G1-Group", + key_name="Key-G1", + key_value="sk-g1-secret", + status="imported", + imported_website_id=w.id, + imported_account_id="ACC-G1", + imported_target_group_id="TG1", + ) + db_session.add(k1) + + # 写入快照指定平台为 anthropic + snapshot = UpstreamRateSnapshot( + upstream_id=u1.id, + snapshot_json=json.dumps({ + "groups": { + "G1": { + "group_name": "G1-Group", + "rate": 0.1, + "platform": "anthropic" + } + } + }) + ) + db_session.add(snapshot) + db_session.commit() + + updated_accounts = [] + create_called = False + + class MockClient: + def __init__(self, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *a): pass + def get_groups(self, *a, **kw): + return [{"id": "TG1", "name": "TG1-Group"}] + def list_accounts(self): + return [ + { + "id": "ACC-G1", + "name": "SmartUp-G1", + "group_ids": ["TG1"], + "platform": "openai", + } + ] + def extract_id(self, val): + return val.get("id") if isinstance(val, dict) else str(val) + def create_account(self, body): + nonlocal create_called + create_called = True + return {"id": "NEW-ACC", "name": body["name"], "group_ids": body["group_ids"]} + def update_account(self, account_id, body): + updated_accounts.append((account_id, body)) + return {"id": account_id, "platform": body.get("platform")} + + monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient) + monkeypatch.setattr("app.routers.websites.sync_account_priorities_for_website", lambda db, wid: []) + + response = organize_website_groups(wid=w.id, db=db_session) + + assert response.success is True + assert not create_called + assert len(updated_accounts) == 1 + assert updated_accounts[0][0] == "ACC-G1" + assert updated_accounts[0][1]["platform"] == "anthropic" + assert "平台从 openai 修正为 anthropic" in response.items[0].message diff --git a/backend/test_upstream_key_account_import.py b/backend/test_upstream_key_account_import.py index 9cf8908..154f02a 100644 --- a/backend/test_upstream_key_account_import.py +++ b/backend/test_upstream_key_account_import.py @@ -410,3 +410,157 @@ def test_import_upstream_key_with_custom_concurrency_and_priority(monkeypatch, d assert body["concurrency"] == 20 assert body["priority"] == 5 assert body["credentials"]["base_url"] == "http://packy.local" + + +def test_import_platform_from_snapshot(monkeypatch, db_session): + """验证快照 platform=anthropic 且名称不含 Claude 时,创建账号平台必须为 anthropic""" + from app.models.snapshot import UpstreamRateSnapshot + website, generated = seed_account_import_rows(db_session) + generated.key_name = "MyCustomKey" # 不含 Claude + + # 写入快照指定平台为 anthropic + snapshot = UpstreamRateSnapshot( + upstream_id=generated.upstream_id, + snapshot_json=json.dumps({ + "groups": { + "vip": { + "group_name": "VIP", + "rate": 0.1, + "platform": "anthropic" + } + } + }) + ) + db_session.add(snapshot) + db_session.commit() + + account_bodies = [] + class FakeWebsiteClient: + def __init__(self, **kwargs): + pass + def __enter__(self): return self + def __exit__(self, *args): return False + def create_account(self, body, endpoint="/accounts"): + account_bodies.append(body) + return {"id": 101, "name": body["name"]} + def account_exists(self, account_id): return True + @staticmethod + def extract_id(data): return "101" + + monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) + + websites_router.import_upstream_keys_as_accounts( + website.id, + ImportAccountsRequest( + upstream_key_ids=[generated.id], + target_group_map={"vip": "7"}, + default_platform="openai", + platform_mode="auto", + ), + db_session, + object(), + ) + assert len(account_bodies) == 1 + assert account_bodies[0]["platform"] == "anthropic" + + +def test_import_platform_fallback_to_name(monkeypatch, db_session): + """验证快照无 platform 但名称含 Claude 时,仍兜底为 anthropic""" + from app.models.snapshot import UpstreamRateSnapshot + website, generated = seed_account_import_rows(db_session) + generated.key_name = "Claude-Key" + + # 写入快照但不含 platform 字段 + snapshot = UpstreamRateSnapshot( + upstream_id=generated.upstream_id, + snapshot_json=json.dumps({ + "groups": { + "vip": { + "group_name": "VIP", + "rate": 0.1, + } + } + }) + ) + db_session.add(snapshot) + db_session.commit() + + account_bodies = [] + class FakeWebsiteClient: + def __init__(self, **kwargs): + pass + def __enter__(self): return self + def __exit__(self, *args): return False + def create_account(self, body, endpoint="/accounts"): + account_bodies.append(body) + return {"id": 101, "name": body["name"]} + def account_exists(self, account_id): return True + @staticmethod + def extract_id(data): return "101" + + monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) + + websites_router.import_upstream_keys_as_accounts( + website.id, + ImportAccountsRequest( + upstream_key_ids=[generated.id], + target_group_map={"vip": "7"}, + default_platform="openai", + platform_mode="auto", + ), + db_session, + object(), + ) + assert len(account_bodies) == 1 + assert account_bodies[0]["platform"] == "anthropic" + + +def test_import_platform_manual_override(monkeypatch, db_session): + """手动模式下不使用快照平台,仍按用户指定平台""" + from app.models.snapshot import UpstreamRateSnapshot + website, generated = seed_account_import_rows(db_session) + + # 快照虽然指定为 anthropic + snapshot = UpstreamRateSnapshot( + upstream_id=generated.upstream_id, + snapshot_json=json.dumps({ + "groups": { + "vip": { + "group_name": "VIP", + "rate": 0.1, + "platform": "anthropic" + } + } + }) + ) + db_session.add(snapshot) + db_session.commit() + + account_bodies = [] + class FakeWebsiteClient: + def __init__(self, **kwargs): + pass + def __enter__(self): return self + def __exit__(self, *args): return False + def create_account(self, body, endpoint="/accounts"): + account_bodies.append(body) + return {"id": 101, "name": body["name"]} + def account_exists(self, account_id): return True + @staticmethod + def extract_id(data): return "101" + + monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeWebsiteClient) + + websites_router.import_upstream_keys_as_accounts( + website.id, + ImportAccountsRequest( + upstream_key_ids=[generated.id], + target_group_map={"vip": "7"}, + default_platform="gemini", + platform_mode="manual", # 手动模式 + ), + db_session, + object(), + ) + assert len(account_bodies) == 1 + assert account_bodies[0]["platform"] == "gemini"