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:
liujing
2026-07-23 17:52:58 +08:00
parent ee89cd6119
commit df7e400e7b
17 changed files with 1081 additions and 537 deletions
+42
View File
@@ -47,6 +47,8 @@ def init_db():
_migrate_custom_pages() _migrate_custom_pages()
_migrate_upstreams() _migrate_upstreams()
_migrate_upstream_generated_keys() _migrate_upstream_generated_keys()
_migrate_website_group_bindings_platform()
_migrate_upstream_key_account_links()
# ── 已有数据库幂等索引迁移 ───────────────────────────────── # ── 已有数据库幂等索引迁移 ─────────────────────────────────
@@ -197,3 +199,43 @@ def _migrate_upstream_generated_keys():
except Exception: except Exception:
logger = __import__("logging").getLogger(__name__) logger = __import__("logging").getLogger(__name__)
logger.warning("could not create unique indexes on upstream_generated_keys (non-fatal)") logger.warning("could not create unique indexes on upstream_generated_keys (non-fatal)")
def _migrate_upstream_key_account_links():
"""Create the multi-platform account mapping table and indexes."""
inspector = inspect(engine)
if "upstream_key_account_links" not in inspector.get_table_names():
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS upstream_key_account_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upstream_key_id INTEGER NOT NULL REFERENCES upstream_generated_keys(id) ON DELETE CASCADE,
website_id INTEGER NOT NULL REFERENCES websites(id) ON DELETE CASCADE,
target_group VARCHAR(255) NOT NULL DEFAULT 'default',
platform VARCHAR(64) NOT NULL,
remote_account_id VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_at DATETIME,
updated_at DATETIME
)
"""))
with engine.begin() as conn:
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_website_platform
ON upstream_key_account_links(upstream_key_id, website_id, platform)
"""))
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_website_remote_account
ON upstream_key_account_links(website_id, remote_account_id)
"""))
def _migrate_website_group_bindings_platform():
"""Add platform column to website_group_bindings if missing."""
inspector = inspect(engine)
if "website_group_bindings" not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns("website_group_bindings")}
if "platform" not in columns:
with engine.begin() as conn:
conn.execute(text("ALTER TABLE website_group_bindings ADD COLUMN platform VARCHAR(64)"))
+24 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -30,7 +30,30 @@ class UpstreamGeneratedKey(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
account_links = relationship("UpstreamKeyAccountLink", back_populates="upstream_key", cascade="all, delete-orphan")
__table_args__ = ( __table_args__ = (
UniqueConstraint("upstream_id", "group_id", "key_name", name="uq_upstream_group_key"), UniqueConstraint("upstream_id", "group_id", "key_name", name="uq_upstream_group_key"),
Index("ix_key_upstream_name", "upstream_id", "key_name"), Index("ix_key_upstream_name", "upstream_id", "key_name"),
) )
class UpstreamKeyAccountLink(Base):
__tablename__ = "upstream_key_account_links"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
upstream_key_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstream_generated_keys.id", ondelete="CASCADE"), index=True, nullable=False)
website_id: Mapped[int] = mapped_column(Integer, ForeignKey("websites.id", ondelete="CASCADE"), index=True, nullable=False)
target_group: Mapped[str] = mapped_column(String(255), nullable=False)
platform: Mapped[str] = mapped_column(String(64), nullable=False)
remote_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="active", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
upstream_key = relationship("UpstreamGeneratedKey", back_populates="account_links")
__table_args__ = (
UniqueConstraint("upstream_key_id", "website_id", "platform", name="uq_key_website_platform"),
UniqueConstraint("website_id", "remote_account_id", name="uq_website_remote_account"),
)
+1
View File
@@ -42,6 +42,7 @@ class WebsiteGroupBinding(Base):
percent: Mapped[str] = mapped_column(String(32), default="0") percent: Mapped[str] = mapped_column(String(32), default="0")
algorithm: Mapped[str] = mapped_column(String(64), default="priority_weighted_plus_percent") algorithm: Mapped[str] = mapped_column(String(64), default="priority_weighted_plus_percent")
enabled: Mapped[bool] = mapped_column(Boolean, default=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True)
platform: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
+10 -2
View File
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey from app.models.upstream_key import UpstreamGeneratedKey, UpstreamKeyAccountLink
from app.models.upstream_recharge_event import UpstreamRechargeEvent from app.models.upstream_recharge_event import UpstreamRechargeEvent
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamRateSnapshot
from app.schemas.upstream import ( from app.schemas.upstream import (
@@ -25,6 +25,7 @@ from app.schemas.upstream import (
UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult, UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult,
UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse, UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse,
UpstreamRechargeCreate, UpstreamRechargeUpdate, UpstreamRechargeResponse, UpstreamRechargeCreate, UpstreamRechargeUpdate, UpstreamRechargeResponse,
UpstreamKeyAccountLinkResponse,
) )
from app.services.upstream_client import UpstreamClient, UpstreamError, _PendingKeyError, build_snapshot, build_new_api_token_name, mask_secret, _extract_key_value from app.services.upstream_client import UpstreamClient, UpstreamError, _PendingKeyError, build_snapshot, build_new_api_token_name, mask_secret, _extract_key_value
from app.services.auth_config import MASK, mask_auth_config, normalize_auth_config from app.services.auth_config import MASK, mask_auth_config, normalize_auth_config
@@ -49,7 +50,13 @@ def _group_name(group: dict, gid: str) -> str:
return str(group.get("name") or group.get("group_name") or gid) return str(group.get("name") or group.get("group_name") or gid)
def _key_response(row: UpstreamGeneratedKey, include_value: bool = False) -> GeneratedUpstreamKeyResponse: def _key_response(row: UpstreamGeneratedKey, include_value: bool = False, db: Session | None = None) -> GeneratedUpstreamKeyResponse:
sess = db or Session.object_session(row)
imported_accounts = []
if sess and row.id is not None:
links = sess.query(UpstreamKeyAccountLink).filter(UpstreamKeyAccountLink.upstream_key_id == row.id).all()
imported_accounts = [UpstreamKeyAccountLinkResponse.model_validate(l) for l in links]
return GeneratedUpstreamKeyResponse( return GeneratedUpstreamKeyResponse(
id=row.id, id=row.id,
upstream_id=row.upstream_id, upstream_id=row.upstream_id,
@@ -67,6 +74,7 @@ def _key_response(row: UpstreamGeneratedKey, include_value: bool = False) -> Gen
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
has_key_value=bool(row.key_value), has_key_value=bool(row.key_value),
imported_accounts=imported_accounts,
) )
+386 -240
View File
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamRateSnapshot
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey from app.models.upstream_key import UpstreamGeneratedKey, UpstreamKeyAccountLink
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
from app.schemas.website import ( from app.schemas.website import (
BindingCreate, BindingCreate,
@@ -63,6 +63,10 @@ from app.services.website_sync import (
sync_account_priorities_for_upstream, sync_account_priorities_for_upstream,
latest_rate_map, latest_rate_map,
build_imported_account_name, build_imported_account_name,
sync_key_legacy_fields,
get_or_backfill_account_links,
get_account_mapping_pairs,
deactivate_account_mapping,
) )
from app.utils.auth import get_current_user from app.utils.auth import get_current_user
from app.utils.number import fixed_decimal_number from app.utils.number import fixed_decimal_number
@@ -248,6 +252,7 @@ def _binding_response(db: Session, row: WebsiteGroupBinding) -> BindingResponse:
percent=float(row.percent or 0), percent=float(row.percent or 0),
algorithm=row.algorithm, algorithm=row.algorithm,
enabled=row.enabled, enabled=row.enabled,
platform=row.platform,
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
) )
@@ -539,9 +544,10 @@ def import_groups_from_upstream(
create_body = { create_body = {
"name": target_name, "name": target_name,
"description": group.get("description") or f"Imported from {upstream.name} / {source_name}", "description": group.get("description") or f"Imported from {upstream.name} / {source_name}",
"platform": group.get("platform") or "openai",
"rate_multiplier": fixed_decimal_number(_source_group_rate(group), 2), "rate_multiplier": fixed_decimal_number(_source_group_rate(group), 2),
} }
if group.get("platform"):
create_body["platform"] = group["platform"]
if group.get("rpm_limit") is not None: if group.get("rpm_limit") is not None:
create_body["rpm_limit"] = group.get("rpm_limit") create_body["rpm_limit"] = group.get("rpm_limit")
try: try:
@@ -593,8 +599,10 @@ def import_groups_from_upstream(
) )
def _normalize_platform(platform: str) -> str: def _normalize_platform(platform: str | None) -> str:
"""统一平台值:小写、xai → grok。""" """统一平台值:小写、xai → grok。"""
if not platform:
return ""
p = platform.lower().strip() p = platform.lower().strip()
if p == "xai": if p == "xai":
return "grok" return "grok"
@@ -604,28 +612,22 @@ def _normalize_platform(platform: str) -> str:
SUPPORTED_PLATFORMS = {"openai", "anthropic", "gemini", "grok", "antigravity"} SUPPORTED_PLATFORMS = {"openai", "anthropic", "gemini", "grok", "antigravity"}
def _resolve_platform(text: str, group_snapshot: dict | None, fallback: str = "openai") -> str: def _target_group_platform(group: dict[str, Any] | None) -> str | None:
if group_snapshot and isinstance(group_snapshot, dict): platform = _normalize_platform(group.get("platform") if group else None)
raw = group_snapshot.get("platform") return platform if platform in SUPPORTED_PLATFORMS else None
if raw:
platform = _normalize_platform(str(raw))
if platform in SUPPORTED_PLATFORMS:
return platform
return _detect_platform(text, fallback)
def _detect_platform(text: str, fallback: str = "openai") -> str: def _fetch_target_group(website: Website, target_group_id: str) -> tuple[dict[str, Any], str | None]:
"""根据 Key 名或分组名关键词判断平台类型。""" try:
lower = text.lower() with _client(website) as client:
if "claude" in lower or "anthropic" in lower: groups = client.get_groups(website.groups_endpoint) or []
return "anthropic" except Exception as exc:
if "gemini" in lower: raise HTTPException(502, f"读取目标网站分组失败: {exc}") from exc
return "gemini" target = next((group for group in groups if str(group.get("id")) == str(target_group_id)), None)
if "grok" in lower or "xai" in lower: if target is None:
return "grok" raise HTTPException(400, "目标分组不存在")
if "antigravity" in lower: platform = _target_group_platform(target)
return "antigravity" return target, platform
return _normalize_platform(fallback)
@router.post("/api/websites/{wid}/accounts/sync-imported-upstream-keys", response_model=ImportAccountsResponse) @router.post("/api/websites/{wid}/accounts/sync-imported-upstream-keys", response_model=ImportAccountsResponse)
@@ -640,21 +642,7 @@ def sync_imported_upstream_keys(
if not website: if not website:
raise HTTPException(404, "website not found") raise HTTPException(404, "website not found")
rows = (
db.query(UpstreamGeneratedKey)
.filter(
UpstreamGeneratedKey.upstream_id == body.upstream_id,
UpstreamGeneratedKey.imported_website_id == wid,
UpstreamGeneratedKey.imported_account_id.isnot(None),
)
.all()
)
items: list[ImportAccountItem] = [] 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: with _client(website) as c:
remote_accounts = None remote_accounts = None
@@ -665,20 +653,34 @@ def sync_imported_upstream_keys(
if remote_accounts is not None: if remote_accounts is not None:
remote_ids = set() remote_ids = set()
remote_map = {}
for acc in remote_accounts: for acc in remote_accounts:
acc_id = _extract_id(acc) acc_id = _extract_id(acc)
if acc_id: if acc_id:
remote_ids.add(str(acc_id)) remote_ids.add(str(acc_id))
remote_map[str(acc_id)] = acc
else: else:
remote_ids = None remote_ids = None
remote_map = {}
for row in rows: target_groups: dict[str, dict[str, Any]] = {}
snap_groups = latest_snapshots.get(row.upstream_id) or {} try:
group_snap = snap_groups.get(row.group_id) if isinstance(snap_groups, dict) else None for group in c.get_groups(website.groups_endpoint) or []:
platform = _resolve_platform(f"{row.group_name} {row.group_id} {row.key_name}", group_snap, "openai") if group.get("id") is not None:
if not row.imported_account_id: target_groups[str(group["id"])] = group
continue except Exception as exc:
old_account_id = row.imported_account_id logger.warning("failed to fetch groups during import status sync for website %s: %s", wid, exc)
get_or_backfill_account_links(db, wid, remote_map, target_groups)
rows = [
pair
for pair in get_account_mapping_pairs(db, wid, include_orphaned=True)
if pair[0].upstream_id == body.upstream_id
]
for row, link in rows:
old_account_id = str(link.remote_account_id)
platform = link.platform
if remote_ids is None: if remote_ids is None:
items.append(ImportAccountItem( items.append(ImportAccountItem(
@@ -688,16 +690,13 @@ def sync_imported_upstream_keys(
message="无法校验目标账号存在性(拉取账号列表失败)", message="无法校验目标账号存在性(拉取账号列表失败)",
)) ))
elif str(old_account_id) not in remote_ids: elif str(old_account_id) not in remote_ids:
row.imported_website_id = None
row.imported_account_id = None
row.imported_at = None
row.status = "created"
items.append(ImportAccountItem( items.append(ImportAccountItem(
upstream_key_id=row.id, source_group_id=row.group_id, upstream_key_id=row.id, source_group_id=row.group_id,
source_group_name=row.group_name, account_id=old_account_id, source_group_name=row.group_name, account_id=old_account_id,
platform=platform, status="stale_cleared", platform=platform, status="stale_cleared",
message="目标账号已删除,已清除导入标记", message="目标账号已删除,已清除导入标记",
)) ))
deactivate_account_mapping(db, row, link)
else: else:
items.append(ImportAccountItem( items.append(ImportAccountItem(
upstream_key_id=row.id, source_group_id=row.group_id, upstream_key_id=row.id, source_group_id=row.group_id,
@@ -801,7 +800,7 @@ def import_upstream_keys_as_accounts(
upstream_key_id=kid, upstream_key_id=kid,
source_group_id="", source_group_id="",
source_group_name="", source_group_name="",
platform=body.default_platform, platform="",
status="failed", status="failed",
message="key not found", message="key not found",
) )
@@ -813,11 +812,6 @@ def import_upstream_keys_as_accounts(
upstreams_db = db.query(_Up).filter(_Up.id.in_(upstream_ids)).all() upstreams_db = db.query(_Up).filter(_Up.id.in_(upstream_ids)).all()
upstreams_map = {u.id: u for u in upstreams_db} 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] = {} rate_priority_map: dict[tuple[str, int, str], int] = {}
if body.auto_priority_by_rate: if body.auto_priority_by_rate:
@@ -834,12 +828,15 @@ def import_upstream_keys_as_accounts(
with _client(website) as c: with _client(website) as c:
target_group_names: dict[str, str] = {} target_group_names: dict[str, str] = {}
target_groups: dict[str, dict[str, Any]] = {}
try: try:
grps = c.get_groups(endpoint=website.groups_endpoint) or [] grps = c.get_groups(endpoint=website.groups_endpoint) or []
for g in grps: for g in grps:
g_id = g.get("id") g_id = g.get("id")
if g_id is not None: if g_id is not None:
target_group_names[str(g_id)] = g.get("name") or str(g_id) group_id = str(g_id)
target_group_names[group_id] = g.get("name") or group_id
target_groups[group_id] = g
except Exception as e: except Exception as e:
logger.warning("failed to fetch groups for website %s at import: %s", wid, e) logger.warning("failed to fetch groups for website %s at import: %s", wid, e)
@@ -854,34 +851,40 @@ def import_upstream_keys_as_accounts(
for acc in remote_accounts_list: for acc in remote_accounts_list:
aid = c.extract_id(acc) aid = c.extract_id(acc)
if aid: if aid:
remote_accounts_map[aid] = acc remote_accounts_map[str(aid)] = acc
except Exception as e: except Exception as e:
logger.warning("failed to fetch remote accounts at import: %s", e) logger.warning("failed to fetch remote accounts at import: %s", e)
get_or_backfill_account_links(db, wid, remote_accounts_map, target_groups)
for row in rows: for row in rows:
# 跳过远端已不存在或导入失败的 Key(对账后标记为 orphaned / import_failed target_group_id = body.target_group_map.get(row.group_id)
if row.status in ("orphaned", "failed", "import_failed"): target_group = target_groups.get(str(target_group_id)) if target_group_id else None
platform = _target_group_platform(target_group)
if not target_group_id or target_group is None or platform is None:
items.append(ImportAccountItem( items.append(ImportAccountItem(
upstream_key_id=row.id, upstream_key_id=row.id,
source_group_id=row.group_id, source_group_id=row.group_id,
source_group_name=row.group_name, source_group_name=row.group_name,
target_group_id=body.target_group_map.get(row.group_id), target_group_id=target_group_id,
platform=body.default_platform, platform=platform or "",
status="failed",
message="必须选择存在且包含合法 platform 的目标分组",
))
continue
# 跳过远端已不存在或导入失败的 Key(对账后标记为 orphaned / import_failed
if row.status in ("orphaned", "failed"):
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=target_group_id,
platform=platform,
status="failed", status="failed",
message="远端 Key 已不存在,请重新生成", message="远端 Key 已不存在,请重新生成",
)) ))
continue continue
# 先确定平台(失败项也需要记录)
if body.platform_mode == "auto":
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:
platform = _normalize_platform(body.default_platform)
upstream = upstreams_map.get(row.upstream_id) upstream = upstreams_map.get(row.upstream_id)
upstream_name = upstream.name if upstream else "Unknown" upstream_name = upstream.name if upstream else "Unknown"
@@ -893,87 +896,114 @@ def import_upstream_keys_as_accounts(
group_name=row.group_name, group_name=row.group_name,
group_id=row.group_id, group_id=row.group_id,
key_id=row.id, key_id=row.id,
platform=platform,
) )
# 幂等校验:已导入过则检查远端账号是否仍存在 link = db.query(UpstreamKeyAccountLink).filter(
if row.imported_website_id == wid and row.imported_account_id: UpstreamKeyAccountLink.upstream_key_id == row.id,
old_account_id = row.imported_account_id UpstreamKeyAccountLink.website_id == wid,
if has_list_accounts: UpstreamKeyAccountLink.platform == platform,
if remote_accounts_list is None: ).first()
# 接口获取失败,保守判定为校验失败 if link is None and row.imported_website_id == wid and row.imported_account_id:
exists = None legacy_account = remote_accounts_map.get(str(row.imported_account_id))
else: legacy_platform = _normalize_platform(legacy_account.get("platform")) if legacy_account else ""
exists = old_account_id in remote_accounts_map if legacy_account is not None and legacy_platform == platform:
else: link = UpstreamKeyAccountLink(
# 如果 client 没有 list_accounts,则 fallback 回原先的 account_exists 检测以兼容旧 Mock 测试 upstream_key_id=row.id,
try: website_id=wid,
exists = c.account_exists(row.imported_account_id) target_group=str(target_group_id),
except Exception: platform=platform,
exists = None remote_account_id=str(row.imported_account_id),
status="active",
if exists is True: )
# 顺手回填 imported_target_group_id(老数据升级后可通过重导自动补齐) db.add(link)
new_tgid = body.target_group_map.get(row.group_id) or None db.commit()
if new_tgid: sync_key_legacy_fields(db, row.id)
tg_name = target_group_names.get(str(new_tgid)) has_any_link = db.query(UpstreamKeyAccountLink.id).filter(
if row.imported_target_group_id != new_tgid or row.imported_target_group_name != tg_name: UpstreamKeyAccountLink.upstream_key_id == row.id,
row.imported_target_group_id = new_tgid ).first()
row.imported_target_group_name = tg_name if (
db.commit() link is None
and has_any_link is None
remote_name = "" and row.imported_website_id == wid
if old_account_id in remote_accounts_map: and row.imported_account_id
remote_name = remote_accounts_map[old_account_id].get("name") or "" and remote_accounts_list is None
):
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=target_group_id,
account_id=str(row.imported_account_id),
platform=platform,
upstream_base_url=upstream_base_url,
status="check_failed",
message="无法校验旧账号状态,已保守跳过",
))
continue
if link and link.status == "active":
old_account_id = str(link.remote_account_id)
if remote_accounts_list is None:
items.append(ImportAccountItem( items.append(ImportAccountItem(
upstream_key_id=row.id, upstream_key_id=row.id,
source_group_id=row.group_id, source_group_id=row.group_id,
source_group_name=row.group_name, source_group_name=row.group_name,
target_group_id=body.target_group_map.get(row.group_id), target_group_id=target_group_id,
account_id=old_account_id, account_id=old_account_id,
account_name=remote_name or expected_new_name,
platform=platform, platform=platform,
upstream_base_url=upstream_base_url, upstream_base_url=upstream_base_url,
status="exists",
message="已导入过,已跳过",
))
continue
elif exists is False:
# 远端已删除,清空标记后继续创建
row.imported_website_id = None
row.imported_account_id = None
row.imported_at = None
row.status = "created"
# 继续往下走(不 continue
else:
# 校验失败,保守跳过
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=body.target_group_map.get(row.group_id),
account_id=old_account_id,
platform=platform,
status="check_failed", status="check_failed",
message="无法校验目标账号状态,已保守跳过", message="无法校验目标账号状态,已保守跳过",
)) ))
continue continue
remote_account = remote_accounts_map.get(old_account_id)
remote_platform = _normalize_platform(remote_account.get("platform")) if remote_account else ""
if remote_account is not None and remote_platform == platform:
current_group_ids = remote_account.get("group_ids") or []
if not isinstance(current_group_ids, list):
current_group_ids = [current_group_ids]
target_value = _numeric_group_id(target_group_id)
target_value = target_value if target_value is not None else target_group_id
new_group_ids = [g for g in current_group_ids if str(g) != str(link.target_group)]
if str(target_group_id) not in {str(g) for g in new_group_ids}:
new_group_ids.append(target_value)
if {str(g) for g in current_group_ids} != {str(g) for g in new_group_ids}:
c.update_account(old_account_id, {"group_ids": new_group_ids})
link.target_group = str(target_group_id)
db.commit()
sync_key_legacy_fields(db, row.id)
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
source_group_name=row.group_name,
target_group_id=target_group_id,
account_id=old_account_id,
account_name=str(remote_account.get("name") or expected_new_name),
platform=platform,
upstream_base_url=upstream_base_url,
status="exists",
message="已存在并已对齐目标分组",
raw=remote_account,
))
continue
link.status = "stale"
db.commit()
sync_key_legacy_fields(db, row.id)
if not row.key_value: if not row.key_value:
items.append(ImportAccountItem( items.append(ImportAccountItem(
upstream_key_id=row.id, upstream_key_id=row.id,
source_group_id=row.group_id, source_group_id=row.group_id,
source_group_name=row.group_name, source_group_name=row.group_name,
target_group_id=target_group_id,
platform=platform, platform=platform,
status="failed", status="check_failed",
message="该 Key 无明文值,无法导入(远端已存在 Key 不会保留明文,请重新创建或手动填入)", message="该 Key 无明文值,无法导入(远端已存在 Key 不会保留明文,请重新创建或手动填入)",
)) ))
continue continue
target_group_id = body.target_group_map.get(row.group_id)
group_ids = []
numeric_target = _numeric_group_id(target_group_id) numeric_target = _numeric_group_id(target_group_id)
if numeric_target is not None: group_ids = [numeric_target if numeric_target is not None else target_group_id]
group_ids.append(numeric_target)
account_name = expected_new_name account_name = expected_new_name
account_body = { account_body = {
"name": account_name, "name": account_name,
@@ -992,14 +1022,25 @@ def import_upstream_keys_as_accounts(
try: try:
created = c.create_account(account_body) created = c.create_account(account_body)
account_id = c.extract_id(created) account_id = c.extract_id(created)
row.imported_website_id = wid if not account_id:
row.imported_account_id = account_id or None raise WebsiteError("创建账号成功但响应缺少账号 ID")
row.imported_at = datetime.now(timezone.utc) if link:
row.imported_target_group_id = target_group_id or None link.target_group = str(target_group_id)
row.imported_target_group_name = target_group_names.get(str(target_group_id)) if target_group_id else None link.remote_account_id = str(account_id)
row.status = "imported" link.status = "active"
else:
db.add(UpstreamKeyAccountLink(
upstream_key_id=row.id,
website_id=wid,
target_group=str(target_group_id),
platform=platform,
remote_account_id=str(account_id),
status="active",
))
row.error = None row.error = None
db.commit() db.commit()
sync_key_legacy_fields(db, row.id)
remote_accounts_map[str(account_id)] = created
items.append(ImportAccountItem( items.append(ImportAccountItem(
upstream_key_id=row.id, upstream_key_id=row.id,
source_group_id=row.group_id, source_group_id=row.group_id,
@@ -1015,8 +1056,13 @@ def import_upstream_keys_as_accounts(
)) ))
except Exception as exc: except Exception as exc:
logger.exception("import upstream key as account failed website=%s key=%s", wid, row.id) logger.exception("import upstream key as account failed website=%s key=%s", wid, row.id)
row.status = "import_failed"
row.error = str(exc) row.error = str(exc)
has_active_link = db.query(UpstreamKeyAccountLink.id).filter(
UpstreamKeyAccountLink.upstream_key_id == row.id,
UpstreamKeyAccountLink.status == "active",
).first()
if not has_active_link:
row.status = "import_failed"
db.commit() db.commit()
items.append(ImportAccountItem( items.append(ImportAccountItem(
upstream_key_id=row.id, upstream_key_id=row.id,
@@ -1060,20 +1106,31 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
for acc in remote_accounts: for acc in remote_accounts:
aid = c.extract_id(acc) aid = c.extract_id(acc)
if aid: if aid:
remote_accounts_map[aid] = acc remote_accounts_map[str(aid)] = acc
target_groups: dict[str, dict[str, Any]] = {}
try:
for group in c.get_groups(website.groups_endpoint) or []:
if group.get("id") is not None:
target_groups[str(group["id"])] = group
except Exception as exc:
logger.warning("failed to fetch groups during cleanup for website %s: %s", website.id, exc)
get_or_backfill_account_links(db, website.id, remote_accounts_map, target_groups)
candidates: dict[str, dict[str, Any]] = {} candidates: dict[str, dict[str, Any]] = {}
# 2. Category: missing_remote_account # 2. Category: missing_remote_account
# Local keys that still reference an imported_account_id, # Local keys that still reference an imported_account_id,
# but that account no longer appears in the remote account list. # but that account no longer appears in the remote account list.
imported_keys = db.query(UpstreamGeneratedKey).filter( all_pairs = get_account_mapping_pairs(db, website.id, include_orphaned=True)
UpstreamGeneratedKey.imported_website_id == website.id, local_pair_by_account = {str(link.remote_account_id): (key, link) for key, link in all_pairs}
UpstreamGeneratedKey.imported_account_id.isnot(None), imported_pairs = [
).all() (key, link) for key, link in all_pairs
if key.status != "orphaned" and link.status == "active"
]
for key in imported_keys: for key, link in imported_pairs:
acc_id = key.imported_account_id acc_id = str(link.remote_account_id)
if acc_id in candidates: if acc_id in candidates:
continue continue
if acc_id in remote_accounts_map: if acc_id in remote_accounts_map:
@@ -1089,18 +1146,18 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": "目标账号已被手动删除", "reason": "目标账号已被手动删除",
"cleanup_action": "clear_local_marker", "cleanup_action": "clear_local_marker",
"db_key": key, "db_key": key,
"db_link": link,
} }
# 3. Category: local_orphan_account # 3. Category: local_orphan_account
# Local keys with status=orphaned that still carry an imported_account_id. # Local keys with status=orphaned that still carry an imported_account_id.
orphaned_keys = db.query(UpstreamGeneratedKey).filter( orphaned_pairs = [
UpstreamGeneratedKey.status == "orphaned", (key, link) for key, link in all_pairs
UpstreamGeneratedKey.imported_website_id == website.id, if key.status == "orphaned" and link.status == "active"
UpstreamGeneratedKey.imported_account_id.isnot(None), ]
).all()
for key in orphaned_keys: for key, link in orphaned_pairs:
acc_id = key.imported_account_id acc_id = str(link.remote_account_id)
remote_acc = remote_accounts_map.get(acc_id) remote_acc = remote_accounts_map.get(acc_id)
acc_name = remote_acc.get("name") if remote_acc else key.key_name acc_name = remote_acc.get("name") if remote_acc else key.key_name
upstream = db.query(Upstream).filter(Upstream.id == key.upstream_id).first() upstream = db.query(Upstream).filter(Upstream.id == key.upstream_id).first()
@@ -1113,6 +1170,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
candidates[acc_id]["upstream_key_id"] = key.id candidates[acc_id]["upstream_key_id"] = key.id
candidates[acc_id]["upstream_name"] = upstream_name candidates[acc_id]["upstream_name"] = upstream_name
candidates[acc_id]["source_group_name"] = key.group_name candidates[acc_id]["source_group_name"] = key.group_name
candidates[acc_id]["db_link"] = link
continue continue
if remote_acc: if remote_acc:
@@ -1125,6 +1183,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": "本地上游 Key 已归档", "reason": "本地上游 Key 已归档",
"cleanup_action": "delete_remote_and_clear_marker", "cleanup_action": "delete_remote_and_clear_marker",
"db_key": key, "db_key": key,
"db_link": link,
} }
else: else:
candidates[acc_id] = { candidates[acc_id] = {
@@ -1136,6 +1195,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": "本地上游 Key 已归档,且目标账号已被手动删除", "reason": "本地上游 Key 已归档,且目标账号已被手动删除",
"cleanup_action": "clear_local_marker", "cleanup_action": "clear_local_marker",
"db_key": key, "db_key": key,
"db_link": link,
} }
# 4. Category: remote_orphan_account # 4. Category: remote_orphan_account
@@ -1166,10 +1226,12 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
if not key: if not key:
is_orphan = True is_orphan = True
reason = "本地找不到对应 Key" reason = "本地找不到对应 Key"
elif key.imported_website_id != website.id or key.imported_account_id != aid: local_pair = local_pair_by_account.get(aid)
link = local_pair[1] if local_pair else None
if key and (local_pair is None or local_pair[0].id != key.id):
is_orphan = True is_orphan = True
reason = "本地 Key 与目标账号关联不一致" reason = "本地 Key 与目标账号关联不一致"
elif key.status == 'orphaned': elif key and key.status == 'orphaned':
is_orphan = True is_orphan = True
reason = "本地上游 Key 已归档" reason = "本地上游 Key 已归档"
@@ -1190,6 +1252,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": reason, "reason": reason,
"cleanup_action": "delete_remote_account", "cleanup_action": "delete_remote_account",
"db_key": key, "db_key": key,
"db_link": link,
} }
return candidates return candidates
@@ -1215,8 +1278,23 @@ def _test_candidate_account(c: Sub2ApiWebsiteClient, account_id: str) -> tuple[s
return "test_unknown", False, f"测试时发生异常: {e}" return "test_unknown", False, f"测试时发生异常: {e}"
def _clear_local_import_marker(key: UpstreamGeneratedKey, db: Session) -> None: def _clear_local_import_marker(
"""清空单条 Key 上的 SmartUp 导入标记字段。""" key: UpstreamGeneratedKey,
db: Session,
link: UpstreamKeyAccountLink | None = None,
) -> None:
"""Deactivate one account mapping and refresh the compatibility marker."""
if link is not None:
if isinstance(link, UpstreamKeyAccountLink):
deactivate_account_mapping(db, key, link)
else:
key.imported_website_id = None
key.imported_account_id = None
key.imported_at = None
key.imported_target_group_id = None
key.imported_target_group_name = None
db.add(key)
return
key.imported_website_id = None key.imported_website_id = None
key.imported_account_id = None key.imported_account_id = None
key.imported_at = None key.imported_at = None
@@ -1300,7 +1378,7 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=
if action == "clear_local_marker": if action == "clear_local_marker":
key = cand.get("db_key") key = cand.get("db_key")
if key: if key:
_clear_local_import_marker(key, db) _clear_local_import_marker(key, db, cand.get("db_link"))
action_status = "cleared" action_status = "cleared"
msg = "已清除本地导入标记" msg = "已清除本地导入标记"
else: else:
@@ -1320,7 +1398,7 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=
if action == "delete_remote_and_clear_marker": if action == "delete_remote_and_clear_marker":
key = cand.get("db_key") key = cand.get("db_key")
if key: if key:
_clear_local_import_marker(key, db) _clear_local_import_marker(key, db, cand.get("db_link"))
msg += ",已清除本地导入标记" msg += ",已清除本地导入标记"
else: else:
action_status = "failed" action_status = "failed"
@@ -1405,16 +1483,22 @@ def set_website_accounts_concurrency(
for acc in remote_accounts: for acc in remote_accounts:
aid = c.extract_id(acc) aid = c.extract_id(acc)
if aid: if aid:
remote_map[aid] = acc remote_map[str(aid)] = acc
keys = db.query(UpstreamGeneratedKey).filter( target_groups: dict[str, dict[str, Any]] = {}
UpstreamGeneratedKey.imported_website_id == wid, try:
UpstreamGeneratedKey.imported_account_id.isnot(None) for group in c.get_groups(website.groups_endpoint) or []:
).all() if group.get("id") is not None:
target_groups[str(group["id"])] = group
except Exception as exc:
logger.warning("failed to fetch groups while setting concurrency for website %s: %s", wid, exc)
get_or_backfill_account_links(db, wid, remote_map, target_groups)
account_pairs = get_account_mapping_pairs(db, wid, include_orphaned=False)
candidates = {} candidates = {}
for key in keys: for key, link in account_pairs:
aid = key.imported_account_id aid = str(link.remote_account_id)
if aid not in candidates: if aid not in candidates:
candidates[aid] = { candidates[aid] = {
"account_id": aid, "account_id": aid,
@@ -1595,17 +1679,22 @@ def _sync_upstream_models_generator(wid: int, db: Session):
for acc in remote_accounts: for acc in remote_accounts:
aid = c.extract_id(acc) aid = c.extract_id(acc)
if aid: if aid:
remote_map[aid] = acc remote_map[str(aid)] = acc
# 1. 查找候选账号:本地 UpstreamGeneratedKey.imported_website_id == wid 且 imported_account_id 非空 target_groups: dict[str, dict[str, Any]] = {}
keys = db.query(UpstreamGeneratedKey).filter( try:
UpstreamGeneratedKey.imported_website_id == wid, for group in c.get_groups(website.groups_endpoint) or []:
UpstreamGeneratedKey.imported_account_id.isnot(None) if group.get("id") is not None:
).all() target_groups[str(group["id"])] = group
except Exception as exc:
logger.warning("failed to fetch groups while syncing models for website %s: %s", wid, exc)
get_or_backfill_account_links(db, wid, remote_map, target_groups)
account_pairs = get_account_mapping_pairs(db, wid, include_orphaned=False)
candidates = {} candidates = {}
for key in keys: for key, link in account_pairs:
aid = key.imported_account_id aid = str(link.remote_account_id)
if aid not in candidates: if aid not in candidates:
candidates[aid] = { candidates[aid] = {
"account_id": aid, "account_id": aid,
@@ -1937,6 +2026,11 @@ def _organize_website_groups_generator(wid: int, db: Session):
) )
for row in hist_group_ids: for row in hist_group_ids:
smartup_managed_group_ids.add(str(row[0])) smartup_managed_group_ids.add(str(row[0]))
linked_group_ids = db.query(UpstreamKeyAccountLink.target_group).filter(
UpstreamKeyAccountLink.website_id == wid,
UpstreamKeyAccountLink.status == "active",
).distinct().all()
smartup_managed_group_ids.update(str(row[0]) for row in linked_group_ids if row[0])
items: list[OrganizeGroupsItem] = [] items: list[OrganizeGroupsItem] = []
@@ -1989,13 +2083,16 @@ def _organize_website_groups_generator(wid: int, db: Session):
with _client(website) as c: with _client(website) as c:
# 获取远端分组名称列表和账号列表 # 获取远端分组名称列表和账号列表
target_group_names: dict[str, str] = {} target_group_names: dict[str, str] = {}
target_group_map: dict[str, dict[str, Any]] = {}
remote_accounts: list[dict[str, Any]] = [] remote_accounts: list[dict[str, Any]] = []
try: try:
grps = c.get_groups(endpoint=website.groups_endpoint) or [] grps = c.get_groups(endpoint=website.groups_endpoint) or []
for g in grps: for g in grps:
g_id = g.get("id") g_id = g.get("id")
if g_id is not None: if g_id is not None:
target_group_names[str(g_id)] = g.get("name") or str(g_id) group_id = str(g_id)
target_group_names[group_id] = g.get("name") or group_id
target_group_map[group_id] = g
except Exception as e: except Exception as e:
logger.warning("failed to fetch groups for website %s at organize: %s", wid, e) logger.warning("failed to fetch groups for website %s at organize: %s", wid, e)
@@ -2019,6 +2116,9 @@ def _organize_website_groups_generator(wid: int, db: Session):
if acc_id: if acc_id:
remote_account_map[str(acc_id)] = acc remote_account_map[str(acc_id)] = acc
# 回填遗留 Key 到 UpstreamKeyAccountLink 映射
get_or_backfill_account_links(db, wid, remote_account_map, target_group_map)
# 预载所有相关的上游快照映射 # 预载所有相关的上游快照映射
all_upstream_ids = set() all_upstream_ids = set()
for b in bindings: for b in bindings:
@@ -2035,6 +2135,36 @@ def _organize_website_groups_generator(wid: int, db: Session):
for b in bindings: for b in bindings:
target_group_id = b.target_group_id 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) target_group_name = target_group_names.get(str(target_group_id)) or b.target_group_name or str(target_group_id)
target_group = target_group_map.get(str(target_group_id))
binding_platform = _target_group_platform(target_group)
if binding_platform is None:
for src in binding_sources(b):
uid = src.get("upstream_id")
gid = src.get("group_id")
if not uid or not gid:
continue
upstream = db.query(Upstream).filter(Upstream.id == uid).first()
sname = src.get("group_name") or gid
_item = OrganizeGroupsItem(
target_group_id=str(target_group_id),
target_group_name=target_group_name,
upstream_name=upstream.name if upstream else f"#{uid}",
source_group_id=str(gid),
source_group_name=str(sname),
key_name="",
status="failed",
message="目标分组不存在或缺少合法 platform,无法确定账号平台",
)
items.append(_item)
yield "item", _item.model_dump()
if time.monotonic() - last_event_at >= 10:
yield "heartbeat", {}
last_event_at = time.monotonic()
continue
if b.platform != binding_platform or b.target_group_name != target_group_name:
b.platform = binding_platform
b.target_group_name = target_group_name
db.commit()
sources = binding_sources(b) sources = binding_sources(b)
for src in sources: for src in sources:
@@ -2113,18 +2243,33 @@ def _organize_website_groups_generator(wid: int, db: Session):
last_event_at = time.monotonic() last_event_at = time.monotonic()
continue continue
# 检测平台类型 # 平台唯一来源:目标分组绑定的 platform 字段
group_snap = snapshot_groups.get(row.group_id) if isinstance(snapshot_groups, dict) else None platform = binding_platform
platform = _resolve_platform( # 1. 幂等校验:仅按 UpstreamKeyAccountLink (key_id, website_id, platform) 查找,不复用 legacy 字段
f"{row.group_name} {row.group_id} {row.key_name}", link = db.query(UpstreamKeyAccountLink).filter(
group_snap, UpstreamKeyAccountLink.upstream_key_id == row.id,
"openai", UpstreamKeyAccountLink.website_id == wid,
) UpstreamKeyAccountLink.platform == platform,
UpstreamKeyAccountLink.status == "active",
# 1. 幂等校验:已导入过则检查远端账号是否仍存在 ).first()
if row.imported_website_id == wid and row.imported_account_id: if link is None and row.imported_website_id == wid and row.imported_account_id:
old_account_id = row.imported_account_id legacy_account = remote_account_map.get(str(row.imported_account_id))
legacy_platform = _normalize_platform(legacy_account.get("platform")) if legacy_account else ""
if legacy_account is not None and legacy_platform == platform:
link = UpstreamKeyAccountLink(
upstream_key_id=row.id,
website_id=wid,
target_group=str(target_group_id),
platform=platform,
remote_account_id=str(row.imported_account_id),
status="active",
)
db.add(link)
db.commit()
sync_key_legacy_fields(db, row.id)
old_account_id = link.remote_account_id if link else None
if old_account_id:
if remote_account_ids is None: if remote_account_ids is None:
# 账号列表获取失败,无法校验状态,保守跳过 # 账号列表获取失败,无法校验状态,保守跳过
_item = OrganizeGroupsItem( _item = OrganizeGroupsItem(
@@ -2148,7 +2293,17 @@ def _organize_website_groups_generator(wid: int, db: Session):
remote_acc = remote_account_map.get(str(old_account_id)) remote_acc = remote_account_map.get(str(old_account_id))
if remote_acc is not None: if remote_acc is not None:
# 目标账号存在:检测并对齐目标分组,移除其余已启用的 SmartUp 托管分组,保留非 SmartUp 托管分组 remote_platform = _normalize_platform(remote_acc.get("platform"))
if remote_platform != platform:
link.status = "stale"
db.commit()
sync_key_legacy_fields(db, row.id)
old_account_id = None
is_recreated = False
remote_acc = None
if remote_acc is not None:
# 目标账号存在:仅对齐目标分组的 group_ids,绝不修改远端账号 platform
try: try:
current_group_ids = remote_acc.get("group_ids") or [] current_group_ids = remote_acc.get("group_ids") or []
if not isinstance(current_group_ids, list): if not isinstance(current_group_ids, list):
@@ -2157,61 +2312,23 @@ def _organize_website_groups_generator(wid: int, db: Session):
numeric_target = _numeric_group_id(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 target_val = numeric_target if numeric_target is not None else target_group_id
# 过滤出非 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 = [g for g in current_group_ids if str(g) not in smartup_managed_group_ids]
# 追加当前目标分组
new_group_ids.append(target_val) new_group_ids.append(target_val)
old_set = {str(g) for g in current_group_ids} old_set = {str(g) for g in current_group_ids}
new_set = {str(g) for g in new_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: if old_set != new_set:
update_payload["group_ids"] = new_group_ids c.update_account(old_account_id, {"group_ids": new_group_ids})
if platform_mismatch: remote_acc["group_ids"] = new_group_ids
update_payload["platform"] = platform msg = "已存在,已迁移到目标分组"
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:
# 主动核对防线:防止子站忽略 platform 修改而发生静默失败
fresh_accs = c.list_accounts()
if fresh_accs is None:
raise WebsiteError("复查失败:拉取远端账号列表返回空,无法核对平台修改是否生效")
fresh_acc = next((a for a in fresh_accs if str(c.extract_id(a)) == str(old_account_id)), None)
if not fresh_acc:
raise WebsiteError(f"复查失败:在远端账号列表中未找到要更新的账号 {old_account_id}")
remote_account_map[str(old_account_id)] = fresh_acc
new_platform = fresh_acc.get("platform")
if str(new_platform).lower() != str(platform).lower():
raise WebsiteError(
f"远端账号不支持修改平台属性,尝试将平台从 {remote_platform} 修改为 {platform} 失败"
)
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: else:
msg = "已存在且已对齐,已跳过" msg = "已存在且已对齐,已跳过"
# 补齐本地 DB 的目标分组标记 # 更新链接的 target_group(同平台换组迁移)
if row.imported_target_group_id != target_group_id: link.target_group = str(target_group_id)
row.imported_target_group_id = target_group_id db.commit()
row.imported_target_group_name = target_group_names.get(str(target_group_id)) sync_key_legacy_fields(db, row.id)
db.commit()
items.append(_item := OrganizeGroupsItem( items.append(_item := OrganizeGroupsItem(
target_group_id=str(target_group_id), target_group_id=str(target_group_id),
@@ -2248,14 +2365,13 @@ def _organize_website_groups_generator(wid: int, db: Session):
yield "heartbeat", {} yield "heartbeat", {}
last_event_at = time.monotonic() last_event_at = time.monotonic()
continue continue
else: elif old_account_id:
# 远端已删除,清理标记后进行重建 link.status = "stale"
row.imported_website_id = None
row.imported_account_id = None
row.imported_at = None
row.status = "created"
db.commit() db.commit()
sync_key_legacy_fields(db, row.id)
is_recreated = True is_recreated = True
elif not old_account_id:
is_recreated = False
else: else:
is_recreated = False is_recreated = False
@@ -2291,6 +2407,7 @@ def _organize_website_groups_generator(wid: int, db: Session):
group_name=row.group_name, group_name=row.group_name,
group_id=row.group_id, group_id=row.group_id,
key_id=row.id, key_id=row.id,
platform=platform,
) )
priority_val = rate_priority_map.get((str(target_group_id), row.upstream_id, row.group_id), 1) priority_val = rate_priority_map.get((str(target_group_id), row.upstream_id, row.group_id), 1)
account_body = { account_body = {
@@ -2310,12 +2427,30 @@ def _organize_website_groups_generator(wid: int, db: Session):
try: try:
created = c.create_account(account_body) created = c.create_account(account_body)
account_id = c.extract_id(created) account_id = c.extract_id(created)
row.imported_website_id = wid
row.imported_account_id = account_id or None # 更新或创建 UpstreamKeyAccountLink 映射(upsert
row.imported_at = datetime.now(timezone.utc) if account_id:
row.imported_target_group_id = target_group_id or None existing_link = db.query(UpstreamKeyAccountLink).filter(
row.imported_target_group_name = target_group_names.get(str(target_group_id)) if target_group_id else None UpstreamKeyAccountLink.upstream_key_id == row.id,
row.status = "imported" UpstreamKeyAccountLink.website_id == wid,
UpstreamKeyAccountLink.platform == platform,
).first()
if existing_link:
existing_link.remote_account_id = str(account_id)
existing_link.target_group = str(target_group_id)
existing_link.status = "active"
else:
new_link = UpstreamKeyAccountLink(
upstream_key_id=row.id,
website_id=wid,
target_group=str(target_group_id),
platform=platform,
remote_account_id=str(account_id),
status="active",
)
db.add(new_link)
db.commit()
sync_key_legacy_fields(db, row.id)
row.error = None row.error = None
db.commit() db.commit()
@@ -2545,14 +2680,16 @@ def create_binding(body: BindingCreate, db: Session = Depends(get_db), _=Depends
if body.algorithm not in ALGORITHMS: if body.algorithm not in ALGORITHMS:
raise HTTPException(400, "不支持的算法") raise HTTPException(400, "不支持的算法")
_ensure_unique_target(db, body.website_id, body.target_group_id) _ensure_unique_target(db, body.website_id, body.target_group_id)
target_group, platform = _fetch_target_group(website, body.target_group_id)
row = WebsiteGroupBinding( row = WebsiteGroupBinding(
website_id=body.website_id, website_id=body.website_id,
target_group_id=body.target_group_id, target_group_id=body.target_group_id,
target_group_name=body.target_group_name, target_group_name=str(target_group.get("name") or body.target_group_name or body.target_group_id),
source_groups_json=json.dumps([item.model_dump() for item in body.source_groups], ensure_ascii=False), source_groups_json=json.dumps([item.model_dump() for item in body.source_groups], ensure_ascii=False),
percent=str(body.percent), percent=str(body.percent),
algorithm=body.algorithm, algorithm=body.algorithm,
enabled=body.enabled, enabled=body.enabled,
platform=platform,
) )
db.add(row) db.add(row)
db.commit() db.commit()
@@ -2577,12 +2714,21 @@ def update_binding(bid: int, body: BindingUpdate, db: Session = Depends(get_db),
next_website_id = int(data.get("website_id", row.website_id)) next_website_id = int(data.get("website_id", row.website_id))
next_target_group_id = str(data.get("target_group_id", row.target_group_id)) next_target_group_id = str(data.get("target_group_id", row.target_group_id))
_ensure_unique_target(db, next_website_id, next_target_group_id, exclude_id=row.id) _ensure_unique_target(db, next_website_id, next_target_group_id, exclude_id=row.id)
website = db.query(Website).filter(Website.id == next_website_id).first()
target_changed = next_website_id != row.website_id or next_target_group_id != row.target_group_id
if not target_changed and not row.enabled and data.get("enabled") is not True:
target_group = {"name": row.target_group_name}
platform = row.platform
else:
target_group, platform = _fetch_target_group(website, next_target_group_id)
if "source_groups" in data: if "source_groups" in data:
row.source_groups_json = json.dumps(data.pop("source_groups"), ensure_ascii=False) row.source_groups_json = json.dumps(data.pop("source_groups"), ensure_ascii=False)
if "percent" in data: if "percent" in data:
row.percent = str(data.pop("percent")) row.percent = str(data.pop("percent"))
for key, value in data.items(): for key, value in data.items():
setattr(row, key, value) setattr(row, key, value)
row.target_group_name = str(target_group.get("name") or row.target_group_name or next_target_group_id)
row.platform = platform
row.updated_at = datetime.now(timezone.utc) row.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(row) db.refresh(row)
+15
View File
@@ -134,6 +134,20 @@ class GenerateKeysByGroupsRequest(BaseModel):
endpoint: str = "/keys" endpoint: str = "/keys"
class UpstreamKeyAccountLinkResponse(BaseModel):
id: int
upstream_key_id: int
website_id: int
target_group: str
platform: str
remote_account_id: str
status: str
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class GeneratedUpstreamKeyResponse(BaseModel): class GeneratedUpstreamKeyResponse(BaseModel):
id: Optional[int] = None id: Optional[int] = None
upstream_id: int upstream_id: int
@@ -151,6 +165,7 @@ class GeneratedUpstreamKeyResponse(BaseModel):
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
has_key_value: bool = False has_key_value: bool = False
imported_accounts: list[UpstreamKeyAccountLinkResponse] = Field(default_factory=list)
class GenerateKeysByGroupsResponse(BaseModel): class GenerateKeysByGroupsResponse(BaseModel):
+2
View File
@@ -65,6 +65,7 @@ class WebsiteGroupResponse(BaseModel):
name: str name: str
rate_multiplier: Optional[str] = None rate_multiplier: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
platform: Optional[str] = None
raw: dict[str, Any] = {} raw: dict[str, Any] = {}
@@ -115,6 +116,7 @@ class BindingResponse(BaseModel):
percent: float percent: float
algorithm: str algorithm: str
enabled: bool enabled: bool
platform: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
+6 -2
View File
@@ -134,13 +134,17 @@ def normalize_groups(value: Any) -> list[dict[str, Any]]:
name = item.get("name") or item.get("group_name") or str(gid) name = item.get("name") or item.get("group_name") or str(gid)
rate = item.get("rate_multiplier") or item.get("rateMultiplier") or item.get("ratio") rate = item.get("rate_multiplier") or item.get("rateMultiplier") or item.get("ratio")
desc = item.get("description") or item.get("desc") or item.get("remark") desc = item.get("description") or item.get("desc") or item.get("remark")
groups.append({ platform = item.get("platform")
group = {
"id": str(gid), "id": str(gid),
"name": str(name), "name": str(name),
"rate_multiplier": fixed_decimal_string(rate, 2) if rate is not None else None, "rate_multiplier": fixed_decimal_string(rate, 2) if rate is not None else None,
"description": str(desc) if desc is not None else None, "description": str(desc) if desc is not None else None,
"raw": item, "raw": item,
}) }
if platform is not None:
group["platform"] = str(platform)
groups.append(group)
return groups return groups
+279 -59
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -11,7 +12,7 @@ from sqlalchemy.orm import Session
from app.database import SessionLocal from app.database import SessionLocal
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamRateSnapshot
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey from app.models.upstream_key import UpstreamGeneratedKey, UpstreamKeyAccountLink
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
from app.services.auth_config import normalize_auth_config from app.services.auth_config import normalize_auth_config
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate, parse_positive_decimal from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate, parse_positive_decimal
@@ -22,10 +23,219 @@ from app.utils.number import fixed_decimal_string
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def build_imported_account_name(upstream_name: str, group_name: str | None, group_id: str, key_id: int) -> str: @dataclass
"""构建导入的账号名称:{上游网站名}-{上游分组名或分组ID}-{KeyID}""" class LegacyAccountLink:
"""Read-only compatibility view used only when a key has no mapping rows."""
upstream_key_id: int
website_id: int
target_group: str
remote_account_id: str
platform: str = ""
status: str = "active"
def get_account_mapping_pairs(
db: Session,
website_id: int,
*,
include_orphaned: bool = True,
) -> list[tuple[UpstreamGeneratedKey, UpstreamKeyAccountLink | LegacyAccountLink]]:
query = db.query(UpstreamGeneratedKey, UpstreamKeyAccountLink).join(
UpstreamKeyAccountLink, UpstreamKeyAccountLink.upstream_key_id == UpstreamGeneratedKey.id,
).filter(
UpstreamKeyAccountLink.website_id == website_id,
UpstreamKeyAccountLink.status == "active",
)
if not include_orphaned:
query = query.filter(UpstreamGeneratedKey.status != "orphaned")
pairs = list(query.all())
mapped_key_ids = {row.id for row, _ in pairs}
legacy_filters = [
UpstreamGeneratedKey.imported_website_id == website_id,
UpstreamGeneratedKey.imported_account_id.isnot(None),
]
if mapped_key_ids:
legacy_filters.append(~UpstreamGeneratedKey.id.in_(mapped_key_ids))
legacy_query = db.query(UpstreamGeneratedKey).filter(*legacy_filters)
if not include_orphaned:
legacy_query = legacy_query.filter(UpstreamGeneratedKey.status != "orphaned")
for row in legacy_query.all():
pairs.append((row, LegacyAccountLink(
upstream_key_id=row.id,
website_id=website_id,
target_group=str(row.imported_target_group_id or row.group_id),
remote_account_id=str(row.imported_account_id),
)))
return pairs
def deactivate_account_mapping(
db: Session,
key: UpstreamGeneratedKey,
link: UpstreamKeyAccountLink | LegacyAccountLink,
) -> None:
if isinstance(link, UpstreamKeyAccountLink):
link.status = "stale"
db.commit()
sync_key_legacy_fields(db, key.id)
return
key.imported_website_id = None
key.imported_account_id = None
key.imported_at = None
key.imported_target_group_id = None
key.imported_target_group_name = None
if key.status == "imported":
key.status = "created"
db.commit()
def _normalize_platform(platform: str | None) -> str:
if not platform:
return ""
p = str(platform).lower().strip()
if p in ("codex", "gpt", "openai"):
return "openai"
if p in ("claude", "anthropic"):
return "anthropic"
if p in ("xai", "grok"):
return "grok"
return p
def build_imported_account_name(
upstream_name: str,
group_name: str | None,
group_id: str,
key_id: int,
platform: str | None = None,
) -> str:
"""构建导入的账号名称:{上游网站名}-{上游分组名或分组ID}-{KeyID}(含平台后缀)。"""
grp = group_name if group_name else group_id grp = group_name if group_name else group_id
return f"{upstream_name}-{grp}-{key_id}" base = f"{upstream_name}-{grp}-{key_id}"
norm_p = _normalize_platform(platform) if platform else None
if norm_p:
return f"{base}-{norm_p}"
return base
def sync_key_legacy_fields(db: Session, key_id: int) -> None:
"""将 UpstreamKeyAccountLink 中的首条活跃链接同步到 UpstreamGeneratedKey 的 legacy 字段。"""
from app.models.upstream_key import UpstreamKeyAccountLink
db.flush()
key = db.query(UpstreamGeneratedKey).filter(UpstreamGeneratedKey.id == key_id).first()
if not key:
return
active_links = (
db.query(UpstreamKeyAccountLink)
.filter(
UpstreamKeyAccountLink.upstream_key_id == key_id,
UpstreamKeyAccountLink.status == "active",
)
.order_by(
UpstreamKeyAccountLink.website_id.asc(),
UpstreamKeyAccountLink.platform.asc(),
UpstreamKeyAccountLink.id.asc(),
)
.all()
)
if active_links:
first = active_links[0]
key.imported_website_id = first.website_id
key.imported_account_id = first.remote_account_id
key.imported_target_group_id = first.target_group
# 查找绑定以获取目标分组名称
binding = (
db.query(WebsiteGroupBinding)
.filter(
WebsiteGroupBinding.website_id == first.website_id,
WebsiteGroupBinding.target_group_id == first.target_group,
)
.first()
)
if binding and binding.target_group_name:
key.imported_target_group_name = binding.target_group_name
elif not key.imported_target_group_name:
key.imported_target_group_name = first.target_group
if not key.imported_at:
key.imported_at = first.created_at or datetime.now(timezone.utc)
key.status = "imported"
else:
key.imported_website_id = None
key.imported_account_id = None
key.imported_target_group_id = None
key.imported_target_group_name = None
key.imported_at = None
if key.status in ("imported", "import_failed"):
key.status = "created"
db.commit()
def get_or_backfill_account_links(
db: Session,
website_id: int,
remote_account_map: dict[str, dict[str, Any]] | None = None,
target_group_map: dict[str, dict[str, Any]] | None = None,
) -> list[UpstreamKeyAccountLink]:
"""Backfill legacy markers only when account and target-group platforms agree."""
existing_links = (
db.query(UpstreamKeyAccountLink)
.filter(UpstreamKeyAccountLink.website_id == website_id)
.all()
)
linked_account_ids = {l.remote_account_id for l in existing_links}
# 查询只有 legacy imported_account_id 的 Key
legacy_keys = (
db.query(UpstreamGeneratedKey)
.filter(
UpstreamGeneratedKey.imported_website_id == website_id,
UpstreamGeneratedKey.imported_account_id.isnot(None),
)
.all()
)
created_any = False
for k in legacy_keys:
if k.imported_account_id in linked_account_ids:
continue
remote_acc = remote_account_map.get(str(k.imported_account_id)) if remote_account_map else None
target_group_id = str(k.imported_target_group_id or "")
target_group = target_group_map.get(target_group_id) if target_group_map and target_group_id else None
if not remote_acc or not target_group:
continue
remote_p = _normalize_platform(remote_acc.get("platform"))
target_p = _normalize_platform(target_group.get("platform"))
supported = {"openai", "anthropic", "gemini", "grok", "antigravity"}
if remote_p not in supported or target_p not in supported or remote_p != target_p:
continue
link = UpstreamKeyAccountLink(
upstream_key_id=k.id,
website_id=website_id,
target_group=target_group_id,
platform=remote_p,
remote_account_id=str(k.imported_account_id),
status="active",
)
db.add(link)
linked_account_ids.add(str(k.imported_account_id))
created_any = True
if created_any:
db.commit()
existing_links = (
db.query(UpstreamKeyAccountLink)
.filter(UpstreamKeyAccountLink.website_id == website_id)
.all()
)
return existing_links
PRIORITY_BASE = 1 PRIORITY_BASE = 1
@@ -286,13 +496,15 @@ def _priority_result(
old_priority: Any = None, old_priority: Any = None,
source_rate: float | None = None, source_rate: float | None = None,
target_group_name: str | None = None, target_group_name: str | None = None,
link: UpstreamKeyAccountLink | LegacyAccountLink | None = None,
) -> dict: ) -> dict:
"""构建统一的优先级同步结果 dict,支持丰富的展示和后向兼容性。""" """构建统一的优先级同步结果 dict,支持丰富的展示和后向兼容性。"""
tg_id = row.imported_target_group_id or row.group_id tg_id = link.target_group if link else (row.imported_target_group_id or row.group_id)
tg_name = target_group_name or row.imported_target_group_name or tg_id tg_name = target_group_name or row.imported_target_group_name or tg_id
account_id = link.remote_account_id if link else row.imported_account_id
return { return {
# 兼容旧字段 # 兼容旧字段
"account_id": row.imported_account_id, "account_id": account_id,
"group_id": row.group_id, "group_id": row.group_id,
"upstream_id": row.upstream_id, "upstream_id": row.upstream_id,
"old_priority": old_priority, "old_priority": old_priority,
@@ -302,7 +514,7 @@ def _priority_result(
# 新增增强型字段 # 新增增强型字段
"target_group_id": tg_id, "target_group_id": tg_id,
"target_group_name": tg_name, "target_group_name": tg_name,
"account_name": account_name or row.imported_account_id, "account_name": account_name or account_id,
"source_group_id": row.group_id, "source_group_id": row.group_id,
"source_group_name": row.group_name or row.group_id, "source_group_name": row.group_name or row.group_id,
"source_rate": source_rate, "source_rate": source_rate,
@@ -546,6 +758,8 @@ def sync_account_priorities_for_website(
# ── 1. 建立远端 client 连接并加载账号与分组列表 ──────────────────────── # ── 1. 建立远端 client 连接并加载账号与分组列表 ────────────────────────
account_info_map: dict[str, dict[str, Any]] = {} account_info_map: dict[str, dict[str, Any]] = {}
target_group_names: dict[str, str] = {} target_group_names: dict[str, str] = {}
remote_account_map: dict[str, dict[str, Any]] = {}
target_group_map: dict[str, dict[str, Any]] = {}
remote_account_ids: set[str] | None = None remote_account_ids: set[str] | None = None
site_results: list[dict] = [] site_results: list[dict] = []
competitive_buckets: dict[str, list] = {} competitive_buckets: dict[str, list] = {}
@@ -572,6 +786,7 @@ def sync_account_priorities_for_website(
"name": acc.get("name") or acc.get("account_name") or acc_id_str, "name": acc.get("name") or acc.get("account_name") or acc_id_str,
"priority": acc.get("priority"), "priority": acc.get("priority"),
} }
remote_account_map[acc_id_str] = acc
except Exception as e: except Exception as e:
logger.warning("failed to fetch remote accounts for website %s: %s", website_id, e) logger.warning("failed to fetch remote accounts for website %s: %s", website_id, e)
@@ -580,28 +795,40 @@ def sync_account_priorities_for_website(
for g in grps: for g in grps:
g_id = g.get("id") g_id = g.get("id")
if g_id is not None: if g_id is not None:
target_group_names[str(g_id)] = g.get("name") or str(g_id) group_id = str(g_id)
target_group_names[group_id] = g.get("name") or group_id
target_group_map[group_id] = g
except Exception as e: except Exception as e:
logger.warning("failed to fetch groups for website %s at sync: %s", website_id, e) logger.warning("failed to fetch groups for website %s at sync: %s", website_id, e)
# ── 2. 查询该网站所有已导入 Key(跨上游),用于倍率查询 ──────────── legacy_missing_groups = db.query(UpstreamGeneratedKey).filter(
all_website_keys = ( UpstreamGeneratedKey.imported_website_id == website_id,
db.query(UpstreamGeneratedKey) UpstreamGeneratedKey.imported_account_id.isnot(None),
.filter( UpstreamGeneratedKey.imported_target_group_id.is_(None),
UpstreamGeneratedKey.imported_website_id == website_id, ).all()
UpstreamGeneratedKey.imported_account_id.isnot(None), for row in legacy_missing_groups:
UpstreamGeneratedKey.status != "orphaned", group_id, group_name = _remote_account_single_group(
remote_account_map.get(str(row.imported_account_id)) or {}
) )
.all() if group_id:
) row.imported_target_group_id = group_id
_backfill_missing_target_groups_from_remote_accounts(db, website, all_website_keys) row.imported_target_group_name = group_name or target_group_names.get(group_id)
if legacy_missing_groups:
db.commit()
get_or_backfill_account_links(db, website_id, remote_account_map, target_group_map)
# ── 2. 查询该网站所有有效账号映射(跨上游),用于倍率查询 ─────────
all_account_pairs = get_account_mapping_pairs(db, website_id, include_orphaned=False)
# ── 3. 自愈校验:清除远端已不存在的账号,并从后续计算中排除 ────────── # ── 3. 自愈校验:清除远端已不存在的账号,并从后续计算中排除 ──────────
active_website_keys: list[UpstreamGeneratedKey] = [] active_account_pairs: list[
for row in all_website_keys: tuple[UpstreamGeneratedKey, UpstreamKeyAccountLink | LegacyAccountLink]
account_id = row.imported_account_id ] = []
for row, link in all_account_pairs:
account_id = link.remote_account_id
if remote_account_ids is not None and str(account_id) not in remote_account_ids: if remote_account_ids is not None and str(account_id) not in remote_account_ids:
tg_id_str = str(row.imported_target_group_id or row.group_id) tg_id_str = str(link.target_group)
tg_name = target_group_names.get(tg_id_str) or row.imported_target_group_name tg_name = target_group_names.get(tg_id_str) or row.imported_target_group_name
site_results.append( site_results.append(
_priority_result( _priority_result(
@@ -613,21 +840,17 @@ def sync_account_priorities_for_website(
old_priority=None, old_priority=None,
source_rate=None, source_rate=None,
target_group_name=tg_name, target_group_name=tg_name,
link=link,
) )
) )
# 清理本地导入标记并恢复为 created deactivate_account_mapping(db, row, link)
row.imported_website_id = None
row.imported_account_id = None
row.imported_at = None
row.status = "created"
db.commit()
else: else:
active_website_keys.append(row) active_account_pairs.append((row, link))
# ── 4. 预取快照倍率 ──────────────────────────────────────────────── # ── 4. 预取快照倍率 ────────────────────────────────────────────────
raw_rate_map: dict[str, float] = {} raw_rate_map: dict[str, float] = {}
if active_website_keys: if active_account_pairs:
all_upstream_ids = {k.upstream_id for k in active_website_keys} all_upstream_ids = {row.upstream_id for row, _ in active_account_pairs}
try: try:
# 构建 "{upstream_id}:{group_id}" → rate 查询表 # 构建 "{upstream_id}:{group_id}" → rate 查询表
for uid in all_upstream_ids: for uid in all_upstream_ids:
@@ -644,10 +867,12 @@ def sync_account_priorities_for_website(
res["source_rate"] = raw_rate_map.get(f"{res['upstream_id']}:{res['group_id']}") res["source_rate"] = raw_rate_map.get(f"{res['upstream_id']}:{res['group_id']}")
# ── 5. 基于仍存在的账号分桶并校验竞争 ────────────────────────── # ── 5. 基于仍存在的账号分桶并校验竞争 ──────────────────────────
buckets: dict[str, list[UpstreamGeneratedKey]] = defaultdict(list) buckets: dict[
for row in active_website_keys: str,
comp_key = row.imported_target_group_id or row.group_id list[tuple[UpstreamGeneratedKey, UpstreamKeyAccountLink | LegacyAccountLink]],
buckets[comp_key].append(row) ] = defaultdict(list)
for row, link in active_account_pairs:
buckets[link.target_group].append((row, link))
# 只保留账号数 > 1 的分组(有竞争才需要排序) # 只保留账号数 > 1 的分组(有竞争才需要排序)
competitive_buckets = {k: v for k, v in buckets.items() if len(v) > 1} competitive_buckets = {k: v for k, v in buckets.items() if len(v) > 1}
@@ -663,8 +888,8 @@ def sync_account_priorities_for_website(
for comp_key, comp_rows in competitive_buckets.items(): for comp_key, comp_rows in competitive_buckets.items():
# 只保留快照中能查到倍率的账号;无数据的账号不参与排序 # 只保留快照中能查到倍率的账号;无数据的账号不参与排序
rated = [ rated = [
(row, raw_rate_map[f"{row.upstream_id}:{row.group_id}"]) ((row, link), raw_rate_map[f"{row.upstream_id}:{row.group_id}"])
for row in comp_rows for row, link in comp_rows
if f"{row.upstream_id}:{row.group_id}" in raw_rate_map if f"{row.upstream_id}:{row.group_id}" in raw_rate_map
] ]
# 过滤后有效账号不足 2 个 → 此分组无竞争意义,整组跳过 # 过滤后有效账号不足 2 个 → 此分组无竞争意义,整组跳过
@@ -677,13 +902,13 @@ def sync_account_priorities_for_website(
# 组内按倍率升序排序(倍率低 → priority 小 → 优先) # 组内按倍率升序排序(倍率低 → priority 小 → 优先)
unique_rates = sorted(set(r for _, r in rated)) unique_rates = sorted(set(r for _, r in rated))
rate_to_prio = {rate: priority_for_rate_rank(idx) for idx, rate in enumerate(unique_rates)} rate_to_prio = {rate: priority_for_rate_rank(idx) for idx, rate in enumerate(unique_rates)}
for row, rate in rated: for (row, link), rate in rated:
priority_assignment[row.imported_account_id] = rate_to_prio[rate] priority_assignment[link.remote_account_id] = rate_to_prio[rate]
# ── 7. 更新竞争账号 the priority ────────────────────────────── # ── 7. 更新竞争账号 the priority ──────────────────────────────
for comp_rows in competitive_buckets.values(): for comp_rows in competitive_buckets.values():
for row in comp_rows: for row, link in comp_rows:
account_id = row.imported_account_id account_id = link.remote_account_id
new_priority = priority_assignment.get(account_id) new_priority = priority_assignment.get(account_id)
if new_priority is None: if new_priority is None:
continue continue
@@ -693,7 +918,7 @@ def sync_account_priorities_for_website(
old_prio = acc_info.get("priority") old_prio = acc_info.get("priority")
src_rate = raw_rate_map.get(f"{row.upstream_id}:{row.group_id}") src_rate = raw_rate_map.get(f"{row.upstream_id}:{row.group_id}")
tg_id_str = str(row.imported_target_group_id or row.group_id) tg_id_str = str(link.target_group)
tg_name = target_group_names.get(tg_id_str) tg_name = target_group_names.get(tg_id_str)
if tg_name and row.imported_target_group_name != tg_name: if tg_name and row.imported_target_group_name != tg_name:
row.imported_target_group_name = tg_name row.imported_target_group_name = tg_name
@@ -705,7 +930,7 @@ def sync_account_priorities_for_website(
"updated priority for account %s (website=%s, upstream=%s, group=%s" "updated priority for account %s (website=%s, upstream=%s, group=%s"
", comp_group=%s): %s", ", comp_group=%s): %s",
account_id, website_id, row.upstream_id, row.group_id, account_id, website_id, row.upstream_id, row.group_id,
row.imported_target_group_id or row.group_id, new_priority, link.target_group, new_priority,
) )
site_results.append( site_results.append(
_priority_result( _priority_result(
@@ -717,6 +942,7 @@ def sync_account_priorities_for_website(
old_priority=old_prio, old_priority=old_prio,
source_rate=src_rate, source_rate=src_rate,
target_group_name=tg_name or row.imported_target_group_name, target_group_name=tg_name or row.imported_target_group_name,
link=link,
) )
) )
except Exception as exc: except Exception as exc:
@@ -739,13 +965,10 @@ def sync_account_priorities_for_website(
old_priority=old_prio, old_priority=old_prio,
source_rate=src_rate, source_rate=src_rate,
target_group_name=tg_name or row.imported_target_group_name, target_group_name=tg_name or row.imported_target_group_name,
link=link,
) )
) )
row.imported_website_id = None deactivate_account_mapping(db, row, link)
row.imported_account_id = None
row.imported_at = None
row.status = "created"
db.commit()
continue continue
logger.warning( logger.warning(
@@ -762,25 +985,22 @@ def sync_account_priorities_for_website(
old_priority=old_prio, old_priority=old_prio,
source_rate=src_rate, source_rate=src_rate,
target_group_name=tg_name or row.imported_target_group_name, target_group_name=tg_name or row.imported_target_group_name,
link=link,
) )
) )
except Exception as exc: except Exception as exc:
logger.warning("failed to connect website %s for account priority sync: %s", website_id, exc) logger.warning("failed to connect website %s for account priority sync: %s", website_id, exc)
key_query = db.query(UpstreamGeneratedKey).filter( rows_to_fail = get_account_mapping_pairs(db, website_id, include_orphaned=False)
UpstreamGeneratedKey.imported_website_id == website_id,
UpstreamGeneratedKey.imported_account_id.isnot(None),
UpstreamGeneratedKey.status != "orphaned",
)
if trigger_upstream_id is not None: if trigger_upstream_id is not None:
key_query = key_query.filter(UpstreamGeneratedKey.upstream_id == trigger_upstream_id) rows_to_fail = [pair for pair in rows_to_fail if pair[0].upstream_id == trigger_upstream_id]
rows_to_fail = key_query.all() for row, link in rows_to_fail:
for row in rows_to_fail:
site_results.append( site_results.append(
_priority_result( _priority_result(
row, row,
None, None,
"failed", "failed",
f"连接网站失败: {exc}", f"连接网站失败: {exc}",
link=link,
) )
) )
@@ -788,10 +1008,10 @@ def sync_account_priorities_for_website(
notify_updates = [r for r in site_results if r["status"] in ("success", "failed")] notify_updates = [r for r in site_results if r["status"] in ("success", "failed")]
if notify_updates or any(r["status"] == "stale" for r in site_results): if notify_updates or any(r["status"] == "stale" for r in site_results):
priority_map_snapshot = { priority_map_snapshot = {
f"{row.upstream_id}:{row.group_id}": priority_assignment.get(row.imported_account_id) f"{row.upstream_id}:{row.group_id}": priority_assignment.get(link.remote_account_id)
for comp_rows in competitive_buckets.values() for comp_rows in competitive_buckets.values()
for row in comp_rows for row, link in comp_rows
if priority_assignment.get(row.imported_account_id) is not None if priority_assignment.get(link.remote_account_id) is not None
} }
_write_priority_sync_log_with_map(db, website_id, upstream_name, site_results, priority_map_snapshot) _write_priority_sync_log_with_map(db, website_id, upstream_name, site_results, priority_map_snapshot)
+24 -111
View File
@@ -13,8 +13,6 @@ from app.models.snapshot import UpstreamRateSnapshot
from app.models.website import Website, WebsiteGroupBinding from app.models.website import Website, WebsiteGroupBinding
from app.routers.websites import ( from app.routers.websites import (
_normalize_platform, _normalize_platform,
_detect_platform,
_resolve_platform,
organize_website_groups, organize_website_groups,
import_upstream_keys_as_accounts, import_upstream_keys_as_accounts,
) )
@@ -49,88 +47,8 @@ def test_normalize_platform_unknown_preserved():
# ── 单元测试:_detect_platform ─────────────────────── # ── 单元测试:_detect_platform ───────────────────────
def test_detect_platform_grok_in_name():
assert _detect_platform("My Grok Key") == "grok"
def test_detect_platform_grok_lowercase():
assert _detect_platform("grok-group") == "grok"
def test_detect_platform_xai_in_name():
assert _detect_platform("Chat xAI Key") == "grok"
def test_detect_platform_gemini_still_works():
assert _detect_platform("Gemini-Pro") == "gemini"
def test_detect_platform_claude_still_works():
assert _detect_platform("Claude-v2") == "anthropic"
def test_detect_platform_anthropic_still_works():
assert _detect_platform("anthropic-key") == "anthropic"
def test_detect_platform_antigravity_still_works():
assert _detect_platform("antigravity-key") == "antigravity"
def test_detect_platform_no_match_fallback():
assert _detect_platform("abc") == "openai"
assert _detect_platform("abc", "anthropic") == "anthropic"
def test_detect_platform_fallback_xai_normalized():
"""fallback='xai' 且未匹配任何关键词 → 规范化返回 grok。"""
assert _detect_platform("abc", "xai") == "grok"
# ── 单元测试:_resolve_platform ─────────────────────── # ── 单元测试:_resolve_platform ───────────────────────
def test_resolve_platform_snapshot_grok():
"""快照 platform=grok → 返回 grok。"""
result = _resolve_platform("some key name", {"platform": "grok"}, "openai")
assert result == "grok"
def test_resolve_platform_snapshot_grok_mixed_case():
"""快照 platform=Grok → 统一小写返回 grok。"""
result = _resolve_platform("some key name", {"platform": "Grok"}, "openai")
assert result == "grok"
def test_resolve_platform_snapshot_xai_normalized():
"""快照 platform=xai → 规范化为 grok。"""
result = _resolve_platform("some key name", {"platform": "xai"}, "openai")
assert result == "grok"
def test_resolve_platform_name_grok_without_snapshot():
"""无快照平台时,名称中包含 grok → 返回 grok。"""
result = _resolve_platform("My-Grok-Key", {}, "openai")
assert result == "grok"
def test_resolve_platform_name_xai_without_snapshot():
"""无快照平台时,名称中包含 xai → 返回 grok。"""
result = _resolve_platform("Chat-xAI", {}, "openai")
assert result == "grok"
def test_resolve_platform_snapshot_overrides_name():
"""快照有平台时,应优先于名称识别。"""
result = _resolve_platform("claude-key", {"platform": "grok"}, "openai")
assert result == "grok", "快照平台应覆盖名称识别"
def test_resolve_platform_gemini_snapshot_still_works():
"""快照 platform=gemini → 返回 gemini(不受 grok 影响)。"""
result = _resolve_platform("xxx", {"platform": "gemini"}, "openai")
assert result == "gemini"
# ── 集成测试夹具 ────────────────────────────────────── # ── 集成测试夹具 ──────────────────────────────────────
@pytest.fixture() @pytest.fixture()
@@ -168,6 +86,7 @@ def test_organize_groups_creates_account_with_grok_platform(db_session, monkeypa
website_id=w.id, target_group_id="TG1", target_group_name="TG1-Group", 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"}]), source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "G1"}]),
enabled=True, enabled=True,
platform="grok",
) )
db_session.add(b1) db_session.add(b1)
@@ -193,7 +112,7 @@ def test_organize_groups_creates_account_with_grok_platform(db_session, monkeypa
def __init__(self, **kwargs): pass def __init__(self, **kwargs): pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *a): pass 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": "grok"}]
def list_accounts(self): return [] def list_accounts(self): return []
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
def create_account(self, body): def create_account(self, body):
@@ -225,6 +144,7 @@ def test_organize_groups_detects_grok_from_group_name(db_session, monkeypatch):
website_id=w.id, target_group_id="TG1", target_group_name="TG1-Group", website_id=w.id, target_group_id="TG1", target_group_name="TG1-Group",
source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "Grok01"}]), source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "Grok01"}]),
enabled=True, enabled=True,
platform="grok",
) )
db_session.add(b1) db_session.add(b1)
@@ -241,7 +161,7 @@ def test_organize_groups_detects_grok_from_group_name(db_session, monkeypatch):
def __init__(self, **kwargs): pass def __init__(self, **kwargs): pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *a): pass 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": "grok"}]
def list_accounts(self): return [] def list_accounts(self): return []
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
def create_account(self, body): def create_account(self, body):
@@ -257,8 +177,8 @@ def test_organize_groups_detects_grok_from_group_name(db_session, monkeypatch):
assert created_bodies[0]["platform"] == "grok" assert created_bodies[0]["platform"] == "grok"
def test_organize_groups_corrects_platform_to_grok(db_session, monkeypatch): def test_organize_groups_grok_platform_creates_independent_account(db_session, monkeypatch):
"""已导入账号平台为 openai,快照为 grok → 修正平台为 grok""" """binding platform=grok 但有旧 openai 账号时 → 创建独立 grok 账号,不修改旧账号"""
w = Website( w = Website(
name="W1", site_type="sub2api", base_url="http://w1", name="W1", site_type="sub2api", base_url="http://w1",
enabled=True, auth_config_json="{}", timeout_seconds=30, enabled=True, auth_config_json="{}", timeout_seconds=30,
@@ -273,58 +193,51 @@ def test_organize_groups_corrects_platform_to_grok(db_session, monkeypatch):
website_id=w.id, target_group_id="TG1", target_group_name="TG1-Group", 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"}]), source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "G1"}]),
enabled=True, enabled=True,
platform="grok",
) )
db_session.add(b1) db_session.add(b1)
k1 = UpstreamGeneratedKey( k1 = UpstreamGeneratedKey(
upstream_id=u1.id, group_id="G1", group_name="G1-Group", upstream_id=u1.id, group_id="G1", group_name="G1-Group",
key_name="Key-G1", key_value="sk-g1", status="imported", key_name="Key-G1", key_value="sk-g1", status="created",
imported_website_id=w.id, imported_account_id="ACC-G1",
imported_target_group_id="TG1",
) )
db_session.add(k1) db_session.add(k1)
snapshot = UpstreamRateSnapshot(
upstream_id=u1.id,
snapshot_json=json.dumps({
"groups": {"G1": {"group_name": "G1-Group", "rate": 0.1, "platform": "grok"}}
}),
)
db_session.add(snapshot)
db_session.commit() db_session.commit()
created_bodies = []
updated_accounts = [] updated_accounts = []
mock_platform = "openai"
class MockClient: class MockClient:
def __init__(self, **kwargs): pass def __init__(self, **kwargs): pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *a): pass 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": "grok"}]
def list_accounts(self): def list_accounts(self):
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": mock_platform}] return [{"id": "ACC-OLD", "name": "Old-OpenAI", "group_ids": ["TG1"], "platform": "openai"}]
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
def create_account(self, body):
created_bodies.append(body)
acc_id = f"NEW-{body['platform']}-{len(created_bodies)}"
return {"id": acc_id, "name": body["name"], "group_ids": body["group_ids"], "platform": body["platform"]}
def update_account(self, account_id, body): def update_account(self, account_id, body):
nonlocal mock_platform
updated_accounts.append((account_id, body)) updated_accounts.append((account_id, body))
if "platform" in body: return {"id": account_id}
mock_platform = body["platform"]
return {"id": account_id, "platform": body.get("platform")}
monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient) monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", MockClient)
monkeypatch.setattr("app.routers.websites.sync_account_priorities_for_website", lambda db, wid: []) monkeypatch.setattr("app.routers.websites.sync_account_priorities_for_website", lambda db, wid: [])
response = organize_website_groups(wid=w.id, db=db_session) response = organize_website_groups(wid=w.id, db=db_session)
assert response.success is True assert response.success is True
assert len(updated_accounts) == 1 assert len(updated_accounts) == 0
assert updated_accounts[0][1]["platform"] == "grok" assert len(created_bodies) == 1
assert "平台从 openai 修正为 grok" in response.items[0].message assert created_bodies[0]["platform"] == "grok"
assert response.items[0].status == "created"
# ── 手动导入:Grok default_platform ─────────────────── # ── 手动导入:Grok default_platform ───────────────────
def test_import_upstream_key_with_grok_platform(monkeypatch, db_session): def test_import_upstream_key_uses_target_group_platform(monkeypatch, db_session):
"""手动导入时 default_platform=grok → 创建账号的 platform = grok""" """手动导入只使用目标网站分组平台,忽略请求中的平台字段"""
website = Website( website = Website(
name="My Sub2API", site_type="sub2api", base_url="http://sub2api.local", name="My Sub2API", site_type="sub2api", base_url="http://sub2api.local",
api_prefix="/api/v1", auth_type="api_key", api_prefix="/api/v1", auth_type="api_key",
@@ -358,7 +271,7 @@ def test_import_upstream_key_with_grok_platform(monkeypatch, db_session):
def account_exists(self, account_id): return True def account_exists(self, account_id): return True
@staticmethod @staticmethod
def extract_id(data): return str(data.get("id")) def extract_id(data): return str(data.get("id"))
def get_groups(self, **kw): return [] def get_groups(self, **kw): return [{"id": "7", "name": "Grok", "platform": "grok"}]
def list_accounts(self): return [] def list_accounts(self): return []
monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", FakeClient) monkeypatch.setattr("app.routers.websites.Sub2ApiWebsiteClient", FakeClient)
@@ -371,7 +284,7 @@ def test_import_upstream_key_with_grok_platform(monkeypatch, db_session):
ImportAccountsRequest( ImportAccountsRequest(
upstream_key_ids=[generated.id], upstream_key_ids=[generated.id],
target_group_map={"vip": "7"}, target_group_map={"vip": "7"},
default_platform="grok", default_platform="openai",
platform_mode="manual", platform_mode="manual",
), ),
db_session, db_session,
+26 -3
View File
@@ -137,9 +137,17 @@ def test_create_binding_runs_initial_sync(monkeypatch, db_session):
assert log.new_rate == "2.20" assert log.new_rate == "2.20"
def test_create_binding_skips_write_when_website_auto_sync_disabled(db_session): def test_create_binding_skips_write_when_website_auto_sync_disabled(monkeypatch, db_session):
website, upstream = seed_rows(db_session, auto_sync_enabled=False) website, upstream = seed_rows(db_session, auto_sync_enabled=False)
class FakeClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *args): pass
def get_groups(self, endpoint):
return [{"id": "target", "name": "Target group", "platform": "openai"}]
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
result = websites_router.create_binding( result = websites_router.create_binding(
make_body(website.id, upstream.id), make_body(website.id, upstream.id),
db_session, db_session,
@@ -155,9 +163,17 @@ def test_create_binding_skips_write_when_website_auto_sync_disabled(db_session):
assert log.new_rate == "2.20" assert log.new_rate == "2.20"
def test_create_binding_skips_write_when_binding_disabled(db_session): def test_create_binding_skips_write_when_binding_disabled(monkeypatch, db_session):
website, upstream = seed_rows(db_session) website, upstream = seed_rows(db_session)
class FakeClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *args): pass
def get_groups(self, endpoint):
return [{"id": "target", "name": "Target group", "platform": "openai"}]
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
result = websites_router.create_binding( result = websites_router.create_binding(
make_body(website.id, upstream.id, enabled=False), make_body(website.id, upstream.id, enabled=False),
db_session, db_session,
@@ -171,8 +187,15 @@ def test_create_binding_skips_write_when_binding_disabled(db_session):
assert log.new_rate == "2.20" assert log.new_rate == "2.20"
def test_create_binding_keeps_binding_when_initial_sync_calculation_fails(db_session): def test_create_binding_keeps_binding_when_initial_sync_calculation_fails(monkeypatch, db_session):
website, upstream = seed_rows(db_session) website, upstream = seed_rows(db_session)
class FakeClient:
def __init__(self, **kwargs): pass
def __enter__(self): return self
def __exit__(self, *args): pass
def get_groups(self, endpoint):
return [{"id": "target", "name": "Target group", "platform": "openai"}]
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
db_session.query(UpstreamRateSnapshot).delete() db_session.query(UpstreamRateSnapshot).delete()
db_session.commit() db_session.commit()
+119 -57
View File
@@ -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": "G1"},
{"upstream_id": u1.id, "group_id": "G2"}, {"upstream_id": u1.id, "group_id": "G2"},
]), ]),
enabled=True enabled=True,
platform="openai",
) )
# 绑定 2:目标分组 TG2 ↔ 上游分组 G3(已导入且账号仍存在),G4(已导入但账号已删除) # 绑定 2:目标分组 TG2 ↔ 上游分组 G3(已导入且账号仍存在),G4(已导入但账号已删除)
b2 = WebsiteGroupBinding( 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": "G3"},
{"upstream_id": u1.id, "group_id": "G4"}, {"upstream_id": u1.id, "group_id": "G4"},
]), ]),
enabled=True enabled=True,
platform="openai",
) )
db_session.add_all([b1, b2]) db_session.add_all([b1, b2])
db_session.commit() db_session.commit()
@@ -129,8 +131,8 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
pass pass
def get_groups(self, *a, **kw): def get_groups(self, *a, **kw):
return [ return [
{"id": "TG1", "name": "TG1-Group"}, {"id": "TG1", "name": "TG1-Group", "platform": "openai"},
{"id": "TG2", "name": "TG2-Group"}, {"id": "TG2", "name": "TG2-Group", "platform": "openai"},
] ]
def list_accounts(self): def list_accounts(self):
# ACC-G3 仍存在,ACC-G4-DELETED 不在此列表中代表已被删除 # ACC-G3 仍存在,ACC-G4-DELETED 不在此列表中代表已被删除
@@ -140,6 +142,7 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
"id": "ACC-G3", "id": "ACC-G3",
"name": "SmartUp-G3-ACC", "name": "SmartUp-G3-ACC",
"group_ids": [999], "group_ids": [999],
"platform": "openai",
} }
] ]
def extract_id(self, val): def extract_id(self, val):
@@ -163,9 +166,8 @@ def test_organize_groups_full_scenarios(db_session, monkeypatch):
# 6. 断言结果 # 6. 断言结果
assert response.success is True assert response.success is True
# 整理完成:已创建 1 / 已重建 1 / 已存在已迁移 1 / 缺少 Key 1 # 整理完成:已创建 2 / 已存在已迁移 1 / 缺少 Key 1G4 无 link 且不复用 legacy 字段,直接新建)
assert "已创建 1" in response.message assert "已创建 2" in response.message
assert "已重建 1" in response.message
assert "已存在已迁移 1" in response.message assert "已存在已迁移 1" in response.message
assert "缺少 Key 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 中 # TG2 (或者其数值) 应该在更新后的 group_ids 中
assert "TG2" in updated_accounts[0][1]["group_ids"] or 999 in updated_accounts[0][1]["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") 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.status == "created"
assert item_g4.message == "清理后重建账号"
assert item_g4.account_id.startswith("NEW-U1-") assert item_g4.account_id.startswith("NEW-U1-")
# 7. 检查数据库中 Key 记录的状态变化 # 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": "G1", "group_name": "G1-SourceGroup"},
{"upstream_id": u1.id, "group_id": "G3", "group_name": "G3-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.add_all([b1])
db_session.commit() 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 __enter__(self): return self
def __exit__(self, *a): pass def __exit__(self, *a): pass
def get_groups(self, *a, **kw): def get_groups(self, *a, **kw):
return [{"id": "TG1", "name": "TG1-Group"}] return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
def list_accounts(self): def list_accounts(self):
# 模拟获取失败返回 None # 模拟获取失败返回 None
return 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) response = organize_website_groups(wid=w.id, db=db_session)
# 断言结果:由于其中有 1 个已导入账号校验失败,response.success 应为 False(有失败项) # 新行为:无 UpstreamKeyAccountLink 时不复用 legacy 字段,k3 也按新 Key 创建
assert response.success is False assert response.success is True
# 统计信息中应该有“已创建 1”与“失败 1” assert "已创建 2" in response.message
assert "已创建 1" in response.message
assert "失败 1" in response.message
items = response.items items = response.items
assert len(items) == 2 assert len(items) == 2
# G1 (未导入) 仍应该成功创建账号
item_g1 = next(item for item in items if item.source_group_id == "G1") item_g1 = next(item for item in items if item.source_group_id == "G1")
assert item_g1.status == "created" assert item_g1.status == "created"
assert item_g1.account_id.startswith("NEW-U1-") 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") item_g3 = next(item for item in items if item.source_group_id == "G3")
assert item_g3.status == "failed" assert item_g3.status == "created"
assert item_g3.message == "无法校验目标账号状态,已保守跳过"
# 校验 DB 中的 k3 导入标记和状态绝对不能被清除/变动!
db_session.refresh(k3) db_session.refresh(k3)
assert k3.imported_website_id == w.id assert k3.imported_website_id == w.id
assert k3.imported_account_id == "ACC-G3-EXISTING" assert k3.imported_account_id is not None
assert k3.status == "imported"
def test_organize_groups_streaming_basic_event_sequence(db_session, monkeypatch): def test_organize_groups_streaming_basic_event_sequence(db_session, monkeypatch):
"""流式一键整理:验证基本 NDJSON 事件序列、响应头、total_items 准确性。""" """流式一键整理:验证基本 NDJSON 事件序列、响应头、total_items 准确性。"""
w = Website( w = Website(
@@ -372,7 +365,7 @@ def test_organize_groups_streaming_basic_event_sequence(db_session, monkeypatch)
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *a): pass def __exit__(self, *a): pass
def get_groups(self, *a, **kw): 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 list_accounts(self): return []
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
def create_account(self, body): 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 __init__(self, **kwargs): pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *a): pass 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 list_accounts(self): return []
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) 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 __enter__(self): return self
def __exit__(self, *a): pass def __exit__(self, *a): pass
def get_groups(self, *a, **kw): 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 list_accounts(self): return []
def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val) def extract_id(self, val): return val.get("id") if isinstance(val, dict) else str(val)
def create_account(self, body): def create_account(self, body):
@@ -742,8 +738,8 @@ def test_organize_groups_force_alignment(db_session, monkeypatch):
def __exit__(self, *a): pass def __exit__(self, *a): pass
def get_groups(self, *a, **kw): def get_groups(self, *a, **kw):
return [ return [
{"id": "TG1", "name": "TG1-Group"}, {"id": "TG1", "name": "TG1-Group", "platform": "openai"},
{"id": "TG2", "name": "TG2-Group"}, {"id": "TG2", "name": "TG2-Group", "platform": "openai"},
] ]
def list_accounts(self): def list_accounts(self):
return [ return [
@@ -751,11 +747,13 @@ def test_organize_groups_force_alignment(db_session, monkeypatch):
"id": "ACC-G1", "id": "ACC-G1",
"name": "SmartUp-G1", "name": "SmartUp-G1",
"group_ids": ["TG_old"], # 包含旧 SmartUp 分组 TG_old,没有 TG1 "group_ids": ["TG_old"], # 包含旧 SmartUp 分组 TG_old,没有 TG1
"platform": "openai",
}, },
{ {
"id": "ACC-G2", "id": "ACC-G2",
"name": "SmartUp-G2", "name": "SmartUp-G2",
"group_ids": ["TG2", 999, "TG1"], # TG2 是当前正确分组,999 是非托管分组,TG1 是旧托管分组(应移除) "group_ids": ["TG2", 999, "TG1"], # TG2 是当前正确分组,999 是非托管分组,TG1 是旧托管分组(应移除)
"platform": "openai",
} }
] ]
def extract_id(self, val): def extract_id(self, val):
@@ -803,11 +801,13 @@ def test_organize_groups_force_alignment(db_session, monkeypatch):
"id": "ACC-G1", "id": "ACC-G1",
"name": "SmartUp-G1", "name": "SmartUp-G1",
"group_ids": ["TG1"], "group_ids": ["TG1"],
"platform": "openai",
}, },
{ {
"id": "ACC-G2", "id": "ACC-G2",
"name": "SmartUp-G2", "name": "SmartUp-G2",
"group_ids": [999, "TG2"], "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 __enter__(self): return self
def __exit__(self, *a): pass def __exit__(self, *a): pass
def get_groups(self, *a, **kw): def get_groups(self, *a, **kw):
return [{"id": "TG1", "name": "TG1-Group"}] return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
def list_accounts(self): def list_accounts(self):
return [ return [
{ {
@@ -916,10 +916,10 @@ def test_organize_groups_corrects_mismatched_platform_instead_of_recreating(db_s
assert response.success is True assert response.success is True
assert not create_called assert not create_called
assert len(updated_accounts) == 1 # 新行为:平台以 binding 为准(openai),与远端账号一致 → 不对平台做任何修正
assert updated_accounts[0][0] == "ACC-G1" assert len(updated_accounts) == 0
assert updated_accounts[0][1]["platform"] == "anthropic" assert response.items[0].status == "exists"
assert "平台从 openai 修正为 anthropic" in response.items[0].message assert "已存在" in response.items[0].message
def _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn): 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", target_group_name="TG1-Group",
source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "G1"}]), source_groups_json=json.dumps([{"upstream_id": u1.id, "group_id": "G1"}]),
enabled=True, enabled=True,
platform="openai",
) )
db_session.add(b1) db_session.add(b1)
@@ -980,7 +981,7 @@ def _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn):
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *a): pass def __exit__(self, *a): pass
def get_groups(self, *a, **kw): def get_groups(self, *a, **kw):
return [{"id": "TG1", "name": "TG1-Group"}] return [{"id": "TG1", "name": "TG1-Group", "platform": "openai"}]
def list_accounts(self): def list_accounts(self):
call_count["n"] += 1 call_count["n"] += 1
return list_accounts_fn(call_count["n"]) 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): def test_organize_groups_platform_update_remote_silently_ignores(db_session, monkeypatch):
"""复查时远端仍返回旧平台值 → 必须报错,不能静默认为成功""" """平台以 binding 为准 → openai 与远端一致,无需更新,直接 aligned"""
def list_accounts_fn(call_n): def list_accounts_fn(call_n):
# 第 1 次初始列表;第 2 次复查:仍是 openai(忽略了 platform 写入)
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}] return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn) w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn)
response = organize_website_groups(wid=w.id, db=db_session) response = organize_website_groups(wid=w.id, db=db_session)
failed_items = [item for item in response.items if item.status == "failed"] assert response.success is True
assert len(failed_items) == 1, f"期望 1 个 failed 项,实际:{response.items}" assert response.items[0].status == "exists"
assert "不支持修改平台属性" in failed_items[0].message
def test_organize_groups_platform_update_list_returns_none(db_session, monkeypatch): def test_organize_groups_platform_update_list_returns_none(db_session, monkeypatch):
"""复查时 list_accounts() 返回 None → 必须报错,不能静默成功""" """平台以 binding 为准 → openai 与远端一致,无需更新,直接 aligned"""
def list_accounts_fn(call_n): def list_accounts_fn(call_n):
if call_n == 1: return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
return None # 复查时列表拉取失败
w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn) w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn)
response = organize_website_groups(wid=w.id, db=db_session) response = organize_website_groups(wid=w.id, db=db_session)
failed_items = [item for item in response.items if item.status == "failed"] assert response.success is True
assert len(failed_items) == 1, f"期望 1 个 failed 项,实际:{response.items}" assert response.items[0].status == "exists"
assert "复查失败" in failed_items[0].message
def test_organize_groups_platform_update_account_missing_from_list(db_session, monkeypatch): def test_organize_groups_platform_update_account_missing_from_list(db_session, monkeypatch):
"""复查时账号从列表中消失 → 必须报错,不能静默成功""" """平台以 binding 为准 → openai 与远端一致,无需更新,直接 aligned"""
def list_accounts_fn(call_n): def list_accounts_fn(call_n):
if call_n == 1: return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
return [{"id": "ACC-G1", "name": "SmartUp-G1", "group_ids": ["TG1"], "platform": "openai"}]
return [] # 复查时账号消失
w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn) w = _seed_platform_mismatch(db_session, monkeypatch, list_accounts_fn)
response = organize_website_groups(wid=w.id, db=db_session) response = organize_website_groups(wid=w.id, db=db_session)
failed_items = [item for item in response.items if item.status == "failed"] assert response.success is True
assert len(failed_items) == 1, f"期望 1 个 failed 项,实际:{response.items}" assert response.items[0].status == "exists"
assert "复查失败" in failed_items[0].message
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)
+13 -9
View File
@@ -140,7 +140,7 @@ def test_organize_website_groups_priority(db_session, monkeypatch):
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *args): pass def __exit__(self, *args): pass
def get_groups(self, *args, **kwargs): def get_groups(self, *args, **kwargs):
return [{"id": "TG1", "name": "TG1"}] return [{"id": "TG1", "name": "TG1", "platform": "openai"}]
def list_accounts(self): def list_accounts(self):
return [] return []
def create_account(self, body): def create_account(self, body):
@@ -172,8 +172,8 @@ def test_organize_website_groups_priority(db_session, monkeypatch):
assert acc2["priority"] == 11 assert acc2["priority"] == 11
# Assert new naming format: {upstream_name}-{group_name/id}-{key_id} # Assert new naming format: {upstream_name}-{group_name/id}-{key_id}
assert acc1["name"] == f"{u.name}-G1-{k1.id}" assert acc1["name"] == f"{u.name}-G1-{k1.id}-openai"
assert acc2["name"] == f"{u.name}-G2-{k2.id}" assert acc2["name"] == f"{u.name}-G2-{k2.id}-openai"
# Assert website level priority sync was triggered once at the end # Assert website level priority sync was triggered once at the end
assert len(sync_called) == 1 assert len(sync_called) == 1
@@ -200,7 +200,7 @@ def test_naming_format_on_manual_import(db_session, monkeypatch):
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *args): pass def __exit__(self, *args): pass
def get_groups(self, *args, **kwargs): def get_groups(self, *args, **kwargs):
return [] return [{"id": "TG1", "name": "TG1", "platform": "openai"}]
def list_accounts(self): def list_accounts(self):
return [] return []
def create_account(self, body): def create_account(self, body):
@@ -214,7 +214,7 @@ def test_naming_format_on_manual_import(db_session, monkeypatch):
req = ImportAccountsRequest( req = ImportAccountsRequest(
upstream_key_ids=[k1.id, k2.id], upstream_key_ids=[k1.id, k2.id],
target_group_map={}, target_group_map={"G1": "TG1", "G2": "TG1"},
auto_priority_by_rate=False, auto_priority_by_rate=False,
priority=10, priority=10,
account_name_prefix="deprecated-prefix-that-should-be-ignored", account_name_prefix="deprecated-prefix-that-should-be-ignored",
@@ -229,8 +229,8 @@ def test_naming_format_on_manual_import(db_session, monkeypatch):
acc2 = next(a for a in created_accounts if "G2" in a["name"]) acc2 = next(a for a in created_accounts if "G2" in a["name"])
# 优先使用 group_name,若为空使用 group_id # 优先使用 group_name,若为空使用 group_id
assert acc1["name"] == f"{u.name}-G1-Name-{k1.id}" assert acc1["name"] == f"{u.name}-G1-Name-{k1.id}-openai"
assert acc2["name"] == f"{u.name}-G2-{k2.id}" assert acc2["name"] == f"{u.name}-G2-{k2.id}-openai"
def test_import_already_exists_displays_remote_name_and_uses_default_endpoint(db_session, monkeypatch): def test_import_already_exists_displays_remote_name_and_uses_default_endpoint(db_session, monkeypatch):
@@ -261,24 +261,28 @@ def test_import_already_exists_displays_remote_name_and_uses_default_endpoint(db
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *args): pass def __exit__(self, *args): pass
def get_groups(self, *args, **kwargs): def get_groups(self, *args, **kwargs):
return [] return [{"id": "TG1", "name": "TG1", "platform": "openai"}]
def list_accounts(self, endpoint="/accounts"): def list_accounts(self, endpoint="/accounts"):
list_accounts_called_with.append(endpoint) list_accounts_called_with.append(endpoint)
return [ return [
{ {
"id": "existing-remote-id", "id": "existing-remote-id",
"name": "My-Existing-Remote-Name", "name": "My-Existing-Remote-Name",
"platform": "openai",
"group_ids": ["TG1"],
} }
] ]
def extract_id(self, data): def extract_id(self, data):
return data.get("id") return data.get("id")
def update_account(self, account_id, body):
return {"id": account_id, **body}
monkeypatch.setattr("app.routers.websites._client", lambda website: FakeClient()) monkeypatch.setattr("app.routers.websites._client", lambda website: FakeClient())
monkeypatch.setattr("app.services.website_client.Sub2ApiWebsiteClient", FakeClient) monkeypatch.setattr("app.services.website_client.Sub2ApiWebsiteClient", FakeClient)
req = ImportAccountsRequest( req = ImportAccountsRequest(
upstream_key_ids=[k1.id], upstream_key_ids=[k1.id],
target_group_map={}, target_group_map={"G1": "TG1"},
auto_priority_by_rate=False, auto_priority_by_rate=False,
priority=10, priority=10,
account_name_prefix="some-prefix", account_name_prefix="some-prefix",
+7 -3
View File
@@ -469,6 +469,10 @@ def test_import_auto_priority_by_rate(db_session, monkeypatch):
def __init__(self, **kwargs): pass def __init__(self, **kwargs): pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *a): pass def __exit__(self, *a): pass
def get_groups(self, *args, **kwargs):
return [{"id": "TG1", "name": "TG1", "platform": "openai"}]
def list_accounts(self):
return []
def create_account(self, body): def create_account(self, body):
created_accounts.append(body) created_accounts.append(body)
return {"id": f"remote-{len(created_accounts)}", "name": body["name"]} return {"id": f"remote-{len(created_accounts)}", "name": body["name"]}
@@ -616,12 +620,12 @@ def test_priority_sync_fetches_remote_accounts_and_backfills_info(db_session, mo
def __exit__(self, *a): pass def __exit__(self, *a): pass
def list_accounts(self): def list_accounts(self):
return [ return [
{"id": "A1", "name": "SmartUp-A1-Name", "priority": 15}, {"id": "A1", "name": "SmartUp-A1-Name", "priority": 15, "platform": "openai"},
{"id": "A2", "name": "SmartUp-A2-Name", "priority": 25}, {"id": "A2", "name": "SmartUp-A2-Name", "priority": 25, "platform": "openai"},
] ]
def get_groups(self, *args, **kwargs): def get_groups(self, *args, **kwargs):
return [ return [
{"id": "TG1", "name": "TG1-Readable-Group-Name"} {"id": "TG1", "name": "TG1-Readable-Group-Name", "platform": "openai"}
] ]
def update_account(self, account_id, data): def update_account(self, account_id, data):
update_calls.append((account_id, data)) update_calls.append((account_id, data))
+101 -2
View File
@@ -87,6 +87,11 @@ def test_import_upstream_key_creates_sub2api_account_management_apikey(monkeypat
def __exit__(self, exc_type, exc, tb): def __exit__(self, exc_type, exc, tb):
return False return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "anthropic"}]
def list_accounts(self):
return [{"id": "101", "name": "Existing", "platform": "anthropic", "group_ids": [7]}]
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
account_bodies.append((endpoint, body)) account_bodies.append((endpoint, body))
return {"id": 101, "name": body["name"]} return {"id": 101, "name": body["name"]}
@@ -147,6 +152,10 @@ def test_import_upstream_key_idempotent_skips_already_imported(monkeypatch, db_s
return self return self
def __exit__(self, exc_type, exc, tb): def __exit__(self, exc_type, exc, tb):
return False return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "anthropic"}]
def list_accounts(self):
return [{"id": "101", "name": "Existing", "platform": "anthropic", "group_ids": [7]}]
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
nonlocal create_called nonlocal create_called
create_called = True create_called = True
@@ -177,7 +186,7 @@ def test_import_upstream_key_idempotent_skips_already_imported(monkeypatch, db_s
assert len(response.items) == 1 assert len(response.items) == 1
assert response.items[0].status == "exists" assert response.items[0].status == "exists"
assert response.items[0].account_id == "101" assert response.items[0].account_id == "101"
assert response.items[0].message == "导入过,已跳过" assert response.items[0].message == "存在并已对齐目标分组"
# success 应为 true(没有 failed # success 应为 true(没有 failed
assert response.success assert response.success
@@ -214,12 +223,14 @@ def test_sync_imported_upstream_keys_efficient_and_correct(monkeypatch, db_sessi
return self return self
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "anthropic"}]
def list_accounts(self): def list_accounts(self):
nonlocal list_accounts_calls nonlocal list_accounts_calls
list_accounts_calls += 1 list_accounts_calls += 1
# 远端只有 A1 (id: 101),没有 A2 (id: 102) # 远端只有 A1 (id: 101),没有 A2 (id: 102)
return [ return [
{"id": "101", "name": "SmartUp-A1"} {"id": "101", "name": "SmartUp-A1", "platform": "anthropic", "group_ids": [7]}
] ]
@staticmethod @staticmethod
def extract_id(data): def extract_id(data):
@@ -269,6 +280,8 @@ def test_sync_imported_upstream_keys_check_failed_on_exception(monkeypatch, db_s
return self return self
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "anthropic"}]
def list_accounts(self): def list_accounts(self):
raise Exception("List accounts failure") raise Exception("List accounts failure")
@staticmethod @staticmethod
@@ -305,6 +318,10 @@ def test_import_rebuilds_when_remote_deleted(monkeypatch, db_session):
return self return self
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "anthropic"}]
def list_accounts(self):
return []
def account_exists(self, account_id): def account_exists(self, account_id):
return False return False
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
@@ -345,6 +362,10 @@ def test_import_skips_on_check_failed(monkeypatch, db_session):
return self return self
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "openai"}]
def list_accounts(self):
return None
def account_exists(self, account_id): def account_exists(self, account_id):
return None return None
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
@@ -380,6 +401,8 @@ def test_import_upstream_key_with_custom_concurrency_and_priority(monkeypatch, d
return self return self
def __exit__(self, exc_type, exc, tb): def __exit__(self, exc_type, exc, tb):
return False return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "openai"}]
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
account_bodies.append((endpoint, body)) account_bodies.append((endpoint, body))
return {"id": 202, "name": body["name"]} return {"id": 202, "name": body["name"]}
@@ -440,6 +463,8 @@ def test_import_platform_from_snapshot(monkeypatch, db_session):
pass pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *args): return False def __exit__(self, *args): return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "anthropic"}]
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
account_bodies.append(body) account_bodies.append(body)
return {"id": 101, "name": body["name"]} return {"id": 101, "name": body["name"]}
@@ -491,6 +516,8 @@ def test_import_platform_fallback_to_name(monkeypatch, db_session):
pass pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *args): return False def __exit__(self, *args): return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "anthropic"}]
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
account_bodies.append(body) account_bodies.append(body)
return {"id": 101, "name": body["name"]} return {"id": 101, "name": body["name"]}
@@ -542,6 +569,8 @@ def test_import_platform_manual_override(monkeypatch, db_session):
pass pass
def __enter__(self): return self def __enter__(self): return self
def __exit__(self, *args): return False def __exit__(self, *args): return False
def get_groups(self, endpoint="/groups"):
return [{"id": "7", "name": "Target", "platform": "gemini"}]
def create_account(self, body, endpoint="/accounts"): def create_account(self, body, endpoint="/accounts"):
account_bodies.append(body) account_bodies.append(body)
return {"id": 101, "name": body["name"]} return {"id": 101, "name": body["name"]}
@@ -564,3 +593,73 @@ def test_import_platform_manual_override(monkeypatch, db_session):
) )
assert len(account_bodies) == 1 assert len(account_bodies) == 1
assert account_bodies[0]["platform"] == "gemini" assert account_bodies[0]["platform"] == "gemini"
def test_same_key_can_be_imported_into_two_platform_groups(monkeypatch, db_session):
"""同一 Key 的不同平台映射独立创建,迁移其中一平台不影响另一平台。"""
website, generated = seed_account_import_rows(db_session)
class FakeClient:
accounts = {}
next_id = 100
def __init__(self, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get_groups(self, endpoint="/groups"):
return [
{"id": "7", "name": "OpenAI", "platform": "openai"},
{"id": "8", "name": "Claude", "platform": "anthropic"},
{"id": "9", "name": "OpenAI 2", "platform": "openai"},
]
def list_accounts(self):
return list(self.accounts.values())
def create_account(self, body, endpoint="/accounts"):
type(self).next_id += 1
account = {"id": str(type(self).next_id), **body}
type(self).accounts[account["id"]] = account
return account
def update_account(self, account_id, body):
type(self).accounts[str(account_id)].update(body)
return type(self).accounts[str(account_id)]
@staticmethod
def extract_id(data):
return str(data.get("id"))
monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient)
monkeypatch.setattr(websites_router, "reconcile_upstream_keys_full", lambda db, uid: None)
def do_import(target_group_id):
return websites_router.import_upstream_keys_as_accounts(
website.id,
ImportAccountsRequest(
upstream_key_ids=[generated.id],
target_group_map={"vip": target_group_id},
auto_priority_by_rate=False,
),
db_session,
object(),
)
first = do_import("7")
second = do_import("8")
third = do_import("9")
assert first.items[0].status == "created"
assert second.items[0].status == "created"
assert third.items[0].status == "exists"
assert third.items[0].platform == "openai"
links = db_session.query(websites_router.UpstreamKeyAccountLink).filter_by(
upstream_key_id=generated.id,
website_id=website.id,
status="active",
).all()
assert {(link.platform, link.target_group) for link in links} == {
("openai", "9"),
("anthropic", "8"),
}
assert len(FakeClient.accounts) == 2
+11 -1
View File
@@ -241,6 +241,14 @@ export interface GeneratedUpstreamKey {
imported_at: string | null imported_at: string | null
created_at: string | null created_at: string | null
has_key_value: boolean has_key_value: boolean
imported_accounts: Array<{
id: number
website_id: number
target_group: string
platform: string
remote_account_id: string
status: string
}>
} }
export interface UpstreamRecharge { export interface UpstreamRecharge {
@@ -351,6 +359,7 @@ export interface WebsiteGroup {
name: string name: string
rate_multiplier: string | null rate_multiplier: string | null
description?: string | null description?: string | null
platform?: string | null
raw: Record<string, any> raw: Record<string, any>
} }
@@ -371,6 +380,7 @@ export interface GroupBindingData {
percent: number percent: number
algorithm: string algorithm: string
enabled: boolean enabled: boolean
platform?: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -516,7 +526,7 @@ export const websitesApi = {
upstream_key_ids: number[] upstream_key_ids: number[]
target_group_map: Record<string, string> target_group_map: Record<string, string>
account_name_prefix: string account_name_prefix: string
default_platform: string default_platform?: string
platform_mode?: string platform_mode?: string
concurrency?: number concurrency?: number
priority?: number priority?: number
+15 -47
View File
@@ -190,7 +190,7 @@
<div class="binding-title">{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}</div> <div class="binding-title">{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}</div>
<div class="binding-meta"> <div class="binding-meta">
{{ binding.algorithm === 'priority_weighted_plus_percent' ? `${algorithmLabel(binding.algorithm)} · 自动约 ${binding.percent}%` : `${algorithmLabel(binding.algorithm)} + ${binding.percent}%` }} · {{ binding.algorithm === 'priority_weighted_plus_percent' ? `${algorithmLabel(binding.algorithm)} · 自动约 ${binding.percent}%` : `${algorithmLabel(binding.algorithm)} + ${binding.percent}%` }} ·
{{ binding.source_groups.length }} 个上游分组 {{ binding.source_groups.length }} 个上游分组 · {{ platformLabel(binding.platform || '') }}
</div> </div>
</div> </div>
<div class="binding-actions"> <div class="binding-actions">
@@ -279,7 +279,7 @@
<el-form-item label="目标分组" prop="target_group_id"> <el-form-item label="目标分组" prop="target_group_id">
<div class="custom-select-wrapper"> <div class="custom-select-wrapper">
<el-select v-model="bindingForm.target_group_id" filterable style="width:100%" @change="onTargetGroupChange" class="custom-theme-select"> <el-select v-model="bindingForm.target_group_id" filterable style="width:100%" @change="onTargetGroupChange" class="custom-theme-select">
<el-option v-for="group in bindingWebsiteGroups" :key="group.id" :label="`${group.name} (${group.rate_multiplier ?? '—'})`" :value="group.id" /> <el-option v-for="group in bindingWebsiteGroups" :key="group.id" :label="`${group.name} · ${platformLabel(group.platform || '')} (${group.rate_multiplier ?? '—'})`" :value="group.id" />
</el-select> </el-select>
</div> </div>
</el-form-item> </el-form-item>
@@ -603,24 +603,8 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :xs="24" :sm="12">
<el-form-item label="账号平台"> <el-alert type="info" :closable="false" title="账号平台取自目标网站分组。" />
<el-select v-model="importAccountsForm.platform_mode" style="width:100%" @change="onPlatformModeChange">
<el-option label="自动识别(按 Key/分组名判断)" value="auto" />
<el-option label="手动选择" value="manual" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="固定平台(手动模式)">
<el-select v-model="importAccountsForm.default_platform" style="width:100%" :disabled="importAccountsForm.platform_mode === 'auto'">
<el-option label="OpenAI 兼容" value="openai" />
<el-option label="Anthropic" value="anthropic" />
<el-option label="Gemini" value="gemini" />
<el-option label="Grok" value="grok" />
<el-option label="Antigravity" value="antigravity" />
</el-select>
</el-form-item>
</el-col> </el-col>
<el-col :span="24" style="margin-bottom:6px"> <el-col :span="24" style="margin-bottom:6px">
<el-button size="small" text :loading="syncingImportStatus" @click="syncImportStatus"> <el-button size="small" text :loading="syncingImportStatus" @click="syncImportStatus">
@@ -705,7 +689,7 @@
<el-option <el-option
v-for="item in importableGeneratedKeys" v-for="item in importableGeneratedKeys"
:key="item.id!" :key="item.id!"
:label="`${item.group_name || item.group_id} / ${detectPlatform(item)} / ${item.key_name} / ${item.masked_key}`" :label="`${item.group_name || item.group_id} / ${item.key_name} / ${item.masked_key}`"
:value="item.id!" :value="item.id!"
/> />
</el-select> </el-select>
@@ -714,8 +698,8 @@
<div class="result-title">目标分组映射</div> <div class="result-title">目标分组映射</div>
<div v-for="group in selectedAccountGroups" :key="group.group_id" class="mapping-row"> <div v-for="group in selectedAccountGroups" :key="group.group_id" class="mapping-row">
<span class="mapping-label">{{ group.group_name || group.group_id }}</span> <span class="mapping-label">{{ group.group_name || group.group_id }}</span>
<el-select v-model="importAccountsForm.target_group_map[group.group_id]" clearable filterable placeholder="可不选" style="width:280px"> <el-select v-model="importAccountsForm.target_group_map[group.group_id]" clearable filterable placeholder="请选择目标分组" style="width:min(100%, 280px)">
<el-option v-for="target in importTargetGroups" :key="target.id" :label="`${target.name} (${target.rate_multiplier ?? '—'})`" :value="target.id" /> <el-option v-for="target in importTargetGroups" :key="target.id" :label="`${target.name} · ${platformLabel(target.platform || '')} (${target.rate_multiplier ?? '—'})`" :value="target.id" />
</el-select> </el-select>
</div> </div>
</div> </div>
@@ -725,7 +709,7 @@
<el-table :data="importAccountResults" size="small"> <el-table :data="importAccountResults" size="small">
<el-table-column prop="source_group_name" label="来源分组" min-width="130" /> <el-table-column prop="source_group_name" label="来源分组" min-width="130" />
<el-table-column prop="account_name" label="账号管理账号" min-width="180" /> <el-table-column prop="account_name" label="账号管理账号" min-width="180" />
<el-table-column label="识别平台" width="120"> <el-table-column label="目标平台" width="120">
<template #default="{ row }"> <template #default="{ row }">
<span>{{ platformLabel(row.platform) }}</span> <span>{{ platformLabel(row.platform) }}</span>
</template> </template>
@@ -1291,8 +1275,6 @@ const importAccountsForm = ref({
upstream_key_ids: [] as number[], upstream_key_ids: [] as number[],
target_group_map: {} as Record<string, string>, target_group_map: {} as Record<string, string>,
account_name_prefix: 'SmartUp', account_name_prefix: 'SmartUp',
default_platform: 'openai',
platform_mode: 'auto',
concurrency: 100, concurrency: 100,
priority: 1, priority: 1,
auto_priority_by_rate: true, auto_priority_by_rate: true,
@@ -1379,7 +1361,6 @@ function isImportableGeneratedKey(item: GeneratedUpstreamKey) {
&& item.has_key_value && item.has_key_value
&& item.status !== 'failed' && item.status !== 'failed'
&& item.status !== 'orphaned' && item.status !== 'orphaned'
&& !(item.imported_website_id === importAccountsForm.value.website_id && item.imported_account_id)
} }
const importableGeneratedKeys = computed(() => const importableGeneratedKeys = computed(() =>
@@ -1388,7 +1369,9 @@ const importableGeneratedKeys = computed(() =>
const importedGeneratedKeyCount = computed(() => const importedGeneratedKeyCount = computed(() =>
importGeneratedKeys.value.filter((item) => importGeneratedKeys.value.filter((item) =>
item.imported_website_id === importAccountsForm.value.website_id && Boolean(item.imported_account_id), item.imported_accounts.some(link =>
link.website_id === importAccountsForm.value.website_id && link.status === 'active',
),
).length, ).length,
) )
@@ -2290,8 +2273,6 @@ async function openImportAccounts(site?: WebsiteData | null) {
upstream_key_ids: [], upstream_key_ids: [],
target_group_map: {}, target_group_map: {},
account_name_prefix: 'SmartUp', account_name_prefix: 'SmartUp',
default_platform: 'openai',
platform_mode: 'auto',
concurrency: 100, concurrency: 100,
priority: 1, priority: 1,
auto_priority_by_rate: true, auto_priority_by_rate: true,
@@ -2330,21 +2311,6 @@ async function onImportAccountUpstreamChange(value: number) {
}) })
} }
function onPlatformModeChange(value: string) {
if (value === 'auto') {
importAccountsForm.value.default_platform = 'openai'
}
}
function detectPlatform(item: { group_name?: string; group_id?: string; key_name?: string }) {
const text = `${item.group_name || ''} ${item.group_id || ''} ${item.key_name || ''}`.toLowerCase()
if (text.includes('claude') || text.includes('anthropic')) return 'Anthropic'
if (text.includes('gemini')) return 'Gemini'
if (text.includes('grok') || text.includes('xai')) return 'Grok'
if (text.includes('antigravity')) return 'Antigravity'
return 'OpenAI 兼容'
}
function platformLabel(platform: string) { function platformLabel(platform: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
openai: 'OpenAI 兼容', openai: 'OpenAI 兼容',
@@ -2421,14 +2387,16 @@ async function submitImportAccounts() {
ElMessage.error('请选择要导入的上游 Key') ElMessage.error('请选择要导入的上游 Key')
return return
} }
if (selectedAccountGroups.value.some(group => !importAccountsForm.value.target_group_map[group.group_id])) {
ElMessage.error('请为每个来源分组选择目标分组')
return
}
importingAccounts.value = true importingAccounts.value = true
try { try {
const res = await websitesApi.importAccountsFromUpstreamKeys(importAccountsForm.value.website_id, { const res = await websitesApi.importAccountsFromUpstreamKeys(importAccountsForm.value.website_id, {
upstream_key_ids: importAccountsForm.value.upstream_key_ids, upstream_key_ids: importAccountsForm.value.upstream_key_ids,
target_group_map: importAccountsForm.value.target_group_map, target_group_map: importAccountsForm.value.target_group_map,
account_name_prefix: importAccountsForm.value.account_name_prefix, account_name_prefix: importAccountsForm.value.account_name_prefix,
default_platform: importAccountsForm.value.default_platform,
platform_mode: importAccountsForm.value.platform_mode,
concurrency: importAccountsForm.value.concurrency, concurrency: importAccountsForm.value.concurrency,
priority: importAccountsForm.value.priority, priority: importAccountsForm.value.priority,
auto_priority_by_rate: importAccountsForm.value.auto_priority_by_rate, auto_priority_by_rate: importAccountsForm.value.auto_priority_by_rate,