from __future__ import annotations import json import logging from decimal import Decimal from typing import Any 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 logger = logging.getLogger(__name__) def binding_sources(binding: WebsiteGroupBinding) -> list[dict[str, Any]]: try: data = json.loads(binding.source_groups_json or "[]") except Exception: return [] return data if isinstance(data, list) else [] def latest_rate_map(db: Session, upstream_id: int) -> dict[str, Any]: row = ( db.query(UpstreamRateSnapshot) .filter(UpstreamRateSnapshot.upstream_id == upstream_id) .order_by(UpstreamRateSnapshot.captured_at.desc()) .first() ) if not row: return {} snapshot = json.loads(row.snapshot_json or "{}") groups = snapshot.get("groups") or {} return groups if isinstance(groups, dict) else {} def get_affected_bindings(db: Session, changes: list[dict[str, Any]], upstream_id: int) -> list[WebsiteGroupBinding]: changed_ids = {str(change.get("group_id")) for change in changes if change.get("group_id") is not None} if not changed_ids: return [] result: list[WebsiteGroupBinding] = [] bindings = db.query(WebsiteGroupBinding).filter(WebsiteGroupBinding.enabled == True).all() for binding in bindings: for source in binding_sources(binding): if int(source.get("upstream_id") or 0) == upstream_id and str(source.get("group_id")) in changed_ids: result.append(binding) break return result def _client_for(website: Website) -> Sub2ApiWebsiteClient: return Sub2ApiWebsiteClient( 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), ) def _log( db: Session, binding: WebsiteGroupBinding, website: Website, source_rates: list[dict[str, Any]], status: str, message: str, old_rate: Any = None, new_rate: Any = None, ) -> WebsiteSyncLog: row = WebsiteSyncLog( website_id=website.id, binding_id=binding.id, target_group_id=binding.target_group_id, target_group_name=binding.target_group_name, algorithm=binding.algorithm, percent=binding.percent, source_rates_json=json.dumps(source_rates, ensure_ascii=False), old_rate=decimal_string(old_rate) if old_rate not in (None, "") else None, new_rate=decimal_string(new_rate) if new_rate not in (None, "") else None, status=status, message=message, ) db.add(row) db.commit() db.refresh(row) return row def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True) -> WebsiteSyncLog: website = db.query(Website).filter(Website.id == binding.website_id).first() if not website: raise WebsiteError("网站不存在") sources = binding_sources(binding) # ── 批量预查:收集所有上游 ID,一次查询上游名称 ── upstream_ids = {int(s.get("upstream_id") or 0) for s in sources if s.get("upstream_id")} upstreams = {} if upstream_ids: rows = db.query(Upstream).filter(Upstream.id.in_(upstream_ids)).all() upstreams = {u.id: u for u in rows} # ── 同一轮 sync 内的快照缓存(调用级,函数返回即释放)── _snap_cache: dict[int, dict[str, Any]] = {} def _get_snap(upstream_id: int) -> dict[str, Any]: if upstream_id not in _snap_cache: _snap_cache[upstream_id] = latest_rate_map(db, upstream_id) return _snap_cache[upstream_id] source_rates: list[dict[str, Any]] = [] for source in sources: upstream_id = int(source.get("upstream_id") or 0) group_id = str(source.get("group_id") or "") groups = _get_snap(upstream_id) group = groups.get(group_id) if group_id else None upstream = upstreams.get(upstream_id) source_rates.append({ "upstream_id": upstream_id, "upstream_name": source.get("upstream_name") or (upstream.name if upstream else ""), "group_id": group_id, "group_name": source.get("group_name") or (group.get("group_name", "") if isinstance(group, dict) else ""), "rate": group.get("rate") if isinstance(group, dict) else None, }) try: target_rate = calculate_target_rate([item.get("rate") for item in source_rates], binding.percent, binding.algorithm) except Exception as exc: return _log(db, binding, website, source_rates, "failed", str(exc)) old_rate = None if write and website.enabled and website.auto_sync_enabled and binding.enabled: try: with _client_for(website) as client: groups = client.get_groups(website.groups_endpoint) target = next((item for item in groups if item.get("id") == binding.target_group_id), None) old_rate = target.get("rate_multiplier") if target else None client.update_group_rate(website.group_update_endpoint, binding.target_group_id, target_rate) website.last_status = "healthy" website.last_error = None except Exception as exc: website.last_status = "unhealthy" website.last_error = str(exc) db.commit() return _log(db, binding, website, source_rates, "failed", f"写回失败:{exc}", old_rate, target_rate) db.commit() log = _log(db, binding, website, source_rates, "success", "同步成功", old_rate, target_rate) old_rate_str = decimal_string(old_rate) if old_rate not in (None, "") else None new_rate_str = decimal_string(target_rate) if old_rate_str != new_rate_str: webhook_service.send_website_rate_changed( db, website.id, website.name, website.base_url, binding.id, binding.target_group_id, binding.target_group_name, old_rate_str, new_rate_str, source_rates, ) return log message = "已计算建议倍率,未写回" if not website.enabled or not website.auto_sync_enabled: message = "网站未启用自动同步,未写回" elif not binding.enabled: message = "绑定未启用,未写回" 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: sync_binding(db, binding, write=True) except Exception as exc: logger.exception("website sync failed for binding %s: %s", binding.id, exc)