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
+10 -2
View File
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
from app.models.admin_user import AdminUser
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.snapshot import UpstreamRateSnapshot
from app.schemas.upstream import (
@@ -25,6 +25,7 @@ from app.schemas.upstream import (
UpstreamCreate, UpstreamUpdate, UpstreamResponse, SnapshotResponse, TestResult,
UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse,
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.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)
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(
id=row.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,
updated_at=row.updated_at,
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.models.snapshot import UpstreamRateSnapshot
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.schemas.website import (
BindingCreate,
@@ -63,6 +63,10 @@ from app.services.website_sync import (
sync_account_priorities_for_upstream,
latest_rate_map,
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.number import fixed_decimal_number
@@ -248,6 +252,7 @@ def _binding_response(db: Session, row: WebsiteGroupBinding) -> BindingResponse:
percent=float(row.percent or 0),
algorithm=row.algorithm,
enabled=row.enabled,
platform=row.platform,
created_at=row.created_at,
updated_at=row.updated_at,
)
@@ -539,9 +544,10 @@ def import_groups_from_upstream(
create_body = {
"name": target_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),
}
if group.get("platform"):
create_body["platform"] = group["platform"]
if group.get("rpm_limit") is not None:
create_body["rpm_limit"] = group.get("rpm_limit")
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。"""
if not platform:
return ""
p = platform.lower().strip()
if p == "xai":
return "grok"
@@ -604,28 +612,22 @@ def _normalize_platform(platform: str) -> str:
SUPPORTED_PLATFORMS = {"openai", "anthropic", "gemini", "grok", "antigravity"}
def _resolve_platform(text: str, group_snapshot: dict | None, fallback: str = "openai") -> str:
if group_snapshot and isinstance(group_snapshot, dict):
raw = group_snapshot.get("platform")
if raw:
platform = _normalize_platform(str(raw))
if platform in SUPPORTED_PLATFORMS:
return platform
return _detect_platform(text, fallback)
def _target_group_platform(group: dict[str, Any] | None) -> str | None:
platform = _normalize_platform(group.get("platform") if group else None)
return platform if platform in SUPPORTED_PLATFORMS else None
def _detect_platform(text: str, fallback: str = "openai") -> str:
"""根据 Key 名或分组名关键词判断平台类型。"""
lower = text.lower()
if "claude" in lower or "anthropic" in lower:
return "anthropic"
if "gemini" in lower:
return "gemini"
if "grok" in lower or "xai" in lower:
return "grok"
if "antigravity" in lower:
return "antigravity"
return _normalize_platform(fallback)
def _fetch_target_group(website: Website, target_group_id: str) -> tuple[dict[str, Any], str | None]:
try:
with _client(website) as client:
groups = client.get_groups(website.groups_endpoint) or []
except Exception as exc:
raise HTTPException(502, f"读取目标网站分组失败: {exc}") from exc
target = next((group for group in groups if str(group.get("id")) == str(target_group_id)), None)
if target is None:
raise HTTPException(400, "目标分组不存在")
platform = _target_group_platform(target)
return target, platform
@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:
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] = []
# 预载最新快照映射,用于结构化 platform 解析
upstream_ids = {row.upstream_id for row in rows}
latest_snapshots = {}
for uid in upstream_ids:
latest_snapshots[uid] = latest_rate_map(db, uid)
with _client(website) as c:
remote_accounts = None
@@ -665,20 +653,34 @@ def sync_imported_upstream_keys(
if remote_accounts is not None:
remote_ids = set()
remote_map = {}
for acc in remote_accounts:
acc_id = _extract_id(acc)
if acc_id:
remote_ids.add(str(acc_id))
remote_map[str(acc_id)] = acc
else:
remote_ids = None
remote_map = {}
for row in rows:
snap_groups = latest_snapshots.get(row.upstream_id) or {}
group_snap = snap_groups.get(row.group_id) if isinstance(snap_groups, dict) else None
platform = _resolve_platform(f"{row.group_name} {row.group_id} {row.key_name}", group_snap, "openai")
if not row.imported_account_id:
continue
old_account_id = row.imported_account_id
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 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:
items.append(ImportAccountItem(
@@ -688,16 +690,13 @@ def sync_imported_upstream_keys(
message="无法校验目标账号存在性(拉取账号列表失败)",
))
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(
upstream_key_id=row.id, source_group_id=row.group_id,
source_group_name=row.group_name, account_id=old_account_id,
platform=platform, status="stale_cleared",
message="目标账号已删除,已清除导入标记",
))
deactivate_account_mapping(db, row, link)
else:
items.append(ImportAccountItem(
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,
source_group_id="",
source_group_name="",
platform=body.default_platform,
platform="",
status="failed",
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_map = {u.id: u for u in upstreams_db}
# 预载最新快照映射,用于结构化 platform 解析
latest_snapshots: dict[int, dict[str, Any]] = {}
for uid in upstream_ids:
latest_snapshots[uid] = latest_rate_map(db, uid)
# 按倍率自动分配优先级
rate_priority_map: dict[tuple[str, int, str], int] = {}
if body.auto_priority_by_rate:
@@ -834,12 +828,15 @@ def import_upstream_keys_as_accounts(
with _client(website) as c:
target_group_names: dict[str, str] = {}
target_groups: dict[str, dict[str, Any]] = {}
try:
grps = c.get_groups(endpoint=website.groups_endpoint) or []
for g in grps:
g_id = g.get("id")
if g_id is not None:
target_group_names[str(g_id)] = g.get("name") or str(g_id)
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:
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:
aid = c.extract_id(acc)
if aid:
remote_accounts_map[aid] = acc
remote_accounts_map[str(aid)] = acc
except Exception as 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:
# 跳过远端已不存在或导入失败的 Key(对账后标记为 orphaned / import_failed
if row.status in ("orphaned", "failed", "import_failed"):
target_group_id = body.target_group_map.get(row.group_id)
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(
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),
platform=body.default_platform,
target_group_id=target_group_id,
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",
message="远端 Key 已不存在,请重新生成",
))
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_name = upstream.name if upstream else "Unknown"
@@ -893,87 +896,114 @@ def import_upstream_keys_as_accounts(
group_name=row.group_name,
group_id=row.group_id,
key_id=row.id,
platform=platform,
)
# 幂等校验:已导入过则检查远端账号是否仍存在
if row.imported_website_id == wid and row.imported_account_id:
old_account_id = row.imported_account_id
if has_list_accounts:
if remote_accounts_list is None:
# 接口获取失败,保守判定为校验失败
exists = None
else:
exists = old_account_id in remote_accounts_map
else:
# 如果 client 没有 list_accounts,则 fallback 回原先的 account_exists 检测以兼容旧 Mock 测试
try:
exists = c.account_exists(row.imported_account_id)
except Exception:
exists = None
if exists is True:
# 顺手回填 imported_target_group_id(老数据升级后可通过重导自动补齐)
new_tgid = body.target_group_map.get(row.group_id) or None
if new_tgid:
tg_name = target_group_names.get(str(new_tgid))
if row.imported_target_group_id != new_tgid or row.imported_target_group_name != tg_name:
row.imported_target_group_id = new_tgid
row.imported_target_group_name = tg_name
db.commit()
remote_name = ""
if old_account_id in remote_accounts_map:
remote_name = remote_accounts_map[old_account_id].get("name") or ""
link = db.query(UpstreamKeyAccountLink).filter(
UpstreamKeyAccountLink.upstream_key_id == row.id,
UpstreamKeyAccountLink.website_id == wid,
UpstreamKeyAccountLink.platform == platform,
).first()
if link is None and row.imported_website_id == wid and row.imported_account_id:
legacy_account = remote_accounts_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)
has_any_link = db.query(UpstreamKeyAccountLink.id).filter(
UpstreamKeyAccountLink.upstream_key_id == row.id,
).first()
if (
link is None
and has_any_link is None
and row.imported_website_id == wid
and row.imported_account_id
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(
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),
target_group_id=target_group_id,
account_id=old_account_id,
account_name=remote_name or expected_new_name,
platform=platform,
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",
message="无法校验目标账号状态,已保守跳过",
))
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:
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="check_failed",
message="该 Key 无明文值,无法导入(远端已存在 Key 不会保留明文,请重新创建或手动填入)",
))
continue
target_group_id = body.target_group_map.get(row.group_id)
group_ids = []
numeric_target = _numeric_group_id(target_group_id)
if numeric_target is not None:
group_ids.append(numeric_target)
group_ids = [numeric_target if numeric_target is not None else target_group_id]
account_name = expected_new_name
account_body = {
"name": account_name,
@@ -992,14 +1022,25 @@ def import_upstream_keys_as_accounts(
try:
created = c.create_account(account_body)
account_id = c.extract_id(created)
row.imported_website_id = wid
row.imported_account_id = account_id or None
row.imported_at = datetime.now(timezone.utc)
row.imported_target_group_id = target_group_id or None
row.imported_target_group_name = target_group_names.get(str(target_group_id)) if target_group_id else None
row.status = "imported"
if not account_id:
raise WebsiteError("创建账号成功但响应缺少账号 ID")
if link:
link.target_group = str(target_group_id)
link.remote_account_id = str(account_id)
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
db.commit()
sync_key_legacy_fields(db, row.id)
remote_accounts_map[str(account_id)] = created
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
@@ -1015,8 +1056,13 @@ def import_upstream_keys_as_accounts(
))
except Exception as exc:
logger.exception("import upstream key as account failed website=%s key=%s", wid, row.id)
row.status = "import_failed"
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()
items.append(ImportAccountItem(
upstream_key_id=row.id,
@@ -1060,20 +1106,31 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
for acc in remote_accounts:
aid = c.extract_id(acc)
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]] = {}
# 2. Category: missing_remote_account
# Local keys that still reference an imported_account_id,
# but that account no longer appears in the remote account list.
imported_keys = db.query(UpstreamGeneratedKey).filter(
UpstreamGeneratedKey.imported_website_id == website.id,
UpstreamGeneratedKey.imported_account_id.isnot(None),
).all()
all_pairs = get_account_mapping_pairs(db, website.id, include_orphaned=True)
local_pair_by_account = {str(link.remote_account_id): (key, link) for key, link in all_pairs}
imported_pairs = [
(key, link) for key, link in all_pairs
if key.status != "orphaned" and link.status == "active"
]
for key in imported_keys:
acc_id = key.imported_account_id
for key, link in imported_pairs:
acc_id = str(link.remote_account_id)
if acc_id in candidates:
continue
if acc_id in remote_accounts_map:
@@ -1089,18 +1146,18 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": "目标账号已被手动删除",
"cleanup_action": "clear_local_marker",
"db_key": key,
"db_link": link,
}
# 3. Category: local_orphan_account
# Local keys with status=orphaned that still carry an imported_account_id.
orphaned_keys = db.query(UpstreamGeneratedKey).filter(
UpstreamGeneratedKey.status == "orphaned",
UpstreamGeneratedKey.imported_website_id == website.id,
UpstreamGeneratedKey.imported_account_id.isnot(None),
).all()
orphaned_pairs = [
(key, link) for key, link in all_pairs
if key.status == "orphaned" and link.status == "active"
]
for key in orphaned_keys:
acc_id = key.imported_account_id
for key, link in orphaned_pairs:
acc_id = str(link.remote_account_id)
remote_acc = remote_accounts_map.get(acc_id)
acc_name = remote_acc.get("name") if remote_acc else key.key_name
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_name"] = upstream_name
candidates[acc_id]["source_group_name"] = key.group_name
candidates[acc_id]["db_link"] = link
continue
if remote_acc:
@@ -1125,6 +1183,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": "本地上游 Key 已归档",
"cleanup_action": "delete_remote_and_clear_marker",
"db_key": key,
"db_link": link,
}
else:
candidates[acc_id] = {
@@ -1136,6 +1195,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": "本地上游 Key 已归档,且目标账号已被手动删除",
"cleanup_action": "clear_local_marker",
"db_key": key,
"db_link": link,
}
# 4. Category: remote_orphan_account
@@ -1166,10 +1226,12 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
if not key:
is_orphan = True
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
reason = "本地 Key 与目标账号关联不一致"
elif key.status == 'orphaned':
elif key and key.status == 'orphaned':
is_orphan = True
reason = "本地上游 Key 已归档"
@@ -1190,6 +1252,7 @@ def _get_cleanup_candidates(db: Session, website: Website, c: Sub2ApiWebsiteClie
"reason": reason,
"cleanup_action": "delete_remote_account",
"db_key": key,
"db_link": link,
}
return candidates
@@ -1215,8 +1278,23 @@ def _test_candidate_account(c: Sub2ApiWebsiteClient, account_id: str) -> tuple[s
return "test_unknown", False, f"测试时发生异常: {e}"
def _clear_local_import_marker(key: UpstreamGeneratedKey, db: Session) -> None:
"""清空单条 Key 上的 SmartUp 导入标记字段。"""
def _clear_local_import_marker(
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_account_id = 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":
key = cand.get("db_key")
if key:
_clear_local_import_marker(key, db)
_clear_local_import_marker(key, db, cand.get("db_link"))
action_status = "cleared"
msg = "已清除本地导入标记"
else:
@@ -1320,7 +1398,7 @@ def execute_cleanup_invalid_accounts(wid: int, db: Session = Depends(get_db), _=
if action == "delete_remote_and_clear_marker":
key = cand.get("db_key")
if key:
_clear_local_import_marker(key, db)
_clear_local_import_marker(key, db, cand.get("db_link"))
msg += ",已清除本地导入标记"
else:
action_status = "failed"
@@ -1405,16 +1483,22 @@ def set_website_accounts_concurrency(
for acc in remote_accounts:
aid = c.extract_id(acc)
if aid:
remote_map[aid] = acc
remote_map[str(aid)] = acc
keys = db.query(UpstreamGeneratedKey).filter(
UpstreamGeneratedKey.imported_website_id == wid,
UpstreamGeneratedKey.imported_account_id.isnot(None)
).all()
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 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 = {}
for key in keys:
aid = key.imported_account_id
for key, link in account_pairs:
aid = str(link.remote_account_id)
if aid not in candidates:
candidates[aid] = {
"account_id": aid,
@@ -1595,17 +1679,22 @@ def _sync_upstream_models_generator(wid: int, db: Session):
for acc in remote_accounts:
aid = c.extract_id(acc)
if aid:
remote_map[aid] = acc
remote_map[str(aid)] = acc
# 1. 查找候选账号:本地 UpstreamGeneratedKey.imported_website_id == wid 且 imported_account_id 非空
keys = db.query(UpstreamGeneratedKey).filter(
UpstreamGeneratedKey.imported_website_id == wid,
UpstreamGeneratedKey.imported_account_id.isnot(None)
).all()
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 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 = {}
for key in keys:
aid = key.imported_account_id
for key, link in account_pairs:
aid = str(link.remote_account_id)
if aid not in candidates:
candidates[aid] = {
"account_id": aid,
@@ -1937,6 +2026,11 @@ def _organize_website_groups_generator(wid: int, db: Session):
)
for row in hist_group_ids:
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] = []
@@ -1989,13 +2083,16 @@ def _organize_website_groups_generator(wid: int, db: Session):
with _client(website) as c:
# 获取远端分组名称列表和账号列表
target_group_names: dict[str, str] = {}
target_group_map: dict[str, dict[str, Any]] = {}
remote_accounts: list[dict[str, Any]] = []
try:
grps = c.get_groups(endpoint=website.groups_endpoint) or []
for g in grps:
g_id = g.get("id")
if g_id is not None:
target_group_names[str(g_id)] = g.get("name") or str(g_id)
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:
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:
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()
for b in bindings:
@@ -2035,6 +2135,36 @@ def _organize_website_groups_generator(wid: int, db: Session):
for b in bindings:
target_group_id = b.target_group_id
target_group_name = target_group_names.get(str(target_group_id)) or b.target_group_name or str(target_group_id)
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)
for src in sources:
@@ -2113,18 +2243,33 @@ def _organize_website_groups_generator(wid: int, db: Session):
last_event_at = time.monotonic()
continue
# 检测平台类型
group_snap = snapshot_groups.get(row.group_id) if isinstance(snapshot_groups, dict) else None
platform = _resolve_platform(
f"{row.group_name} {row.group_id} {row.key_name}",
group_snap,
"openai",
)
# 1. 幂等校验:已导入过则检查远端账号是否仍存在
if row.imported_website_id == wid and row.imported_account_id:
old_account_id = row.imported_account_id
# 平台唯一来源:目标分组绑定的 platform 字段
platform = binding_platform
# 1. 幂等校验:仅按 UpstreamKeyAccountLink (key_id, website_id, platform) 查找,不复用 legacy 字段
link = db.query(UpstreamKeyAccountLink).filter(
UpstreamKeyAccountLink.upstream_key_id == row.id,
UpstreamKeyAccountLink.website_id == wid,
UpstreamKeyAccountLink.platform == platform,
UpstreamKeyAccountLink.status == "active",
).first()
if link is None and row.imported_website_id == wid and 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:
# 账号列表获取失败,无法校验状态,保守跳过
_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))
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:
current_group_ids = remote_acc.get("group_ids") or []
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)
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.append(target_val)
old_set = {str(g) for g in current_group_ids}
new_set = {str(g) for g in new_group_ids}
remote_platform = remote_acc.get("platform")
if remote_platform is not None:
platform_mismatch = (str(remote_platform).lower() != str(platform).lower())
else:
platform_mismatch = (str(platform).lower() != "openai")
update_payload = {}
if old_set != new_set:
update_payload["group_ids"] = new_group_ids
if platform_mismatch:
update_payload["platform"] = platform
if update_payload:
c.update_account(old_account_id, update_payload)
if "group_ids" in update_payload:
remote_acc["group_ids"] = new_group_ids
if "platform" in update_payload:
# 主动核对防线:防止子站忽略 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)
c.update_account(old_account_id, {"group_ids": new_group_ids})
remote_acc["group_ids"] = new_group_ids
msg = "已存在,已迁移到目标分组"
else:
msg = "已存在且已对齐,已跳过"
# 补齐本地 DB 的目标分组标记
if row.imported_target_group_id != target_group_id:
row.imported_target_group_id = target_group_id
row.imported_target_group_name = target_group_names.get(str(target_group_id))
db.commit()
# 更新链接的 target_group(同平台换组迁移)
link.target_group = str(target_group_id)
db.commit()
sync_key_legacy_fields(db, row.id)
items.append(_item := OrganizeGroupsItem(
target_group_id=str(target_group_id),
@@ -2248,14 +2365,13 @@ def _organize_website_groups_generator(wid: int, db: Session):
yield "heartbeat", {}
last_event_at = time.monotonic()
continue
else:
# 远端已删除,清理标记后进行重建
row.imported_website_id = None
row.imported_account_id = None
row.imported_at = None
row.status = "created"
elif old_account_id:
link.status = "stale"
db.commit()
sync_key_legacy_fields(db, row.id)
is_recreated = True
elif not old_account_id:
is_recreated = False
else:
is_recreated = False
@@ -2291,6 +2407,7 @@ def _organize_website_groups_generator(wid: int, db: Session):
group_name=row.group_name,
group_id=row.group_id,
key_id=row.id,
platform=platform,
)
priority_val = rate_priority_map.get((str(target_group_id), row.upstream_id, row.group_id), 1)
account_body = {
@@ -2310,12 +2427,30 @@ def _organize_website_groups_generator(wid: int, db: Session):
try:
created = c.create_account(account_body)
account_id = c.extract_id(created)
row.imported_website_id = wid
row.imported_account_id = account_id or None
row.imported_at = datetime.now(timezone.utc)
row.imported_target_group_id = target_group_id or None
row.imported_target_group_name = target_group_names.get(str(target_group_id)) if target_group_id else None
row.status = "imported"
# 更新或创建 UpstreamKeyAccountLink 映射(upsert
if account_id:
existing_link = db.query(UpstreamKeyAccountLink).filter(
UpstreamKeyAccountLink.upstream_key_id == row.id,
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
db.commit()
@@ -2545,14 +2680,16 @@ def create_binding(body: BindingCreate, db: Session = Depends(get_db), _=Depends
if body.algorithm not in ALGORITHMS:
raise HTTPException(400, "不支持的算法")
_ensure_unique_target(db, body.website_id, body.target_group_id)
target_group, platform = _fetch_target_group(website, body.target_group_id)
row = WebsiteGroupBinding(
website_id=body.website_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),
percent=str(body.percent),
algorithm=body.algorithm,
enabled=body.enabled,
platform=platform,
)
db.add(row)
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_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)
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:
row.source_groups_json = json.dumps(data.pop("source_groups"), ensure_ascii=False)
if "percent" in data:
row.percent = str(data.pop("percent"))
for key, value in data.items():
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)
db.commit()
db.refresh(row)