Compare commits

..

7 Commits

Author SHA1 Message Date
SmartUp Developer 84b714ced3 fix: sync admin password from .env on startup, add host.docker.internal for local sub2api
- _init_admin now re-hashes password if .env password differs from DB
- docker-compose: add extra_hosts host.docker.internal:host-gateway
  so containers can reach host services via host.docker.internal
2026-05-17 12:52:09 +08:00
SmartUp Developer 0ad8796285 fix: restore docker-compose env-var pattern, fix .env conflict from shell env
docker-compose.yml now reads ADMIN_PASSWORD/JWT_SECRET from env or
.env file; the shell had stale ADMIN_PASSWORD=changeme123 which
overrode .env — document this for future runs.
2026-05-17 12:40:26 +08:00
SmartUp Developer 5c60627fb6 fix: Vite type declaration, non-idempotent retry, hardcoded test IP
- Add frontend/src/vite-env.d.ts (reference vite/client) to fix vue-tsc build
- Restrict axios-retry to GET/HEAD/OPTIONS only (avoid replaying mutations)
- Convert test_upstream.py to accept URL via CLI args instead of hardcoded IP
2026-05-17 11:56:49 +08:00
SmartUp Developer 2934473770 fix: remove stale _decimal_str ref, add context manager to HTTP clients
- UpstreamClient & Sub2ApiWebsiteClient: add __enter__/__exit__
- Convert all call sites to `with Client(...) as c:` pattern
- Remove unused `upstream_name`/`upstream_base_url` locals in scheduler
- Fix stale _decimal_str→decimal_string in _rate_from_group
2026-05-17 11:29:51 +08:00
SmartUp Developer 8a6ed249be fix: complete remaining 8 optimization items
- HTTP connection pooling: UpstreamClient & WebsiteClient reuse httpx.Client
- Deduplicate decimal_string into shared app/utils/number.py
- Split scheduler transaction: snapshot write → webhook/website sync in separate sessions
- Remove hardcoded 170.106.100.210 migration from database.py
- Reset consecutive_failures on upstream update
- Healthcheck: install curl, replace python -c with curl -f
- Add .dockerignore to reduce build context
- Frontend: add axios-retry with exponential backoff (5xx/network errors only)
2026-05-17 11:09:35 +08:00
SmartUp Developer ad16618406 fix: address multiple code audit findings
- CORS: replace wildcard with explicit origin list from CORS_ORIGINS env
- Auth: enforce strong defaults, JWT blacklist (RevokedToken model), login rate limiting
- Auth: validate password length before bcrypt (72-byte limit)
- Scheduler: single-threaded worker to mitigate SQLite write contention
- Scheduler: graceful shutdown (wait=True)
- Snapshots: add prune_snapshots() with configurable retention count
- Storage: isolate localStorage keys via VITE_APP_KEY prefix
- Config: add cors_origins, login_rate_limit, snapshot_retention_count settings
2026-05-17 10:52:18 +08:00
SmartUp Developer a42ecf7bcc docs: add optimization-review.md — code audit findings 2026-05-17 04:12:00 +08:00
37 changed files with 1343 additions and 330 deletions
+30
View File
@@ -0,0 +1,30 @@
# dependencies
node_modules/
backend/__pycache__/
backend/**/__pycache__/
backend/*.py[cod]
.venv/
venv/
# data & runtime
data/
*.log
# git
.git/
.gitignore
.gitattributes
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# env
.env
.env.*
+5 -3
View File
@@ -1,11 +1,13 @@
# ===== 必填 ===== # ===== 必填 =====
# 管理员账号(首次启动自动创建) # 管理员账号(首次启动自动创建)
ADMIN_EMAIL=admin@smartup.local ADMIN_EMAIL=admin@smartup.local
ADMIN_PASSWORD=changeme123 ADMIN_PASSWORD=replace-with-a-strong-password
# ===== 推荐配置 ===== # ===== 推荐配置 =====
# JWT 签名密钥(生产环境请替换): openssl rand -hex 32 # JWT 签名密钥: openssl rand -hex 32
JWT_SECRET=change-me-in-production JWT_SECRET=replace-with-openssl-rand-hex-32
# 允许访问 API 的前端源,多个用逗号分隔
CORS_ORIGINS=http://localhost:8899,http://127.0.0.1:8899
# ===== 可选 ===== # ===== 可选 =====
# 监听端口(默认 8899 # 监听端口(默认 8899
+1
View File
@@ -25,6 +25,7 @@ RUN apt-get update \
libfreetype6 libgbm1 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 \ libfreetype6 libgbm1 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 \
libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \ libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1 xvfb \ libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1 xvfb \
curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN playwright install chromium RUN playwright install chromium
+2
View File
@@ -5,6 +5,8 @@ SERVICE ?= smartup
up: up:
$(COMPOSE) up -d --build $(COMPOSE) up -d --build
@port=$$(grep -E '^SERVER_PORT=' .env 2>/dev/null | tail -n 1 | cut -d= -f2-); \
printf '访问地址:http://localhost:%s\n' "$${port:-8899}"
down: down:
$(COMPOSE) down $(COMPOSE) down
+10 -2
View File
@@ -4,9 +4,13 @@ from functools import lru_cache
class Settings(BaseSettings): class Settings(BaseSettings):
admin_email: str = "admin@smartup.local" admin_email: str = "admin@smartup.local"
admin_password: str = "changeme" admin_password: str = ""
jwt_secret: str = "change-me-in-production" jwt_secret: str = ""
jwt_expire_hours: int = 24 jwt_expire_hours: int = 24
cors_origins: str = "http://localhost:8899,http://127.0.0.1:8899"
login_rate_limit_attempts: int = 5
login_rate_limit_window_seconds: int = 300
snapshot_retention_count: int = 500
database_url: str = "sqlite:////app/data/app.db" database_url: str = "sqlite:////app/data/app.db"
tz: str = "Asia/Shanghai" tz: str = "Asia/Shanghai"
# consecutive failures before upstream goes unhealthy # consecutive failures before upstream goes unhealthy
@@ -14,6 +18,10 @@ class Settings(BaseSettings):
browser_profiles_dir: str = "/app/data/browser-profiles" browser_profiles_dir: str = "/app/data/browser-profiles"
browser_headless: bool = True browser_headless: bool = True
@property
def cors_origin_list(self) -> list[str]:
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"} model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"}
+2 -13
View File
@@ -26,7 +26,7 @@ def get_db():
def init_db(): def init_db():
"""Create all tables.""" """Create all tables."""
# import models so SQLAlchemy registers them # import models so SQLAlchemy registers them
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website # noqa: F401 from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token # noqa: F401
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_custom_pages() _migrate_custom_pages()
@@ -64,15 +64,4 @@ def _migrate_custom_pages():
"AND NULLIF(TRIM(login_password), '') IS NOT NULL" "AND NULLIF(TRIM(login_password), '') IS NOT NULL"
) )
) )
conn.execute(
text(
"UPDATE custom_pages "
"SET access_mode = 'remote_browser', use_proxy = 0 "
"WHERE url LIKE :host OR url LIKE :host_slash OR url LIKE :host_port"
),
{
"host": "%://170.106.100.210",
"host_slash": "%://170.106.100.210/%",
"host_port": "%://170.106.100.210:%",
},
)
+27 -9
View File
@@ -12,7 +12,7 @@ from app.config import get_settings
from app.database import init_db from app.database import init_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.database import SessionLocal from app.database import SessionLocal
from app.utils.auth import hash_password from app.utils.auth import hash_password, verify_password, validate_password_supported
from app.services.scheduler import start_scheduler, stop_scheduler from app.services.scheduler import start_scheduler, stop_scheduler
from app.routers import auth, upstreams, webhooks, logs, custom_pages, browser_sessions, websites from app.routers import auth, upstreams, webhooks, logs, custom_pages, browser_sessions, websites
from app.services.browser_session_service import browser_sessions as browser_session_service from app.services.browser_session_service import browser_sessions as browser_session_service
@@ -21,15 +21,33 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _init_admin() -> None: def _validate_runtime_settings() -> None:
settings = get_settings() settings = get_settings()
if not settings.admin_password: if not settings.admin_password:
logger.warning("ADMIN_PASSWORD not set, skip admin init") raise RuntimeError("ADMIN_PASSWORD must be set")
return if settings.admin_password in {"changeme", "changeme123"}:
raise RuntimeError("ADMIN_PASSWORD must not use the default placeholder")
if not settings.jwt_secret or settings.jwt_secret == "change-me-in-production":
raise RuntimeError("JWT_SECRET must be set to a non-default value")
if not settings.cors_origin_list:
raise RuntimeError("CORS_ORIGINS must include at least one explicit origin")
validate_password_supported(settings.admin_password)
def _init_admin() -> None:
settings = get_settings()
db = SessionLocal() db = SessionLocal()
try: try:
exists = db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first() existing = db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first()
if not exists: if existing:
# Sync password hash if .env has changed since first creation
if not verify_password(settings.admin_password, existing.password_hash):
existing.password_hash = hash_password(settings.admin_password)
db.commit()
logger.info("admin password updated: %s", settings.admin_email)
else:
logger.info("admin user already exists: %s", settings.admin_email)
else:
user = AdminUser( user = AdminUser(
email=settings.admin_email, email=settings.admin_email,
password_hash=hash_password(settings.admin_password), password_hash=hash_password(settings.admin_password),
@@ -37,14 +55,13 @@ def _init_admin() -> None:
db.add(user) db.add(user)
db.commit() db.commit()
logger.info("admin user created: %s", settings.admin_email) logger.info("admin user created: %s", settings.admin_email)
else:
logger.info("admin user already exists: %s", settings.admin_email)
finally: finally:
db.close() db.close()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
_validate_runtime_settings()
init_db() init_db()
_init_admin() _init_admin()
start_scheduler() start_scheduler()
@@ -63,9 +80,10 @@ app = FastAPI(
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
) )
settings = get_settings()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=settings.cors_origin_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
+14
View File
@@ -0,0 +1,14 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class RevokedToken(Base):
__tablename__ = "revoked_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
jti: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
+62 -6
View File
@@ -1,18 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, status from datetime import datetime, timezone
from threading import Lock
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings
from app.database import get_db from app.database import get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.revoked_token import RevokedToken
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
from app.utils.auth import verify_password, create_access_token, get_current_user from app.utils.auth import bearer_scheme, create_access_token, decode_token_payload, get_current_user, verify_password
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
_login_attempts: dict[tuple[str, str], list[float]] = {}
_login_attempts_lock = Lock()
def _login_key(request: Request, email: str) -> tuple[str, str]:
forwarded = request.headers.get("x-forwarded-for", "").split(",", 1)[0].strip()
ip = forwarded or (request.client.host if request.client else "unknown")
return ip, email.lower()
def _check_login_limit(key: tuple[str, str]) -> None:
settings = get_settings()
now = datetime.now(timezone.utc).timestamp()
cutoff = now - settings.login_rate_limit_window_seconds
with _login_attempts_lock:
attempts = [item for item in _login_attempts.get(key, []) if item >= cutoff]
if len(attempts) >= settings.login_rate_limit_attempts:
_login_attempts[key] = attempts
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="登录尝试过多,请稍后再试")
_login_attempts[key] = attempts
def _record_login_failure(key: tuple[str, str]) -> None:
with _login_attempts_lock:
_login_attempts.setdefault(key, []).append(datetime.now(timezone.utc).timestamp())
def _clear_login_failures(key: tuple[str, str]) -> None:
with _login_attempts_lock:
_login_attempts.pop(key, None)
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)): def login(req: LoginRequest, request: Request, db: Session = Depends(get_db)):
key = _login_key(request, req.email)
_check_login_limit(key)
user = db.query(AdminUser).filter(AdminUser.email == req.email).first() user = db.query(AdminUser).filter(AdminUser.email == req.email).first()
if not user or not verify_password(req.password, user.password_hash): try:
password_ok = bool(user and verify_password(req.password, user.password_hash))
except ValueError:
password_ok = False
if not password_ok:
_record_login_failure(key)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
_clear_login_failures(key)
token = create_access_token(user.email) token = create_access_token(user.email)
return TokenResponse(access_token=token) return TokenResponse(access_token=token)
@@ -23,6 +68,17 @@ def me(current_user: AdminUser = Depends(get_current_user)):
@router.post("/logout") @router.post("/logout")
def logout(): def logout(
# JWT is stateless — client discards token credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
_current_user: AdminUser = Depends(get_current_user),
):
payload = decode_token_payload(credentials.credentials)
jti = payload.get("jti") if payload else None
exp = payload.get("exp") if payload else None
if jti and exp and not db.query(RevokedToken).filter(RevokedToken.jti == jti).first():
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
db.add(RevokedToken(jti=jti, expires_at=expires_at))
db.query(RevokedToken).filter(RevokedToken.expires_at < datetime.now(timezone.utc)).delete(synchronize_session=False)
db.commit()
return {"message": "logged out"} return {"message": "logged out"}
+34 -10
View File
@@ -41,6 +41,10 @@ class BrowserSessionResponse(BaseModel):
title: str title: str
class BrowserSelectionResponse(BaseModel):
text: str
class BrowserEvent(BaseModel): class BrowserEvent(BaseModel):
type: Literal["click", "dblclick", "mousemove", "mousedown", "mouseup", "type", "key", "scroll", "reload", "back", "forward", "resize"] type: Literal["click", "dblclick", "mousemove", "mousedown", "mouseup", "type", "key", "scroll", "reload", "back", "forward", "resize"]
x: Optional[float] = None x: Optional[float] = None
@@ -119,6 +123,14 @@ async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_
raise _error_from_browser(exc) raise _error_from_browser(exc)
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
async def get_selection(session_id: str, _=Depends(get_current_user)):
try:
return BrowserSelectionResponse(text=await browser_sessions.selected_text(session_id))
except Exception as exc:
raise _error_from_browser(exc)
@router.delete("/{session_id}", status_code=204) @router.delete("/{session_id}", status_code=204)
async def close_session(session_id: str, _=Depends(get_current_user)): async def close_session(session_id: str, _=Depends(get_current_user)):
await browser_sessions.close(session_id) await browser_sessions.close(session_id)
@@ -126,9 +138,12 @@ async def close_session(session_id: str, _=Depends(get_current_user)):
# ——— WebSocket stream ——— # ——— WebSocket stream ———
# Frame interval & diff detection # Frame interval & diff detection
_WS_MIN_INTERVAL = 0.05 # 50 ms floor (≈20 fps max) _WS_MIN_INTERVAL = 0.10
_WS_IDLE_INTERVAL = 0.15 # 150 ms when nothing changed recently _WS_IDLE_INTERVAL = 0.35
_WS_ACTIVE_INTERVAL = 0.08 # 80 ms right after a user event _WS_ACTIVE_INTERVAL = 0.12
_WS_BACKOFF_INTERVAL = 0.60
_WS_DEEP_IDLE_INTERVAL = 1.00
_WS_ACTIVE_WINDOW = 1.25
async def _ws_authenticate(token: Optional[str]) -> bool: async def _ws_authenticate(token: Optional[str]) -> bool:
@@ -163,10 +178,11 @@ async def session_ws(
# Track when a user event arrived so we can temporarily speed up # Track when a user event arrived so we can temporarily speed up
last_event_at: float = 0.0 last_event_at: float = 0.0
last_frame_hash: str = "" last_frame_hash: str = ""
unchanged_count = 0
# Task: receive events from client # Task: receive events from client
async def receive_loop(): async def receive_loop():
nonlocal last_event_at nonlocal last_event_at, unchanged_count
try: try:
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
@@ -179,8 +195,9 @@ async def session_ws(
continue continue
payload: dict[str, Any] = {k: v for k, v in msg.items() if k != "type"} payload: dict[str, Any] = {k: v for k, v in msg.items() if k != "type"}
try: try:
await browser_sessions.event(session_id, msg_type, payload) await browser_sessions.event(session_id, msg_type, payload, include_state=False)
last_event_at = asyncio.get_event_loop().time() last_event_at = asyncio.get_event_loop().time()
unchanged_count = 0
except Exception as exc: except Exception as exc:
logger.warning("ws event error: %s", exc) logger.warning("ws event error: %s", exc)
try: try:
@@ -194,17 +211,22 @@ async def session_ws(
# Task: push screenshots # Task: push screenshots
async def push_loop(): async def push_loop():
nonlocal last_frame_hash nonlocal last_frame_hash, unchanged_count
try: try:
while True: while True:
now = asyncio.get_event_loop().time() now = asyncio.get_event_loop().time()
# Faster cadence right after a user interaction if (now - last_event_at) < _WS_ACTIVE_WINDOW:
interval = _WS_ACTIVE_INTERVAL if (now - last_event_at) < 1.0 else _WS_IDLE_INTERVAL interval = _WS_ACTIVE_INTERVAL
elif unchanged_count >= 9:
interval = _WS_DEEP_IDLE_INTERVAL
elif unchanged_count >= 3:
interval = _WS_BACKOFF_INTERVAL
else:
interval = _WS_IDLE_INTERVAL
try: try:
frame = await browser_sessions.screenshot(session_id) frame = await browser_sessions.screenshot(session_id)
except KeyError: except KeyError:
# Session gone
await websocket.send_json({"error": "session_not_found"}) await websocket.send_json({"error": "session_not_found"})
break break
except Exception as exc: except Exception as exc:
@@ -212,14 +234,16 @@ async def session_ws(
await asyncio.sleep(interval) await asyncio.sleep(interval)
continue continue
# Only push if content changed
frame_hash = hashlib.md5(frame).hexdigest() frame_hash = hashlib.md5(frame).hexdigest()
if frame_hash != last_frame_hash: if frame_hash != last_frame_hash:
last_frame_hash = frame_hash last_frame_hash = frame_hash
unchanged_count = 0
try: try:
await websocket.send_bytes(frame) await websocket.send_bytes(frame)
except Exception: except Exception:
break break
else:
unchanged_count += 1
await asyncio.sleep(max(_WS_MIN_INTERVAL, interval)) await asyncio.sleep(max(_WS_MIN_INTERVAL, interval))
except (WebSocketDisconnect, asyncio.CancelledError): except (WebSocketDisconnect, asyncio.CancelledError):
+33 -21
View File
@@ -121,6 +121,8 @@ def update_upstream(
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():
setattr(u, k, v) setattr(u, k, v)
# Reset failure counter on any update — the user may have fixed the issue
u.consecutive_failures = 0
u.updated_at = datetime.now(timezone.utc) u.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(u) db.refresh(u)
@@ -144,19 +146,29 @@ def test_upstream(uid: int, db: Session = Depends(get_db), _=Depends(get_current
if not u: if not u:
raise HTTPException(404, "upstream not found") raise HTTPException(404, "upstream not found")
auth_config = json.loads(u.auth_config_json or "{}") auth_config = json.loads(u.auth_config_json or "{}")
client = UpstreamClient( with UpstreamClient(
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=auth_config, auth_config=auth_config,
timeout=float(u.timeout_seconds), timeout=float(u.timeout_seconds),
) ) as client:
try: try:
client.login() client.login()
groups = client.get_available_groups(u.groups_endpoint) groups = client.get_available_groups(u.groups_endpoint)
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组") u.last_status = "healthy"
except Exception as exc: u.last_error = None
return TestResult(success=False, message="连接失败", detail=str(exc)) u.last_checked_at = datetime.now(timezone.utc)
u.consecutive_failures = 0
db.commit()
return TestResult(success=True, message=f"连接成功,获取到 {len(groups)} 个分组")
except Exception as exc:
u.last_status = "unhealthy"
u.last_error = str(exc)
u.last_checked_at = datetime.now(timezone.utc)
u.consecutive_failures = (u.consecutive_failures or 0) + 1
db.commit()
return TestResult(success=False, message="连接失败", detail=str(exc))
@router.post("/{uid}/check-now", response_model=TestResult) @router.post("/{uid}/check-now", response_model=TestResult)
@@ -165,24 +177,24 @@ def check_now(uid: int, db: Session = Depends(get_db), _=Depends(get_current_use
if not u: if not u:
raise HTTPException(404, "upstream not found") raise HTTPException(404, "upstream not found")
auth_config = json.loads(u.auth_config_json or "{}") auth_config = json.loads(u.auth_config_json or "{}")
client = UpstreamClient( with UpstreamClient(
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=auth_config, auth_config=auth_config,
timeout=float(u.timeout_seconds), timeout=float(u.timeout_seconds),
) ) as client:
try: try:
client.login() client.login()
groups = client.get_available_groups(u.groups_endpoint) groups = client.get_available_groups(u.groups_endpoint)
raw_rates = client.get_group_rates(u.rate_endpoint) raw_rates = client.get_group_rates(u.rate_endpoint)
snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates) snapshot = build_snapshot(u.id, u.base_url, u.api_prefix, groups, raw_rates)
except Exception as exc: except Exception as exc:
u.consecutive_failures = (u.consecutive_failures or 0) + 1 u.consecutive_failures = (u.consecutive_failures or 0) + 1
u.last_error = str(exc) u.last_error = str(exc)
u.last_checked_at = datetime.now(timezone.utc) u.last_checked_at = datetime.now(timezone.utc)
db.commit() db.commit()
return TestResult(success=False, message="检测失败", detail=str(exc)) return TestResult(success=False, message="检测失败", detail=str(exc))
prev_row = ( prev_row = (
db.query(UpstreamRateSnapshot) db.query(UpstreamRateSnapshot)
+6 -2
View File
@@ -176,6 +176,8 @@ def delete_website(wid: int, db: Session = Depends(get_db), _=Depends(get_curren
row = db.query(Website).filter(Website.id == wid).first() row = db.query(Website).filter(Website.id == wid).first()
if not row: if not row:
raise HTTPException(404, "website not found") raise HTTPException(404, "website not found")
db.query(WebsiteSyncLog).filter(WebsiteSyncLog.website_id == wid).delete(synchronize_session=False)
db.query(WebsiteGroupBinding).filter(WebsiteGroupBinding.website_id == wid).delete(synchronize_session=False)
db.delete(row) db.delete(row)
db.commit() db.commit()
@@ -186,7 +188,8 @@ def test_website(wid: int, db: Session = Depends(get_db), _=Depends(get_current_
if not row: if not row:
raise HTTPException(404, "website not found") raise HTTPException(404, "website not found")
try: try:
groups = _client(row).get_groups(row.groups_endpoint) with _client(row) as c:
groups = c.get_groups(row.groups_endpoint)
row.last_status = "healthy" row.last_status = "healthy"
row.last_error = None row.last_error = None
row.last_checked_at = datetime.now(timezone.utc) row.last_checked_at = datetime.now(timezone.utc)
@@ -206,7 +209,8 @@ def list_website_groups(wid: int, db: Session = Depends(get_db), _=Depends(get_c
if not row: if not row:
raise HTTPException(404, "website not found") raise HTTPException(404, "website not found")
try: try:
return _client(row).get_groups(row.groups_endpoint) with _client(row) as c:
return c.get_groups(row.groups_endpoint)
except Exception as exc: except Exception as exc:
raise HTTPException(502, str(exc)) raise HTTPException(502, str(exc))
@@ -98,9 +98,16 @@ class BrowserSessionService:
session = self._get(session_id) session = self._get(session_id)
async with session.lock: async with session.lock:
self._ensure_open(session) self._ensure_open(session)
return await session.page.screenshot(type="jpeg", quality=78, full_page=False) return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
async def event(self, session_id: str, event_type: str, payload: dict[str, Any]) -> dict[str, Any]: async def event(
self,
session_id: str,
event_type: str,
payload: dict[str, Any],
*,
include_state: bool = True,
) -> dict[str, Any] | None:
session = self._get(session_id) session = self._get(session_id)
async with session.lock: async with session.lock:
self._ensure_open(session) self._ensure_open(session)
@@ -141,8 +148,17 @@ class BrowserSessionService:
await page.set_viewport_size({"width": width, "height": height}) await page.set_viewport_size({"width": width, "height": height})
else: else:
raise ValueError("Unsupported browser event") raise ValueError("Unsupported browser event")
if not include_state:
return None
return await self._session_state(session) return await self._session_state(session)
async def selected_text(self, session_id: str) -> str:
session = self._get(session_id)
async with session.lock:
self._ensure_open(session)
value = await session.page.evaluate("() => window.getSelection()?.toString() || ''")
return str(value or "")
async def close(self, session_id: str) -> None: async def close(self, session_id: str) -> None:
session = self._discard_session(session_id) session = self._discard_session(session_id)
if not session: if not session:
+95 -44
View File
@@ -5,6 +5,7 @@ import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,14 +13,14 @@ 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.upstream_client import UpstreamClient, UpstreamError, build_snapshot from app.services.upstream_client import UpstreamClient, UpstreamError, build_snapshot
from app.services.snapshot_service import diff_snapshots from app.services.snapshot_service import diff_snapshots, prune_snapshots
from app.services import webhook_service from app.services import webhook_service
from app.services import website_sync from app.services import website_sync
from app.config import get_settings from app.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_scheduler = BackgroundScheduler(timezone="UTC") _scheduler = BackgroundScheduler(timezone="UTC", executors={"default": ThreadPoolExecutor(max_workers=1)})
def get_scheduler() -> BackgroundScheduler: def get_scheduler() -> BackgroundScheduler:
@@ -27,8 +28,14 @@ def get_scheduler() -> BackgroundScheduler:
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).
Phase 1 — upstream API call + snapshot write (single transaction).
Phase 2 — webhook/website sync (separate sessions, so a notification
failure never rolls back the snapshot).
"""
settings = get_settings() settings = get_settings()
# ── Phase 1: upstream check + DB write ──────────────────────────
db: Session = SessionLocal() db: Session = SessionLocal()
try: try:
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first() upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
@@ -37,42 +44,45 @@ def _check_upstream(upstream_id: int) -> None:
return return
auth_config = json.loads(upstream.auth_config_json or "{}") auth_config = json.loads(upstream.auth_config_json or "{}")
client = UpstreamClient( was_unhealthy = upstream.last_status == "unhealthy"
snapshot = None
changes = None
with UpstreamClient(
base_url=upstream.base_url, base_url=upstream.base_url,
api_prefix=upstream.api_prefix, api_prefix=upstream.api_prefix,
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),
) ) as client:
try:
was_unhealthy = upstream.last_status == "unhealthy" client.login()
groups = client.get_available_groups(upstream.groups_endpoint)
try: raw_rates = client.get_group_rates(upstream.rate_endpoint)
client.login() snapshot = build_snapshot(
groups = client.get_available_groups(upstream.groups_endpoint) upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates
raw_rates = client.get_group_rates(upstream.rate_endpoint)
snapshot = build_snapshot(
upstream.id, upstream.base_url, upstream.api_prefix, groups, raw_rates
)
except Exception as exc:
# failure path
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
upstream.last_error = str(exc)
upstream.last_checked_at = datetime.now(timezone.utc)
threshold = settings.unhealthy_threshold
if upstream.consecutive_failures >= threshold and upstream.last_status != "unhealthy":
upstream.last_status = "unhealthy"
db.commit()
webhook_service.send_status_event(
db, upstream.id, upstream.name, upstream.base_url,
"upstream_unhealthy", str(exc)
) )
else: except Exception as exc:
# failure path
upstream.consecutive_failures = (upstream.consecutive_failures or 0) + 1
upstream.last_error = str(exc)
upstream.last_checked_at = datetime.now(timezone.utc)
threshold = settings.unhealthy_threshold
became_unhealthy = (
upstream.consecutive_failures >= threshold
and upstream.last_status != "unhealthy"
)
if became_unhealthy:
upstream.last_status = "unhealthy"
db.commit() db.commit()
logger.warning("upstream %s check failed: %s", upstream.name, exc) logger.warning("upstream %s check failed: %s", upstream.name, exc)
return # Phase 2: notify unhealthy in a fresh session
if became_unhealthy:
_notify_status(upstream.id, upstream.name, upstream.base_url,
"upstream_unhealthy", str(exc))
return
# success path # success path (client auto-closed by `with`)
prev_snapshot_row = ( prev_snapshot_row = (
db.query(UpstreamRateSnapshot) db.query(UpstreamRateSnapshot)
.filter(UpstreamRateSnapshot.upstream_id == upstream_id) .filter(UpstreamRateSnapshot.upstream_id == upstream_id)
@@ -89,6 +99,7 @@ def _check_upstream(upstream_id: int) -> None:
captured_at=datetime.now(timezone.utc), captured_at=datetime.now(timezone.utc),
) )
db.add(new_row) db.add(new_row)
prune_snapshots(db, upstream_id, settings.snapshot_retention_count)
# update upstream status # update upstream status
upstream.last_status = "healthy" upstream.last_status = "healthy"
@@ -97,20 +108,60 @@ def _check_upstream(upstream_id: int) -> None:
upstream.consecutive_failures = 0 upstream.consecutive_failures = 0
db.commit() db.commit()
if was_unhealthy: logger.info(
webhook_service.send_status_event( "upstream %s: %d rate change(s)" if changes else "upstream %s: no changes",
db, upstream.id, upstream.name, upstream.base_url, "upstream_recovered" upstream.name, len(changes) if changes else 0,
) )
if changes: finally:
webhook_service.send_rate_changed( db.close()
db, upstream.id, upstream.name, upstream.base_url, changes
)
website_sync.sync_affected_bindings(db, upstream.id, changes)
logger.info("upstream %s: %d rate change(s)", upstream.name, len(changes))
else:
logger.debug("upstream %s: no changes", upstream.name)
# ── Phase 2: notifications (independent sessions) ──────────────
if was_unhealthy:
_notify_status(upstream_id, upstream.name, upstream.base_url, "upstream_recovered")
if changes:
_notify_rate_changed(upstream_id, upstream.name, upstream.base_url, changes)
_sync_website_bindings(upstream_id, changes)
def _notify_status(
upstream_id: int,
upstream_name: str,
base_url: str,
event: str,
error: str = "",
) -> None:
db = SessionLocal()
try:
webhook_service.send_status_event(db, upstream_id, upstream_name, base_url, event, error)
except Exception:
logger.exception("status webhook failed for upstream %s", upstream_name)
finally:
db.close()
def _notify_rate_changed(
upstream_id: int,
upstream_name: str,
base_url: str,
changes: list[dict[str, Any]],
) -> None:
db = SessionLocal()
try:
webhook_service.send_rate_changed(db, upstream_id, upstream_name, base_url, changes)
except Exception:
logger.exception("rate webhook failed for upstream %s", upstream_name)
finally:
db.close()
def _sync_website_bindings(upstream_id: int, changes: list[dict[str, Any]]) -> None:
db = SessionLocal()
try:
website_sync.sync_affected_bindings(db, upstream_id, changes)
except Exception:
logger.exception("website sync failed for upstream %s", upstream_id)
finally: finally:
db.close() db.close()
@@ -155,4 +206,4 @@ def start_scheduler() -> None:
def stop_scheduler() -> None: def stop_scheduler() -> None:
if _scheduler.running: if _scheduler.running:
_scheduler.shutdown(wait=False) _scheduler.shutdown(wait=True)
+21
View File
@@ -1,6 +1,10 @@
"""Snapshot diff logic.""" """Snapshot diff logic."""
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy.orm import Session
from app.models.snapshot import UpstreamRateSnapshot
def diff_snapshots( def diff_snapshots(
previous: Optional[dict[str, Any]], previous: Optional[dict[str, Any]],
@@ -37,3 +41,20 @@ def diff_snapshots(
"new_rate": None, "new_rate": None,
}) })
return changes return changes
def prune_snapshots(db: Session, upstream_id: int, keep: int) -> None:
if keep <= 0:
return
stale_ids = [
row_id
for (row_id,) in (
db.query(UpstreamRateSnapshot.id)
.filter(UpstreamRateSnapshot.upstream_id == upstream_id)
.order_by(UpstreamRateSnapshot.captured_at.desc(), UpstreamRateSnapshot.id.desc())
.offset(keep)
.all()
)
]
if stale_ids:
db.query(UpstreamRateSnapshot).filter(UpstreamRateSnapshot.id.in_(stale_ids)).delete(synchronize_session=False)
+32 -35
View File
@@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from decimal import Decimal, InvalidOperation
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
from app.utils.number import decimal_string
class UpstreamError(RuntimeError): class UpstreamError(RuntimeError):
pass pass
@@ -66,19 +67,6 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
return None return None
def _decimal_str(value: Any) -> str:
if value is None or value == "":
return ""
try:
d = Decimal(str(value))
except (InvalidOperation, ValueError):
return str(value)
n = d.normalize()
if n == n.to_integral():
return str(n.quantize(Decimal("1")))
return format(n, "f")
def _group_id(group: dict[str, Any]) -> str: def _group_id(group: dict[str, Any]) -> str:
for key in ("id", "group_id", "groupId"): for key in ("id", "group_id", "groupId"):
v = group.get(key) v = group.get(key)
@@ -95,7 +83,7 @@ def _rate_from_group(group: dict[str, Any]) -> str:
"effective_rate_multiplier", "effectiveRateMultiplier", "effective_rate_multiplier", "effectiveRateMultiplier",
"rate_multiplier", "rateMultiplier", "rate_multiplier", "rateMultiplier",
): ):
r = _decimal_str(group.get(key)) r = decimal_string(group.get(key))
if r: if r:
return r return r
return "" return ""
@@ -117,7 +105,7 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
if isinstance(parsed, dict): if isinstance(parsed, dict):
result: dict[str, str] = {} result: dict[str, str] = {}
for k, v in parsed.items(): for k, v in parsed.items():
r = _decimal_str(v) r = decimal_string(v)
if r: if r:
result[str(k)] = r result[str(k)] = r
return result return result
@@ -127,7 +115,7 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
# In case it's returned as dict directly # In case it's returned as dict directly
result = {} result = {}
for k, v in val.items(): for k, v in val.items():
r = _decimal_str(v) r = decimal_string(v)
if r: if r:
result[str(k)] = r result[str(k)] = r
return result return result
@@ -153,13 +141,13 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
result: dict[str, str] = {} result: dict[str, str] = {}
for k, v in candidates.items(): for k, v in candidates.items():
if isinstance(v, dict): if isinstance(v, dict):
r = _decimal_str( r = decimal_string(
v.get("rate_multiplier") or v.get("rateMultiplier") v.get("rate_multiplier") or v.get("rateMultiplier")
or v.get("user_rate_multiplier") or v.get("userRateMultiplier") or v.get("user_rate_multiplier") or v.get("userRateMultiplier")
or v.get("ratio") or v.get("ratio")
) )
else: else:
r = _decimal_str(v) r = decimal_string(v)
if r: if r:
result[str(k)] = r result[str(k)] = r
return result return result
@@ -221,6 +209,16 @@ class UpstreamClient:
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 = ""
self._client = httpx.Client(timeout=timeout)
def close(self) -> None:
self._client.close()
def __enter__(self) -> UpstreamClient:
return self
def __exit__(self, *args: Any) -> None:
self.close()
def _url(self, path: str) -> str: def _url(self, path: str) -> str:
prefix = f"/{self.api_prefix}" if self.api_prefix else "" prefix = f"/{self.api_prefix}" if self.api_prefix else ""
@@ -250,22 +248,21 @@ class UpstreamClient:
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:
url = self._url(path) url = self._url(path)
with httpx.Client(timeout=self.timeout) as client: if body is not None:
if body is not None: resp = self._client.request(
resp = client.request( method,
method, url,
url, json=body,
json=body, headers=self._headers(auth),
headers=self._headers(auth), cookies=self._cookies,
cookies=self._cookies, )
) else:
else: resp = self._client.request(
resp = client.request( method,
method, url,
url, headers=self._headers(auth),
headers=self._headers(auth), cookies=self._cookies,
cookies=self._cookies, )
)
self._cookies.update(dict(resp.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", "")
+13 -15
View File
@@ -6,24 +6,13 @@ from urllib.parse import quote
import httpx import httpx
from app.utils.number import decimal_string
class WebsiteError(RuntimeError): class WebsiteError(RuntimeError):
pass pass
def decimal_string(value: Any) -> str:
if value is None or value == "":
return ""
try:
d = Decimal(str(value))
except (InvalidOperation, ValueError):
return str(value)
n = d.normalize()
if n == n.to_integral():
return str(n.quantize(Decimal("1")))
return format(n, "f")
def parse_positive_decimal(value: Any) -> Decimal | None: def parse_positive_decimal(value: Any) -> Decimal | None:
if value is None or value == "": if value is None or value == "":
return None return None
@@ -111,6 +100,16 @@ class Sub2ApiWebsiteClient:
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._client = httpx.Client(timeout=timeout)
def close(self) -> None:
self._client.close()
def __enter__(self) -> Sub2ApiWebsiteClient:
return self
def __exit__(self, *args: Any) -> None:
self.close()
def _url(self, path: str) -> str: def _url(self, path: str) -> str:
prefix = f"/{self.api_prefix}" if self.api_prefix else "" prefix = f"/{self.api_prefix}" if self.api_prefix else ""
@@ -130,8 +129,7 @@ class Sub2ApiWebsiteClient:
return headers return headers
def _request(self, method: str, path: str, body: Any = None) -> Any: def _request(self, method: str, path: str, body: Any = None) -> Any:
with httpx.Client(timeout=self.timeout) as client: resp = self._client.request(method, self._url(path), json=body, headers=self._headers())
resp = client.request(method, self._url(path), json=body, headers=self._headers())
resp.raise_for_status() resp.raise_for_status()
if not resp.content: if not resp.content:
return None return None
+5 -5
View File
@@ -118,11 +118,11 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True)
old_rate = None old_rate = None
if write and website.enabled and website.auto_sync_enabled and binding.enabled: if write and website.enabled and website.auto_sync_enabled and binding.enabled:
try: try:
client = _client_for(website) with _client_for(website) as client:
groups = client.get_groups(website.groups_endpoint) groups = client.get_groups(website.groups_endpoint)
target = next((item for item in groups if item.get("id") == binding.target_group_id), None) target = next((item for item in groups if item.get("id") == binding.target_group_id), None)
old_rate = target.get("rate_multiplier") if target else None old_rate = target.get("rate_multiplier") if target else None
client.update_group_rate(website.group_update_endpoint, binding.target_group_id, target_rate) client.update_group_rate(website.group_update_endpoint, binding.target_group_id, target_rate)
website.last_status = "healthy" website.last_status = "healthy"
website.last_error = None website.last_error = None
except Exception as exc: except Exception as exc:
+34 -12
View File
@@ -1,25 +1,37 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from uuid import uuid4
from jose import JWTError, jwt from jose import JWTError, jwt
import bcrypt import bcrypt
from fastapi import Depends, HTTPException, Query, Request, status from fastapi import Depends, HTTPException, Query, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.database import get_db from app.database import get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.revoked_token import RevokedToken
ALGORITHM = "HS256" ALGORITHM = "HS256"
BCRYPT_MAX_PASSWORD_BYTES = 72
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
def validate_password_supported(password: str) -> None:
if len(password.encode("utf-8")) > BCRYPT_MAX_PASSWORD_BYTES:
raise ValueError("password must be at most 72 bytes when UTF-8 encoded")
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
pw = password.encode("utf-8")[:72] validate_password_supported(password)
pw = password.encode("utf-8")
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8") return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool: def verify_password(plain: str, hashed: str) -> bool:
pw = plain.encode("utf-8")[:72] validate_password_supported(plain)
pw = plain.encode("utf-8")
return bcrypt.checkpw(pw, hashed.encode("utf-8")) return bcrypt.checkpw(pw, hashed.encode("utf-8"))
@@ -27,19 +39,29 @@ def create_access_token(email: str, expires_hours: Optional[int] = None) -> str:
settings = get_settings() settings = get_settings()
hours = expires_hours or settings.jwt_expire_hours hours = expires_hours or settings.jwt_expire_hours
expire = datetime.now(timezone.utc) + timedelta(hours=hours) expire = datetime.now(timezone.utc) + timedelta(hours=hours)
data = {"sub": email, "exp": expire} data = {"sub": email, "exp": expire, "jti": uuid4().hex}
return jwt.encode(data, settings.jwt_secret, algorithm=ALGORITHM) return jwt.encode(data, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[str]: def decode_token_payload(token: str) -> Optional[dict]:
settings = get_settings() settings = get_settings()
try: try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM]) return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
return payload.get("sub")
except JWTError: except JWTError:
return None return None
def decode_token(token: str) -> Optional[str]:
payload = decode_token_payload(token)
return payload.get("sub") if payload else None
def _is_revoked(db: Session, jti: str | None) -> bool:
if not jti:
return True
return db.query(RevokedToken).filter(RevokedToken.jti == jti).first() is not None
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -47,10 +69,10 @@ def get_current_user(
token = credentials.credentials if credentials else None token = credentials.credentials if credentials else None
if not token: if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
email = decode_token(token) payload = decode_token_payload(token)
if not email: if not payload or _is_revoked(db, payload.get("jti")):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(AdminUser).filter(AdminUser.email == email).first() user = db.query(AdminUser).filter(AdminUser.email == payload.get("sub")).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user return user
@@ -65,10 +87,10 @@ def get_user_from_token_param(
raw = token or (credentials.credentials if credentials else None) raw = token or (credentials.credentials if credentials else None)
if not raw: if not raw:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
email = decode_token(raw) payload = decode_token_payload(raw)
if not email: if not payload or _is_revoked(db, payload.get("jti")):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(AdminUser).filter(AdminUser.email == email).first() user = db.query(AdminUser).filter(AdminUser.email == payload.get("sub")).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user return user
+25
View File
@@ -0,0 +1,25 @@
"""Shared numeric formatting utilities."""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any
def decimal_string(value: Any) -> str:
"""Format a numeric value as a clean decimal string.
- None / empty → ""
- Whole numbers → no decimal point (e.g. "5")
- Decimals → trailing zeros stripped (e.g. "3.14")
- Unparseable → raw str()
"""
if value is None or value == "":
return ""
try:
d = Decimal(str(value))
except (InvalidOperation, ValueError):
return str(value)
n = d.normalize()
if n == n.to_integral():
return str(n.quantize(Decimal("1")))
return format(n, "f")
+29 -22
View File
@@ -6,31 +6,38 @@ from app.services.upstream_client import UpstreamClient
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
def main(): def main():
client = UpstreamClient( # Usage: python test_upstream.py <base_url> [api_prefix] [auth_type] [token]
base_url="http://170.106.100.210:55555", # Example: python test_upstream.py http://localhost:8000 "" bearer "sk-xxx"
api_prefix="", import sys
auth_type="bearer", base_url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000"
auth_config={"token": ""}, # We don't have token, but /api/group/ in some new-api may be open, or fail with 401 api_prefix = sys.argv[2] if len(sys.argv) > 2 else ""
auth_type = sys.argv[3] if len(sys.argv) > 3 else "bearer"
token = sys.argv[4] if len(sys.argv) > 4 else ""
with UpstreamClient(
base_url=base_url,
api_prefix=api_prefix,
auth_type=auth_type,
auth_config={"token": token},
timeout=10.0, timeout=10.0,
) ) as client:
try: try:
groups = client.get_available_groups("/api/group/") groups = client.get_available_groups("/api/group/")
print("Groups:", groups) print("Groups:", groups)
except Exception as e: except Exception as e:
print("Groups Error:", e) print("Groups Error:", e)
try: try:
rates = client.get_group_rates("/api/option/?key=GroupRatio") rates = client.get_group_rates("/api/option/?key=GroupRatio")
print("Rates:", rates) print("Rates:", rates)
except Exception as e: except Exception as e:
print("Rates Error:", e) print("Rates Error:", e)
try: try:
from app.services.upstream_client import _extract_rates_map, _unwrap_list from app.services.upstream_client import _extract_rates_map, _unwrap_list
print("Unwrapped Groups:", _unwrap_list(groups)) print("Unwrapped Groups:", _unwrap_list(groups))
print("Extracted Rates:", _extract_rates_map(rates)) print("Extracted Rates:", _extract_rates_map(rates))
except Exception as e: except Exception as e:
pass pass
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+6 -3
View File
@@ -7,17 +7,20 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8899}:8000" - "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8899}:8000"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@smartup.local} - ADMIN_EMAIL=admin@smartup.local
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} - ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
- JWT_SECRET=${JWT_SECRET:-change-me-in-production} - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:8899,http://127.0.0.1:8899}
- DATABASE_URL=sqlite:////app/data/app.db - DATABASE_URL=sqlite:////app/data/app.db
- TZ=${TZ:-Asia/Shanghai} - TZ=${TZ:-Asia/Shanghai}
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3} - UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=5).read()"] test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+25
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"axios-retry": "^4.5.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"element-plus": "^2.8.8", "element-plus": "^2.8.8",
"pinia": "^2.2.6", "pinia": "^2.2.6",
@@ -1202,6 +1203,18 @@
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/axios-retry": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/axios-retry/-/axios-retry-4.5.0.tgz",
"integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==",
"license": "Apache-2.0",
"dependencies": {
"is-retry-allowed": "^2.2.0"
},
"peerDependencies": {
"axios": "0.x || 1.x"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1593,6 +1606,18 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/is-retry-allowed": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz",
"integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+6 -5
View File
@@ -8,13 +8,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.4.5",
"pinia": "^2.2.6",
"element-plus": "^2.8.8",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9", "axios": "^1.7.9",
"dayjs": "^1.11.13" "axios-retry": "^4.5.0",
"dayjs": "^1.11.13",
"element-plus": "^2.8.8",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
+21 -2
View File
@@ -1,17 +1,35 @@
import axios from 'axios' import axios from 'axios'
import axiosRetry from 'axios-retry'
import router from '@/router' import router from '@/router'
import { authStorageKeys } from '@/authStorage'
export const api = axios.create({ export const api = axios.create({
baseURL: '/', baseURL: '/',
timeout: 30000, timeout: 30000,
}) })
axiosRetry(api, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
onRetry: (_retryCount, _err, _requestConfig) => {
// no-op — could log in dev
},
// Only retry idempotent methods — never retry POST/PUT/PATCH/DELETE
retryCondition: (err) => {
const method = (err.config?.method ?? '').toUpperCase()
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) return false
// Retry on network errors or 5xx, but never on 401/403/404/4xx
if (!err.response) return true
return err.response.status >= 500 && err.response.status < 600
},
})
api.interceptors.response.use( api.interceptors.response.use(
(r) => r, (r) => r,
(err) => { (err) => {
if (err.response?.status === 401) { if (err.response?.status === 401) {
localStorage.removeItem('smartup_token') localStorage.removeItem(authStorageKeys.token)
localStorage.removeItem('smartup_email') localStorage.removeItem(authStorageKeys.email)
router.push('/login') router.push('/login')
} }
return Promise.reject(err) return Promise.reject(err)
@@ -293,6 +311,7 @@ export const browserSessionsApi = {
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`), get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
event: (id: string, data: BrowserEventPayload) => event: (id: string, data: BrowserEventPayload) =>
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data), api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
close: (id: string) => api.delete(`/api/browser-sessions/${id}`), close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
screenshotUrl: (id: string, token?: string) => { screenshotUrl: (id: string, token?: string) => {
const params = new URLSearchParams({ t: String(Date.now()) }) const params = new URLSearchParams({ t: String(Date.now()) })
+5
View File
@@ -91,6 +91,11 @@ img {
margin: 0 auto; margin: 0 auto;
} }
.shell-page.shell-page-fluid {
width: 100%;
max-width: none;
}
.page-section { .page-section {
display: grid; display: grid;
gap: 1.25rem; gap: 1.25rem;
+7
View File
@@ -0,0 +1,7 @@
const appKey = import.meta.env.VITE_APP_KEY || location.pathname.replace(/\W+/g, '_') || 'smartup'
const prefix = `smartup_${appKey}`
export const authStorageKeys = {
token: `${prefix}_token`,
email: `${prefix}_email`,
}
+7 -12
View File
@@ -19,13 +19,6 @@
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-title">监控中枢</div> <div class="sidebar-section-title">监控中枢</div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
<span class="nav-copy">
<strong>上游管理</strong>
<small>轮询健康度倍率快照</small>
</span>
</router-link>
<router-link to="/websites" class="nav-item" active-class="active" @click="closeMobileNav"> <router-link to="/websites" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><OfficeBuilding /></el-icon></span> <span class="nav-icon"><el-icon><OfficeBuilding /></el-icon></span>
<span class="nav-copy"> <span class="nav-copy">
@@ -33,6 +26,13 @@
<small>目标站点分组映射自动同步</small> <small>目标站点分组映射自动同步</small>
</span> </span>
</router-link> </router-link>
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
<span class="nav-copy">
<strong>上游管理</strong>
<small>轮询健康度倍率快照</small>
</span>
</router-link>
<router-link to="/webhooks" class="nav-item" active-class="active" @click="closeMobileNav"> <router-link to="/webhooks" class="nav-item" active-class="active" @click="closeMobileNav">
<span class="nav-icon"><el-icon><Bell /></el-icon></span> <span class="nav-icon"><el-icon><Bell /></el-icon></span>
<span class="nav-copy"> <span class="nav-copy">
@@ -531,11 +531,6 @@ watch([() => route.path, customPages], () => {
background: rgba(25, 19, 16, 0.7); background: rgba(25, 19, 16, 0.7);
} }
.topbar:not(.compact) {
width: min(100%, var(--content-max));
margin: 0 auto;
}
.topbar.compact { .topbar.compact {
min-height: 2.8rem; min-height: 2.8rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
+7 -6
View File
@@ -1,24 +1,25 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { api } from '@/api' import { api } from '@/api'
import { authStorageKeys } from '@/authStorage'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(localStorage.getItem('smartup_token') || '') const token = ref<string>(localStorage.getItem(authStorageKeys.token) || '')
const email = ref<string>(localStorage.getItem('smartup_email') || '') const email = ref<string>(localStorage.getItem(authStorageKeys.email) || '')
function setToken(t: string, e: string) { function setToken(t: string, e: string) {
token.value = t token.value = t
email.value = e email.value = e
localStorage.setItem('smartup_token', t) localStorage.setItem(authStorageKeys.token, t)
localStorage.setItem('smartup_email', e) localStorage.setItem(authStorageKeys.email, e)
api.defaults.headers.common['Authorization'] = `Bearer ${t}` api.defaults.headers.common['Authorization'] = `Bearer ${t}`
} }
function clear() { function clear() {
token.value = '' token.value = ''
email.value = '' email.value = ''
localStorage.removeItem('smartup_token') localStorage.removeItem(authStorageKeys.token)
localStorage.removeItem('smartup_email') localStorage.removeItem(authStorageKeys.email)
delete api.defaults.headers.common['Authorization'] delete api.defaults.headers.common['Authorization']
} }
+1 -1
View File
@@ -100,7 +100,7 @@ const formRef = ref<FormInstance>()
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
const form = ref({ email: 'admin@smartup.local', password: 'changeme123' }) const form = ref({ email: '', password: '' })
const rules = { const rules = {
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }], email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
+38 -12
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="shell-page page-section"> <div class="shell-page shell-page-fluid page-section">
<div class="page-header surface-card page-block"> <div class="page-header surface-card page-block">
<div class="page-heading"> <div class="page-heading">
<p class="page-kicker">Delivery Trace</p> <p class="page-kicker">Delivery Trace</p>
@@ -7,11 +7,11 @@
<p class="page-desc">查看所有 Webhook 通知的发送记录</p> <p class="page-desc">查看所有 Webhook 通知的发送记录</p>
</div> </div>
<div class="filters"> <div class="filters">
<el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="loadList"> <el-select v-model="filterStatus" placeholder="状态" clearable style="width:110px" @change="handleFilterChange">
<el-option label="成功" value="success" /> <el-option label="成功" value="success" />
<el-option label="失败" value="failed" /> <el-option label="失败" value="failed" />
</el-select> </el-select>
<el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="loadList"> <el-select v-model="filterEvent" placeholder="事件类型" clearable style="width:150px" @change="handleFilterChange">
<el-option label="上游倍率变更" value="upstream_rate_changed" /> <el-option label="上游倍率变更" value="upstream_rate_changed" />
<el-option label="网站倍率变更" value="website_rate_changed" /> <el-option label="网站倍率变更" value="website_rate_changed" />
<el-option label="服务异常" value="upstream_unhealthy" /> <el-option label="服务异常" value="upstream_unhealthy" />
@@ -58,9 +58,13 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="pagination"> <div class="pagination">
<el-button :disabled="offset === 0" @click="prevPage" size="small">上一页</el-button> <div class="page-info">
<span class="page-info"> {{ offset / limit + 1 }} </span> {{ currentPage }} · 每页 {{ pageSize }}
<el-button :disabled="list.length < limit" @click="nextPage" size="small">下一页</el-button> </div>
<div class="page-actions">
<el-button :disabled="offset === 0 || tableLoading" @click="prevPage" size="small">上一页</el-button>
<el-button :disabled="!hasNextPage || tableLoading" @click="nextPage" size="small">下一页</el-button>
</div>
</div> </div>
</div> </div>
@@ -91,7 +95,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { logsApi, type LogData } from '@/api' import { logsApi, type LogData } from '@/api'
@@ -102,7 +106,9 @@ const detailRow = ref<LogData | null>(null)
const filterStatus = ref('') const filterStatus = ref('')
const filterEvent = ref('') const filterEvent = ref('')
const offset = ref(0) const offset = ref(0)
const limit = 50 const pageSize = 50
const hasNextPage = ref(false)
const currentPage = computed(() => Math.floor(offset.value / pageSize) + 1)
const EVENT_LABELS: Record<string, string> = { const EVENT_LABELS: Record<string, string> = {
upstream_rate_changed: '上游倍率变更', upstream_rate_changed: '上游倍率变更',
@@ -124,10 +130,11 @@ async function loadList() {
const res = await logsApi.list({ const res = await logsApi.list({
status: filterStatus.value || undefined, status: filterStatus.value || undefined,
event_type: filterEvent.value || undefined, event_type: filterEvent.value || undefined,
limit, limit: pageSize + 1,
offset: offset.value, offset: offset.value,
}) })
list.value = res.data hasNextPage.value = res.data.length > pageSize
list.value = res.data.slice(0, pageSize)
} finally { } finally {
tableLoading.value = false tableLoading.value = false
} }
@@ -138,12 +145,17 @@ function viewDetail(row: LogData) {
detailVisible.value = true detailVisible.value = true
} }
function handleFilterChange() {
offset.value = 0
loadList()
}
function prevPage() { function prevPage() {
offset.value = Math.max(0, offset.value - limit) offset.value = Math.max(0, offset.value - pageSize)
loadList() loadList()
} }
function nextPage() { function nextPage() {
offset.value += limit offset.value += pageSize
loadList() loadList()
} }
@@ -158,4 +170,18 @@ onMounted(loadList)
.page-header { .page-header {
border-radius: var(--radius-shell); border-radius: var(--radius-shell);
} }
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
}
.page-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style> </style>
+103 -10
View File
@@ -18,6 +18,11 @@
<el-icon><Back /></el-icon> <el-icon><Back /></el-icon>
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip v-if="isRemoteBrowser" content="复制远程选中文本">
<el-button size="small" text @click="copyRemoteSelection">
<el-icon><DocumentCopy /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip v-if="isRemoteBrowser" content="前进"> <el-tooltip v-if="isRemoteBrowser" content="前进">
<el-button size="small" text @click="sendRemoteCommand('forward')"> <el-button size="small" text @click="sendRemoteCommand('forward')">
<el-icon><Right /></el-icon> <el-icon><Right /></el-icon>
@@ -67,7 +72,7 @@
class="remote-screen" class="remote-screen"
alt="" alt=""
draggable="false" draggable="false"
@load="() => { iframeLoading = false }" @load="onRemoteImageLoad"
@error="() => handleRemoteSessionFailure(undefined, '远程浏览器截图加载失败')" @error="() => handleRemoteSessionFailure(undefined, '远程浏览器截图加载失败')"
@pointerdown.stop.prevent="onRemotePointerDown" @pointerdown.stop.prevent="onRemotePointerDown"
@pointermove.stop.prevent="onRemotePointerMove" @pointermove.stop.prevent="onRemotePointerMove"
@@ -126,7 +131,7 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, markRaw } f
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy,
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine, Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House, Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
@@ -164,7 +169,12 @@ const isReconnectingRemoteBrowser = ref(false)
const remoteErrorState = ref<RemoteBrowserErrorState | null>(null) const remoteErrorState = ref<RemoteBrowserErrorState | null>(null)
let startRemoteBrowserPromise: Promise<void> | null = null let startRemoteBrowserPromise: Promise<void> | null = null
let screenshotObjectUrl = '' let screenshotObjectUrl = ''
let previousScreenshotObjectUrl = ''
let pendingScreenshotBlob: Blob | null = null
let screenshotFrameRequest: number | undefined
let mouseMoveTimer: number | undefined let mouseMoveTimer: number | undefined
let wheelTimer: number | undefined
let pendingWheel: { deltaX: number; deltaY: number; x: number; y: number } | null = null
let caretHideTimer: number | undefined let caretHideTimer: number | undefined
// Caret / cursor overlay // Caret / cursor overlay
@@ -183,8 +193,11 @@ let wsReconnectTimer: number | undefined
let wsReconnectAttempts = 0 let wsReconnectAttempts = 0
const WS_MAX_RECONNECT = 5 const WS_MAX_RECONNECT = 5
const WS_RECONNECT_BASE_MS = 800 const WS_RECONNECT_BASE_MS = 800
const REMOTE_DRAG_MOVE_INTERVAL_MS = 16 const WS_BACKPRESSURE_BYTES = 256 * 1024
const REMOTE_HOVER_MOVE_INTERVAL_MS = 80 const REMOTE_DRAG_MOVE_INTERVAL_MS = 32
const REMOTE_HOVER_MOVE_INTERVAL_MS = 120
const REMOTE_WHEEL_INTERVAL_MS = 45
const HIGH_FREQUENCY_EVENTS = new Set(['mousemove', 'scroll'])
type RemoteBrowserErrorState = { type RemoteBrowserErrorState = {
title: string title: string
@@ -318,10 +331,7 @@ function connectRemoteWs() {
socket.onmessage = (evt) => { socket.onmessage = (evt) => {
if (evt.data instanceof Blob) { if (evt.data instanceof Blob) {
// Binary frame = JPEG screenshot queueRemoteScreenshot(evt.data)
const newUrl = URL.createObjectURL(evt.data)
setRemoteScreenshotUrl(newUrl)
iframeLoading.value = false
return return
} }
// Text frame = JSON control message // Text frame = JSON control message
@@ -368,6 +378,11 @@ function stopRemoteWs() {
window.clearTimeout(wsReconnectTimer) window.clearTimeout(wsReconnectTimer)
wsReconnectTimer = undefined wsReconnectTimer = undefined
} }
if (wheelTimer !== undefined) {
window.clearTimeout(wheelTimer)
wheelTimer = undefined
}
pendingWheel = null
if (ws) { if (ws) {
// Prevent onclose from triggering reconnect // Prevent onclose from triggering reconnect
const old = ws const old = ws
@@ -380,11 +395,13 @@ function stopRemoteWs() {
async function sendRemoteEvent(payload: BrowserEventPayload) { async function sendRemoteEvent(payload: BrowserEventPayload) {
if (!props.active || isStartingRemoteBrowser.value) return if (!props.active || isStartingRemoteBrowser.value) return
if (!remoteSession.value) return if (!remoteSession.value) return
const highFrequency = HIGH_FREQUENCY_EVENTS.has(payload.type)
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
if (highFrequency && ws.bufferedAmount > WS_BACKPRESSURE_BYTES) return
ws.send(JSON.stringify(payload)) ws.send(JSON.stringify(payload))
return return
} }
// Fallback: HTTP POST (e.g. during reconnect window) if (highFrequency) return
try { try {
const res = await browserSessionsApi.event(remoteSession.value.id, payload) const res = await browserSessionsApi.event(remoteSession.value.id, payload)
remoteSession.value = res.data remoteSession.value = res.data
@@ -397,6 +414,22 @@ function sendRemoteCommand(type: 'reload' | 'back' | 'forward') {
sendRemoteEvent({ type }) sendRemoteEvent({ type })
} }
async function copyRemoteSelection() {
if (!remoteSession.value) return
try {
const res = await browserSessionsApi.selection(remoteSession.value.id)
const text = res.data.text.trim()
if (!text) {
ElMessage.warning('远程页面没有选中文本')
return
}
await navigator.clipboard.writeText(text)
ElMessage.success('已复制远程选中文本')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '复制失败')
}
}
function remoteViewport() { function remoteViewport() {
const rect = remoteFrameRef.value?.getBoundingClientRect() const rect = remoteFrameRef.value?.getBoundingClientRect()
return { return {
@@ -513,7 +546,23 @@ function onRemotePointerCancel(event: PointerEvent) {
function onRemoteWheel(event: WheelEvent) { function onRemoteWheel(event: WheelEvent) {
const point = eventPoint(event) const point = eventPoint(event)
if (!point) return if (!point) return
sendRemoteEvent({ type: 'scroll', delta_x: event.deltaX, delta_y: event.deltaY, ...point }) if (!pendingWheel) {
pendingWheel = { deltaX: 0, deltaY: 0, ...point }
}
pendingWheel.deltaX += event.deltaX
pendingWheel.deltaY += event.deltaY
pendingWheel.x = point.x
pendingWheel.y = point.y
if (wheelTimer !== undefined) return
wheelTimer = window.setTimeout(flushRemoteWheel, REMOTE_WHEEL_INTERVAL_MS)
}
function flushRemoteWheel() {
wheelTimer = undefined
const wheel = pendingWheel
pendingWheel = null
if (!wheel) return
sendRemoteEvent({ type: 'scroll', delta_x: wheel.deltaX, delta_y: wheel.deltaY, x: wheel.x, y: wheel.y })
} }
function onRemoteKeydown(event: KeyboardEvent) { function onRemoteKeydown(event: KeyboardEvent) {
@@ -521,6 +570,12 @@ function onRemoteKeydown(event: KeyboardEvent) {
// which we handle in onRemotePaste with the actual clipboard text. // which we handle in onRemotePaste with the actual clipboard text.
const isPaste = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v' const isPaste = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v'
if (isPaste) return if (isPaste) return
const isCopy = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c'
if (isCopy) {
event.preventDefault()
copyRemoteSelection()
return
}
// Prevent browser from handling other keys (scrolling, shortcuts, etc.) // Prevent browser from handling other keys (scrolling, shortcuts, etc.)
event.preventDefault() event.preventDefault()
@@ -568,7 +623,45 @@ async function closeRemoteSession() {
await browserSessionsApi.close(id).catch(() => undefined) await browserSessionsApi.close(id).catch(() => undefined)
} }
function queueRemoteScreenshot(blob: Blob) {
pendingScreenshotBlob = blob
if (screenshotFrameRequest !== undefined) return
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
}
function renderPendingScreenshot() {
screenshotFrameRequest = undefined
const blob = pendingScreenshotBlob
pendingScreenshotBlob = null
if (!blob) return
const nextUrl = URL.createObjectURL(blob)
if (previousScreenshotObjectUrl) URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = screenshotObjectUrl
screenshotObjectUrl = nextUrl
remoteScreenshotUrl.value = nextUrl
}
function onRemoteImageLoad() {
iframeLoading.value = false
if (previousScreenshotObjectUrl) {
URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = ''
}
if (pendingScreenshotBlob && screenshotFrameRequest === undefined) {
screenshotFrameRequest = window.requestAnimationFrame(renderPendingScreenshot)
}
}
function setRemoteScreenshotUrl(url: string) { function setRemoteScreenshotUrl(url: string) {
if (screenshotFrameRequest !== undefined) {
window.cancelAnimationFrame(screenshotFrameRequest)
screenshotFrameRequest = undefined
}
pendingScreenshotBlob = null
if (previousScreenshotObjectUrl) {
URL.revokeObjectURL(previousScreenshotObjectUrl)
previousScreenshotObjectUrl = ''
}
if (screenshotObjectUrl) { if (screenshotObjectUrl) {
URL.revokeObjectURL(screenshotObjectUrl) URL.revokeObjectURL(screenshotObjectUrl)
screenshotObjectUrl = '' screenshotObjectUrl = ''
+376 -72
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="shell-page page-section upstreams-page"> <div class="shell-page shell-page-fluid page-section upstreams-page">
<section class="page-header upstreams-hero surface-card"> <section class="page-header upstreams-hero surface-card">
<div class="page-heading"> <div class="page-heading">
<p class="page-kicker">Monitoring Matrix</p> <p class="page-kicker">Monitoring Matrix</p>
@@ -44,85 +44,166 @@
</article> </article>
</section> </section>
<section class="surface-card data-stage"> <section class="upstreams-content">
<div class="section-header data-stage-head"> <section class="surface-card data-stage">
<div> <div class="section-header data-stage-head">
<div class="section-caption">Upstream Registry</div> <div>
<h3 class="data-stage-title brand-type">检测与变更控制台</h3> <div class="section-caption">Upstream Registry</div>
<h3 class="data-stage-title brand-type">检测与变更控制台</h3>
</div>
<p class="data-stage-note">点击详情可查看快照历史分组倍率与最近错误</p>
</div> </div>
<p class="data-stage-note">点击详情可查看快照历史分组倍率与最近错误</p>
</div>
<el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%"> <el-table :data="list" v-loading="tableLoading" row-key="id" style="width: 100%">
<el-table-column label="来源" min-width="220"> <el-table-column label="来源" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-name">{{ row.name }}</div> <div class="cell-name">{{ row.name }}</div>
<div class="cell-url mono">{{ row.base_url }}</div> <div class="cell-url mono">{{ row.base_url }}</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="118"> <el-table-column label="状态" width="118">
<template #default="{ row }"> <template #default="{ row }">
<span :class="['status-badge', row.last_status]"> <span :class="['status-badge', row.last_status]">
<span class="dot" /> <span class="dot" />
{{ statusLabel(row.last_status) }} {{ statusLabel(row.last_status) }}
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="启用" width="88" align="center"> <el-table-column label="启用" width="88" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-switch v-model="row.enabled" @change="toggleEnabled(row)" /> <el-switch v-model="row.enabled" @change="toggleEnabled(row)" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="认证" width="132"> <el-table-column label="认证" width="132">
<template #default="{ row }"> <template #default="{ row }">
<span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span> <span class="status-badge auth-badge">{{ authLabel(row.auth_type) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="检测间隔" width="112"> <el-table-column label="检测间隔" width="112">
<template #default="{ row }"> <template #default="{ row }">
<span class="mono time-inline">{{ row.check_interval_seconds }}s</span> <span class="mono time-inline">{{ row.check_interval_seconds }}s</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="最近检测" min-width="168"> <el-table-column label="最近检测" min-width="168">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span> <span v-if="row.last_checked_at" class="time-text mono">{{ fmtTime(row.last_checked_at) }}</span>
<span v-else class="muted">未检测</span> <span v-else class="muted">未检测</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="最近错误" min-width="220"> <el-table-column label="最近错误" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300"> <el-tooltip v-if="row.last_error" :content="row.last_error" placement="top" :show-after="300">
<span class="error-text">{{ shrinkError(row.last_error) }}</span> <span class="error-text">{{ shrinkError(row.last_error) }}</span>
</el-tooltip> </el-tooltip>
<span v-else class="muted">无异常</span> <span v-else class="muted">无异常</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="258" fixed="right"> <el-table-column label="操作" width="258" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-row"> <div class="action-row">
<el-button size="small" text @click="openEdit(row)" title="编辑"> <el-button size="small" text @click="openEdit(row)" title="编辑">
<el-icon><Edit /></el-icon> <el-icon><Edit /></el-icon>
</el-button> </el-button>
<el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button> <el-button size="small" text type="success" @click="testUpstream(row)" :loading="row._testing">测试</el-button>
<el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button> <el-button size="small" text type="primary" @click="checkNow(row)" :loading="row._checking">检测</el-button>
<el-button size="small" text type="info" @click="openDetail(row)"> <el-button size="small" text type="info" @click="openDetail(row)">
<el-icon><List /></el-icon> <el-icon><List /></el-icon>
详情 详情
</el-button> </el-button>
<el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除"> <el-button size="small" text type="danger" @click="confirmDelete(row)" title="删除">
<el-icon><Delete /></el-icon> <el-icon><Delete /></el-icon>
</el-button> </el-button>
</div>
</template>
</el-table-column>
</el-table>
</section>
<aside class="upstreams-side surface-card">
<section class="side-section overview-section">
<div class="section-header insight-head">
<div>
<div class="section-caption">Runtime Snapshot</div>
<h3 class="insight-title">运行概览</h3>
</div> </div>
</template> <span class="insight-pill">健康率 {{ healthyRate }}%</span>
</el-table-column> </div>
</el-table> <div class="insight-grid">
<div class="insight-metric">
<span>异常节点</span>
<strong>{{ metrics.unhealthy }}</strong>
</div>
<div class="insight-metric">
<span>已启用</span>
<strong>{{ metrics.enabled }}</strong>
</div>
<div class="insight-metric">
<span>待检测</span>
<strong>{{ pendingChecks }}</strong>
</div>
<div class="insight-metric">
<span>最近检测</span>
<strong>{{ latestCheckedAt ? fmtTime(latestCheckedAt) : '—' }}</strong>
</div>
</div>
</section>
<section class="side-section">
<div class="section-header insight-head compact">
<div>
<div class="section-caption">Need Attention</div>
<h3 class="insight-title">待关注节点</h3>
</div>
</div>
<div v-if="attentionList.length > 0" class="feed-list">
<button
v-for="row in attentionList"
:key="row.id"
type="button"
class="feed-item"
@click="openDetail(row)"
>
<div class="feed-main">
<div class="feed-top">
<span class="feed-name">{{ row.name }}</span>
<span :class="['status-badge', row.last_status]">
<span class="dot" />{{ statusLabel(row.last_status) }}
</span>
</div>
<div class="feed-meta">{{ row.last_error ? shrinkError(row.last_error) : '最近状态异常,建议查看快照详情' }}</div>
</div>
<span class="feed-link">详情</span>
</button>
</div>
<div v-else class="empty-hint side-empty">当前没有需要关注的异常节点</div>
</section>
<section class="side-section">
<div class="section-header insight-head compact">
<div>
<div class="section-caption">Recent Activity</div>
<h3 class="insight-title">最近检测</h3>
</div>
</div>
<div v-if="recentChecks.length > 0" class="timeline-list">
<div v-for="row in recentChecks" :key="row.id" class="timeline-item">
<div class="timeline-main">
<div class="timeline-name">{{ row.name }}</div>
<div class="timeline-meta mono">{{ fmtTime(row.last_checked_at!) }}</div>
</div>
<el-button size="small" text type="primary" @click="openDetail(row)">查看</el-button>
</div>
</div>
<div v-else class="empty-hint side-empty">还没有检测记录</div>
</section>
</aside>
</section> </section>
<el-drawer <el-drawer
@@ -382,6 +463,34 @@ const metrics = computed(() => ({
unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length, unhealthy: list.value.filter((item) => item.last_status === 'unhealthy').length,
})) }))
const healthyRate = computed(() => {
if (metrics.value.total === 0) return 0
return Math.round((metrics.value.healthy / metrics.value.total) * 100)
})
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
const latestCheckedAt = computed(() => {
const times = list.value
.map((item) => item.last_checked_at)
.filter((value): value is string => Boolean(value))
.sort((a, b) => new Date(toUTC(b)).getTime() - new Date(toUTC(a)).getTime())
return times[0] || ''
})
const attentionList = computed(() =>
list.value
.filter((item) => item.last_status === 'unhealthy' || item.last_error)
.slice(0, 4),
)
const recentChecks = computed(() =>
[...list.value]
.filter((item) => Boolean(item.last_checked_at))
.sort((a, b) => new Date(toUTC(b.last_checked_at as string)).getTime() - new Date(toUTC(a.last_checked_at as string)).getTime())
.slice(0, 5),
)
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', api_key: 'API Key', login_password: '邮箱密码' }[s] || s) const authLabel = (s: string) => ({ none: '无认证', bearer: 'Bearer', api_key: 'API Key', login_password: '邮箱密码' }[s] || s)
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z` const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
@@ -468,6 +577,7 @@ async function testUpstream(row: any) {
const res = await upstreamsApi.test(row.id) const res = await upstreamsApi.test(row.id)
if (res.data.success) ElMessage.success(res.data.message) if (res.data.success) ElMessage.success(res.data.message)
else ElMessage.error(res.data.detail || res.data.message) else ElMessage.error(res.data.detail || res.data.message)
await loadList()
} finally { } finally {
row._testing = false row._testing = false
} }
@@ -544,7 +654,9 @@ onMounted(loadList)
} }
.upstreams-hero { .upstreams-hero {
padding: 1.35rem; align-items: center;
padding: 1.2rem 1.25rem;
min-height: 8.7rem;
border-radius: var(--radius-shell); border-radius: var(--radius-shell);
background: background:
radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%), radial-gradient(circle at top right, rgba(217, 139, 66, 0.14), transparent 24%),
@@ -552,15 +664,43 @@ onMounted(loadList)
var(--bg-panel); var(--bg-panel);
} }
.upstreams-hero .page-heading {
gap: 0.32rem;
}
.upstreams-hero .page-title {
font-size: clamp(1.95rem, 1.45rem + 1.2vw, 2.65rem);
}
.upstreams-hero .page-desc {
max-width: 50rem;
font-size: 0.9rem;
line-height: 1.55;
}
.hero-actions { .hero-actions {
align-self: flex-end; align-self: flex-end;
} }
.upstreams-content {
display: grid;
grid-template-columns: minmax(0, 1.9fr) minmax(360px, 0.82fr);
gap: 1rem;
align-items: stretch;
}
.data-stage { .data-stage {
padding: 1rem; padding: 1rem;
min-width: 0;
}
.data-stage,
.upstreams-side {
min-height: 33rem;
} }
.data-stage-head { .data-stage-head {
min-height: 4.65rem;
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
@@ -576,6 +716,149 @@ onMounted(loadList)
line-height: 1.6; line-height: 1.6;
} }
.upstreams-side {
display: grid;
align-content: start;
padding: 1rem;
min-width: 0;
}
.side-section {
padding-block: 1rem;
border-top: 1px solid var(--border-color);
}
.side-section:first-child {
padding-top: 0;
border-top: 0;
}
.side-section:last-child {
padding-bottom: 0;
}
.overview-section {
min-height: 12.4rem;
}
.insight-head {
min-height: 3.75rem;
margin-bottom: 0.9rem;
}
.insight-head.compact {
margin-bottom: 0.75rem;
}
.insight-title {
margin: 0.24rem 0 0;
font-size: 1.05rem;
font-weight: 700;
}
.insight-pill {
display: inline-flex;
align-items: center;
padding: 0.38rem 0.7rem;
border-radius: 999px;
background: rgba(239, 175, 99, 0.12);
border: 1px solid rgba(239, 175, 99, 0.18);
color: var(--color-primary-strong);
font-size: 0.76rem;
font-weight: 700;
}
.insight-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.insight-metric {
display: grid;
gap: 0.28rem;
padding: 0.85rem 0.9rem;
border-radius: var(--radius-control);
background: rgba(255, 244, 232, 0.03);
border: 1px solid rgba(255, 244, 232, 0.06);
}
.insight-metric span {
color: var(--text-muted);
font-size: 0.75rem;
}
.insight-metric strong {
color: var(--text-primary);
font-size: 1rem;
line-height: 1.35;
}
.feed-list,
.timeline-list {
display: grid;
gap: 0.7rem;
}
.feed-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
width: 100%;
padding: 0.85rem 0.9rem;
border: 1px solid rgba(255, 244, 232, 0.06);
border-radius: var(--radius-control);
background: rgba(255, 244, 232, 0.02);
color: inherit;
text-align: left;
cursor: pointer;
}
.feed-item:hover {
border-color: rgba(239, 175, 99, 0.2);
background: rgba(255, 244, 232, 0.04);
}
.feed-main,
.timeline-main {
min-width: 0;
display: grid;
gap: 0.32rem;
}
.feed-top,
.timeline-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.feed-name,
.timeline-name {
color: var(--text-primary);
font-weight: 700;
}
.feed-meta,
.timeline-meta {
color: var(--text-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.feed-link {
color: var(--color-primary-strong);
font-size: 0.8rem;
white-space: nowrap;
}
.side-empty {
min-height: auto;
padding: 0.6rem 0;
}
.auth-badge { .auth-badge {
background: rgba(134, 183, 199, 0.12); background: rgba(134, 183, 199, 0.12);
color: var(--color-info); color: var(--color-info);
@@ -714,6 +997,12 @@ onMounted(loadList)
} }
} }
@media (max-width: 1199px) {
.upstreams-content {
grid-template-columns: 1fr;
}
}
@media (min-width: 1200px) { @media (min-width: 1200px) {
.info-cards { .info-cards {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -722,10 +1011,16 @@ onMounted(loadList)
@media (max-width: 767px) { @media (max-width: 767px) {
.upstreams-hero, .upstreams-hero,
.data-stage { .data-stage,
.upstreams-side {
padding: 1rem; padding: 1rem;
} }
.data-stage,
.upstreams-side {
min-height: 0;
}
.hero-actions { .hero-actions {
width: 100%; width: 100%;
} }
@@ -733,5 +1028,14 @@ onMounted(loadList)
.hero-actions :deep(.el-button) { .hero-actions :deep(.el-button) {
flex: 1 1 100%; flex: 1 1 100%;
} }
.insight-grid {
grid-template-columns: 1fr;
}
.feed-item,
.timeline-item {
align-items: flex-start;
}
} }
</style> </style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="shell-page page-section"> <div class="shell-page shell-page-fluid page-section">
<div class="page-header surface-card page-block"> <div class="page-header surface-card page-block">
<div class="page-heading"> <div class="page-heading">
<p class="page-kicker">Delivery Mesh</p> <p class="page-kicker">Delivery Mesh</p>
+8 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="shell-page page-section websites-page"> <div class="shell-page shell-page-fluid page-section websites-page">
<div class="page-header surface-card page-block"> <div class="page-header surface-card page-block">
<div class="page-heading"> <div class="page-heading">
<p class="page-kicker">Sync Orchestration</p> <p class="page-kicker">Sync Orchestration</p>
@@ -44,7 +44,7 @@
<span v-else class="muted"></span> <span v-else class="muted"></span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200"> <el-table-column label="操作" width="174" align="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-row"> <div class="action-row">
<el-tooltip content="查看分组" placement="top" :show-after="300"> <el-tooltip content="查看分组" placement="top" :show-after="300">
@@ -637,12 +637,15 @@ onMounted(loadAll)
.action-row { .action-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 4px; gap: 2px;
min-width: 0;
} }
.action-row .el-button.is-circle { .action-row .el-button.is-circle {
width: 28px; width: 26px;
height: 28px; height: 26px;
margin-left: 0;
} }
.binding-actions { .binding-actions {
display: flex; display: flex;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+233
View File
@@ -0,0 +1,233 @@
# SmartUp 项目优化审查报告
> 基于代码审计整理的优化点,按严重程度分级。
> 审查范围:backend/ 全部 Python 代码、frontend/src/ 全部 TS/Vue 代码、Dockerfile、docker-compose.yml。
---
## 🔴 严重 —— 生产稳定性 & 安全风险
### 1. SQLite 并发写导致锁冲突
**位置**`backend/app/services/scheduler.py:28-91``_check_upstream`
**问题**APScheduler `BackgroundScheduler` 使用线程池执行检测任务,每个线程独立调用 `SessionLocal()` 创建 SQLAlchemy session。SQLite 的锁粒度是整个文件,多线程并发 `commit()`(尤其是 `website_sync.sync_binding()` 内多次提交)必然出现 `database is locked` 错误。
```python
# scheduler.py:28 — 每个 job 开独立 session
db: Session = SessionLocal()
...
# website_sync.py:178-180 — 内部多次 commit
db.commit()
...
db.commit()
```
**建议**:上游检测改为单线程串行队列(`apscheduler` 设置 `max_instances=1` + 单 worker),或将 SQLite 替换为 PostgreSQL。
### 2. CORS 通配符 + credentials 违反规范
**位置**`backend/app/main.py:48-53`
**问题**`allow_origins=["*"]``allow_credentials=True` 同时设置,浏览器端会忽略通配符(CORS 规范要求显式 origin)。当前实际行为取决于 FastAPI 中间件实现,不可靠。
```python
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # ← 通配符
allow_credentials=True, # ← 不允许与通配符共存
...
)
```
**建议**`allow_origins` 设为显式列表(从环境变量读取),或关闭 `allow_credentials`
### 3. HTTP 客户端每次请求新建连接,无连接池
**位置**
- `backend/app/services/upstream_client.py``UpstreamClient._request`,约 L230
- `backend/app/services/website_client.py``Sub2ApiWebsiteClient._request`,约 L95
**问题**:每次 API 调用都创建新的 `httpx.Client()`,用完即销毁。一个上游检测周期(login → groups → rates)建立 3 个独立 TCP 连接;N 个上游即 3N 个连接。无连接复用、无 DNS 缓存。
```python
# upstream_client.py
def _request(self, method, path, body=None, auth=True):
with httpx.Client(timeout=self.timeout) as client: # ← 每个请求新建
resp = client.request(...)
```
**建议**:在 `__init__` 中创建 `httpx.AsyncClient` 或复用 `Client`,析构时关闭。
### 4. 快照表无限增长
**位置**`backend/app/models/snapshot.py``UpstreamRateSnapshot` 模型)
**问题**:每次检测成功都 INSERT 一行,无任何清理策略。按每 10 分钟一次、数据量约 2KB/行估算:
- 1 个上游 × 1 年 ≈ 52K 行 ≈ 100MB+
- 10 个上游 × 1 年 ≈ 0.5M 行 ≈ 1GB+
`snapshot_json``Text` 字段存全量 JSON,查询 `ORDER BY captured_at DESC` 扫描行数随时间线性增长。
**建议**:定时清理(保留最近 N 条或最近 M 天),或只保存 diff 增量。
### 5. 弱默认凭据 + 无登录限流
**位置**
- `backend/app/config.py:7-8`
- `backend/app/routers/auth.py:12-20`
- `backend/app/utils/auth.py:41-57`
**问题**
- `admin_password` 默认值 `"changeme"``jwt_secret` 默认值 `"change-me-in-production"`
- `/api/auth/login` 无任何速率限制,可暴力枚举
- `/api/auth/logout` 是空操作(JWT 无黑名单),token 过期前一直有效
- JWT 不包含 `jti` 等唯一标识,无法按需吊销
```python
# config.py
admin_password: str = "changeme"
jwt_secret: str = "change-me-in-production"
# auth.py
@router.post("/logout")
def logout():
return {"message": "logged out"} # ← 纯空操作
```
**建议**:登录端点加限流(内存令牌桶或 `slowapi`);密码至少 8 位校验;JWT 加入 `jti` + 内存/DB 黑名单。
### 6. 密码被 bcrypt 静默截断到 72 字节
**位置**`backend/app/utils/auth.py:12-18`
**问题**`hash_password``verify_password` 都做 `[:72]` 截断。用户设置超长密码时无提示,登录时输入完整密码也能匹配——用户永远不会发现密码被截断了。
```python
pw = password.encode("utf-8")[:72]
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
```
**建议**:在设置/修改密码时检查长度并给出提示,或改用非截断的哈希方案。
---
## 🟡 中等 —— 代码质量 & 维护性
### 7. 重复工具函数
**位置**
- `backend/app/services/upstream_client.py:69-80``_decimal_str`
- `backend/app/services/website_client.py:17-28``decimal_string`
**问题**:同一份 Decimal 格式化逻辑在两个文件中重复实现,行为略有差异(函数名不同但逻辑相同)。修改一个必然漏掉另一个。
**建议**:抽取到 `app/utils/number.py` 统一引用。
### 8. 事务边界模糊,部分失败导致状态不一致
**位置**`backend/app/services/scheduler.py:55-88`
**问题**`_check_upstream` 在同一个 DB session 内顺序执行:
1. 写 snapshot → `db.commit()`
2. 调用 `webhook_service.send_rate_changed()` → 内部 `httpx.post` + `db.commit()`
3. 调用 `website_sync.sync_affected_bindings()` → 内部多次 `httpx.put` + 多次 `db.commit()`
如果步骤 2 或 3 失败,步骤 1 的 snapshot 已经提交,但 webhook 和网站同步丢失——数据处于"检测到了变化但没通知"的不一致状态。
**建议**:检测(只读 + snapshot 写入)与通知/同步(webhook + 网站写回)分离为两个独立事务。
### 9. 硬编码上游 IP 迁移逻辑
**位置**`backend/app/database.py:58-65`
**问题**`_migrate_custom_pages()` 包含针对特定 IP 的数据迁移:
```python
"host": "%://170.106.100.210",
```
这是特定部署的遗留数据修正,放在通用数据库初始化代码中。其他部署者每次启动都会跑这段无用代码。
**建议**:移出到独立 migration 脚本,或在检测到实际数据时按需执行。
### 10. Scheduler 关闭不等待进行中的任务
**位置**`backend/app/services/scheduler.py:120`
**问题**`_scheduler.shutdown(wait=False)` 立即返回,正在执行的 job 可能被中断(正在写 DB 或发 HTTP 请求)。
```python
def stop_scheduler() -> None:
if _scheduler.running:
_scheduler.shutdown(wait=False) # ← 不等待
```
**建议**:设 `wait=True` 或给一个合理的 grace period(如 30s)。
### 11. `consecutive_failures` 未在启用/编辑时重置
**位置**`backend/app/services/scheduler.py:60-75`
**问题**:失败计数只在检测成功时归零。如果一个上游被禁用(`enabled=False`)、修改配置、再启用,旧的失败计数会延续——可能立即标记为 unhealthy。同样,手动 `check-now` 成功也没有重置计数器。
**建议**:编辑上游或重新启用时重置计数;`check-now` 成功路径也一并重置。
---
## 🟢 轻量 —— 体验 & 运维改进
### 12. 前端 localStorage key 易冲突
**位置**`frontend/src/api/index.ts:14-15`
**问题**:硬编码 `'smartup_token'` / `'smartup_email'`,同域名下多个实例会互相覆盖。
**建议**:用环境变量或 `import.meta.env.VITE_APP_KEY` 做 key 前缀。
### 13. 健康检查开销大
**位置**`docker-compose.yml:23-28`
**问题**`CMD ["python", "-c", "import urllib.request; ..."]` 每 30 秒启动一次完整 Python 解释器,每次约 50-100ms 启动耗时。
**建议**Dockerfile 安装 `curl`,改为 `curl -f http://localhost:8000/healthz`
### 14. 缺少 `.dockerignore`
**位置**:项目根目录
**问题**:无 `.dockerignore``node_modules/``__pycache__``data/``.venv/` 等大规模目录都会被打包进入 Docker build context,拖慢构建。
**建议**:添加 `.dockerignore`,排除 `node_modules/``data/``*.pyc``.git/``.venv/` 等。
### 15. 前端请求无网络重试
**位置**`frontend/src/api/index.ts:6-20`
**问题**:axios 拦截器遇到 401 直接跳登录页。网络瞬断、502/503 等临时故障无重试。
**建议**:引入 `axios-retry` 实现指数退避重试(对非 401/4xx 可重试)。
### 16. 快照分页 API 实现微妙
**位置**`backend/app/routers/upstreams.py:256-294`
**问题**`list_snapshots` 为了计算 diff 数量多取 1 行,与 `offset` + `limit` 配合时逻辑复杂:
```python
rows = (
db.query(...)
.offset(offset)
.limit(limit + 1) # 多取 1 行用于 diff
.all()
)
```
`offset > 0` 时,多取的 1 行是相对于 offset 的额外行,并不一定是前一页的最后一行——diff 结果可能不准。
**建议**:放弃"附带 diff 计数"设计,改为前端单独调 diff 接口,或直接在前次快照 id 上做 diff。
---
## 📋 优先级实施建议
| 优先级 | 编号 | 估算工时 | 影响面 |
|--------|------|---------|--------|
| P0 | 1, 2, 3, 4, 5 | 1-2 天 | DB 锁死 / 安全漏洞 / 性能衰减 |
| P1 | 6, 7, 8, 10, 11 | 0.5 天 | 数据不一致 / 维护负担 |
| P2 | 9, 12, 13, 14, 15, 16 | 0.5 天 | 部署体验 / 代码整洁 |
---
*生成日期:2026-06-13 · 审查范围:commit HEAD*