From 397a14c978463deef9e329abc0459d0886009b1d Mon Sep 17 00:00:00 2001 From: liumangmang Date: Wed, 1 Jul 2026 11:21:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(website):=20=E6=94=AF=E6=8C=81=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E7=8E=B0=E6=9C=89=E7=BB=91=E5=AE=9A=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E5=88=86=E7=BB=84=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=A0=A1=E9=AA=8C=E8=A1=A5=E9=BD=90=E3=80=81?= =?UTF-8?q?=E8=87=AA=E6=84=88=E9=87=8D=E5=BB=BA=E4=B8=8E=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=20Key=20=E6=8F=90=E7=A4=BA=20(=E5=90=AB=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E4=BF=9D=E5=AE=88=E8=B7=B3=E8=BF=87=E5=8F=8A=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E5=B1=82=E7=BA=A7=E5=8C=B9=E9=85=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/websites.py | 375 ++++++++++++++++++++++++++++++++ backend/app/schemas/website.py | 19 ++ backend/test_organize_groups.py | 324 +++++++++++++++++++++++++++ frontend/src/api/index.ts | 15 ++ frontend/src/views/Websites.vue | 95 ++++++++ 5 files changed, 828 insertions(+) create mode 100644 backend/test_organize_groups.py diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index 281bf0d..6b4f098 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -34,6 +34,8 @@ from app.schemas.website import ( WebsiteSyncLogResponse, WebsiteUpdate, WebsiteBatchSyncResponse, + OrganizeGroupsItem, + OrganizeGroupsResponse, ) from app.services.website_client import Sub2ApiWebsiteClient, _extract_id @@ -43,6 +45,7 @@ from app.services.website_sync import ( build_rate_priority_map, reconcile_upstream_keys_full, sync_account_priorities_for_upstream, + latest_rate_map, ) from app.utils.auth import get_current_user from app.utils.number import fixed_decimal_number @@ -767,6 +770,378 @@ def import_upstream_keys_as_accounts( ) +@router.post("/api/websites/{wid}/groups/organize", response_model=OrganizeGroupsResponse) +def organize_website_groups( + 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, "目前只支持 sub2api") + + # 1. 读取当前网站的所有启用绑定关系 + bindings = ( + db.query(WebsiteGroupBinding) + .filter(WebsiteGroupBinding.website_id == wid, WebsiteGroupBinding.enabled == True) + .all() + ) + + items: list[OrganizeGroupsItem] = [] + + # 2. 收集所有的 upstream_id 并执行对账,以保证本地 Key 的状态最新 + upstream_ids = set() + for b in bindings: + sources = binding_sources(b) + for src in sources: + uid = src.get("upstream_id") + if uid: + upstream_ids.add(int(uid)) + + for uid in upstream_ids: + try: + reconcile_upstream_keys_full(db, uid) + except Exception as exc: + logger.warning("organize reconcile failed for upstream %s: %s", uid, exc) + + # 3. 按倍率自动分配优先级 + rate_priority_map: dict[str, int] = {} + try: + rate_priority_map = _build_rate_priority_map(db, upstream_ids) + except Exception: + pass + + with _client(website) as c: + # 获取远端分组名称列表和账号列表 + target_group_names: dict[str, str] = {} + remote_accounts: list[dict[str, Any]] = [] + try: + grps = c.get_groups(endpoint=website.groups_endpoint) or [] + for g in grps: + g_id = g.get("id") + if g_id is not None: + target_group_names[str(g_id)] = g.get("name") or str(g_id) + except Exception as e: + logger.warning("failed to fetch groups for website %s at organize: %s", wid, e) + + remote_account_ids: set[str] | None = None + try: + remote_accounts = c.list_accounts() + if remote_accounts is not None: + remote_account_ids = set() + for acc in remote_accounts: + acc_id = c.extract_id(acc) + if acc_id: + remote_account_ids.add(str(acc_id)) + except Exception as e: + logger.warning("failed to fetch remote accounts for website %s at organize: %s", wid, e) + + # 构建 remote_account_map 用于快速查询账号状态 + remote_account_map = {} + if remote_accounts is not None: + for acc in remote_accounts: + acc_id = c.extract_id(acc) + if acc_id: + remote_account_map[str(acc_id)] = acc + + # 对每个绑定关系遍历其对应的上游 Key 进行整理 + for b in bindings: + target_group_id = b.target_group_id + target_group_name = target_group_names.get(str(target_group_id)) or b.target_group_name or str(target_group_id) + sources = binding_sources(b) + + for src in sources: + uid = src.get("upstream_id") + gid = src.get("group_id") + if not uid or not gid: + continue + + upstream_name = db.query(Upstream.name).filter(Upstream.id == uid).scalar() or f"#{uid}" + + # 来源名称优先取 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): + source_group_name = g_data.get("name") or g_data.get("group_name") or "" + if not source_group_name: + source_group_name = gid + + # 查询该 (upstream_id, group_id) 的非 orphaned Key + keys = ( + db.query(UpstreamGeneratedKey) + .filter( + UpstreamGeneratedKey.upstream_id == uid, + UpstreamGeneratedKey.group_id == gid, + UpstreamGeneratedKey.status != "orphaned", + ) + .all() + ) + + if not keys: + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name="", + account_id=None, + account_name=None, + status="missing_key", + message="请先生成上游 Key", + ) + ) + continue + + # 查询来源上游的 Base URL 用于账号创建 + upstream_base_url = "" + _u = db.query(Upstream).filter(Upstream.id == uid).first() + if _u: + upstream_base_url = _u.base_url + + for row in keys: + # 跳过状态为 failed 的 Key + if row.status == "failed": + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name=row.key_name, + account_id=None, + account_name=None, + status="failed", + message="上游 Key 生成状态为失败", + ) + ) + continue + + # 检测平台类型 + platform = _detect_platform( + f"{row.group_name} {row.group_id} {row.key_name}", + "openai", + ) + + # 1. 幂等校验:已导入过则检查远端账号是否仍存在 + if row.imported_website_id == wid and row.imported_account_id: + old_account_id = row.imported_account_id + + if remote_account_ids is None: + # 账号列表获取失败,无法校验状态,保守跳过 + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name=row.key_name, + account_id=old_account_id, + status="failed", + message="无法校验目标账号状态,已保守跳过", + ) + ) + continue + + remote_acc = remote_account_map.get(str(old_account_id)) + + if remote_acc is not None: + # 目标账号存在:跳过或补齐目标分组记录 + 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 + + new_group_ids = list(current_group_ids) + if target_val not in new_group_ids: + new_group_ids.append(target_val) + + c.update_account(old_account_id, {"group_ids": new_group_ids}) + # 顺便更新下 remote_account_map 中的数据,防止同个账号后续在其他绑定中重复补齐 + remote_acc["group_ids"] = new_group_ids + msg = "账号已存在,已补齐目标分组绑定" + else: + msg = "已存在且已绑定,已跳过" + + # 补齐本地 DB 的目标分组标记 + if row.imported_target_group_id != target_group_id: + row.imported_target_group_id = target_group_id + row.imported_target_group_name = target_group_names.get(str(target_group_id)) + db.commit() + + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name=row.key_name, + account_id=old_account_id, + account_name=str(remote_acc.get("name") or ""), + status="exists", + message=msg, + ) + ) + continue + except Exception as exc: + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name=row.key_name, + account_id=old_account_id, + status="failed", + message=f"更新绑定失败: {exc}", + ) + ) + continue + else: + # 远端已删除,清理标记后进行重建 + row.imported_website_id = None + row.imported_account_id = None + row.imported_at = None + row.status = "created" + db.commit() + is_recreated = True + else: + is_recreated = False + + # 2. 检查是否有明文 Key + if not row.key_value: + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name=row.key_name, + status="failed", + message="该 Key 无明文值,无法导入", + ) + ) + continue + + # 3. 创建账号并绑定当前目标分组 + group_ids = [] + numeric_target = _numeric_group_id(target_group_id) + if numeric_target is not None: + group_ids.append(numeric_target) + else: + group_ids.append(target_group_id) + + account_name = f"SmartUp-{row.group_name or row.group_id}-{row.id}" + priority_val = rate_priority_map.get(f"{row.upstream_id}:{row.group_id}", 1) + account_body = { + "name": account_name, + "platform": platform, + "type": "apikey", + "credentials": { + "api_key": row.key_value, + "base_url": upstream_base_url, + }, + "group_ids": group_ids, + "rate_multiplier": 1, + "concurrency": 10, + "priority": priority_val, + "notes": f"Imported by SmartUp from upstream key #{row.id}", + } + try: + created = c.create_account(account_body) + account_id = c.extract_id(created) + row.imported_website_id = wid + row.imported_account_id = account_id or None + row.imported_at = datetime.now(timezone.utc) + row.imported_target_group_id = target_group_id or None + row.imported_target_group_name = target_group_names.get(str(target_group_id)) if target_group_id else None + row.status = "imported" + row.error = None + db.commit() + + # 将新创建的账号加入 remote_account_map 缓存,以防同账号的其它绑定关系后续做幂等校验 + if account_id: + remote_account_map[str(account_id)] = created + + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name=row.key_name, + account_id=account_id or None, + account_name=str(created.get("name") or account_name), + status="recreated" if is_recreated else "created", + message="清理后重建账号" if is_recreated else "已创建账号", + ) + ) + except Exception as exc: + logger.exception("organize create account failed website=%s key=%s", wid, row.id) + row.status = "import_failed" + row.error = str(exc) + db.commit() + items.append( + OrganizeGroupsItem( + target_group_id=str(target_group_id), + target_group_name=target_group_name, + upstream_name=upstream_name, + source_group_id=str(gid), + source_group_name=str(source_group_name), + key_name=row.key_name, + status="failed", + message=str(exc), + ) + ) + + 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") + 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 missing_key_count: + parts.append(f"缺少 Key {missing_key_count}") + if failed_count: + parts.append(f"失败 {failed_count}") + + message = "整理完成:" + " / ".join(parts) if parts else "整理完成:无任何绑定或数据" + return OrganizeGroupsResponse( + success=failed_count == 0, + message=message, + items=items, + ) + + @router.get("/api/group-bindings", response_model=List[BindingResponse]) def list_bindings(db: Session = Depends(get_db), _=Depends(get_current_user)): rows = db.query(WebsiteGroupBinding).order_by(WebsiteGroupBinding.id.desc()).all() diff --git a/backend/app/schemas/website.py b/backend/app/schemas/website.py index 7d47c33..f55d9be 100644 --- a/backend/app/schemas/website.py +++ b/backend/app/schemas/website.py @@ -213,3 +213,22 @@ class WebsiteBatchSyncResponse(BaseModel): skipped: int message: str logs: list[WebsiteSyncLogResponse] + + +class OrganizeGroupsItem(BaseModel): + target_group_id: str + target_group_name: str + upstream_name: str + source_group_id: str + source_group_name: str + key_name: Optional[str] = None + account_id: Optional[str] = None + account_name: Optional[str] = None + status: str + message: str = "" + + +class OrganizeGroupsResponse(BaseModel): + success: bool + message: str + items: list[OrganizeGroupsItem] diff --git a/backend/test_organize_groups.py b/backend/test_organize_groups.py new file mode 100644 index 0000000..3e703aa --- /dev/null +++ b/backend/test_organize_groups.py @@ -0,0 +1,324 @@ +import json +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, WebsiteGroupBinding +from app.routers.websites import organize_website_groups + + +@pytest.fixture() +def db_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + # Ensure all models are created + 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_organize_groups_full_scenarios(db_session, monkeypatch): + """测试一键整理分组的各种核心场景: + 1. 已有 Key 未导入时,创建目标账号并绑定目标分组 + 2. 已导入且目标账号存在时,不重复创建(若未绑定目标分组则补齐绑定) + 3. 已导入但目标账号不存在时,清理旧标记并重建 + 4. 绑定关系存在但没有对应 Key 时,返回 missing_key + 5. 多个目标分组、多上游分组绑定时,正确分类 + """ + # 1. 建立测试 Website 和 Upstream + 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) + + # 2. 建立绑定关系 + # 绑定 1:目标分组 TG1 ↔ 上游分组 G1(有 Key 未导入),G2(没有 Key — 对应 missing_key) + 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"}, + {"upstream_id": u1.id, "group_id": "G2"}, + ]), + enabled=True + ) + # 绑定 2:目标分组 TG2 ↔ 上游分组 G3(已导入且账号仍存在),G4(已导入但账号已删除) + 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": "G3"}, + {"upstream_id": u1.id, "group_id": "G4"}, + ]), + enabled=True + ) + db_session.add_all([b1, b2]) + db_session.commit() + + # 3. 建立上游 Key 记录 + # G1: 未导入 Key + k1 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G1", + group_name="G1-Name", + key_name="Key-G1", + key_value="sk-g1-secret", + status="created", + ) + # G3: 已导入且账号存在 + k3 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G3", + group_name="G3-Name", + key_name="Key-G3", + key_value="sk-g3-secret", + status="imported", + imported_website_id=w.id, + imported_account_id="ACC-G3", + imported_target_group_id="TG2", + ) + # G4: 已导入但账号已删除 + k4 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G4", + group_name="G4-Name", + key_name="Key-G4", + key_value="sk-g4-secret", + status="imported", + imported_website_id=w.id, + imported_account_id="ACC-G4-DELETED", + imported_target_group_id="TG2", + ) + db_session.add_all([k1, k3, k4]) + db_session.commit() + + # 4. Mock Sub2ApiWebsiteClient 交互 + created_accounts = [] + 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): + # ACC-G3 仍存在,ACC-G4-DELETED 不在此列表中代表已被删除 + # 另外,我们假设 ACC-G3 目前绑定的 group_ids 是 [999],即不包含 TG2 (或整型数值) + return [ + { + "id": "ACC-G3", + "name": "SmartUp-G3-ACC", + "group_ids": [999], + } + ] + def extract_id(self, val): + return val.get("id") if isinstance(val, dict) else str(val) + def create_account(self, body): + # 模拟创建账号 + acc_id = f"NEW-{body['name']}" + new_acc = {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"]} + created_accounts.append(new_acc) + return new_acc + 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) + + # 5. 执行一键整理 + response = organize_website_groups(wid=w.id, db=db_session) + + # 6. 断言结果 + assert response.success is True + # 整理完成:创建账号 2 (k1创建 + k4重建) / 已存在 1 (k3已存在并补齐) / 缺少 Key 1 (G2缺少) + assert "创建账号 2" in response.message + assert "已存在 1" in response.message + assert "缺少 Key 1" in response.message + + items = response.items + assert len(items) == 4 + + # 按 target_group_id 分类校验 + # TG1 分组下的 G1 (已创建) + item_g1 = next(item for item in items if item.target_group_id == "TG1" and item.source_group_id == "G1") + assert item_g1.status == "created" + assert item_g1.key_name == "Key-G1" + assert item_g1.account_id.startswith("NEW-SmartUp-") + + # TG1 分组下的 G2 (缺少 Key) + item_g2 = next(item for item in items if item.target_group_id == "TG1" and item.source_group_id == "G2") + assert item_g2.status == "missing_key" + assert item_g2.message == "请先生成上游 Key" + + # 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 + # 校验是否触发了 update_account 补齐了 TG2 + assert len(updated_accounts) == 1 + assert updated_accounts[0][0] == "ACC-G3" + # TG2 (或者其数值) 应该在更新后的 group_ids 中 + assert "TG2" in updated_accounts[0][1]["group_ids"] or 999 in updated_accounts[0][1]["group_ids"] + + # TG2 分组下的 G4 (清理后重建) + 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.account_id.startswith("NEW-SmartUp-") + + # 7. 检查数据库中 Key 记录的状态变化 + db_session.refresh(k1) + db_session.refresh(k3) + db_session.refresh(k4) + + assert k1.imported_website_id == w.id + assert k1.imported_account_id is not None + assert k1.imported_target_group_id == "TG1" + assert k1.status == "imported" + + assert k3.imported_target_group_id == "TG2" + + assert k4.imported_website_id == w.id + assert k4.imported_account_id is not None + assert k4.imported_target_group_id == "TG2" + assert k4.status == "imported" + + +def test_organize_groups_list_accounts_none_conservatively_skips(db_session, monkeypatch): + """测试一键整理分组时,若远端账号列表拉取失败返回 None: + - 已导入的账号不得被清除或重建,而应该保守跳过(状态记为 failed,提示无法校验)。 + - 未导入的账号仍能正常导入创建。 + """ + 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(未导入),G3(已导入) + 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", "group_name": "G1-SourceGroup"}, + {"upstream_id": u1.id, "group_id": "G3", "group_name": "G3-SourceGroup"}, + ]), + enabled=True + ) + db_session.add_all([b1]) + db_session.commit() + + # k1: 未导入 Key + k1 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G1", + group_name="G1-SourceGroup", + key_name="Key-G1", + key_value="sk-g1-secret", + status="created", + ) + # k3: 已导入 Key + k3 = UpstreamGeneratedKey( + upstream_id=u1.id, + group_id="G3", + group_name="G3-SourceGroup", + key_name="Key-G3", + key_value="sk-g3-secret", + status="imported", + imported_website_id=w.id, + imported_account_id="ACC-G3-EXISTING", + imported_target_group_id="TG1", + ) + db_session.add_all([k1, k3]) + 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": "TG1", "name": "TG1-Group"}] + def list_accounts(self): + # 模拟获取失败返回 None! + return None + def extract_id(self, val): + return val.get("id") if isinstance(val, dict) else str(val) + def create_account(self, body): + acc_id = f"NEW-{body['name']}" + new_acc = {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"]} + created_accounts.append(new_acc) + return new_acc + + monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient) + + # 执行一键整理 + 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 + + 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-SmartUp-") + assert item_g1.source_group_name == "G1-SourceGroup" # 校验 group_name 优先从绑定中获取! + + # G3 (已导入) 应该保守跳过 + item_g3 = next(item for item in items if item.source_group_id == "G3") + assert item_g3.status == "failed" + assert item_g3.message == "无法校验目标账号状态,已保守跳过" + + # 校验 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" diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 4705520..d5125ee 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -316,6 +316,19 @@ export interface WebsiteBatchSyncResponse { logs: WebsiteSyncLog[] } +export interface OrganizeGroupsItem { + target_group_id: string + target_group_name: string + upstream_name: string + source_group_id: string + source_group_name: string + key_name: string | null + account_id: string | null + account_name: string | null + status: string + message: string +} + export const websitesApi = { list: () => api.get('/api/websites'), create: (data: WebsiteForm) => api.post('/api/websites', data), @@ -339,6 +352,8 @@ export const websitesApi = { priority?: number auto_priority_by_rate?: boolean }) => api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/import-upstream-keys`, data), + organizeGroups: (id: number) => + api.post<{ success: boolean; message: string; items: OrganizeGroupsItem[] }>(`/api/websites/${id}/groups/organize`), listBindings: () => api.get('/api/group-bindings'), createBinding: (data: GroupBindingForm) => api.post('/api/group-bindings', data), updateBinding: (id: number, data: Partial) => api.put(`/api/group-bindings/${id}`, data), diff --git a/frontend/src/views/Websites.vue b/frontend/src/views/Websites.vue index 5e903f4..2172a2d 100644 --- a/frontend/src/views/Websites.vue +++ b/frontend/src/views/Websites.vue @@ -111,6 +111,16 @@ > 一键同步 + + 一键整理分组 + 新增绑定
@@ -449,6 +459,58 @@ 创建账号管理账号 + + +
+ {{ organizeMessage }} +
+ + + + + + + + + + + + + + + + + + + + + +
@@ -472,6 +534,7 @@ import { type WebsiteForm, type WebsiteGroup, type WebsiteSyncLog, + type OrganizeGroupsItem, } from '@/api' const websites = ref<(WebsiteData & { _testing?: boolean })[]>([]) @@ -576,6 +639,11 @@ const importAccountsForm = ref({ }) const importAccountResults = ref([]) +const organizeDialog = ref(false) +const organizeLoading = ref(false) +const organizeResults = ref([]) +const organizeMessage = ref('') + const upstreamGroupOptions = computed(() => { const rows: Array<{ key: string; label: string; source: BindingSourceGroup }> = [] for (const upstream of upstreams.value) { @@ -1000,6 +1068,33 @@ async function syncAllWebsiteBindings() { } } +async function organizeWebsiteGroups() { + if (!selectedWebsite.value) return + try { + await ElMessageBox.confirm( + '一键整理分组将根据当前的“分组管理”绑定关系,自动执行已导入账号的校验补齐,以及未导入账号的自动创建。确认执行?', + '一键整理分组', + { type: 'info' } + ) + } catch { + return + } + + organizeLoading.value = true + try { + const res = await websitesApi.organizeGroups(selectedWebsite.value.id) + organizeMessage.value = res.data.message + organizeResults.value = res.data.items + organizeDialog.value = true + // 刷新相关数据 + await Promise.all([loadLogs(), loadWebsiteGroups(), loadBindings()]) + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '一键整理分组失败') + } finally { + organizeLoading.value = false + } +} + async function toggleBinding(row: GroupBindingData) { try { await websitesApi.updateBinding(row.id, { enabled: row.enabled })