From fe3f9f4a4c141a0013a000d49aef0db57fea71f6 Mon Sep 17 00:00:00 2001 From: SmartUp Developer Date: Thu, 2 Jul 2026 10:42:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=80=E9=94=AE=E6=95=B4=E7=90=86?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E5=AF=B9=E9=BD=90=E5=88=86=E7=BB=84=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=97=A7=E6=89=98=E7=AE=A1?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=B9=B6=E4=BC=98=E5=8C=96=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8C=BA=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/websites.py | 60 ++++++++---- backend/test_organize_groups.py | 167 ++++++++++++++++++++++++++++++-- 2 files changed, 202 insertions(+), 25 deletions(-) diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 8a2eccc..e530fb0 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -1300,6 +1300,22 @@ def organize_website_groups( .all() ) + smartup_managed_group_ids = {str(b.target_group_id) for b in bindings if b.target_group_id} + + # 合并已导入 Key 上历史记录的 imported_target_group_id,以确保变更绑定后能清理旧分组 + hist_group_ids = ( + db.query(UpstreamGeneratedKey.imported_target_group_id) + .filter( + UpstreamGeneratedKey.imported_website_id == wid, + UpstreamGeneratedKey.imported_target_group_id.isnot(None), + UpstreamGeneratedKey.imported_target_group_id != "", + ) + .distinct() + .all() + ) + for row in hist_group_ids: + smartup_managed_group_ids.add(str(row[0])) + items: list[OrganizeGroupsItem] = [] # 2. 收集所有的 upstream_id 并执行对账,以保证本地 Key 的状态最新 @@ -1474,28 +1490,30 @@ def organize_website_groups( remote_acc = remote_account_map.get(str(old_account_id)) if remote_acc is not None: - # 目标账号存在:跳过或补齐目标分组记录 + # 目标账号存在:检测并对齐目标分组,移除其余已启用的 SmartUp 托管分组,保留非 SmartUp 托管分组 try: current_group_ids = remote_acc.get("group_ids") or [] if not isinstance(current_group_ids, list): current_group_ids = [current_group_ids] - str_group_ids = [str(g) for g in current_group_ids] - if str(target_group_id) not in str_group_ids: - # 需要补齐绑定关系 - numeric_target = _numeric_group_id(target_group_id) - target_val = numeric_target if numeric_target is not None else target_group_id + numeric_target = _numeric_group_id(target_group_id) + target_val = numeric_target if numeric_target is not None else target_group_id - new_group_ids = list(current_group_ids) - if target_val not in new_group_ids: - new_group_ids.append(target_val) + # 过滤出非 SmartUp 托管的 group_ids + new_group_ids = [g for g in current_group_ids if str(g) not in smartup_managed_group_ids] + # 追加当前目标分组 + new_group_ids.append(target_val) + old_set = {str(g) for g in current_group_ids} + new_set = {str(g) for g in new_group_ids} + + if old_set != new_set: c.update_account(old_account_id, {"group_ids": new_group_ids}) - # 顺便更新下 remote_account_map 中的数据,防止同个账号后续在其他绑定中重复补齐 + # 顺便更新下 remote_account_map 中的数据,防止同个账号后续在其他绑定中重复处理 remote_acc["group_ids"] = new_group_ids - msg = "账号已存在,已补齐目标分组绑定" + msg = "已存在,已迁移到目标分组" else: - msg = "已存在且已绑定,已跳过" + msg = "已存在且已对齐,已跳过" # 补齐本地 DB 的目标分组标记 if row.imported_target_group_id != target_group_id: @@ -1638,17 +1656,21 @@ def organize_website_groups( ) created_count = sum(1 for item in items if item.status == "created") - exists_count = sum(1 for item in items if item.status == "exists") - missing_key_count = sum(1 for item in items if item.status == "missing_key") recreated_count = sum(1 for item in items if item.status == "recreated") + aligned_count = sum(1 for item in items if item.status == "exists" and "已对齐" in item.message) + migrated_count = sum(1 for item in items if item.status == "exists" and "已迁移" in item.message) + missing_key_count = sum(1 for item in items if item.status == "missing_key") failed_count = sum(1 for item in items if item.status == "failed") parts = [] - total_created = created_count + recreated_count - if total_created: - parts.append(f"创建账号 {total_created}") - if exists_count: - parts.append(f"已存在 {exists_count}") + if created_count: + parts.append(f"已创建 {created_count}") + if recreated_count: + parts.append(f"已重建 {recreated_count}") + if aligned_count: + parts.append(f"已存在且已对齐 {aligned_count}") + if migrated_count: + parts.append(f"已存在已迁移 {migrated_count}") if missing_key_count: parts.append(f"缺少 Key {missing_key_count}") if failed_count: diff --git a/backend/test_organize_groups.py b/backend/test_organize_groups.py index 39790e1..f89401e 100644 --- a/backend/test_organize_groups.py +++ b/backend/test_organize_groups.py @@ -163,9 +163,10 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch): # 6. 断言结果 assert response.success is True - # 整理完成:创建账号 2 (k1创建 + k4重建) / 已存在 1 (k3已存在并补齐) / 缺少 Key 1 (G2缺少) - assert "创建账号 2" in response.message - assert "已存在 1" in response.message + # 整理完成:已创建 1 / 已重建 1 / 已存在已迁移 1 / 缺少 Key 1 + assert "已创建 1" in response.message + assert "已重建 1" in response.message + assert "已存在已迁移 1" in response.message assert "缺少 Key 1" in response.message items = response.items @@ -186,7 +187,7 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch): # TG2 分组下的 G3 (已存在并补齐) item_g3 = next(item for item in items if item.target_group_id == "TG2" and item.source_group_id == "G3") assert item_g3.status == "exists" - assert "已补齐目标分组绑定" in item_g3.message + assert "已迁移到目标分组" in item_g3.message # 校验是否触发了 update_account 补齐了 TG2 assert len(updated_accounts) == 1 assert updated_accounts[0][0] == "ACC-G3" @@ -301,8 +302,8 @@ def test_organize_groups_list_accounts_none_conservatively_skips(db_session, mon # 断言结果:由于其中有 1 个已导入账号校验失败,response.success 应为 False(有失败项) assert response.success is False - # 统计信息中应该有“创建账号 1”与“失败 1” - assert "创建账号 1" in response.message + # 统计信息中应该有“已创建 1”与“失败 1” + assert "已创建 1" in response.message assert "失败 1" in response.message items = response.items @@ -324,3 +325,157 @@ def test_organize_groups_list_accounts_none_conservatively_skips(db_session, mon assert k3.imported_website_id == w.id assert k3.imported_account_id == "ACC-G3-EXISTING" assert k3.status == "imported" + + +def test_organize_groups_force_alignment(db_session, monkeypatch): + """测试强对齐场景: + 1. 已存在账号在旧 SmartUp 分组,整理后移除旧分组并加入新分组。 + 2. 已存在账号同时有非 SmartUp 分组,整理后保留非 SmartUp 分组。 + 3. 已存在账号已经完全一致,整理不重复调用更新接口。 + 4. 多目标分组、多上游来源时,每个账号按自己的当前绑定关系对齐。 + """ + 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) + + # 两个绑定关系: + # 绑定 1:目标分组 TG1 ↔ G1 + # 绑定 2:目标分组 TG2 ↔ G2 + 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 + ) + b2 = WebsiteGroupBinding( + website_id=w.id, + target_group_id="TG2", + target_group_name="TG2-Group", + source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "G2"}]), + enabled=True + ) + db_session.add_all([b1, b2]) + db_session.commit() + + # 3. 建立 3 个 Key,对应 3 个不同的远端账号状态 + # k1: 原本在 TG_old (旧 SmartUp 分组),但根据绑定 1 它现在应该在 TG1 + 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="TG_old", # 旧 SmartUp 分组 + ) + # k2: 已经在 TG2 (正确),且同时有非 SmartUp 分组 (999) 和另一个旧 SmartUp 分组 (TG1 - 应该被移除) + k2 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G2", + group_name="G2-Group", + key_name="Key-G2", + key_value="sk-g2-secret", + status="imported", + imported_website_id=w.id, + imported_account_id="ACC-G2", + imported_target_group_id="TG2", + ) + db_session.add_all([k1, k2]) + db_session.commit() + + updated_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": "TG1", "name": "TG1-Group"}, + {"id": "TG2", "name": "TG2-Group"}, + ] + def list_accounts(self): + return [ + { + "id": "ACC-G1", + "name": "SmartUp-G1", + "group_ids": ["TG_old"], # 包含旧 SmartUp 分组 TG_old,没有 TG1 + }, + { + "id": "ACC-G2", + "name": "SmartUp-G2", + "group_ids": ["TG2", 999, "TG1"], # TG2 是当前正确分组,999 是非托管分组,TG1 是旧托管分组(应移除) + } + ] + def extract_id(self, val): + return val.get("id") if isinstance(val, dict) else str(val) + def update_account(self, account_id, body): + updated_accounts.append((account_id, body)) + return {"id": account_id, "group_ids": body.get("group_ids")} + + 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 + + # ACC-G1 (G1) 的 group_ids 应变更为 ["TG1"] (TG2 被移除了) + # ACC-G2 (G2) 的 group_ids 应变更为 [999, "TG2"] (TG1 被移除了,999 保留) + assert len(updated_accounts) == 2 + + # 验证 ACC-G1 变更 + up_g1 = next(item for item in updated_accounts if item[0] == "ACC-G1") + assert set(str(g) for g in up_g1[1]["group_ids"]) == {"TG1"} + + # 验证 ACC-G2 变更 + up_g2 = next(item for item in updated_accounts if item[0] == "ACC-G2") + assert set(str(g) for g in up_g2[1]["group_ids"]) == {"999", "TG2"} + + # 验证本地 DB 的标记已被同步更新为正确的 TG1 / TG2 + db_session.refresh(k1) + db_session.refresh(k2) + assert k1.imported_target_group_id == "TG1" + assert k2.imported_target_group_id == "TG2" + + # 验证结果消息汇总包含 aligned 0, migrated 2 (因为两个都发生了迁移/更新) + assert "已存在已迁移 2" in response.message + + # 让我们跑一次完全一致的再次整理,验证不触发 update_account (已对齐) + updated_accounts.clear() + + class MockClientAligned(MockClient): + def list_accounts(self): + return [ + { + "id": "ACC-G1", + "name": "SmartUp-G1", + "group_ids": ["TG1"], + }, + { + "id": "ACC-G2", + "name": "SmartUp-G2", + "group_ids": [999, "TG2"], + } + ] + + monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClientAligned) + response_aligned = organize_website_groups(wid=w.id, db=db_session) + assert response_aligned.success is True + # 既然已经一致,不会触发 update_account + assert len(updated_accounts) == 0 + assert "已存在且已对齐 2" in response_aligned.message