feat: sync account priorities after rate changes

This commit is contained in:
liumangmang
2026-05-29 17:51:12 +08:00
parent c5778bb3e7
commit 5c20ddc8e6
21 changed files with 1510 additions and 26 deletions
+246
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app.models.snapshot import UpstreamRateSnapshot
from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, calculate_target_rate, decimal_string
from app.services import webhook_service
@@ -171,6 +172,251 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True)
return _log(db, binding, website, source_rates, "success", message, old_rate, target_rate)
def _snapshot_group_rate(group: dict) -> float:
"""从快照分组数据中提取倍率(兼容多个字段名)。"""
raw = group.get("rate") or group.get("default_rate") or group.get("rate_multiplier") or 1
try:
return float(raw)
except (TypeError, ValueError):
return 1.0
def build_rate_priority_map(db: Session, upstream_ids: set[int]) -> dict[str, int]:
"""根据上游分组倍率构建 f"{upstream_id}:{group_id}" → priority 映射。
使用 (upstream_id, group_id) 复合键避免不同上游的同名分组互相覆盖。
遍历所有涉及的上游的最新快照,收集分组的倍率,按倍率升序排列后赋值 priority。
倍率最低的 priority=1,次低的 priority=2,以此类推。相同倍率的分组共享同一 priority。
"""
group_rates: dict[str, float] = {}
for uid in upstream_ids:
groups = latest_rate_map(db, uid)
for gid, g in groups.items():
if not isinstance(g, dict):
continue
rate = _snapshot_group_rate(g)
key = f"{uid}:{gid}"
group_rates[key] = rate
unique_rates = sorted(set(group_rates.values()))
rate_to_priority = {rate: idx + 1 for idx, rate in enumerate(unique_rates)}
return {key: rate_to_priority[rate] for key, rate in group_rates.items()}
def _priority_result(row, new_priority: int | None, status: str, message: str) -> dict:
"""构建统一的优先级同步结果 dict。"""
return {
"account_id": row.imported_account_id,
"group_id": row.group_id,
"upstream_id": row.upstream_id,
"old_priority": None,
"new_priority": new_priority,
"status": status,
"message": message,
}
def _write_priority_sync_log_with_map(
db: Session, wid: int, upstream_name: str,
results: list[dict], priority_map: dict[str, int],
) -> None:
"""写入 priority_sync 日志,同时保存账号明细和 priority_map 快照。
source_rates_json 格式:[{"_meta": "priority_map", "data": {...}}, {"account_id": ..., ...}, ...]
兼容 WebsiteSyncLogResponse.source_rates: list[dict] 类型约束。
"""
log_results: list[dict] = [
{"_meta": "priority_map", "data": dict(priority_map)},
]
log_results.extend(results)
success = sum(1 for r in results if r["status"] == "success")
failed = sum(1 for r in results if r["status"] == "failed")
skipped = sum(1 for r in results if r["status"] == "skipped")
parts = []
if success:
parts.append(f"{success} 个更新成功")
if failed:
parts.append(f"{failed} 个失败")
if skipped:
parts.append(f"{skipped} 个跳过")
log = WebsiteSyncLog(
website_id=wid,
binding_id=None,
target_group_id="",
target_group_name="",
algorithm="priority_sync",
percent=0,
source_rates_json=json.dumps(log_results, ensure_ascii=False, default=str),
old_rate=None,
new_rate=None,
status="failed" if failed else "success",
message=f"优先级同步(上游={upstream_name}):{''.join(parts)} / 共 {len(results)}",
)
db.add(log)
db.commit()
def _try_send_priority_webhook(
db: Session, wid: int, website_name: str,
upstream_id: int, upstream_name: str,
updates: list[dict],
) -> None:
"""发送 account_priority_changed webhook,失败不抛异常。"""
if not updates:
return
# 如果没传入名称,尝试从 DB 查
resolved_name = website_name
if not resolved_name:
row = db.query(Website.name).filter(Website.id == wid).first()
if row:
resolved_name = row[0]
else:
resolved_name = f"网站#{wid}"
try:
webhook_service.send_account_priority_changed(
db,
website_id=wid,
website_name=resolved_name,
upstream_id=upstream_id,
upstream_name=upstream_name,
updates=updates,
)
except Exception as exc:
logger.warning("account_priority_changed webhook failed for website %s: %s", wid, exc)
def sync_account_priorities_for_upstream(db: Session, upstream_id: int) -> list[dict]:
"""上游倍率变化后,自动更新已导入下游账号的 priority。
查询该上游下所有已导入(非 orphaned)的 Key,按目标网站分组后重新计算全局优先级,
并通过 update_account API 推送到下游网站。返回详细结果列表。
同时写入 WebsiteSyncLog 持久化审计日志,并通过 webhook 发送通知。
"""
from app.services.website_client import Sub2ApiWebsiteClient as Client
key_rows = (
db.query(UpstreamGeneratedKey)
.filter(
UpstreamGeneratedKey.upstream_id == upstream_id,
UpstreamGeneratedKey.imported_website_id.isnot(None),
UpstreamGeneratedKey.imported_account_id.isnot(None),
UpstreamGeneratedKey.status != "orphaned",
)
.all()
)
if not key_rows:
return []
upstream_name = db.query(Upstream.name).filter(Upstream.id == upstream_id).scalar() or f"#{upstream_id}"
# 按 imported_website_id 分组
website_groups: dict[int, list[UpstreamGeneratedKey]] = {}
for row in key_rows:
wid = row.imported_website_id
if wid not in website_groups:
website_groups[wid] = []
website_groups[wid].append(row)
all_results: list[dict] = []
for wid, rows in website_groups.items():
website = db.query(Website).filter(Website.id == wid).first()
if not website or not website.enabled:
logger.info("skip account priority sync: website %s not found or disabled", wid)
site_results = []
for row in rows:
r = _priority_result(row, None, "failed", "网站不可用")
site_results.append(r)
all_results.append(r)
_write_priority_sync_log_with_map(db, wid, upstream_name, site_results, {})
_try_send_priority_webhook(db, wid, "", upstream_id, upstream_name, site_results)
continue
# 查询该网站所有已导入 Key(跨上游),实现全局优先级排序
all_website_keys = (
db.query(UpstreamGeneratedKey)
.filter(
UpstreamGeneratedKey.imported_website_id == wid,
UpstreamGeneratedKey.imported_account_id.isnot(None),
UpstreamGeneratedKey.status != "orphaned",
)
.all()
)
all_upstream_ids = {k.upstream_id for k in all_website_keys}
try:
priority_map = build_rate_priority_map(db, all_upstream_ids)
except Exception as exc:
logger.warning("build_rate_priority_map failed for website %s: %s", wid, exc)
site_results = []
for row in all_website_keys:
r = _priority_result(row, None, "failed", f"构建优先级映射失败: {exc}")
site_results.append(r)
all_results.append(r)
_write_priority_sync_log_with_map(db, wid, upstream_name, site_results, {})
_try_send_priority_webhook(db, wid, "", upstream_id, upstream_name, site_results)
continue
if not priority_map:
logger.info("skip account priority sync for website %s: empty priority map", wid)
site_results = []
for row in all_website_keys:
r = _priority_result(row, None, "skipped", "无上游倍率数据")
site_results.append(r)
all_results.append(r)
_write_priority_sync_log_with_map(db, wid, upstream_name, site_results, {})
_try_send_priority_webhook(db, wid, "", upstream_id, upstream_name, site_results)
continue
site_results: list[dict] = []
try:
with Client(
base_url=website.base_url,
api_prefix=website.api_prefix,
auth_type=website.auth_type,
auth_config=json.loads(website.auth_config_json or "{}"),
timeout=float(website.timeout_seconds),
) as client:
for row in all_website_keys:
account_id = row.imported_account_id
if not account_id:
continue
new_priority = priority_map.get(f"{row.upstream_id}:{row.group_id}")
if new_priority is None:
site_results.append(
_priority_result(row, None, "skipped", "无倍率数据,跳过")
)
continue
try:
client.update_account(account_id, {"priority": new_priority})
logger.info(
"updated priority for account %s (website=%s, upstream=%s, group=%s): %s",
account_id, wid, row.upstream_id, row.group_id, new_priority,
)
site_results.append(
_priority_result(row, new_priority, "success", f"优先级已更新为 {new_priority}")
)
except Exception as exc:
logger.warning(
"failed to update priority for account %s (website=%s): %s",
account_id, wid, exc,
)
site_results.append(
_priority_result(row, new_priority, "failed", str(exc))
)
except Exception as exc:
logger.warning("failed to connect website %s for account priority sync: %s", wid, exc)
for row in all_website_keys:
site_results.append(
_priority_result(row, None, "failed", f"连接网站失败: {exc}")
)
all_results.extend(site_results)
_write_priority_sync_log_with_map(db, wid, upstream_name, site_results, priority_map)
_try_send_priority_webhook(db, wid, website.name, upstream_id, upstream_name, site_results)
return all_results
def sync_affected_bindings(db: Session, upstream_id: int, changes: list[dict[str, Any]]) -> None:
for binding in get_affected_bindings(db, changes, upstream_id):
try: