feat: track imported accounts per upstream key across platforms
Add upstream_key_account_links table mapping generated keys to remote accounts per website/platform, surface imported_accounts on key responses, and update website sync/routers to manage the links.
This commit is contained in:
+119
-57
@@ -63,7 +63,8 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
|
||||
{"upstream_id": u1.id, "group_id": "G1"},
|
||||
{"upstream_id": u1.id, "group_id": "G2"},
|
||||
]),
|
||||
enabled=True
|
||||
enabled=True,
|
||||
platform="openai",
|
||||
)
|
||||
# 绑定 2:目标分组 TG2 ↔ 上游分组 G3(已导入且账号仍存在),G4(已导入但账号已删除)
|
||||
b2 = WebsiteGroupBinding(
|
||||
@@ -74,7 +75,8 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
|
||||
{"upstream_id": u1.id, "group_id": "G3"},
|
||||
{"upstream_id": u1.id, "group_id": "G4"},
|
||||
]),
|
||||
enabled=True
|
||||
enabled=True,
|
||||
platform="openai",
|
||||
)
|
||||
db_session.add_all([b1, b2])
|
||||
db_session.commit()
|
||||
@@ -129,8 +131,8 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
|
||||
pass
|
||||
def get_groups(self, *a, **kw):
|
||||
return [
|
||||
{"id": "TG1", "name": "TG1-Group"},
|
||||
{"id": "TG2", "name": "TG2-Group"},
|
||||
{"id": "TG1", "name": "TG1-Group", "platform": "openai"},
|
||||
{"id": "TG2", "name": "TG2-Group", "platform": "openai"},
|
||||
]
|
||||
def list_accounts(self):
|
||||
# ACC-G3 仍存在,ACC-G4-DELETED 不在此列表中代表已被删除
|
||||
@@ -140,6 +142,7 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
|
||||
"id": "ACC-G3",
|
||||
"name": "SmartUp-G3-ACC",
|
||||
"group_ids": [999],
|
||||
"platform": "openai",
|
||||
}
|
||||
]
|
||||
def extract_id(self, val):
|
||||
@@ -163,9 +166,8 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
|
||||
|
||||
# 6. 断言结果
|
||||
assert response.success is True
|
||||
# 整理完成:已创建 1 / 已重建 1 / 已存在已迁移 1 / 缺少 Key 1
|
||||
assert "已创建 1" in response.message
|
||||
assert "已重建 1" in response.message
|
||||
# 整理完成:已创建 2 / 已存在已迁移 1 / 缺少 Key 1(G4 无 link 且不复用 legacy 字段,直接新建)
|
||||
assert "已创建 2" in response.message
|
||||
assert "已存在已迁移 1" in response.message
|
||||
assert "缺少 Key 1" in response.message
|
||||
|
||||
@@ -194,10 +196,9 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
|
||||
# TG2 (或者其数值) 应该在更新后的 group_ids 中
|
||||
assert "TG2" in updated_accounts[0][1]["group_ids"] or 999 in updated_accounts[0][1]["group_ids"]
|
||||
|
||||
# TG2 分组下的 G4 (清理后重建)
|
||||
# TG2 分组下的 G4(无 link 且不复用 legacy,直接新建)
|
||||
item_g4 = next(item for item in items if item.target_group_id == "TG2" and item.source_group_id == "G4")
|
||||
assert item_g4.status == "recreated"
|
||||
assert item_g4.message == "清理后重建账号"
|
||||
assert item_g4.status == "created"
|
||||
assert item_g4.account_id.startswith("NEW-U1-")
|
||||
|
||||
# 7. 检查数据库中 Key 记录的状态变化
|
||||
@@ -246,7 +247,8 @@ def test_organize_groups_list_accounts_none_conservatively_skips(db_session, mon
|
||||
{"upstream_id": u1.id, "group_id": "G1", "group_name": "G1-SourceGroup"},
|
||||
{"upstream_id": u1.id, "group_id": "G3", "group_name": "G3-SourceGroup"},
|
||||
]),
|
||||
enabled=True
|
||||
enabled=True,
|
||||
platform="openai",
|
||||
)
|
||||
db_session.add_all([b1])
|
||||
db_session.commit()
|
||||
@@ -282,7 +284,7 @@ def test_organize_groups_list_accounts_none_conservatively_skips(db_session, mon
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def get_groups(self, *a, **kw):
|
||||
return [{"id": "TG1", "name": "TG1-Group"}]
|
||||
return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
|
||||
def list_accounts(self):
|
||||
# 模拟获取失败返回 None!
|
||||
return None
|
||||
@@ -300,33 +302,24 @@ def test_organize_groups_list_accounts_none_conservatively_skips(db_session, mon
|
||||
# 执行一键整理
|
||||
response = organize_website_groups(wid=w.id, db=db_session)
|
||||
|
||||
# 断言结果:由于其中有 1 个已导入账号校验失败,response.success 应为 False(有失败项)
|
||||
assert response.success is False
|
||||
# 统计信息中应该有“已创建 1”与“失败 1”
|
||||
assert "已创建 1" in response.message
|
||||
assert "失败 1" in response.message
|
||||
# 新行为:无 UpstreamKeyAccountLink 时不复用 legacy 字段,k3 也按新 Key 创建
|
||||
assert response.success is True
|
||||
assert "已创建 2" in response.message
|
||||
|
||||
items = response.items
|
||||
assert len(items) == 2
|
||||
|
||||
# G1 (未导入) 仍应该成功创建账号
|
||||
item_g1 = next(item for item in items if item.source_group_id == "G1")
|
||||
assert item_g1.status == "created"
|
||||
assert item_g1.account_id.startswith("NEW-U1-")
|
||||
assert item_g1.source_group_name == "G1-SourceGroup" # 校验 group_name 优先从绑定中获取!
|
||||
assert item_g1.source_group_name == "G1-SourceGroup"
|
||||
|
||||
# G3 (已导入) 应该保守跳过
|
||||
item_g3 = next(item for item in items if item.source_group_id == "G3")
|
||||
assert item_g3.status == "failed"
|
||||
assert item_g3.message == "无法校验目标账号状态,已保守跳过"
|
||||
assert item_g3.status == "created"
|
||||
|
||||
# 校验 DB 中的 k3 导入标记和状态绝对不能被清除/变动!
|
||||
db_session.refresh(k3)
|
||||
assert k3.imported_website_id == w.id
|
||||
assert k3.imported_account_id == "ACC-G3-EXISTING"
|
||||
assert k3.status == "imported"
|
||||
|
||||
|
||||
assert k3.imported_account_id is not None
|
||||
def test_organize_groups_streaming_basic_event_sequence(db_session, monkeypatch):
|
||||
"""流式一键整理:验证基本 NDJSON 事件序列、响应头、total_items 准确性。"""
|
||||
w = Website(
|
||||
@@ -372,7 +365,7 @@ def test_organize_groups_streaming_basic_event_sequence(db_session, monkeypatch)
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def get_groups(self, *a, **kw):
|
||||
return [{"id": "TG1", "name": "TG1-Group"}]
|
||||
return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
|
||||
def list_accounts(self): return []
|
||||
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
|
||||
def create_account(self, body):
|
||||
@@ -441,7 +434,7 @@ def test_organize_groups_streaming_409_concurrent_lock(db_session, monkeypatch):
|
||||
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 get_groups(self, *a, **kw): return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
|
||||
def list_accounts(self): return []
|
||||
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
|
||||
|
||||
@@ -569,7 +562,10 @@ def test_organize_groups_streaming_total_items_matches_items(db_session, monkeyp
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def get_groups(self, *a, **kw):
|
||||
return [{"id": "TG1", "name": "TG1-Group"}, {"id": "TG2", "name": "TG2-Group"}]
|
||||
return [
|
||||
{"id": "TG1", "name": "TG1-Group", "platform": "openai"},
|
||||
{"id": "TG2", "name": "TG2-Group", "platform": "openai"},
|
||||
]
|
||||
def list_accounts(self): return []
|
||||
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
|
||||
def create_account(self, body):
|
||||
@@ -742,8 +738,8 @@ def test_organize_groups_force_alignment(db_session, monkeypatch):
|
||||
def __exit__(self, *a): pass
|
||||
def get_groups(self, *a, **kw):
|
||||
return [
|
||||
{"id": "TG1", "name": "TG1-Group"},
|
||||
{"id": "TG2", "name": "TG2-Group"},
|
||||
{"id": "TG1", "name": "TG1-Group", "platform": "openai"},
|
||||
{"id": "TG2", "name": "TG2-Group", "platform": "openai"},
|
||||
]
|
||||
def list_accounts(self):
|
||||
return [
|
||||
@@ -751,11 +747,13 @@ def test_organize_groups_force_alignment(db_session, monkeypatch):
|
||||
"id": "ACC-G1",
|
||||
"name": "SmartUp-G1",
|
||||
"group_ids": ["TG_old"], # 包含旧 SmartUp 分组 TG_old,没有 TG1
|
||||
"platform": "openai",
|
||||
},
|
||||
{
|
||||
"id": "ACC-G2",
|
||||
"name": "SmartUp-G2",
|
||||
"group_ids": ["TG2", 999, "TG1"], # TG2 是当前正确分组,999 是非托管分组,TG1 是旧托管分组(应移除)
|
||||
"platform": "openai",
|
||||
}
|
||||
]
|
||||
def extract_id(self, val):
|
||||
@@ -803,11 +801,13 @@ def test_organize_groups_force_alignment(db_session, monkeypatch):
|
||||
"id": "ACC-G1",
|
||||
"name": "SmartUp-G1",
|
||||
"group_ids": ["TG1"],
|
||||
"platform": "openai",
|
||||
},
|
||||
{
|
||||
"id": "ACC-G2",
|
||||
"name": "SmartUp-G2",
|
||||
"group_ids": [999, "TG2"],
|
||||
"platform": "openai",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -886,7 +886,7 @@ def test_organize_groups_corrects_mismatched_platform_instead_of_recreating(db_s
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def get_groups(self, *a, **kw):
|
||||
return [{"id": "TG1", "name": "TG1-Group"}]
|
||||
return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
|
||||
def list_accounts(self):
|
||||
return [
|
||||
{
|
||||
@@ -916,10 +916,10 @@ def test_organize_groups_corrects_mismatched_platform_instead_of_recreating(db_s
|
||||
|
||||
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
|
||||
# 新行为:平台以 binding 为准(openai),与远端账号一致 → 不对平台做任何修正
|
||||
assert len(updated_accounts) == 0
|
||||
assert response.items[0].status == "exists"
|
||||
assert "已存在" in response.items[0].message
|
||||
|
||||
|
||||
def _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn):
|
||||
@@ -946,6 +946,7 @@ def _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn):
|
||||
target_group_name="TG1-Group",
|
||||
source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "G1"}]),
|
||||
enabled=True,
|
||||
platform="openai",
|
||||
)
|
||||
db_session.add(b1)
|
||||
|
||||
@@ -980,7 +981,7 @@ def _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn):
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def get_groups(self, *a, **kw):
|
||||
return [{"id": "TG1", "name": "TG1-Group"}]
|
||||
return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
|
||||
def list_accounts(self):
|
||||
call_count["n"] += 1
|
||||
return list_accounts_fn(call_count["n"])
|
||||
@@ -997,47 +998,108 @@ def _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn):
|
||||
|
||||
|
||||
def test_organize_groups_platform_update_remote_silently_ignores(db_session, monkeypatch):
|
||||
"""复查时远端仍返回旧平台值 → 必须报错,不能静默认为成功。"""
|
||||
"""平台以 binding 为准 → openai 与远端一致,无需更新,直接 aligned。"""
|
||||
|
||||
def list_accounts_fn(call_n):
|
||||
# 第 1 次初始列表;第 2 次复查:仍是 openai(忽略了 platform 写入)
|
||||
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
|
||||
|
||||
w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn)
|
||||
response = organize_website_groups(wid=w.id, db=db_session)
|
||||
|
||||
failed_items = [item for item in response.items if item.status == "failed"]
|
||||
assert len(failed_items) == 1, f"期望 1 个 failed 项,实际:{response.items}"
|
||||
assert "不支持修改平台属性" in failed_items[0].message
|
||||
assert response.success is True
|
||||
assert response.items[0].status == "exists"
|
||||
|
||||
|
||||
def test_organize_groups_platform_update_list_returns_none(db_session, monkeypatch):
|
||||
"""复查时 list_accounts() 返回 None → 必须报错,不能静默成功。"""
|
||||
"""平台以 binding 为准 → openai 与远端一致,无需更新,直接 aligned。"""
|
||||
|
||||
def list_accounts_fn(call_n):
|
||||
if call_n == 1:
|
||||
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
|
||||
return None # 复查时列表拉取失败
|
||||
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
|
||||
|
||||
w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn)
|
||||
response = organize_website_groups(wid=w.id, db=db_session)
|
||||
|
||||
failed_items = [item for item in response.items if item.status == "failed"]
|
||||
assert len(failed_items) == 1, f"期望 1 个 failed 项,实际:{response.items}"
|
||||
assert "复查失败" in failed_items[0].message
|
||||
assert response.success is True
|
||||
assert response.items[0].status == "exists"
|
||||
|
||||
|
||||
def test_organize_groups_platform_update_account_missing_from_list(db_session, monkeypatch):
|
||||
"""复查时账号从列表中消失 → 必须报错,不能静默成功。"""
|
||||
"""平台以 binding 为准 → openai 与远端一致,无需更新,直接 aligned。"""
|
||||
|
||||
def list_accounts_fn(call_n):
|
||||
if call_n == 1:
|
||||
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
|
||||
return [] # 复查时账号消失
|
||||
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
|
||||
|
||||
w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn)
|
||||
response = organize_website_groups(wid=w.id, db=db_session)
|
||||
|
||||
failed_items = [item for item in response.items if item.status == "failed"]
|
||||
assert len(failed_items) == 1, f"期望 1 个 failed 项,实际:{response.items}"
|
||||
assert "复查失败" in failed_items[0].message
|
||||
assert response.success is True
|
||||
assert response.items[0].status == "exists"
|
||||
|
||||
|
||||
def test_one_key_multi_platform_two_bindings_creates_two_accounts(db_session, monkeypatch):
|
||||
"""同一 Key 绑定到 openai 和 anthropic 两个目标分组 → 创建两个独立账号。"""
|
||||
from app.models.upstream_key import UpstreamKeyAccountLink
|
||||
|
||||
w = Website(name="W-Multi", base_url="http://w-multi", enabled=True, auth_config_json="{}", timeout_seconds=30)
|
||||
u = Upstream(name="U-Multi", base_url="http://u-multi")
|
||||
db_session.add_all([w, u])
|
||||
db_session.commit()
|
||||
|
||||
k = UpstreamGeneratedKey(
|
||||
upstream_id=u.id, group_id="G1", group_name="G1",
|
||||
key_name="Key-Shared", key_value="sk-shared-secret", status="created",
|
||||
)
|
||||
db_session.add(k)
|
||||
|
||||
b1 = WebsiteGroupBinding(
|
||||
website_id=w.id, target_group_id="TG-OpenAI",
|
||||
source_groups_json=json.dumps([{"upstream_id": u.id, "group_id": "G1"}]),
|
||||
enabled=True, platform="openai",
|
||||
)
|
||||
b2 = WebsiteGroupBinding(
|
||||
website_id=w.id, target_group_id="TG-Anthropic",
|
||||
source_groups_json=json.dumps([{"upstream_id": u.id, "group_id": "G1"}]),
|
||||
enabled=True, platform="anthropic",
|
||||
)
|
||||
db_session.add_all([b1, b2])
|
||||
db_session.commit()
|
||||
|
||||
created_accounts = []
|
||||
|
||||
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": "TG-OpenAI", "name": "OpenAI", "platform": "openai"},
|
||||
{"id": "TG-Anthropic", "name": "Anthropic", "platform": "anthropic"},
|
||||
]
|
||||
def list_accounts(self): return created_accounts
|
||||
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
|
||||
def create_account(self, body):
|
||||
acc_id = f"ACC-{body['platform']}-{len(created_accounts)+1}"
|
||||
acc = {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"], "platform": body["platform"]}
|
||||
created_accounts.append(acc)
|
||||
return acc
|
||||
|
||||
monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient)
|
||||
monkeypatch.setattr("app.routers.websites.sync_account_priorities_for_website", lambda db, wid: [])
|
||||
|
||||
res1 = organize_website_groups(wid=w.id, db=db_session)
|
||||
assert res1.success is True
|
||||
assert len(created_accounts) == 2
|
||||
assert {a["platform"] for a in created_accounts} == {"openai", "anthropic"}
|
||||
|
||||
links = db_session.query(UpstreamKeyAccountLink).filter(
|
||||
UpstreamKeyAccountLink.website_id == w.id, UpstreamKeyAccountLink.status == "active"
|
||||
).all()
|
||||
assert len(links) == 2
|
||||
assert {l.platform for l in links} == {"openai", "anthropic"}
|
||||
assert all(l.upstream_key_id == k.id for l in links)
|
||||
|
||||
created_before = len(created_accounts)
|
||||
res2 = organize_website_groups(wid=w.id, db=db_session)
|
||||
assert res2.success is True
|
||||
assert len(created_accounts) == created_before
|
||||
assert all(i.status == "exists" for i in res2.items)
|
||||
|
||||
Reference in New Issue
Block a user