- 后端新增 /admin/users 路由(仅 owner):
- GET /admin/users 列出全部用户
- POST /admin/users 创建新用户(固定 role=member,API 层禁止提权)
- PATCH /admin/users/{id} 重置密码 / 启停 / 改昵称 / 改邮箱
(role 永远不可改,owner 不能禁用自己)
- DELETE /admin/users/{id} 软删除(is_active=False,保留外键完整性)
- 用户管理用软删除而非硬删除,理由:
bookmarks / subscriptions / api_tokens / article_reads 都有 user_id 外键,
硬删会污染历史数据;禁用后用户登不上,效果等同删除且可恢复
- 后端永远不返回 password_hash(UserOut 不含该字段)
- 前端 AdminUsers.vue + 路由 /admin/users + 侧栏菜单'用户管理(Admin)'
操作按钮对自己 / owner 自动隐藏(自锁 + 防误改,后端兜底拒绝)
- main.py 注册 admin_users 路由
- schemas/user.py 提供 UserOut/UserCreate/UserUpdate/UserDeleteResult
- articles.ts adminApi 加 listUsers / createUser / updateUser / deleteUser + UserOut 类型
无 alembic 迁移(user 表 role / is_active / email / display_name 字段早就有)
122 lines
3.5 KiB
Python
122 lines
3.5 KiB
Python
"""FastAPI 入口。
|
|
|
|
- 注册路由
|
|
- 启动 / 关闭事件:连接池、调度器
|
|
- CORS
|
|
- 全局异常处理
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from app.api import admin, admin_llm, admin_users, articles, auth, bookmarks, ingest, me, search, sources, subscriptions
|
|
from app.config import settings
|
|
from app.database import engine
|
|
from app.redis_client import close_redis, get_redis
|
|
|
|
logger = logging.getLogger("news.api")
|
|
logging.basicConfig(
|
|
level=settings.log_level,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# 启动
|
|
logger.info("api starting, tz=%s", settings.tz)
|
|
# 触发 redis 连接
|
|
await get_redis().ping()
|
|
yield
|
|
# 关闭
|
|
logger.info("api shutting down")
|
|
await close_redis()
|
|
await engine.dispose()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Diary News",
|
|
description="Private news aggregator",
|
|
version="0.1.0",
|
|
default_response_class=JSONResponse,
|
|
lifespan=lifespan,
|
|
docs_url="/api/docs" if settings.log_level == "DEBUG" else None,
|
|
redoc_url=None,
|
|
)
|
|
|
|
# CORS:网页 + Android,简单放开(私有)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # MVP 放开,生产收紧
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# === 全局异常处理(RFC 7807) ===
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_exc_handler(request: Request, exc: StarletteHTTPException):
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={
|
|
"type": "about:blank",
|
|
"title": exc.detail if isinstance(exc.detail, str) else "Error",
|
|
"status": exc.status_code,
|
|
"instance": str(request.url),
|
|
},
|
|
headers=exc.headers or None,
|
|
)
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_exc_handler(request: Request, exc: RequestValidationError):
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content={
|
|
"type": "about:blank",
|
|
"title": "Validation Error",
|
|
"status": 422,
|
|
"errors": exc.errors(),
|
|
"instance": str(request.url),
|
|
},
|
|
)
|
|
|
|
|
|
# === 路由 ===
|
|
API_PREFIX = "/api/v1"
|
|
|
|
app.include_router(auth.router, prefix=API_PREFIX)
|
|
app.include_router(me.router, prefix=API_PREFIX)
|
|
app.include_router(articles.router, prefix=API_PREFIX)
|
|
app.include_router(sources.router, prefix=API_PREFIX)
|
|
app.include_router(bookmarks.router, prefix=API_PREFIX)
|
|
app.include_router(subscriptions.router, prefix=API_PREFIX)
|
|
app.include_router(ingest.router, prefix=API_PREFIX)
|
|
app.include_router(search.router, prefix=API_PREFIX)
|
|
app.include_router(admin.router, prefix=API_PREFIX)
|
|
app.include_router(admin_llm.router, prefix=API_PREFIX)
|
|
app.include_router(admin_users.router, prefix=API_PREFIX)
|
|
|
|
|
|
# === 健康检查 ===
|
|
@app.get(f"{API_PREFIX}/healthz", include_in_schema=False)
|
|
async def healthz():
|
|
try:
|
|
await get_redis().ping()
|
|
except Exception as e:
|
|
return JSONResponse({"status": "degraded", "redis": str(e)}, status_code=503)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get(f"{API_PREFIX}/", include_in_schema=False)
|
|
async def root():
|
|
return {"name": "diary-news", "version": app.version, "docs": f"{API_PREFIX}/docs"}
|