Files
Nexus/server/api/settings.py
T
Your Name e9feaa6cdc 前后端一致性审计 + 批量操作 + 调度同步模式
- 服务器列表: 添加批量选择(checkbox)+批量推送/删除操作栏
- 推送页面: 支持从服务器列表页预选服务器跳转(sessionStorage)
- 调度页面: 添加同步模式选择(增量/全量/校验和)
- 后端: PushSchedule模型+Schema+API+ScheduleRunner添加sync_mode字段
- 仪表盘: 移除重复的函数定义和初始化调用
- 数据库迁移: 启动时自动执行schema migration(添加sync_mode列)
- 安装向导: 添加schema migration步骤

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:01:17 +08:00

384 lines
12 KiB
Python

"""Nexus — Settings & Schedules API Routes
Presentation layer — system settings + push schedules + password presets + audit logs.
Security: All write operations require JWT authentication.
Immutable settings (secret_key, api_key, encryption_key, database_url) cannot be modified via API.
Sensitive values are masked in GET responses.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, SettingUpdatePayload
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog, Admin
from sqlalchemy.ext.asyncio import AsyncSession
# Settings that cannot be modified via API (encryption consistency)
IMMUTABLE_KEYS = {"secret_key", "api_key", "encryption_key", "database_url"}
# Settings whose values are masked in GET responses
SENSITIVE_KEYS = {"secret_key", "api_key", "encryption_key", "redis_url"}
router = APIRouter(prefix="/api/settings", tags=["settings"])
# ── System Settings ──
@router.get("/", response_model=list)
async def list_settings(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all system settings (sensitive values masked)"""
repo = SettingRepositoryImpl(db)
settings = await repo.get_all()
result = []
for s in settings:
val = s.value
if s.key in SENSITIVE_KEYS and val:
val = val[:8] + "..." if len(val) > 8 else "***"
result.append({"key": s.key, "value": val, "updated_at": str(s.updated_at)})
return result
@router.get("/{key}", response_model=dict)
async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""Get a single setting by key (sensitive values masked)"""
repo = SettingRepositoryImpl(db)
value = await repo.get(key)
if value is None:
raise HTTPException(status_code=404, detail="Setting not found")
if key in SENSITIVE_KEYS and value:
value = value[:8] + "..." if len(value) > 8 else "***"
return {"key": key, "value": value}
@router.put("/{key}", response_model=dict)
async def set_setting(
key: str,
payload: SettingUpdatePayload,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Set a system setting value (immutable keys are rejected)"""
if key in IMMUTABLE_KEYS:
raise HTTPException(status_code=403, detail=f"Setting '{key}' is immutable and cannot be modified via API")
repo = SettingRepositoryImpl(db)
setting = await repo.set(key, str(payload.value))
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_setting",
target_type="setting",
detail=f"key={key}",
ip_address="",
))
return {"key": setting.key, "value": setting.value}
# ── Push Schedules ──
schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@schedule_router.get("/", response_model=list)
async def list_schedules(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all push schedules"""
repo = PushScheduleRepositoryImpl(db)
schedules = await repo.get_all()
return [_schedule_to_dict(s) for s in schedules]
@schedule_router.post("/", response_model=dict, status_code=201)
async def create_schedule(
payload: ScheduleCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new push schedule"""
repo = PushScheduleRepositoryImpl(db)
schedule = PushSchedule(**payload.model_dump())
created = await repo.create(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_schedule",
target_type="schedule",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
))
return _schedule_to_dict(created)
@schedule_router.put("/{id}", response_model=dict)
async def update_schedule(
id: int,
payload: ScheduleUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a push schedule"""
repo = PushScheduleRepositoryImpl(db)
schedule = await repo.get_by_id(id)
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(schedule, key) and key != "id":
setattr(schedule, key, value)
updated = await repo.update(schedule)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="update_schedule",
target_type="schedule",
target_id=id,
detail=f"name={updated.name}",
ip_address="",
))
return _schedule_to_dict(updated)
@schedule_router.delete("/{id}", status_code=204)
async def delete_schedule(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a push schedule"""
repo = PushScheduleRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Schedule not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_schedule",
target_type="schedule",
target_id=id,
ip_address="",
))
# ── Password Presets ──
preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
@preset_router.get("/", response_model=list)
async def list_presets(admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db)):
"""List all password presets"""
repo = PasswordPresetRepositoryImpl(db)
presets = await repo.get_all()
return [{"id": p.id, "name": p.name, "created_at": str(p.created_at)} for p in presets]
@preset_router.post("/", response_model=dict, status_code=201)
async def create_preset(
payload: PresetCreate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a password preset (password will be encrypted)"""
from server.infrastructure.database.crypto import encrypt_value
repo = PasswordPresetRepositoryImpl(db)
encrypted = encrypt_value(payload.encrypted_pw)
preset = PasswordPreset(name=payload.name, encrypted_pw=encrypted)
created = await repo.create(preset)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="create_preset",
target_type="preset",
target_id=created.id,
detail=f"name={created.name}",
ip_address="",
))
return {"id": created.id, "name": created.name}
@preset_router.delete("/{id}", status_code=204)
async def delete_preset(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a password preset"""
repo = PasswordPresetRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Preset not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_preset",
target_type="preset",
target_id=id,
ip_address="",
))
# ── Audit Logs ──
audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
@audit_router.get("/", response_model=dict)
async def list_audit_logs(
action: Optional[str] = None,
limit: int = 50,
offset: int = 0,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List audit logs with pagination"""
repo = AuditLogRepositoryImpl(db)
total = await repo.count(action)
if action:
logs = await repo.get_by_action(action, limit, offset)
else:
logs = await repo.get_recent(limit, offset)
return {
"total": total,
"limit": limit,
"offset": offset,
"items": [_audit_to_dict(log) for log in logs],
}
# ── Retry Jobs ──
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
@retry_router.get("/", response_model=list)
async def list_retry_jobs(
limit: int = 100,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List all retry jobs (not just pending)"""
repo = PushRetryJobRepositoryImpl(db)
jobs = await repo.get_all(limit)
return [_retry_to_dict(j) for j in jobs]
@retry_router.post("/{id}/retry", response_model=dict)
async def retry_job(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Manually retry a failed/pending job"""
repo = PushRetryJobRepositoryImpl(db)
job = await repo.get_by_id(id)
if not job:
raise HTTPException(status_code=404, detail="Retry job not found")
# Reset the job to pending state for immediate retry
job = await repo.update_status(
id, "pending",
retry_count=0,
next_retry_at=None,
last_error=None,
)
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="retry_job",
target_type="retry",
target_id=id,
detail=f"Manual retry triggered for job #{id}",
))
return _retry_to_dict(job)
@retry_router.delete("/{id}", status_code=204)
async def delete_retry_job(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a retry job"""
repo = PushRetryJobRepositoryImpl(db)
job = await repo.get_by_id(id)
if not job:
raise HTTPException(status_code=404, detail="Retry job not found")
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="delete_retry",
target_type="retry",
target_id=id,
detail=f"Retry job #{id} deleted",
))
await repo.delete(id)
# ── Helper functions ──
def _schedule_to_dict(schedule: PushSchedule) -> dict:
return {
"id": schedule.id,
"name": schedule.name,
"source_path": schedule.source_path,
"server_ids": schedule.server_ids,
"cron_expr": schedule.cron_expr,
"sync_mode": schedule.sync_mode or "incremental",
"enabled": schedule.enabled,
"last_run_at": str(schedule.last_run_at) if schedule.last_run_at else None,
"created_at": str(schedule.created_at) if schedule.created_at else None,
}
def _audit_to_dict(log: AuditLog) -> dict:
return {
"id": log.id,
"admin_username": log.admin_username,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"ip_address": log.ip_address,
"created_at": str(log.created_at) if log.created_at else None,
}
def _retry_to_dict(job: PushRetryJob) -> dict:
return {
"id": job.id,
"server_id": job.server_id,
"server_name": job.server_name,
"operator": job.operator,
"source_path": job.source_path,
"target_path": job.target_path,
"status": job.status,
"retry_count": job.retry_count,
"max_retries": job.max_retries,
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
"last_error": job.last_error,
"created_at": str(job.created_at) if job.created_at else None,
}