feat: support upstream token refresh

This commit is contained in:
liumangmang
2026-06-30 14:34:40 +08:00
parent 2d1dcb8f9f
commit c5bb63cf82
15 changed files with 895 additions and 140 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ class Upstream(Base):
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
base_url: Mapped[str] = mapped_column(String(512), nullable=False) base_url: Mapped[str] = mapped_column(String(512), nullable=False)
api_prefix: Mapped[str] = mapped_column(String(128), default="/api/v1") api_prefix: Mapped[str] = mapped_column(String(128), default="/api/v1")
# none | bearer | api_key | login_password # none | bearer | new_api_token | nox_token | api_key | cookie | login_password
auth_type: Mapped[str] = mapped_column(String(32), default="login_password") auth_type: Mapped[str] = mapped_column(String(32), default="login_password")
# JSON: {"email":"..","password":".."} or {"token":".."} etc. # JSON: {"email":"..","password":".."} or {"token":".."} etc.
auth_config_json: Mapped[str] = mapped_column(Text, default="{}") auth_config_json: Mapped[str] = mapped_column(Text, default="{}")
+1 -1
View File
@@ -15,7 +15,7 @@ from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"]) router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"}) SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value", "refresh_token"})
class CaptureExtractResponse(BaseModel): class CaptureExtractResponse(BaseModel):
+34 -36
View File
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import SessionLocal, get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey from app.models.upstream_key import UpstreamGeneratedKey
@@ -25,6 +25,7 @@ from app.schemas.upstream import (
UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse, UpstreamBatchActionItem, UpstreamBatchActionSummary, UpstreamBatchActionResponse,
) )
from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot, mask_secret, _extract_key_value from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot, mask_secret, _extract_key_value
from app.services.auth_config import MASK, mask_auth_config, normalize_auth_config
from app.services.snapshot_service import diff_snapshots from app.services.snapshot_service import diff_snapshots
from app.services import scheduler as sched_svc from app.services import scheduler as sched_svc
from app.services import webhook_service from app.services import webhook_service
@@ -33,20 +34,6 @@ from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/upstreams", tags=["upstreams"]) router = APIRouter(prefix="/api/upstreams", tags=["upstreams"])
MASK = "***"
SECRET_KEYS = {"password", "token", "key", "secret"}
AUTH_CONFIG_ALLOWED_KEYS = {
"none": set(),
"bearer": {"token"},
"nox_token": {"token", "user_id", "new_api_user", "provider"},
"api_key": {"key", "header"},
"cookie": {"cookie_string", "new_api_user", "user_id", "provider"},
"login_password": {"email", "password", "login_path", "username_field", "new_api_user", "user_id"},
}
def _group_id(group: dict) -> str: def _group_id(group: dict) -> str:
for key in ("id", "group_id", "groupId"): for key in ("id", "group_id", "groupId"):
value = group.get(key) value = group.get(key)
@@ -80,21 +67,23 @@ def _key_response(row: UpstreamGeneratedKey, include_value: bool = False) -> Gen
) )
def _mask_auth_config(auth_type: str, cfg: dict) -> dict: def _persist_auth_config_update(db: Session, upstream: Upstream, updated_config: dict[str, Any]) -> None:
masked = {} config_json = json.dumps(
for k, v in cfg.items(): normalize_auth_config(upstream.auth_type, updated_config),
if k.lower() in SECRET_KEYS and v: ensure_ascii=False,
masked[k] = MASK )
else: write_db = SessionLocal()
masked[k] = v updated_at = datetime.now(timezone.utc)
return masked try:
row = write_db.query(Upstream).filter(Upstream.id == upstream.id).first()
if row:
def _normalize_auth_config(auth_type: str, cfg: dict) -> dict: row.auth_config_json = config_json
allowed = AUTH_CONFIG_ALLOWED_KEYS.get(auth_type) row.updated_at = updated_at
if allowed is None: write_db.commit()
return cfg finally:
return {k: v for k, v in cfg.items() if k in allowed} write_db.close()
upstream.auth_config_json = config_json
upstream.updated_at = updated_at
def _extract_plaintext_key(payload: dict[str, Any] | None) -> str: def _extract_plaintext_key(payload: dict[str, Any] | None) -> str:
@@ -134,7 +123,7 @@ def _to_response(u: Upstream) -> UpstreamResponse:
base_url=u.base_url, base_url=u.base_url,
api_prefix=u.api_prefix, api_prefix=u.api_prefix,
auth_type=u.auth_type, auth_type=u.auth_type,
auth_config_masked=_mask_auth_config(u.auth_type, cfg), auth_config_masked=mask_auth_config(u.auth_type, cfg),
rate_endpoint=u.rate_endpoint, rate_endpoint=u.rate_endpoint,
groups_endpoint=u.groups_endpoint, groups_endpoint=u.groups_endpoint,
enabled=u.enabled, enabled=u.enabled,
@@ -205,6 +194,7 @@ def list_generated_keys(uid: int, db: Session = Depends(get_db), _=Depends(get_c
auth_type=upstream.auth_type, auth_type=upstream.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(upstream.timeout_seconds), timeout=float(upstream.timeout_seconds),
on_auth_config_update=lambda updated: _persist_auth_config_update(db, upstream, updated),
) as client: ) as client:
client.login() client.login()
for prefix in website_sync._fetch_remote_managed_prefixes(db, uid): for prefix in website_sync._fetch_remote_managed_prefixes(db, uid):
@@ -343,8 +333,11 @@ def _is_nox_api_upstream(upstream: Upstream) -> bool:
and upstream.groups_endpoint == "/api/user/self/groups" and upstream.groups_endpoint == "/api/user/self/groups"
and ( and (
upstream.auth_type == "nox_token" upstream.auth_type == "nox_token"
or auth_config.get("login_path") == "/api/user/login" or auth_config.get("provider") == "nox-api"
or bool(auth_config.get("user_id")) or (
auth_config.get("provider") not in {"new-api", "new-api-user"}
and auth_config.get("login_path") == "/api/user/login"
)
) )
) )
@@ -354,7 +347,9 @@ def _is_new_api_user_upstream(upstream: Upstream) -> bool:
return ( return (
upstream.api_prefix.strip("/") == "" upstream.api_prefix.strip("/") == ""
and ( and (
bool(auth_config.get("new_api_user")) upstream.auth_type == "new_api_token"
or auth_config.get("provider") in {"new-api", "new-api-user"}
or bool(auth_config.get("new_api_user"))
or ( or (
upstream.groups_endpoint == "/api/user/self/groups" upstream.groups_endpoint == "/api/user/self/groups"
and auth_config.get("login_path") != "/api/user/login" and auth_config.get("login_path") != "/api/user/login"
@@ -540,6 +535,7 @@ def generate_keys_by_groups(
auth_type=u.auth_type, auth_type=u.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(u.timeout_seconds), timeout=float(u.timeout_seconds),
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
) as client: ) as client:
try: try:
client.login() client.login()
@@ -581,6 +577,7 @@ def _test_upstream_core(db: Session, u: Upstream) -> UpstreamBatchActionItem:
auth_type=u.auth_type, auth_type=u.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(u.timeout_seconds), timeout=float(u.timeout_seconds),
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
) as client: ) as client:
client.login() client.login()
groups = client.get_available_groups(u.groups_endpoint) groups = client.get_available_groups(u.groups_endpoint)
@@ -620,6 +617,7 @@ def _check_now_core(db: Session, u: Upstream) -> tuple[str, bool]:
auth_type=u.auth_type, auth_type=u.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(u.timeout_seconds), timeout=float(u.timeout_seconds),
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
) as client: ) as client:
client.login() client.login()
groups = client.get_available_groups(u.groups_endpoint) groups = client.get_available_groups(u.groups_endpoint)
@@ -802,7 +800,7 @@ def create_upstream(
base_url=body.base_url.rstrip("/"), base_url=body.base_url.rstrip("/"),
api_prefix=body.api_prefix, api_prefix=body.api_prefix,
auth_type=body.auth_type, auth_type=body.auth_type,
auth_config_json=json.dumps(_normalize_auth_config(body.auth_type, body.auth_config), ensure_ascii=False), auth_config_json=json.dumps(normalize_auth_config(body.auth_type, body.auth_config), ensure_ascii=False),
rate_endpoint=body.rate_endpoint, rate_endpoint=body.rate_endpoint,
groups_endpoint=body.groups_endpoint, groups_endpoint=body.groups_endpoint,
enabled=body.enabled, enabled=body.enabled,
@@ -847,7 +845,7 @@ def update_upstream(
if v != MASK: # don't overwrite with mask placeholder if v != MASK: # don't overwrite with mask placeholder
existing[k] = v existing[k] = v
next_auth_type = data.get("auth_type", u.auth_type) next_auth_type = data.get("auth_type", u.auth_type)
u.auth_config_json = json.dumps(_normalize_auth_config(next_auth_type, existing), ensure_ascii=False) u.auth_config_json = json.dumps(normalize_auth_config(next_auth_type, existing), ensure_ascii=False)
if "base_url" in data: if "base_url" in data:
data["base_url"] = data["base_url"].rstrip("/") data["base_url"] = data["base_url"].rstrip("/")
for k, v in data.items(): for k, v in data.items():
+5 -1
View File
@@ -5,6 +5,9 @@ from pydantic import BaseModel, Field
class AuthConfigBearer(BaseModel): class AuthConfigBearer(BaseModel):
token: str = "" token: str = ""
refresh_token: str = ""
expires_in: Optional[int] = None
token_expires_at: Optional[int] = None
class AuthConfigApiKey(BaseModel): class AuthConfigApiKey(BaseModel):
@@ -16,13 +19,14 @@ class AuthConfigLoginPassword(BaseModel):
email: str = "" email: str = ""
password: str = "" password: str = ""
login_path: str = "/auth/login" login_path: str = "/auth/login"
username_field: str = "email"
class UpstreamCreate(BaseModel): class UpstreamCreate(BaseModel):
name: str name: str
base_url: str base_url: str
api_prefix: str = "/api/v1" api_prefix: str = "/api/v1"
auth_type: str = "login_password" # none | bearer | api_key | login_password auth_type: str = "login_password" # none | bearer | new_api_token | nox_token | api_key | cookie | login_password
auth_config: dict[str, Any] = {} auth_config: dict[str, Any] = {}
rate_endpoint: str = "/groups/rates" rate_endpoint: str = "/groups/rates"
groups_endpoint: str = "/groups/available" groups_endpoint: str = "/groups/available"
+43 -6
View File
@@ -9,10 +9,12 @@ from urllib.parse import urlparse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Keys likely to contain auth tokens in storage # Keys likely to contain auth tokens in storage
TOKEN_KEYS = frozenset({ ACCESS_TOKEN_KEYS = frozenset({
"token", "access_token", "accessToken", "jwt", "auth_token", "authToken", "token", "access_token", "accesstoken", "jwt", "auth_token", "authtoken",
"refresh_token", "refreshToken", "id_token", "session_token", "id_token", "idtoken", "session_token", "sessiontoken",
}) })
REFRESH_TOKEN_KEYS = frozenset({"refresh_token", "refreshtoken"})
TOKEN_KEYS = ACCESS_TOKEN_KEYS | REFRESH_TOKEN_KEYS
SECRET_KEYS = frozenset({ SECRET_KEYS = frozenset({
"secret", "api_key", "apiKey", "apikey", "secret", "api_key", "apiKey", "apikey",
}) })
@@ -116,6 +118,27 @@ def _curate_candidates(
extra=bundle_extra, extra=bundle_extra,
) )
# 0.5. Sub2API browser sessions keep access token and refresh token separately.
storage_sources = [("localStorage", local_storage), ("sessionStorage", session_storage)]
access_entry = _find_storage_token_entry(storage_sources, ACCESS_TOKEN_KEYS)
refresh_entry = _find_storage_token_entry(storage_sources, REFRESH_TOKEN_KEYS)
if access_entry and refresh_entry:
access_source, access_value = access_entry
refresh_source, refresh_value = refresh_entry
_add(
candidates,
"bearer_token",
f"{access_source}+{refresh_source}",
access_value,
_preview(access_value),
"Sub2API Bearer + Refresh Token",
96,
extra={
"refresh_token": refresh_value,
"refresh_token_preview": _preview(refresh_value),
},
)
# 1. CDP-captured network headers (high confidence) # 1. CDP-captured network headers (high confidence)
seen = set() seen = set()
for h in auth_headers: for h in auth_headers:
@@ -141,9 +164,10 @@ def _curate_candidates(
if not isinstance(val, str) or not val: if not isinstance(val, str) or not val:
continue continue
key_lower = key.lower() key_lower = key.lower()
is_refresh_key = any(k in key_lower for k in REFRESH_TOKEN_KEYS)
# Explicit auth-named keys # Explicit auth-named keys
if any(k in key_lower for k in TOKEN_KEYS): if any(k in key_lower for k in TOKEN_KEYS) and not is_refresh_key:
preview = _preview(val) preview = _preview(val)
score = 85 if "token" in key_lower and val.count(".") >= 2 else 75 score = 85 if "token" in key_lower and val.count(".") >= 2 else 75
_add(candidates, "bearer_token", f"{store_name}.{key}", val, preview, _add(candidates, "bearer_token", f"{store_name}.{key}", val, preview,
@@ -153,14 +177,14 @@ def _curate_candidates(
f"{store_name}.{key}", 70) f"{store_name}.{key}", 70)
# Looks like a JWT (xx.yy.zz format) # Looks like a JWT (xx.yy.zz format)
if val.count(".") >= 2 and 20 < len(val) < 5000: if not is_refresh_key and val.count(".") >= 2 and 20 < len(val) < 5000:
if val not in seen: if val not in seen:
seen.add(val) seen.add(val)
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val), _add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
f"{store_name}.{key} (JWT)", 80) f"{store_name}.{key} (JWT)", 80)
# sk-xxx API key pattern # sk-xxx API key pattern
if val.startswith("sk-") and len(val) > 10: if not is_refresh_key and val.startswith("sk-") and len(val) > 10:
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val), _add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
f"{store_name}.{key} (API Key)", 90) f"{store_name}.{key} (API Key)", 90)
@@ -198,6 +222,19 @@ def _find_user_header(headers: list[dict[str, str]]) -> str:
return "" return ""
def _find_storage_token_entry(
stores: list[tuple[str, dict[str, str]]],
key_names: frozenset[str],
) -> tuple[str, str] | None:
for store_name, store in stores:
for key, value in store.items():
if key.lower() not in key_names:
continue
if isinstance(value, str) and value.strip():
return f"{store_name}.{key}", value.strip()
return None
def _find_storage_value(*stores: dict[str, str], key: str) -> str: def _find_storage_value(*stores: dict[str, str], key: str) -> str:
for store in stores: for store in stores:
value = store.get(key) value = store.get(key)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
MASK = "***"
SECRET_KEYS = {"password", "token", "refresh_token", "access_token", "auth_token", "key", "secret"}
AUTH_CONFIG_ALLOWED_KEYS = {
"none": set(),
"bearer": {"token", "refresh_token", "expires_in", "token_expires_at"},
"new_api_token": {"token", "user_id", "new_api_user", "provider"},
"nox_token": {"token", "user_id", "new_api_user", "provider"},
"api_key": {"key", "header"},
"cookie": {"cookie_string", "new_api_user", "user_id", "provider"},
"login_password": {
"email", "password", "login_path", "username_field", "new_api_user", "user_id", "provider",
"token", "refresh_token", "expires_in", "token_expires_at",
},
}
def mask_auth_config(auth_type: str, cfg: dict) -> dict:
masked = {}
for k, v in cfg.items():
if k.lower() in SECRET_KEYS and v:
masked[k] = MASK
else:
masked[k] = v
return masked
def normalize_auth_config(auth_type: str, cfg: dict) -> dict:
allowed = AUTH_CONFIG_ALLOWED_KEYS.get(auth_type)
if allowed is None:
return cfg
return {k: v for k, v in cfg.items() if k in allowed}
+23
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -12,6 +13,7 @@ from sqlalchemy.orm import Session
from app.database import SessionLocal from app.database import SessionLocal
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamRateSnapshot
from app.services.auth_config import normalize_auth_config
from app.services.upstream_client import UpstreamClient, build_snapshot from app.services.upstream_client import UpstreamClient, build_snapshot
from app.services.snapshot_service import diff_snapshots, prune_snapshots from app.services.snapshot_service import diff_snapshots, prune_snapshots
from app.services import webhook_service from app.services import webhook_service
@@ -27,6 +29,25 @@ def get_scheduler() -> BackgroundScheduler:
return _scheduler return _scheduler
def _persist_auth_config_update(upstream: Upstream, updated_config: dict[str, Any]) -> None:
config_json = json.dumps(
normalize_auth_config(upstream.auth_type, updated_config),
ensure_ascii=False,
)
updated_at = datetime.now(timezone.utc)
db = SessionLocal()
try:
row = db.query(Upstream).filter(Upstream.id == upstream.id).first()
if row:
row.auth_config_json = config_json
row.updated_at = updated_at
db.commit()
upstream.auth_config_json = row.auth_config_json
upstream.updated_at = updated_at
finally:
db.close()
def _check_upstream(upstream_id: int) -> None: def _check_upstream(upstream_id: int) -> None:
"""Full upstream check executed by scheduler (runs in thread). """Full upstream check executed by scheduler (runs in thread).
@@ -55,6 +76,7 @@ def _check_upstream(upstream_id: int) -> None:
auth_type=upstream.auth_type, auth_type=upstream.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(upstream.timeout_seconds), timeout=float(upstream.timeout_seconds),
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
) as client: ) as client:
try: try:
client.login() client.login()
@@ -235,6 +257,7 @@ def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at:
auth_type=upstream.auth_type, auth_type=upstream.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(upstream.timeout_seconds), timeout=float(upstream.timeout_seconds),
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
) as client: ) as client:
client.login() client.login()
remote_key_ids = website_sync._fetch_remote_managed_key_ids(db, client, upstream_id) remote_key_ids = website_sync._fetch_remote_managed_key_ids(db, client, upstream_id)
+158 -18
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import time import time
from typing import Any, Optional from typing import Any, Callable, Optional
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
@@ -32,6 +32,38 @@ def _find_token(value: Any) -> str:
return "" return ""
def _find_refresh_token(value: Any) -> str:
if isinstance(value, dict):
for key in ("refresh_token", "refreshToken"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate:
return candidate
for key in ("data", "result", "user", "session"):
tok = _find_refresh_token(value.get(key))
if tok:
return tok
return ""
def _find_expires_in(value: Any) -> int | None:
if isinstance(value, dict):
for key in ("expires_in", "expiresIn"):
candidate = value.get(key)
if candidate is None:
continue
try:
expires_in = int(float(candidate))
except (TypeError, ValueError):
continue
if expires_in > 0:
return expires_in
for key in ("data", "result", "user", "session"):
expires_in = _find_expires_in(value.get(key))
if expires_in is not None:
return expires_in
return None
def _clean_auth_header_value(value: Any, field_name: str) -> str: def _clean_auth_header_value(value: Any, field_name: str) -> str:
text = str(value or "").strip() text = str(value or "").strip()
if not text: if not text:
@@ -289,12 +321,14 @@ class UpstreamClient:
auth_type: str, auth_type: str,
auth_config: dict[str, Any], auth_config: dict[str, Any],
timeout: float = 30.0, timeout: float = 30.0,
on_auth_config_update: Callable[[dict[str, Any]], None] | None = None,
) -> None: ) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.api_prefix = api_prefix.strip("/") self.api_prefix = api_prefix.strip("/")
self.auth_type = auth_type self.auth_type = auth_type
self.auth_config = auth_config self.auth_config = auth_config
self.timeout = timeout self.timeout = timeout
self.on_auth_config_update = on_auth_config_update
self._token: str = "" self._token: str = ""
self._cookies: dict[str, str] = {} self._cookies: dict[str, str] = {}
self._new_api_user: str = "" self._new_api_user: str = ""
@@ -321,6 +355,7 @@ class UpstreamClient:
bool(self.auth_config.get("new_api_user")) bool(self.auth_config.get("new_api_user"))
or login_path == "/api/user/login" or login_path == "/api/user/login"
or self.auth_type == "cookie" or self.auth_type == "cookie"
or self.auth_type == "new_api_token"
or self.auth_type == "nox_token" or self.auth_type == "nox_token"
) )
) )
@@ -349,6 +384,13 @@ class UpstreamClient:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
if user_id: if user_id:
headers["Nox-Api-User"] = user_id headers["Nox-Api-User"] = user_id
elif self.auth_type == "new_api_token":
token = _clean_auth_header_value(self.auth_config.get("token", ""), "New-API access token")
user_id = self._user_header_value()
if token:
headers["Authorization"] = f"Bearer {token}"
if user_id:
headers["New-Api-User"] = user_id
elif self.auth_type == "api_key": elif self.auth_type == "api_key":
key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key") key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key")
header = self.auth_config.get("header", "Authorization") header = self.auth_config.get("header", "Authorization")
@@ -371,28 +413,117 @@ class UpstreamClient:
headers["Nox-Api-User"] = self._new_api_user headers["Nox-Api-User"] = self._new_api_user
return headers return headers
def _is_sub2api_bearer(self) -> bool:
return self.auth_type == "bearer" and self.api_prefix == "api/v1"
def _remember_auth_tokens(
self,
token: str = "",
refresh_token: str = "",
expires_in: int | None = None,
) -> None:
changed = False
if token and self.auth_config.get("token") != token:
self.auth_config["token"] = token
changed = True
if refresh_token and self.auth_config.get("refresh_token") != refresh_token:
self.auth_config["refresh_token"] = refresh_token
changed = True
if expires_in is not None:
if self.auth_config.get("expires_in") != expires_in:
self.auth_config["expires_in"] = expires_in
changed = True
token_expires_at = int(time.time()) + expires_in
if self.auth_config.get("token_expires_at") != token_expires_at:
self.auth_config["token_expires_at"] = token_expires_at
changed = True
if changed and self.on_auth_config_update:
self.on_auth_config_update(dict(self.auth_config))
def _refresh_sub2api_bearer_token(self) -> bool:
if not self._is_sub2api_bearer():
return False
refresh_token = str(self.auth_config.get("refresh_token") or "").strip()
if not refresh_token:
return False
try:
resp = self._client.request(
"POST",
self._url("/auth/refresh"),
json={"refresh_token": refresh_token},
headers=self._headers(auth=False),
cookies=self._cookies,
)
self._cookies.update(dict(resp.cookies))
resp.raise_for_status()
payload = resp.json() if resp.content else {}
except Exception:
return False
token = _find_token(payload)
if not token:
return False
self._remember_auth_tokens(
token=token,
refresh_token=_find_refresh_token(payload) or refresh_token,
expires_in=_find_expires_in(payload),
)
return True
def _send_request(
self,
method: str,
url: str,
auth: bool = True,
allow_refresh: bool = True,
**kwargs: Any,
) -> httpx.Response:
resp = self._client.request(
method,
url,
headers=self._headers(auth),
cookies=self._cookies,
**kwargs,
)
self._cookies.update(dict(resp.cookies))
if (
getattr(resp, "status_code", None) == 401
and auth
and allow_refresh
and self._is_sub2api_bearer()
and self._refresh_sub2api_bearer_token()
):
resp = self._client.request(
method,
url,
headers=self._headers(auth),
cookies=self._cookies,
**kwargs,
)
self._cookies.update(dict(resp.cookies))
return resp
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any: def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
if auth and self.auth_type == "cookie" and "user/self" in path and not self.auth_config.get("new_api_user"): if auth and self.auth_type == "cookie" and "user/self" in path and not self._user_header_value():
raise UpstreamError("New-API user endpoint requires New-Api-User; re-extract the session cookie after login and save the upstream") raise UpstreamError("New-API user endpoint requires New-Api-User; re-extract the session cookie after login and save the upstream")
if auth and self.auth_type == "new_api_token" and "user/self" in path and not self._user_header_value():
raise UpstreamError("New-API endpoint requires New-Api-User; please fill user id and retry")
if auth and self.auth_type == "nox_token" and "user/self" in path and not self._user_header_value(): if auth and self.auth_type == "nox_token" and "user/self" in path and not self._user_header_value():
raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry") raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry")
url = self._url(path) url = self._url(path)
if body is not None: if body is not None:
resp = self._client.request( resp = self._send_request(
method, method,
url, url,
json=body, json=body,
headers=self._headers(auth), auth=auth,
cookies=self._cookies,
) )
else: else:
resp = self._client.request( resp = self._send_request(
method, method,
url, url,
headers=self._headers(auth), auth=auth,
cookies=self._cookies,
) )
self._cookies.update(dict(resp.cookies))
resp.raise_for_status() resp.raise_for_status()
ct = resp.headers.get("content-type", "") ct = resp.headers.get("content-type", "")
if not resp.content: if not resp.content:
@@ -449,14 +580,11 @@ class UpstreamClient:
return items, meta return items, meta
def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]: 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._send_request(
"GET", "GET",
self._url(path), self._url(path),
params=params, params=params,
headers=self._headers(),
cookies=self._cookies,
) )
self._cookies.update(dict(resp.cookies))
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")
@@ -585,14 +713,28 @@ class UpstreamClient:
return return
email = self.auth_config.get("email", "") email = self.auth_config.get("email", "")
password = self.auth_config.get("password", "") password = self.auth_config.get("password", "")
login_path = self.auth_config.get("login_path", "/auth/login") default_login_path = "/api/user/login" if self.api_prefix == "" else "/auth/login"
username_field = self.auth_config.get("username_field", "email") login_path = self.auth_config.get("login_path") or default_login_path
default_username_field = "username" if login_path == "/api/user/login" else "email"
username_field = self.auth_config.get("username_field") or default_username_field
if not email or not password: if not email or not password:
raise UpstreamError("login_password auth requires email and password in auth_config") raise UpstreamError("login_password auth requires email and password in auth_config")
resp = self._request("POST", login_path, {username_field: email, "password": password}, auth=False) resp = self._request("POST", login_path, {username_field: email, "password": password}, auth=False)
token = _find_token(resp) token = _find_token(resp)
if token: if token:
self._token = token self._token = token
self._new_api_user = (
self.auth_config.get("new_api_user", "")
or self.auth_config.get("user_id", "")
or _find_user_id(resp)
)
refresh_token = _find_refresh_token(resp)
if self.api_prefix == "api/v1" or refresh_token:
self._remember_auth_tokens(
token=token,
refresh_token=refresh_token,
expires_in=_find_expires_in(resp),
)
return return
if self._cookies: if self._cookies:
self._new_api_user = self.auth_config.get("new_api_user", "") or _find_user_id(resp) self._new_api_user = self.auth_config.get("new_api_user", "") or _find_user_id(resp)
@@ -655,12 +797,10 @@ class UpstreamClient:
if status: if status:
params["status"] = status params["status"] = status
url = self._url(endpoint) url = self._url(endpoint)
resp = self._client.request( resp = self._send_request(
"GET", "GET",
url, url,
params=params if params else None, params=params if params else None,
headers=self._headers(),
cookies=self._cookies,
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
+23
View File
@@ -3,14 +3,17 @@ from __future__ import annotations
import json import json
import logging import logging
from decimal import Decimal from decimal import Decimal
from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.snapshot import UpstreamRateSnapshot from app.models.snapshot import UpstreamRateSnapshot
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey from app.models.upstream_key import UpstreamGeneratedKey
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
from app.services.auth_config import normalize_auth_config
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate
from app.services.upstream_client import UpstreamClient from app.services.upstream_client import UpstreamClient
from app.services import webhook_service from app.services import webhook_service
@@ -23,6 +26,25 @@ PRIORITY_BASE = 1
PRIORITY_STEP = 10 PRIORITY_STEP = 10
def _persist_upstream_auth_config(upstream: Upstream, updated_config: dict[str, Any]) -> None:
config_json = json.dumps(
normalize_auth_config(upstream.auth_type, updated_config),
ensure_ascii=False,
)
updated_at = datetime.now(timezone.utc)
db = SessionLocal()
try:
row = db.query(Upstream).filter(Upstream.id == upstream.id).first()
if row:
row.auth_config_json = config_json
row.updated_at = updated_at
db.commit()
upstream.auth_config_json = row.auth_config_json
upstream.updated_at = updated_at
finally:
db.close()
def priority_for_rate_rank(rank: int) -> int: def priority_for_rate_rank(rank: int) -> int:
"""Convert a zero-based sorted rate rank to an account priority.""" """Convert a zero-based sorted rate rank to an account priority."""
return PRIORITY_BASE + rank * PRIORITY_STEP return PRIORITY_BASE + rank * PRIORITY_STEP
@@ -672,6 +694,7 @@ def reconcile_upstream_keys_full(db: Session, upstream_id: int) -> bool:
auth_type=upstream.auth_type, auth_type=upstream.auth_type,
auth_config=auth_config, auth_config=auth_config,
timeout=float(upstream.timeout_seconds), timeout=float(upstream.timeout_seconds),
on_auth_config_update=lambda updated: _persist_upstream_auth_config(upstream, updated),
) as client: ) as client:
client.login() client.login()
# 获取远端 Key 列表(支持自定义 managed_prefix # 获取远端 Key 列表(支持自定义 managed_prefix
+33
View File
@@ -218,6 +218,39 @@ def test_new_api_user_propagated_to_bundle():
assert bundle.get("new_api_user") == "42" assert bundle.get("new_api_user") == "42"
def test_storage_access_and_refresh_token_build_sub2api_bearer_candidate():
candidates = _curate_candidates(
cookies=[],
local_storage={"auth_token": "access-token", "refresh_token": "refresh-token"},
session_storage={},
auth_headers=[],
page_url="https://sub2api.example.com/",
)
assert candidates[0]["type"] == "bearer_token"
assert candidates[0]["value"] == "access-token"
assert candidates[0]["refresh_token"] == "refresh-token"
assert candidates[0]["label"] == "Sub2API Bearer + Refresh Token"
assert not any(
c["type"] == "bearer_token" and c.get("value") == "refresh-token"
for c in candidates
)
def test_storage_access_token_without_refresh_keeps_bearer_candidate():
candidates = _curate_candidates(
cookies=[],
local_storage={"auth_token": "access-token"},
session_storage={},
auth_headers=[],
page_url="https://sub2api.example.com/",
)
assert candidates[0]["type"] == "bearer_token"
assert candidates[0]["value"] == "access-token"
assert "refresh_token" not in candidates[0]
def test_browser_import_payload_builds_cookie_bundle_with_new_api_user(): def test_browser_import_payload_builds_cookie_bundle_with_new_api_user():
from app.services.browser_import_service import build_import_result from app.services.browser_import_service import build_import_result
+46
View File
@@ -0,0 +1,46 @@
from app.services.upstream_client import UpstreamClient
def test_new_api_token_headers_include_authorization_and_new_api_user():
client = UpstreamClient(
base_url="http://newapi.local",
api_prefix="",
auth_type="new_api_token",
auth_config={"token": "access-token", "user_id": "7"},
)
headers = client._headers()
assert headers["Authorization"] == "Bearer access-token"
assert headers["New-Api-User"] == "7"
assert "Nox-Api-User" not in headers
def test_nox_token_headers_include_authorization_and_nox_api_user():
client = UpstreamClient(
base_url="http://nox.local",
api_prefix="",
auth_type="nox_token",
auth_config={"token": "nox-access-token", "user_id": "9"},
)
headers = client._headers()
assert headers["Authorization"] == "Bearer nox-access-token"
assert headers["Nox-Api-User"] == "9"
assert "New-Api-User" not in headers
def test_bearer_headers_do_not_include_new_or_nox_user_headers():
client = UpstreamClient(
base_url="http://sub2api.local",
api_prefix="/api/v1",
auth_type="bearer",
auth_config={"token": "sk-sub2api", "user_id": "11", "new_api_user": "11"},
)
headers = client._headers()
assert headers["Authorization"] == "Bearer sk-sub2api"
assert "New-Api-User" not in headers
assert "Nox-Api-User" not in headers
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import httpx
import pytest
from app.services import upstream_client
from app.services.upstream_client import UpstreamClient
class FakeHttpClient:
def __init__(self, handler):
self.handler = handler
self.calls = []
def request(self, method: str, url: str, **kwargs):
request = httpx.Request(method, url)
self.calls.append({
"method": method,
"path": request.url.path,
"headers": kwargs.get("headers") or {},
"json": kwargs.get("json"),
})
return self.handler(request, kwargs)
def close(self) -> None:
pass
def _install_fake_client(monkeypatch, handler) -> FakeHttpClient:
fake = FakeHttpClient(handler)
monkeypatch.setattr(upstream_client.httpx, "Client", lambda timeout=None: fake)
return fake
def _response(request: httpx.Request, status_code: int, payload=None) -> httpx.Response:
return httpx.Response(status_code, json=payload, request=request)
def test_sub2api_bearer_refreshes_on_401_and_retries(monkeypatch):
updates: list[dict] = []
def handler(request: httpx.Request, kwargs):
if request.url.path == "/api/v1/groups/available":
auth = kwargs["headers"].get("Authorization")
if auth == "Bearer old-access":
return _response(request, 401, {"detail": "expired"})
assert auth == "Bearer new-access"
return _response(request, 200, [{"id": "g1", "name": "G1"}])
if request.url.path == "/api/v1/auth/refresh":
assert kwargs["json"] == {"refresh_token": "old-refresh"}
return _response(request, 200, {
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 3600,
})
raise AssertionError(f"unexpected request: {request.method} {request.url}")
fake = _install_fake_client(monkeypatch, handler)
client = UpstreamClient(
base_url="http://sub2api.local",
api_prefix="/api/v1",
auth_type="bearer",
auth_config={"token": "old-access", "refresh_token": "old-refresh"},
on_auth_config_update=updates.append,
)
groups = client.get_available_groups("/groups/available")
assert groups == [{"id": "g1", "name": "G1"}]
assert [(c["method"], c["path"]) for c in fake.calls] == [
("GET", "/api/v1/groups/available"),
("POST", "/api/v1/auth/refresh"),
("GET", "/api/v1/groups/available"),
]
assert updates[-1]["token"] == "new-access"
assert updates[-1]["refresh_token"] == "new-refresh"
assert updates[-1]["expires_in"] == 3600
def test_sub2api_bearer_without_refresh_token_does_not_refresh(monkeypatch):
def handler(request: httpx.Request, kwargs):
assert request.url.path == "/api/v1/groups/available"
return _response(request, 401, {"detail": "expired"})
fake = _install_fake_client(monkeypatch, handler)
client = UpstreamClient(
base_url="http://sub2api.local",
api_prefix="/api/v1",
auth_type="bearer",
auth_config={"token": "old-access"},
)
with pytest.raises(httpx.HTTPStatusError):
client.get_available_groups("/groups/available")
assert [(c["method"], c["path"]) for c in fake.calls] == [
("GET", "/api/v1/groups/available"),
]
def test_non_sub2api_bearer_does_not_refresh(monkeypatch):
def handler(request: httpx.Request, kwargs):
assert request.url.path == "/groups/available"
return _response(request, 401, {"detail": "expired"})
fake = _install_fake_client(monkeypatch, handler)
client = UpstreamClient(
base_url="http://plain-bearer.local",
api_prefix="",
auth_type="bearer",
auth_config={"token": "old-access", "refresh_token": "old-refresh"},
)
with pytest.raises(httpx.HTTPStatusError):
client.get_available_groups("/groups/available")
assert [(c["method"], c["path"]) for c in fake.calls] == [
("GET", "/groups/available"),
]
def test_login_password_saves_sub2api_refresh_token(monkeypatch):
updates: list[dict] = []
def handler(request: httpx.Request, kwargs):
assert request.url.path == "/api/v1/auth/login"
assert kwargs["json"] == {"email": "user@example.com", "password": "secret"}
return _response(request, 200, {
"access_token": "login-access",
"refresh_token": "login-refresh",
"expires_in": 1800,
})
_install_fake_client(monkeypatch, handler)
client = UpstreamClient(
base_url="http://sub2api.local",
api_prefix="/api/v1",
auth_type="login_password",
auth_config={"email": "user@example.com", "password": "secret"},
on_auth_config_update=updates.append,
)
client.login()
assert client._headers()["Authorization"] == "Bearer login-access"
assert updates[-1]["token"] == "login-access"
assert updates[-1]["refresh_token"] == "login-refresh"
assert updates[-1]["expires_in"] == 1800
def test_login_password_empty_prefix_defaults_to_user_api_login(monkeypatch):
def handler(request: httpx.Request, kwargs):
assert request.url.path == "/api/user/login"
assert kwargs["json"] == {"username": "admin", "password": "secret"}
return _response(request, 200, {
"token": "nox-access",
"user": {"id": 9},
})
_install_fake_client(monkeypatch, handler)
client = UpstreamClient(
base_url="http://nox.local",
api_prefix="",
auth_type="login_password",
auth_config={"email": "admin", "password": "secret"},
)
client.login()
headers = client._headers()
assert headers["Authorization"] == "Bearer nox-access"
assert headers["Nox-Api-User"] == "9"
assert headers["New-Api-User"] == "9"
+2
View File
@@ -461,6 +461,8 @@ export interface AuthCaptureCandidate {
cookie_names?: string[] cookie_names?: string[]
new_api_user?: string new_api_user?: string
user_id?: string user_id?: string
refresh_token?: string
refresh_token_preview?: string
} }
export interface AuthCaptureResult { export interface AuthCaptureResult {
+13 -1
View File
@@ -112,7 +112,18 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void (e: 'update:modelValue', v: boolean): void
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string; cookie_count?: number; cookie_names?: string[]; new_api_user?: string; user_id?: string }): void (e: 'select', candidate: {
type: string
value: string
source: string
cookie_name?: string
cookie_value?: string
cookie_count?: number
cookie_names?: string[]
new_api_user?: string
user_id?: string
refresh_token?: string
}): void
}>() }>()
const visible = ref(props.modelValue) const visible = ref(props.modelValue)
@@ -330,6 +341,7 @@ async function confirmImportSelection() {
cookie_names: fullCandidate.cookie_names, cookie_names: fullCandidate.cookie_names,
user_id: resolveNewApiUser(rawResult.data.result, fullCandidate), user_id: resolveNewApiUser(rawResult.data.result, fullCandidate),
new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate), new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate),
refresh_token: fullCandidate.refresh_token,
}) })
closeDialog() closeDialog()
} catch (e: any) { } catch (e: any) {
+304 -74
View File
@@ -129,12 +129,14 @@
<el-input v-model="form.base_url" placeholder="https://example.com" /> <el-input v-model="form.base_url" placeholder="https://example.com" />
</el-form-item> </el-form-item>
<el-form-item label="API Prefix"> <el-form-item label="API Prefix">
<el-input v-model="form.api_prefix" placeholder="/api/v1" /> <el-input v-model="form.api_prefix" :placeholder="apiPrefixPlaceholder" />
<div v-if="apiPrefixHint" class="form-hint">{{ apiPrefixHint }}</div>
</el-form-item> </el-form-item>
<el-form-item label="认证方式"> <el-form-item label="认证方式">
<el-select v-model="form.auth_type" style="width: 100%"> <el-select v-model="form.auth_type" style="width: 100%" @change="handleAuthTypeChange">
<el-option label="无认证" value="none" /> <el-option label="无认证" value="none" />
<el-option label="Bearer Token" value="bearer" /> <el-option label="Bearer Token" value="bearer" />
<el-option label="New-API Access Token" value="new_api_token" />
<el-option label="Nox Access Token" value="nox_token" /> <el-option label="Nox Access Token" value="nox_token" />
<el-option label="Cookie" value="cookie" /> <el-option label="Cookie" value="cookie" />
<el-option label="API Key" value="api_key" /> <el-option label="API Key" value="api_key" />
@@ -151,6 +153,25 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item v-if="form.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1'" label="Refresh Token">
<el-input v-model="form.auth_config.refresh_token" type="password" show-password placeholder="可选,真实浏览器提取后自动填入" />
<div class="form-hint">没有 refresh token access token 过期后需要重新提取或改用邮箱密码登录</div>
</el-form-item>
</template>
<template v-else-if="form.auth_type === 'new_api_token'">
<el-form-item label="New-API Access Token">
<div class="auth-field-row">
<el-input v-model="form.auth_config.token" type="password" show-password placeholder="上游 /api/user/token 生成的 access token" />
<el-button size="small" @click="openAuthCapture">
<el-icon><Pointer /></el-icon>
提取
</el-button>
</div>
</el-form-item>
<el-form-item label="New-API User ID">
<el-input v-model="form.auth_config.user_id" placeholder="例如 1" />
<div class="form-hint">请求 <code>/api/user/self/groups</code> 时需要同时发送 <code>New-Api-User</code>不要反复点上游重新生成<code>/api/user/token</code> 会覆盖旧 access token</div>
</el-form-item>
</template> </template>
<template v-else-if="form.auth_type === 'nox_token'"> <template v-else-if="form.auth_type === 'nox_token'">
<el-form-item label="Nox Access Token"> <el-form-item label="Nox Access Token">
@@ -177,6 +198,7 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<div class="form-hint">Cookie 仅作为 fallback它受 sessionCloudflare 校验和上游重启影响通常不如 access token 稳定</div>
</template> </template>
<template v-else-if="form.auth_type === 'api_key'"> <template v-else-if="form.auth_type === 'api_key'">
<el-form-item label="API Key"> <el-form-item label="API Key">
@@ -187,6 +209,12 @@
</el-form-item> </el-form-item>
</template> </template>
<template v-else-if="form.auth_type === 'login_password'"> <template v-else-if="form.auth_type === 'login_password'">
<el-form-item label="账号字段">
<el-select v-model="form.auth_config.username_field" style="width: 100%">
<el-option label="邮箱 email" value="email" />
<el-option label="用户名 username" value="username" />
</el-select>
</el-form-item>
<el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'"> <el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'">
<el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" /> <el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" />
</el-form-item> </el-form-item>
@@ -194,20 +222,20 @@
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" /> <el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
</el-form-item> </el-form-item>
<el-form-item label="登录接口路径"> <el-form-item label="登录接口路径">
<el-input v-model="form.auth_config.login_path" placeholder="/auth/login" /> <el-input v-model="form.auth_config.login_path" :placeholder="loginPathPlaceholder" />
</el-form-item> </el-form-item>
</template> </template>
<el-form-item label="分组接口"> <el-form-item label="分组接口">
<el-input v-model="form.groups_endpoint" placeholder="/groups/available" /> <el-input v-model="form.groups_endpoint" :placeholder="groupsEndpointPlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="倍率接口"> <el-form-item label="倍率接口">
<el-input v-model="form.rate_endpoint" placeholder="/groups/rates" /> <el-input v-model="form.rate_endpoint" :placeholder="rateEndpointPlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="余额接口"> <el-form-item label="余额接口">
<el-input v-model="form.balance_endpoint" placeholder="留空则不获取余额,如 /auth/me" /> <el-input v-model="form.balance_endpoint" :placeholder="balanceEndpointPlaceholder" />
</el-form-item> </el-form-item>
<el-form-item label="余额字段路径"> <el-form-item label="余额字段路径">
<el-input v-model="form.balance_response_path" placeholder="balance、data.quota" /> <el-input v-model="form.balance_response_path" :placeholder="balanceResponsePathPlaceholder" />
<div class="form-hint">JSON 点分路径例如 <code>balance</code> <code>data.quota</code></div> <div class="form-hint">JSON 点分路径例如 <code>balance</code> <code>data.quota</code></div>
</el-form-item> </el-form-item>
<el-form-item label="余额除数"> <el-form-item label="余额除数">
@@ -446,20 +474,80 @@ const saving = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const defaultForm = () => ({ type QuickPlatform = 'sub2api' | 'new-api-user' | 'nox-api' | 'custom'
name: '', type KnownQuickPlatform = Exclude<QuickPlatform, 'custom'>
base_url: '', type AuthType = 'none' | 'bearer' | 'new_api_token' | 'nox_token' | 'cookie' | 'api_key' | 'login_password'
const USER_API_GROUPS_ENDPOINT = '/api/user/self/groups'
const USER_API_BALANCE_ENDPOINT = '/api/user/self'
const USER_API_LOGIN_PATH = '/api/user/login'
const SUB2API_LOGIN_PATH = '/auth/login'
const platformDefaults: Record<KnownQuickPlatform, {
api_prefix: string
auth_type: AuthType
auth_config: Record<string, any>
rate_endpoint: string
groups_endpoint: string
balance_endpoint: string
balance_response_path: string
balance_divisor: number
}> = {
sub2api: {
api_prefix: '/api/v1', api_prefix: '/api/v1',
auth_type: 'login_password', auth_type: 'login_password',
auth_config: { email: '', password: '', login_path: '/auth/login' } as Record<string, any>, auth_config: { email: '', password: '', login_path: SUB2API_LOGIN_PATH, username_field: 'email' },
rate_endpoint: '/groups/rates', rate_endpoint: '/groups/rates',
groups_endpoint: '/groups/available', groups_endpoint: '/groups/available',
enabled: true,
check_interval_seconds: 600,
timeout_seconds: 30,
balance_endpoint: '/auth/me', balance_endpoint: '/auth/me',
balance_response_path: 'data.balance', balance_response_path: 'data.balance',
balance_divisor: 1.0, balance_divisor: 1.0,
},
'new-api-user': {
api_prefix: '',
auth_type: 'login_password',
auth_config: { email: '', password: '', login_path: USER_API_LOGIN_PATH, username_field: 'username', provider: 'new-api' },
rate_endpoint: USER_API_GROUPS_ENDPOINT,
groups_endpoint: USER_API_GROUPS_ENDPOINT,
balance_endpoint: USER_API_BALANCE_ENDPOINT,
balance_response_path: 'data.quota',
balance_divisor: 500000,
},
'nox-api': {
api_prefix: '',
auth_type: 'login_password',
auth_config: { email: '', password: '', login_path: USER_API_LOGIN_PATH, username_field: 'username', provider: 'nox-api' },
rate_endpoint: USER_API_GROUPS_ENDPOINT,
groups_endpoint: USER_API_GROUPS_ENDPOINT,
balance_endpoint: USER_API_BALANCE_ENDPOINT,
balance_response_path: 'data.quota',
balance_divisor: 500000,
},
}
const platformLabels: Record<QuickPlatform, string> = {
sub2api: 'Sub2API',
'new-api-user': 'New-API',
'nox-api': 'Nox-API',
custom: '自定义',
}
const quickPlatform = ref<QuickPlatform>('sub2api')
const defaultForm = () => ({
name: '',
base_url: '',
api_prefix: platformDefaults.sub2api.api_prefix,
auth_type: platformDefaults.sub2api.auth_type,
auth_config: { ...platformDefaults.sub2api.auth_config } as Record<string, any>,
rate_endpoint: platformDefaults.sub2api.rate_endpoint,
groups_endpoint: platformDefaults.sub2api.groups_endpoint,
enabled: true,
check_interval_seconds: 600,
timeout_seconds: 30,
balance_endpoint: platformDefaults.sub2api.balance_endpoint,
balance_response_path: platformDefaults.sub2api.balance_response_path,
balance_divisor: platformDefaults.sub2api.balance_divisor,
balance_alert_threshold: null as number | null, balance_alert_threshold: null as number | null,
}) })
const form = ref(defaultForm()) const form = ref(defaultForm())
@@ -470,6 +558,42 @@ const rules = {
const authCaptureVisible = ref(false) const authCaptureVisible = ref(false)
const selectedPlatformDefaults = computed(() =>
quickPlatform.value === 'custom' ? null : platformDefaults[quickPlatform.value],
)
const apiPrefixPlaceholder = computed(() => {
const defaults = selectedPlatformDefaults.value
if (!defaults) return '/api/v1 或留空'
return defaults.api_prefix || `留空(${platformLabels[quickPlatform.value]} 默认)`
})
const apiPrefixHint = computed(() => {
if (quickPlatform.value !== 'new-api-user' && quickPlatform.value !== 'nox-api') return ''
return `${platformLabels[quickPlatform.value]} 用户接口默认不加 API Prefix,留空即可。`
})
const loginPathPlaceholder = computed(() => {
const defaults = selectedPlatformDefaults.value
return defaults?.auth_config.login_path || SUB2API_LOGIN_PATH
})
const groupsEndpointPlaceholder = computed(() =>
selectedPlatformDefaults.value?.groups_endpoint || '/groups/available',
)
const rateEndpointPlaceholder = computed(() =>
selectedPlatformDefaults.value?.rate_endpoint || '/groups/rates',
)
const balanceEndpointPlaceholder = computed(() =>
selectedPlatformDefaults.value?.balance_endpoint || '留空则不获取余额,如 /auth/me',
)
const balanceResponsePathPlaceholder = computed(() =>
selectedPlatformDefaults.value?.balance_response_path || '如 balance、data.quota',
)
const authCaptureInitialUrl = computed(() => { const authCaptureInitialUrl = computed(() => {
const base = (form.value.base_url || '').replace(/\/+$/, '') const base = (form.value.base_url || '').replace(/\/+$/, '')
if (!base) return '' if (!base) return ''
@@ -478,7 +602,7 @@ const authCaptureInitialUrl = computed(() => {
const authCapturePreferredTypes = computed<AuthCaptureCandidate['type'][]>(() => { const authCapturePreferredTypes = computed<AuthCaptureCandidate['type'][]>(() => {
if (quickPlatform.value === 'sub2api') return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie'] if (quickPlatform.value === 'sub2api') return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
if (quickPlatform.value === 'new-api-user') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key'] if (quickPlatform.value === 'new-api-user') return ['bearer_token', 'cookie_bundle', 'cookie', 'api_key']
if (quickPlatform.value === 'nox-api') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key'] if (quickPlatform.value === 'nox-api') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key']
return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie'] return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
}) })
@@ -496,27 +620,47 @@ function handleAuthCaptureSelect(candidate: {
cookie_names?: string[] cookie_names?: string[]
new_api_user?: string new_api_user?: string
user_id?: string user_id?: string
refresh_token?: string
}) { }) {
if (candidate.type === 'bearer_token') { if (candidate.type === 'bearer_token') {
if (quickPlatform.value === 'nox-api') { if (quickPlatform.value === 'nox-api') {
form.value.auth_type = 'nox_token' form.value.auth_type = 'nox_token'
form.value.auth_config.token = candidate.value form.value.auth_config.token = candidate.value
form.value.auth_config.provider = 'nox-api' form.value.auth_config.provider = 'nox-api'
applyUserApiEndpoints()
if (candidate.user_id || candidate.new_api_user) { if (candidate.user_id || candidate.new_api_user) {
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
} }
ElMessage.success('已填入 Nox Access Token') ElMessage.success('已填入 Nox Access Token')
} else if (quickPlatform.value === 'new-api-user') {
form.value.auth_type = 'new_api_token'
form.value.auth_config.token = candidate.value
form.value.auth_config.provider = 'new-api'
if (candidate.user_id || candidate.new_api_user) {
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
}
applyUserApiEndpoints()
ElMessage.success('已填入 New-API Access Token')
} else { } else {
form.value.auth_type = 'bearer' form.value.auth_type = 'bearer'
form.value.auth_config.token = candidate.value form.value.auth_config.token = candidate.value
if (candidate.refresh_token) {
form.value.auth_config.refresh_token = candidate.refresh_token
ElMessage.success('已填入 Bearer Token 和 Refresh Token')
} else {
ElMessage.success('已填入 Bearer Token') ElMessage.success('已填入 Bearer Token')
if (quickPlatform.value === 'sub2api' || form.value.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') {
ElMessage.warning('未提取到 Refresh Token,过期后需要重新提取或改用邮箱密码登录')
}
}
} }
} else if (candidate.type === 'cookie_bundle') { } else if (candidate.type === 'cookie_bundle') {
// 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串 // 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串
form.value.auth_type = 'cookie' form.value.auth_type = 'cookie'
form.value.auth_config.cookie_string = candidate.value form.value.auth_config.cookie_string = candidate.value
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') { if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
form.value.auth_config.provider = quickPlatform.value form.value.auth_config.provider = quickPlatform.value === 'nox-api' ? 'nox-api' : 'new-api'
} }
if (quickPlatform.value === 'sub2api') { if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用') ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
@@ -524,13 +668,9 @@ function handleAuthCaptureSelect(candidate: {
if (candidate.new_api_user) { if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user form.value.auth_config.new_api_user = candidate.new_api_user
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
form.value.api_prefix = '' applyUserApiEndpoints()
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') { } else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
form.value.api_prefix = '' applyUserApiEndpoints()
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') { if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
ElMessage.warning(`已填入完整 Cookie 组,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`) ElMessage.warning(`已填入完整 Cookie 组,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
} }
@@ -543,7 +683,7 @@ function handleAuthCaptureSelect(candidate: {
? `${candidate.cookie_name}=${candidate.cookie_value}` ? `${candidate.cookie_name}=${candidate.cookie_value}`
: candidate.value : candidate.value
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') { if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') {
form.value.auth_config.provider = quickPlatform.value form.value.auth_config.provider = quickPlatform.value === 'nox-api' ? 'nox-api' : 'new-api'
} }
if (quickPlatform.value === 'sub2api') { if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用') ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
@@ -551,13 +691,9 @@ function handleAuthCaptureSelect(candidate: {
if (candidate.new_api_user) { if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user form.value.auth_config.new_api_user = candidate.new_api_user
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
form.value.api_prefix = '' applyUserApiEndpoints()
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
} else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') { } else if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
form.value.api_prefix = '' applyUserApiEndpoints()
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') { if (quickPlatform.value === 'new-api-user' || quickPlatform.value === 'nox-api') {
ElMessage.warning(`已填入 Cookie,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`) ElMessage.warning(`已填入 Cookie,但未提取到 ${quickPlatform.value === 'nox-api' ? 'Nox-Api-User' : 'New-Api-User'},请重新登录后再提取`)
} }
@@ -571,71 +707,152 @@ function handleAuthCaptureSelect(candidate: {
} else if (candidate.type === 'credential') { } else if (candidate.type === 'credential') {
// Try to guess — if value starts with 'sk-', treat as bearer // Try to guess — if value starts with 'sk-', treat as bearer
if (candidate.value.startsWith('sk-')) { if (candidate.value.startsWith('sk-')) {
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : 'bearer' form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : quickPlatform.value === 'new-api-user' ? 'new_api_token' : 'bearer'
form.value.auth_config.token = candidate.value form.value.auth_config.token = candidate.value
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api' if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
if (quickPlatform.value === 'nox-api' && (candidate.user_id || candidate.new_api_user)) { if (quickPlatform.value === 'new-api-user') form.value.auth_config.provider = 'new-api'
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') applyUserApiEndpoints()
if ((quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') && (candidate.user_id || candidate.new_api_user)) {
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
if (quickPlatform.value === 'new-api-user') form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
} }
ElMessage.success('已填入 Bearer Token (sk-key)') ElMessage.success(quickPlatform.value === 'new-api-user' ? '已填入 New-API Access Token' : '已填入 Bearer Token (sk-key)')
} else { } else {
form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : 'bearer' form.value.auth_type = quickPlatform.value === 'nox-api' ? 'nox_token' : quickPlatform.value === 'new-api-user' ? 'new_api_token' : 'bearer'
form.value.auth_config.token = candidate.value form.value.auth_config.token = candidate.value
if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api' if (quickPlatform.value === 'nox-api') form.value.auth_config.provider = 'nox-api'
if (quickPlatform.value === 'nox-api' && (candidate.user_id || candidate.new_api_user)) { if (quickPlatform.value === 'new-api-user') form.value.auth_config.provider = 'new-api'
if (quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') applyUserApiEndpoints()
if ((quickPlatform.value === 'nox-api' || quickPlatform.value === 'new-api-user') && (candidate.user_id || candidate.new_api_user)) {
form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user form.value.auth_config.user_id = candidate.user_id || candidate.new_api_user
if (quickPlatform.value === 'new-api-user') form.value.auth_config.new_api_user = candidate.new_api_user || candidate.user_id
} }
ElMessage.success('已填入认证信息') ElMessage.success('已填入认证信息')
} }
} }
} }
const quickPlatform = ref('sub2api') function isKnownQuickPlatform(value: string): value is KnownQuickPlatform {
return value === 'sub2api' || value === 'new-api-user' || value === 'nox-api'
}
function inferPlatform(row: Pick<UpstreamData, 'api_prefix' | 'groups_endpoint' | 'auth_type' | 'auth_config_masked'>): string { function normalizeQuickPlatform(value: string): QuickPlatform {
return isKnownQuickPlatform(value) ? value : 'custom'
}
function platformProvider(platform: QuickPlatform): string {
if (platform === 'nox-api') return 'nox-api'
if (platform === 'new-api-user') return 'new-api'
return ''
}
function normalizeAuthType(value: string): AuthType {
if (value === 'none'
|| value === 'bearer'
|| value === 'new_api_token'
|| value === 'nox_token'
|| value === 'cookie'
|| value === 'api_key'
|| value === 'login_password') {
return value
}
return 'none'
}
function applyUserApiEndpoints() {
form.value.api_prefix = ''
form.value.groups_endpoint = USER_API_GROUPS_ENDPOINT
form.value.rate_endpoint = USER_API_GROUPS_ENDPOINT
}
function inferPlatform(row: Pick<UpstreamData, 'api_prefix' | 'groups_endpoint' | 'auth_type' | 'auth_config_masked'>): QuickPlatform {
if (row.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') return 'sub2api' if (row.api_prefix?.replace(/^\/|\/$/g, '') === 'api/v1') return 'sub2api'
if (row.api_prefix === '' && row.groups_endpoint === '/api/user/self/groups') { if (row.api_prefix === '' && row.groups_endpoint === USER_API_GROUPS_ENDPOINT) {
if (row.auth_type === 'nox_token' || row.auth_config_masked?.provider === 'nox-api') return 'nox-api' if (row.auth_type === 'nox_token' || row.auth_config_masked?.provider === 'nox-api') return 'nox-api'
if (row.auth_type === 'new_api_token' || row.auth_config_masked?.provider === 'new-api') return 'new-api-user'
return 'new-api-user' return 'new-api-user'
} }
return 'custom' return 'custom'
} }
function handlePlatformChange(val: string) { function handlePlatformChange(val: string) {
if (val === 'sub2api') { const platform = normalizeQuickPlatform(val)
form.value.api_prefix = '/api/v1' if (platform === 'custom') {
form.value.groups_endpoint = '/groups/available'
form.value.rate_endpoint = '/groups/rates'
form.value.auth_type = 'login_password'
form.value.auth_config.login_path = '/auth/login'
form.value.auth_config.username_field = 'email'
form.value.balance_endpoint = '/auth/me'
form.value.balance_response_path = 'data.balance'
form.value.balance_divisor = 1.0
} else if (val === 'new-api-user') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
form.value.auth_type = 'login_password'
form.value.auth_config.provider = 'new-api-user'
form.value.auth_config.login_path = '/api/user/login'
form.value.auth_config.username_field = 'username'
form.value.balance_endpoint = '/api/user/self'
form.value.balance_response_path = 'data.quota'
form.value.balance_divisor = 500000
} else if (val === 'nox-api') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
form.value.rate_endpoint = '/api/user/self/groups'
form.value.auth_type = 'nox_token'
form.value.auth_config = { token: '', user_id: '', provider: 'nox-api' }
form.value.balance_endpoint = '/api/user/self'
form.value.balance_response_path = 'data.quota'
form.value.balance_divisor = 500000
} else {
form.value.balance_endpoint = '' form.value.balance_endpoint = ''
form.value.balance_response_path = '' form.value.balance_response_path = ''
form.value.balance_divisor = 1.0 form.value.balance_divisor = 1.0
return
}
const defaults = platformDefaults[platform]
form.value.api_prefix = defaults.api_prefix
form.value.groups_endpoint = defaults.groups_endpoint
form.value.rate_endpoint = defaults.rate_endpoint
form.value.auth_type = defaults.auth_type
form.value.auth_config = { ...defaults.auth_config }
form.value.balance_endpoint = defaults.balance_endpoint
form.value.balance_response_path = defaults.balance_response_path
form.value.balance_divisor = defaults.balance_divisor
}
function isUserApiPasswordLoginPlatform(): boolean {
return quickPlatform.value === 'new-api-user'
|| quickPlatform.value === 'nox-api'
|| (form.value.api_prefix === '' && form.value.groups_endpoint === USER_API_GROUPS_ENDPOINT)
}
function handleAuthTypeChange(val: string) {
const provider = platformProvider(quickPlatform.value)
if (val === 'none') {
form.value.auth_config = {}
return
}
if (val === 'bearer') {
form.value.auth_config = {
token: form.value.auth_config.token || '',
refresh_token: form.value.auth_config.refresh_token || '',
}
return
}
if (val === 'new_api_token') {
form.value.auth_config = {
token: form.value.auth_config.token || '',
user_id: form.value.auth_config.user_id || form.value.auth_config.new_api_user || '',
provider: 'new-api',
}
return
}
if (val === 'nox_token') {
form.value.auth_config = {
token: form.value.auth_config.token || '',
user_id: form.value.auth_config.user_id || form.value.auth_config.new_api_user || '',
provider: 'nox-api',
}
return
}
if (val === 'cookie') {
form.value.auth_config = {
cookie_string: form.value.auth_config.cookie_string || '',
...(provider ? { provider } : {}),
}
return
}
if (val === 'api_key') {
form.value.auth_config = {
key: form.value.auth_config.key || '',
header: form.value.auth_config.header || 'Authorization',
}
return
}
if (val !== 'login_password') return
const userApiLogin = isUserApiPasswordLoginPlatform()
form.value.auth_config = {
email: form.value.auth_config.email || '',
password: form.value.auth_config.password || '',
login_path: userApiLogin ? USER_API_LOGIN_PATH : (form.value.auth_config.login_path || SUB2API_LOGIN_PATH),
username_field: userApiLogin ? 'username' : (form.value.auth_config.username_field || 'email'),
...(provider ? { provider } : {}),
} }
} }
@@ -685,9 +902,10 @@ function usesTokenEndpointUpstream(row: UpstreamData | null) {
if (!row) return false if (!row) return false
return row.api_prefix === '' return row.api_prefix === ''
&& ( && (
row.groups_endpoint === '/api/user/self/groups' row.groups_endpoint === USER_API_GROUPS_ENDPOINT
|| row.auth_config_masked?.login_path === '/api/user/login' || row.auth_config_masked?.login_path === USER_API_LOGIN_PATH
|| Boolean(row.auth_config_masked?.new_api_user) || Boolean(row.auth_config_masked?.new_api_user)
|| row.auth_type === 'new_api_token'
|| row.auth_type === 'nox_token' || row.auth_type === 'nox_token'
) )
} }
@@ -714,7 +932,7 @@ const recentChecks = computed(() =>
) )
const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s) const statusLabel = (s: string) => ({ healthy: '健康', unhealthy: '异常', unknown: '未知' }[s] || s)
const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', nox_token: 'Nox Token', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s) const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', new_api_token: 'New-API Token', nox_token: 'Nox Token', cookie: 'Cookie', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
function formatBalance(value: number | null | undefined): string { function formatBalance(value: number | null | undefined): string {
if (value === null || value === undefined) return '—' if (value === null || value === undefined) return '—'
@@ -765,7 +983,7 @@ function openEdit(row: UpstreamData) {
name: row.name, name: row.name,
base_url: row.base_url, base_url: row.base_url,
api_prefix: row.api_prefix, api_prefix: row.api_prefix,
auth_type: row.auth_type, auth_type: normalizeAuthType(row.auth_type),
auth_config: { ...(row.auth_config_masked as Record<string, any>) }, auth_config: { ...(row.auth_config_masked as Record<string, any>) },
rate_endpoint: row.rate_endpoint, rate_endpoint: row.rate_endpoint,
groups_endpoint: row.groups_endpoint, groups_endpoint: row.groups_endpoint,
@@ -785,11 +1003,23 @@ async function handleSave() {
if (!valid) return if (!valid) return
saving.value = true saving.value = true
try { try {
const payload = {
...form.value,
auth_config: { ...(form.value.auth_config as Record<string, any>) },
}
if (payload.auth_type === 'new_api_token') {
payload.auth_config.provider = 'new-api'
if (payload.auth_config.user_id && !payload.auth_config.new_api_user) {
payload.auth_config.new_api_user = payload.auth_config.user_id
}
} else if (payload.auth_type === 'nox_token') {
payload.auth_config.provider = 'nox-api'
}
if (editingId.value) { if (editingId.value) {
await upstreamsApi.update(editingId.value, form.value) await upstreamsApi.update(editingId.value, payload)
ElMessage.success('保存成功') ElMessage.success('保存成功')
} else { } else {
await upstreamsApi.create(form.value as any) await upstreamsApi.create(payload as any)
ElMessage.success('创建成功') ElMessage.success('创建成功')
} }
drawerVisible.value = false drawerVisible.value = false