feat: sync upstream keys and reorder priorities
This commit is contained in:
@@ -23,6 +23,9 @@ from app.schemas.website import (
|
|||||||
ImportGroupItem,
|
ImportGroupItem,
|
||||||
ImportGroupsRequest,
|
ImportGroupsRequest,
|
||||||
ImportGroupsResponse,
|
ImportGroupsResponse,
|
||||||
|
ReorderPriorityItem,
|
||||||
|
ReorderPriorityRequest,
|
||||||
|
ReorderPriorityResponse,
|
||||||
SyncImportStatusRequest,
|
SyncImportStatusRequest,
|
||||||
TestResult,
|
TestResult,
|
||||||
WebsiteCreate,
|
WebsiteCreate,
|
||||||
@@ -34,7 +37,13 @@ from app.schemas.website import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from app.services.website_client import Sub2ApiWebsiteClient
|
from app.services.website_client import Sub2ApiWebsiteClient
|
||||||
from app.services.website_sync import binding_sources, sync_binding, build_rate_priority_map, reconcile_upstream_keys_full
|
from app.services.website_sync import (
|
||||||
|
binding_sources,
|
||||||
|
sync_binding,
|
||||||
|
build_rate_priority_map,
|
||||||
|
reconcile_upstream_keys_full,
|
||||||
|
sync_account_priorities_for_upstream,
|
||||||
|
)
|
||||||
from app.utils.auth import get_current_user
|
from app.utils.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(tags=["websites"])
|
router = APIRouter(tags=["websites"])
|
||||||
@@ -463,6 +472,44 @@ def sync_imported_upstream_keys(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/websites/{wid}/accounts/reorder-priority", response_model=ReorderPriorityResponse)
|
||||||
|
def reorder_account_priorities(
|
||||||
|
wid: int,
|
||||||
|
body: ReorderPriorityRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
website = db.query(Website).filter(Website.id == wid).first()
|
||||||
|
if not website:
|
||||||
|
raise HTTPException(404, "website not found")
|
||||||
|
if website.site_type != "sub2api":
|
||||||
|
raise HTTPException(400, "目前只支持 sub2api")
|
||||||
|
|
||||||
|
upstream = db.query(Upstream).filter(Upstream.id == body.upstream_id).first()
|
||||||
|
if not upstream:
|
||||||
|
raise HTTPException(404, "upstream not found")
|
||||||
|
|
||||||
|
results = sync_account_priorities_for_upstream(db, body.upstream_id, website_id=wid)
|
||||||
|
failed_count = sum(1 for item in results if item.get("status") == "failed")
|
||||||
|
success_count = sum(1 for item in results if item.get("status") == "success")
|
||||||
|
skipped_count = sum(1 for item in results if item.get("status") == "skipped")
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if success_count:
|
||||||
|
parts.append(f"更新 {success_count}")
|
||||||
|
if failed_count:
|
||||||
|
parts.append(f"失败 {failed_count}")
|
||||||
|
if skipped_count:
|
||||||
|
parts.append(f"跳过 {skipped_count}")
|
||||||
|
message = "、".join(parts) + f" / 共 {len(results)} 个" if parts else "没有需要重排的账号"
|
||||||
|
|
||||||
|
return ReorderPriorityResponse(
|
||||||
|
success=failed_count == 0,
|
||||||
|
message=message,
|
||||||
|
items=[ReorderPriorityItem(**item) for item in results],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/websites/{wid}/accounts/import-upstream-keys", response_model=ImportAccountsResponse)
|
@router.post("/api/websites/{wid}/accounts/import-upstream-keys", response_model=ImportAccountsResponse)
|
||||||
def import_upstream_keys_as_accounts(
|
def import_upstream_keys_as_accounts(
|
||||||
wid: int,
|
wid: int,
|
||||||
|
|||||||
@@ -180,6 +180,26 @@ class ImportAccountsResponse(BaseModel):
|
|||||||
items: list[ImportAccountItem]
|
items: list[ImportAccountItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ReorderPriorityRequest(BaseModel):
|
||||||
|
upstream_id: int = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ReorderPriorityItem(BaseModel):
|
||||||
|
account_id: Optional[str] = None
|
||||||
|
group_id: str = ""
|
||||||
|
upstream_id: int
|
||||||
|
old_priority: Optional[int] = None
|
||||||
|
new_priority: Optional[int] = None
|
||||||
|
status: str
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ReorderPriorityResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
items: list[ReorderPriorityItem]
|
||||||
|
|
||||||
|
|
||||||
class WebsiteBatchSyncResponse(BaseModel):
|
class WebsiteBatchSyncResponse(BaseModel):
|
||||||
total: int
|
total: int
|
||||||
success: int
|
success: int
|
||||||
|
|||||||
@@ -407,17 +407,25 @@ class UpstreamClient:
|
|||||||
out["group_name"] = str(out.get("group"))
|
out["group_name"] = str(out.get("group"))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _list_new_api_tokens(
|
@staticmethod
|
||||||
self,
|
def _extract_new_api_token_items(payload: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||||
search: str = "",
|
nested = _unwrap_data(payload)
|
||||||
group_id: str | int | None = None,
|
meta: dict[str, Any] = {}
|
||||||
) -> list[dict[str, Any]]:
|
items: list[dict[str, Any]] | None = None
|
||||||
if search:
|
if isinstance(nested, list):
|
||||||
path = "/api/token/search"
|
items = [i for i in nested if isinstance(i, dict)]
|
||||||
params = {"keyword": search, "token": "", "p": 1, "size": 100}
|
elif isinstance(nested, dict):
|
||||||
else:
|
meta = nested
|
||||||
path = "/api/token/"
|
for key in ("items", "tokens", "list", "records"):
|
||||||
params = {"p": 1, "size": 100}
|
value = nested.get(key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
items = [i for i in value if isinstance(i, dict)]
|
||||||
|
break
|
||||||
|
if items is None:
|
||||||
|
raise UpstreamError("unexpected New-API token list response")
|
||||||
|
return items, meta
|
||||||
|
|
||||||
|
def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||||
resp = self._client.request(
|
resp = self._client.request(
|
||||||
"GET",
|
"GET",
|
||||||
self._url(path),
|
self._url(path),
|
||||||
@@ -429,23 +437,80 @@ class UpstreamClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
self._ensure_api_success(data, "list New-API tokens")
|
self._ensure_api_success(data, "list New-API tokens")
|
||||||
nested = _unwrap_data(data)
|
return self._extract_new_api_token_items(data)
|
||||||
items: list[dict[str, Any]] | None = None
|
|
||||||
if isinstance(nested, list):
|
def _list_all_new_api_tokens(self, page_size: int = 100, max_pages: int = 20) -> list[dict[str, Any]]:
|
||||||
items = [i for i in nested if isinstance(i, dict)]
|
all_items: list[dict[str, Any]] = []
|
||||||
elif isinstance(nested, dict):
|
for page in range(1, max_pages + 1):
|
||||||
for key in ("items", "tokens", "list", "records"):
|
items, meta = self._request_new_api_token_list(
|
||||||
value = nested.get(key)
|
"/api/token/",
|
||||||
if isinstance(value, list):
|
{"p": page, "size": page_size},
|
||||||
items = [i for i in value if isinstance(i, dict)]
|
)
|
||||||
break
|
all_items.extend(items)
|
||||||
if items is None:
|
total = meta.get("total") if isinstance(meta, dict) else None
|
||||||
raise UpstreamError("unexpected New-API token list response")
|
if isinstance(total, int) and len(all_items) >= total:
|
||||||
normalized = [self._normalize_key_record(i) for i in items]
|
break
|
||||||
|
if len(items) < page_size:
|
||||||
|
break
|
||||||
|
return [self._normalize_key_record(i) for i in all_items]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matches_new_api_token_search(record: dict[str, Any], search: str) -> bool:
|
||||||
|
if not search:
|
||||||
|
return True
|
||||||
|
needle = search.strip()
|
||||||
|
if not needle:
|
||||||
|
return True
|
||||||
|
name = str(record.get("name") or record.get("key_name") or "")
|
||||||
|
key = str(record.get("key") or record.get("api_key") or record.get("apiKey") or record.get("token") or "")
|
||||||
|
return name.startswith(needle) or name == needle or key == needle
|
||||||
|
|
||||||
|
def _hydrate_new_api_token_key(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out = dict(record)
|
||||||
|
key_value = _extract_key_value(out)
|
||||||
|
if key_value and "*" not in key_value:
|
||||||
|
out["key"] = key_value
|
||||||
|
out["masked_key"] = out.get("masked_key") or mask_secret(key_value)
|
||||||
|
return out
|
||||||
|
token_id = out.get("id")
|
||||||
|
if token_id is None:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
plaintext = self._get_new_api_token_key(token_id)
|
||||||
|
except Exception:
|
||||||
|
return out
|
||||||
|
out["key"] = plaintext
|
||||||
|
out["masked_key"] = mask_secret(plaintext)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _list_new_api_tokens(
|
||||||
|
self,
|
||||||
|
search: str = "",
|
||||||
|
group_id: str | int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
if search:
|
||||||
|
search_items, _ = self._request_new_api_token_list(
|
||||||
|
"/api/token/search",
|
||||||
|
{"keyword": search, "token": "", "p": 1, "size": 100},
|
||||||
|
)
|
||||||
|
normalized.extend(self._normalize_key_record(i) for i in search_items)
|
||||||
|
|
||||||
|
all_tokens = self._list_all_new_api_tokens()
|
||||||
|
seen_ids = {str(i.get("id")) for i in normalized if i.get("id") is not None}
|
||||||
|
for item in all_tokens:
|
||||||
|
item_id = item.get("id")
|
||||||
|
if item_id is not None and str(item_id) in seen_ids:
|
||||||
|
continue
|
||||||
|
if self._matches_new_api_token_search(item, search):
|
||||||
|
normalized.append(item)
|
||||||
|
if item_id is not None:
|
||||||
|
seen_ids.add(str(item_id))
|
||||||
|
|
||||||
if group_id is not None:
|
if group_id is not None:
|
||||||
gid = str(group_id)
|
gid = str(group_id)
|
||||||
normalized = [i for i in normalized if str(i.get("group_id") or i.get("group") or "") == gid]
|
normalized = [i for i in normalized if str(i.get("group_id") or i.get("group") or "") == gid]
|
||||||
return normalized
|
return [self._hydrate_new_api_token_key(i) for i in normalized]
|
||||||
|
|
||||||
def _get_new_api_token_key(self, token_id: str | int) -> str:
|
def _get_new_api_token_key(self, token_id: str | int) -> str:
|
||||||
payload = self._request("POST", f"/api/token/{token_id}/key")
|
payload = self._request("POST", f"/api/token/{token_id}/key")
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ from app.services import webhook_service
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
PRIORITY_BASE = 1
|
||||||
|
PRIORITY_STEP = 10
|
||||||
|
|
||||||
|
|
||||||
|
def priority_for_rate_rank(rank: int) -> int:
|
||||||
|
"""Convert a zero-based sorted rate rank to an account priority."""
|
||||||
|
return PRIORITY_BASE + rank * PRIORITY_STEP
|
||||||
|
|
||||||
|
|
||||||
def binding_sources(binding: WebsiteGroupBinding) -> list[dict[str, Any]]:
|
def binding_sources(binding: WebsiteGroupBinding) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
data = json.loads(binding.source_groups_json or "[]")
|
data = json.loads(binding.source_groups_json or "[]")
|
||||||
@@ -187,7 +196,7 @@ def build_rate_priority_map(db: Session, upstream_ids: set[int]) -> dict[str, in
|
|||||||
|
|
||||||
使用 (upstream_id, group_id) 复合键避免不同上游的同名分组互相覆盖。
|
使用 (upstream_id, group_id) 复合键避免不同上游的同名分组互相覆盖。
|
||||||
遍历所有涉及的上游的最新快照,收集分组的倍率,按倍率升序排列后赋值 priority。
|
遍历所有涉及的上游的最新快照,收集分组的倍率,按倍率升序排列后赋值 priority。
|
||||||
倍率最低的 priority=1,次低的 priority=2,以此类推。相同倍率的分组共享同一 priority。
|
倍率最低的 priority=1,次低的 priority=11,以此类推。相同倍率的分组共享同一 priority。
|
||||||
"""
|
"""
|
||||||
group_rates: dict[str, float] = {}
|
group_rates: dict[str, float] = {}
|
||||||
for uid in upstream_ids:
|
for uid in upstream_ids:
|
||||||
@@ -199,7 +208,7 @@ def build_rate_priority_map(db: Session, upstream_ids: set[int]) -> dict[str, in
|
|||||||
key = f"{uid}:{gid}"
|
key = f"{uid}:{gid}"
|
||||||
group_rates[key] = rate
|
group_rates[key] = rate
|
||||||
unique_rates = sorted(set(group_rates.values()))
|
unique_rates = sorted(set(group_rates.values()))
|
||||||
rate_to_priority = {rate: idx + 1 for idx, rate in enumerate(unique_rates)}
|
rate_to_priority = {rate: priority_for_rate_rank(idx) for idx, rate in enumerate(unique_rates)}
|
||||||
return {key: rate_to_priority[rate] for key, rate in group_rates.items()}
|
return {key: rate_to_priority[rate] for key, rate in group_rates.items()}
|
||||||
|
|
||||||
|
|
||||||
@@ -285,27 +294,30 @@ def _try_send_priority_webhook(
|
|||||||
logger.warning("account_priority_changed webhook failed for website %s: %s", wid, 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]:
|
def sync_account_priorities_for_upstream(
|
||||||
|
db: Session,
|
||||||
|
upstream_id: int,
|
||||||
|
website_id: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
"""上游倍率变化后,自动更新已导入下游账号的 priority。
|
"""上游倍率变化后,自动更新已导入下游账号的 priority。
|
||||||
|
|
||||||
只处理同一目标分组内有多个账号(存在竞争)的情况:
|
只处理同一目标分组内有多个账号(存在竞争)的情况:
|
||||||
- 竞争分组键:imported_target_group_id(老数据 fallback 到 group_id)
|
- 竞争分组键:imported_target_group_id(老数据 fallback 到 group_id)
|
||||||
- 同一竞争分组内按倍率升序排序,priority 从 1 开始(相同倍率共享)
|
- 同一竞争分组内按倍率升序排序,priority 从 1 开始,每档间隔 10(相同倍率共享)
|
||||||
- 单账号分组:完全跳过,不调用 update_account,不发通知
|
- 单账号分组:完全跳过,不调用 update_account,不发通知
|
||||||
- 无竞争分组:直接返回,不写日志,不发通知
|
- 无竞争分组:直接返回,不写日志,不发通知
|
||||||
"""
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
key_rows = (
|
key_query = db.query(UpstreamGeneratedKey).filter(
|
||||||
db.query(UpstreamGeneratedKey)
|
UpstreamGeneratedKey.upstream_id == upstream_id,
|
||||||
.filter(
|
UpstreamGeneratedKey.imported_website_id.isnot(None),
|
||||||
UpstreamGeneratedKey.upstream_id == upstream_id,
|
UpstreamGeneratedKey.imported_account_id.isnot(None),
|
||||||
UpstreamGeneratedKey.imported_website_id.isnot(None),
|
UpstreamGeneratedKey.status != "orphaned",
|
||||||
UpstreamGeneratedKey.imported_account_id.isnot(None),
|
|
||||||
UpstreamGeneratedKey.status != "orphaned",
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
if website_id is not None:
|
||||||
|
key_query = key_query.filter(UpstreamGeneratedKey.imported_website_id == website_id)
|
||||||
|
key_rows = key_query.all()
|
||||||
if not key_rows:
|
if not key_rows:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -387,7 +399,7 @@ def sync_account_priorities_for_upstream(db: Session, upstream_id: int) -> list[
|
|||||||
continue
|
continue
|
||||||
# 组内按倍率升序排序(倍率低 → priority 小 → 优先)
|
# 组内按倍率升序排序(倍率低 → priority 小 → 优先)
|
||||||
unique_rates = sorted(set(r for _, r in rated))
|
unique_rates = sorted(set(r for _, r in rated))
|
||||||
rate_to_prio = {rate: idx + 1 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, rate in rated:
|
||||||
priority_assignment[row.imported_account_id] = rate_to_prio[rate]
|
priority_assignment[row.imported_account_id] = rate_to_prio[rate]
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
- 竞争分组键 = imported_target_group_id or group_id(老数据 fallback)
|
- 竞争分组键 = imported_target_group_id or group_id(老数据 fallback)
|
||||||
- 只有同一竞争分组内账号数 > 1 时才更新 priority / 发通知
|
- 只有同一竞争分组内账号数 > 1 时才更新 priority / 发通知
|
||||||
- 不同分组各 1 个账号:不调用 update_account,不发通知
|
- 不同分组各 1 个账号:不调用 update_account,不发通知
|
||||||
- 同一目标分组多账号:组内按倍率升序独立排序,priority 从 1 开始
|
- 同一目标分组多账号:组内按倍率升序独立排序,priority 从 1 开始,每档间隔 10
|
||||||
- 两个目标分组各有多账号:彼此独立,每组内 priority 都从 1 开始
|
- 两个目标分组各有多账号:彼此独立,每组内 priority 都从 1 开始,每档间隔 10
|
||||||
- 老数据 imported_target_group_id=NULL:fallback group_id,不报错
|
- 老数据 imported_target_group_id=NULL:fallback group_id,不报错
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -164,9 +164,9 @@ def test_same_target_group_two_accounts_updated(db_session, monkeypatch):
|
|||||||
|
|
||||||
assert len(update_calls) == 2
|
assert len(update_calls) == 2
|
||||||
priority_map = {aid: data["priority"] for aid, data in update_calls}
|
priority_map = {aid: data["priority"] for aid, data in update_calls}
|
||||||
# G1 rate=1.0 → priority=1(低倍率优先);G2 rate=2.0 → priority=2
|
# G1 rate=1.0 → priority=1(低倍率优先);G2 rate=2.0 → priority=11
|
||||||
assert priority_map["A1"] == 1
|
assert priority_map["A1"] == 1
|
||||||
assert priority_map["A2"] == 2
|
assert priority_map["A2"] == 11
|
||||||
|
|
||||||
# 写了日志
|
# 写了日志
|
||||||
log = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == w.id).first()
|
log = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == w.id).first()
|
||||||
@@ -185,9 +185,9 @@ def test_two_target_groups_independent_priority(db_session, monkeypatch):
|
|||||||
|
|
||||||
_make_snapshot(db_session, u1.id, {
|
_make_snapshot(db_session, u1.id, {
|
||||||
"G1": 1.0, # → TG1 中排 priority=1
|
"G1": 1.0, # → TG1 中排 priority=1
|
||||||
"G2": 2.0, # → TG1 中排 priority=2
|
"G2": 2.0, # → TG1 中排 priority=11
|
||||||
"G3": 0.5, # → TG2 中排 priority=1
|
"G3": 0.5, # → TG2 中排 priority=1
|
||||||
"G4": 3.0, # → TG2 中排 priority=2
|
"G4": 3.0, # → TG2 中排 priority=11
|
||||||
})
|
})
|
||||||
|
|
||||||
# TG1: G1(1.0), G2(2.0)
|
# TG1: G1(1.0), G2(2.0)
|
||||||
@@ -208,12 +208,12 @@ def test_two_target_groups_independent_priority(db_session, monkeypatch):
|
|||||||
priority_map = {aid: data["priority"] for aid, data in update_calls}
|
priority_map = {aid: data["priority"] for aid, data in update_calls}
|
||||||
assert len(update_calls) == 4
|
assert len(update_calls) == 4
|
||||||
|
|
||||||
# TG1 内部:G1(1.0)→p1, G2(2.0)→p2
|
# TG1 内部:G1(1.0)→p1, G2(2.0)→p11
|
||||||
assert priority_map["A1"] == 1
|
assert priority_map["A1"] == 1
|
||||||
assert priority_map["A2"] == 2
|
assert priority_map["A2"] == 11
|
||||||
# TG2 内部:G3(0.5)→p1, G4(3.0)→p2(独立从 1 开始)
|
# TG2 内部:G3(0.5)→p1, G4(3.0)→p11(独立从 1 开始)
|
||||||
assert priority_map["A3"] == 1
|
assert priority_map["A3"] == 1
|
||||||
assert priority_map["A4"] == 2
|
assert priority_map["A4"] == 11
|
||||||
|
|
||||||
|
|
||||||
def test_old_data_null_target_group_fallback(db_session, monkeypatch):
|
def test_old_data_null_target_group_fallback(db_session, monkeypatch):
|
||||||
@@ -334,9 +334,9 @@ def test_partial_missing_rate_sufficient_accounts_still_updates(db_session, monk
|
|||||||
updated = {c[0]: c[1]["priority"] for c in update_calls}
|
updated = {c[0]: c[1]["priority"] for c in update_calls}
|
||||||
# A3 无快照 → 不参与排序,不被更新
|
# A3 无快照 → 不参与排序,不被更新
|
||||||
assert "A3" not in updated
|
assert "A3" not in updated
|
||||||
# A1(G1, rate=1.0) → priority=1;A2(G2, rate=2.0) → priority=2
|
# A1(G1, rate=1.0) → priority=1;A2(G2, rate=2.0) → priority=11
|
||||||
assert updated["A1"] == 1
|
assert updated["A1"] == 1
|
||||||
assert updated["A2"] == 2
|
assert updated["A2"] == 11
|
||||||
|
|
||||||
|
|
||||||
def test_priority_sync_log_structure(db_session, monkeypatch):
|
def test_priority_sync_log_structure(db_session, monkeypatch):
|
||||||
@@ -392,7 +392,7 @@ def test_priority_sync_cross_upstream_group(db_session):
|
|||||||
priority_map = build_rate_priority_map(db_session, {u1.id, u2.id})
|
priority_map = build_rate_priority_map(db_session, {u1.id, u2.id})
|
||||||
|
|
||||||
assert priority_map[f"{u1.id}:VIP"] == 1
|
assert priority_map[f"{u1.id}:VIP"] == 1
|
||||||
assert priority_map[f"{u2.id}:VIP"] == 2
|
assert priority_map[f"{u2.id}:VIP"] == 11
|
||||||
assert len(priority_map) == 2
|
assert len(priority_map) == 2
|
||||||
|
|
||||||
|
|
||||||
@@ -446,8 +446,46 @@ def test_import_auto_priority_by_rate(db_session, monkeypatch):
|
|||||||
import_upstream_keys_as_accounts(w.id, req, db_session)
|
import_upstream_keys_as_accounts(w.id, req, db_session)
|
||||||
|
|
||||||
assert len(created_accounts) == 2
|
assert len(created_accounts) == 2
|
||||||
# G2 rate=1.0 → priority 1;G1 rate=2.0 → priority 2
|
# G2 rate=1.0 → priority 1;G1 rate=2.0 → priority 11
|
||||||
p1 = next(a["priority"] for a in created_accounts if "G1" in a["name"])
|
p1 = next(a["priority"] for a in created_accounts if "G1" in a["name"])
|
||||||
p2 = next(a["priority"] for a in created_accounts if "G2" in a["name"])
|
p2 = next(a["priority"] for a in created_accounts if "G2" in a["name"])
|
||||||
assert p2 == 1
|
assert p2 == 1
|
||||||
assert p1 == 2
|
assert p1 == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_priority_endpoint_scopes_to_current_website(db_session, monkeypatch):
|
||||||
|
"""手动重排只影响当前网站,不误改同一上游导入到其他网站的账号。"""
|
||||||
|
from app.routers.websites import reorder_account_priorities
|
||||||
|
from app.schemas.website import ReorderPriorityRequest
|
||||||
|
|
||||||
|
w1 = Website(name="W1", base_url="http://w1", enabled=True,
|
||||||
|
auth_config_json="{}", timeout_seconds=30)
|
||||||
|
w2 = Website(name="W2", base_url="http://w2", enabled=True,
|
||||||
|
auth_config_json="{}", timeout_seconds=30)
|
||||||
|
u1 = Upstream(name="U1", base_url="http://u1")
|
||||||
|
db_session.add_all([w1, w2, u1])
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(w1); db_session.refresh(w2); db_session.refresh(u1)
|
||||||
|
|
||||||
|
_make_snapshot(db_session, u1.id, {"G1": 1.0, "G2": 2.0})
|
||||||
|
|
||||||
|
_make_key(db_session, u1.id, "G1", "K1", "V1", w1.id, "W1A1", imported_target_group_id="TG1")
|
||||||
|
_make_key(db_session, u1.id, "G2", "K2", "V2", w1.id, "W1A2", imported_target_group_id="TG1")
|
||||||
|
_make_key(db_session, u1.id, "G1", "K3", "V3", w2.id, "W2A1", imported_target_group_id="TG1")
|
||||||
|
_make_key(db_session, u1.id, "G2", "K4", "V4", w2.id, "W2A2", imported_target_group_id="TG1")
|
||||||
|
|
||||||
|
update_calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.website_sync.Sub2ApiWebsiteClient",
|
||||||
|
make_mock_client(update_calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = reorder_account_priorities(
|
||||||
|
w1.id,
|
||||||
|
ReorderPriorityRequest(upstream_id=u1.id),
|
||||||
|
db_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = {account_id: payload["priority"] for account_id, payload in update_calls}
|
||||||
|
assert response.success is True
|
||||||
|
assert updated == {"W1A1": 1, "W1A2": 11}
|
||||||
|
|||||||
@@ -637,6 +637,136 @@ def test_new_api_create_token_fetches_plaintext_key(monkeypatch):
|
|||||||
assert created_bodies[0]["expired_time"] > 0
|
assert created_bodies[0]["expired_time"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_api_list_tokens_uses_full_list_and_fetches_plaintext_when_search_misses():
|
||||||
|
"""New-API search 可能不匹配前缀;应拉完整 token 列表并按 id 回填明文。"""
|
||||||
|
from app.services.upstream_client import UpstreamClient
|
||||||
|
|
||||||
|
client = UpstreamClient(
|
||||||
|
base_url="http://newapi.local",
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="cookie",
|
||||||
|
auth_config={"cookie_string": "session=abc", "new_api_user": "7"},
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._payload = payload
|
||||||
|
self.cookies = {}
|
||||||
|
self.headers = {"content-type": "application/json"}
|
||||||
|
self.content = b"{}"
|
||||||
|
self.text = "{}"
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
class FakeHttpClient:
|
||||||
|
def request(self, method, url, **kwargs):
|
||||||
|
path = url.replace("http://newapi.local", "")
|
||||||
|
params = kwargs.get("params") or {}
|
||||||
|
if method == "GET" and path == "/api/token/search":
|
||||||
|
assert params["keyword"] == "SmartUp"
|
||||||
|
return FakeResponse({
|
||||||
|
"success": True,
|
||||||
|
"data": {"page": 1, "page_size": 100, "total": 0, "items": []},
|
||||||
|
})
|
||||||
|
if method == "GET" and path == "/api/token/":
|
||||||
|
return FakeResponse({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 100,
|
||||||
|
"total": 4,
|
||||||
|
"items": [
|
||||||
|
{"id": 447, "name": "SmartUp-4-gptpro-gpt pro", "group": "gpt pro", "key": "sk-XE2o********WWh"},
|
||||||
|
{"id": 446, "name": "SmartUp-4-gptplus-gpt plus", "group": "gpt plus", "key": "sk-JRi1********rtum"},
|
||||||
|
{"id": 445, "name": "SmartUp-4-claude特价kiro-claude 特价kiro", "group": "claude 特价kiro", "key": "sk-Aldb********08W2"},
|
||||||
|
{"id": 56, "name": "plus", "group": "gpt plus", "key": "sk-20cB********pEfE"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if method == "POST" and path == "/api/token/447/key":
|
||||||
|
return FakeResponse({"success": True, "data": {"key": "sk-gptpro-plain"}})
|
||||||
|
if method == "POST" and path == "/api/token/446/key":
|
||||||
|
return FakeResponse({"success": True, "data": {"key": "sk-gptplus-plain"}})
|
||||||
|
if method == "POST" and path == "/api/token/445/key":
|
||||||
|
return FakeResponse({"success": True, "data": {"key": "sk-claude-plain"}})
|
||||||
|
raise AssertionError(f"unexpected request {method} {path} {params}")
|
||||||
|
|
||||||
|
client._client = FakeHttpClient()
|
||||||
|
|
||||||
|
rows = client.list_api_keys(search="SmartUp", status="active")
|
||||||
|
|
||||||
|
assert [row["id"] for row in rows] == [447, 446, 445]
|
||||||
|
assert [row["group_id"] for row in rows] == ["gpt pro", "gpt plus", "claude 特价kiro"]
|
||||||
|
assert [row["key"] for row in rows] == ["sk-gptpro-plain", "sk-gptplus-plain", "sk-claude-plain"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_keys_persists_new_api_tokens_with_plaintext(db_session, monkeypatch):
|
||||||
|
"""generated-keys 应把 New-API 远端 token 回填成本地可导入记录。"""
|
||||||
|
from app.routers import upstreams as upstreams_router
|
||||||
|
from app.models.upstream_key import UpstreamGeneratedKey
|
||||||
|
|
||||||
|
upstream = Upstream(
|
||||||
|
name="NewAPI",
|
||||||
|
base_url="http://newapi.local",
|
||||||
|
api_prefix="",
|
||||||
|
auth_type="cookie",
|
||||||
|
auth_config_json=json.dumps({"cookie_string": "session=abc", "new_api_user": "7"}),
|
||||||
|
groups_endpoint="/api/user/self/groups",
|
||||||
|
rate_endpoint="/api/user/self/groups",
|
||||||
|
)
|
||||||
|
db_session.add(upstream)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(upstream)
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_api_keys(self, search="", status="active"):
|
||||||
|
assert search == "SmartUp"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 447,
|
||||||
|
"name": "SmartUp-4-gptpro-gpt pro",
|
||||||
|
"group": "gpt pro",
|
||||||
|
"group_id": "gpt pro",
|
||||||
|
"key": "sk-gptpro-plain",
|
||||||
|
"masked_key": "sk-g********lain",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(upstreams_router, "UpstreamClient", FakeClient)
|
||||||
|
|
||||||
|
response = upstreams_router.list_generated_keys(upstream.id, db_session, object())
|
||||||
|
|
||||||
|
assert len(response) == 1
|
||||||
|
assert response[0].has_key_value is True
|
||||||
|
assert response[0].id is not None
|
||||||
|
assert response[0].key_name == "SmartUp-4-gptpro-gpt pro"
|
||||||
|
|
||||||
|
row = db_session.query(UpstreamGeneratedKey).filter(
|
||||||
|
UpstreamGeneratedKey.upstream_id == upstream.id,
|
||||||
|
UpstreamGeneratedKey.key_id == "447",
|
||||||
|
).one()
|
||||||
|
assert row.group_id == "gpt pro"
|
||||||
|
assert row.group_name == "gpt pro"
|
||||||
|
assert row.key_value == "sk-gptpro-plain"
|
||||||
|
assert row.managed_prefix == "SmartUp"
|
||||||
|
|
||||||
|
|
||||||
def test_generate_keys_allows_new_api_user_upstream(db_session, monkeypatch):
|
def test_generate_keys_allows_new_api_user_upstream(db_session, monkeypatch):
|
||||||
"""New-API 普通账号上游应允许按分组生成 token。"""
|
"""New-API 普通账号上游应允许按分组生成 token。"""
|
||||||
from app.routers import upstreams as upstreams_router
|
from app.routers import upstreams as upstreams_router
|
||||||
|
|||||||
@@ -291,6 +291,16 @@ export interface ImportAccountItem {
|
|||||||
raw: Record<string, any>
|
raw: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReorderPriorityItem {
|
||||||
|
account_id: string | null
|
||||||
|
group_id: string
|
||||||
|
upstream_id: number
|
||||||
|
old_priority: number | null
|
||||||
|
new_priority: number | null
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface WebsiteBatchSyncResponse {
|
export interface WebsiteBatchSyncResponse {
|
||||||
total: number
|
total: number
|
||||||
success: number
|
success: number
|
||||||
@@ -311,6 +321,8 @@ export const websitesApi = {
|
|||||||
api.post<{ success: boolean; message: string; items: ImportGroupItem[] }>(`/api/websites/${id}/groups/import-from-upstream/${upstreamId}`, data),
|
api.post<{ success: boolean; message: string; items: ImportGroupItem[] }>(`/api/websites/${id}/groups/import-from-upstream/${upstreamId}`, data),
|
||||||
syncImportedUpstreamKeys: (id: number, data: { upstream_id: number }) =>
|
syncImportedUpstreamKeys: (id: number, data: { upstream_id: number }) =>
|
||||||
api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/sync-imported-upstream-keys`, data),
|
api.post<{ success: boolean; message: string; items: ImportAccountItem[] }>(`/api/websites/${id}/accounts/sync-imported-upstream-keys`, data),
|
||||||
|
reorderAccountPriorities: (id: number, data: { upstream_id: number }) =>
|
||||||
|
api.post<{ success: boolean; message: string; items: ReorderPriorityItem[] }>(`/api/websites/${id}/accounts/reorder-priority`, data),
|
||||||
importAccountsFromUpstreamKeys: (id: number, data: {
|
importAccountsFromUpstreamKeys: (id: number, data: {
|
||||||
upstream_key_ids: number[]
|
upstream_key_ids: number[]
|
||||||
target_group_map: Record<string, string>
|
target_group_map: Record<string, string>
|
||||||
|
|||||||
@@ -324,6 +324,16 @@
|
|||||||
<el-button size="small" text :loading="syncingImportStatus" @click="syncImportStatus">
|
<el-button size="small" text :loading="syncingImportStatus" @click="syncImportStatus">
|
||||||
<el-icon><Refresh /></el-icon>刷新导入状态
|
<el-icon><Refresh /></el-icon>刷新导入状态
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
:loading="reorderingPriorities"
|
||||||
|
:disabled="!importAccountsForm.website_id || !importAccountsForm.upstream_id"
|
||||||
|
@click="reorderImportPriorities"
|
||||||
|
>
|
||||||
|
<el-icon><Sort /></el-icon>重排优先级
|
||||||
|
</el-button>
|
||||||
<span v-if="importSyncStatus" style="font-size:12px;color:var(--text-muted);margin-left:8px">
|
<span v-if="importSyncStatus" style="font-size:12px;color:var(--text-muted);margin-left:8px">
|
||||||
已校验已导入标记 {{ importSyncStatus.total }} 个,清除 {{ importSyncStatus.cleared }} 个失效标记
|
已校验已导入标记 {{ importSyncStatus.total }} 个,清除 {{ importSyncStatus.cleared }} 个失效标记
|
||||||
</span>
|
</span>
|
||||||
@@ -353,7 +363,7 @@
|
|||||||
<el-form-item label="优先级">
|
<el-form-item label="优先级">
|
||||||
<template #label>
|
<template #label>
|
||||||
<span>优先级</span>
|
<span>优先级</span>
|
||||||
<el-tooltip content="按倍率自动分配优先级:倍率最低的上游分组优先级最高(priority=1),依次递增" placement="top" :show-after="300">
|
<el-tooltip content="按倍率自动分配优先级:倍率最低的上游分组优先级最高(priority=1),后续按 10 递增:11、21..." placement="top" :show-after="300">
|
||||||
<el-icon style="margin-left:4px;vertical-align:middle;color:var(--text-muted)"><WarningFilled /></el-icon>
|
<el-icon style="margin-left:4px;vertical-align:middle;color:var(--text-muted)"><WarningFilled /></el-icon>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
@@ -447,7 +457,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import type { FormInstance } from 'element-plus'
|
import type { FormInstance } from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { ArrowDown, Delete, Edit, Plus, Grid, Connection, Link, Upload, Key, Refresh, WarningFilled } from '@element-plus/icons-vue'
|
import { ArrowDown, Delete, Edit, Plus, Grid, Connection, Link, Upload, Key, Refresh, Sort, WarningFilled } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
upstreamsApi,
|
upstreamsApi,
|
||||||
websitesApi,
|
websitesApi,
|
||||||
@@ -485,6 +495,7 @@ const importingAccounts = ref(false)
|
|||||||
const generatedKeyLoading = ref(false)
|
const generatedKeyLoading = ref(false)
|
||||||
const importSyncStatus = ref<{ total: number; cleared: number; failed: number } | null>(null)
|
const importSyncStatus = ref<{ total: number; cleared: number; failed: number } | null>(null)
|
||||||
const syncingImportStatus = ref(false)
|
const syncingImportStatus = ref(false)
|
||||||
|
const reorderingPriorities = ref(false)
|
||||||
|
|
||||||
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
|
||||||
const algorithmLabel = (s: string) => ({ max_plus_percent: '最高倍率', average_plus_percent: '平均倍率', min_plus_percent: '最低倍率' }[s] || s)
|
const algorithmLabel = (s: string) => ({ max_plus_percent: '最高倍率', average_plus_percent: '平均倍率', min_plus_percent: '最低倍率' }[s] || s)
|
||||||
@@ -752,6 +763,29 @@ async function syncImportStatus() {
|
|||||||
return reloaded
|
return reloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reorderImportPriorities() {
|
||||||
|
const websiteId = importAccountsForm.value.website_id
|
||||||
|
const upstreamId = importAccountsForm.value.upstream_id
|
||||||
|
if (!websiteId || !upstreamId) {
|
||||||
|
ElMessage.error('请选择目标网站和来源上游')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reorderingPriorities.value = true
|
||||||
|
try {
|
||||||
|
const res = await websitesApi.reorderAccountPriorities(websiteId, { upstream_id: upstreamId })
|
||||||
|
if (importAccountsForm.value.website_id !== websiteId || importAccountsForm.value.upstream_id !== upstreamId) return
|
||||||
|
ElMessage[res.data.success ? 'success' : 'warning'](res.data.message)
|
||||||
|
await Promise.all([
|
||||||
|
loadImportGeneratedKeys(upstreamId),
|
||||||
|
loadLogs(),
|
||||||
|
])
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.detail || '重排优先级失败')
|
||||||
|
} finally {
|
||||||
|
reorderingPriorities.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadImportGeneratedKeys(upstreamId: number) {
|
async function loadImportGeneratedKeys(upstreamId: number) {
|
||||||
importGeneratedKeys.value = []
|
importGeneratedKeys.value = []
|
||||||
if (!upstreamId) return
|
if (!upstreamId) return
|
||||||
|
|||||||
Reference in New Issue
Block a user