feat: add admin session rbac foundation

This commit is contained in:
duanshuwen committed 2026-08-25 22:47:59 +08:00
1 parent 8f000811a8
commit 2cdf7f9d7a
22 files changed
+1945 -68

No files matched your search

+196 -39
View File
@@ -7,10 +7,10 @@ import re
from email.utils import formatdate
from uuid import uuid4
from urllib.parse import quote, urlsplit, urlunsplit
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from ..auth import create_token, get_actor_id, require_admin, verify_password
from ..auth import get_actor_id, issue_admin_session, require_admin, rotate_admin_session, verify_password
from ..api_response import success_response
from ..config import get_settings
from ..database import get_db
@@ -37,6 +37,8 @@ from ..models import (
WanfaRoute,
utc_now,
)
from ..redis_session import AdminSessionStore, RedisUnavailableError, get_admin_session_store, hash_refresh_token
from ..rbac import apply_data_scope, build_admin_permission_context, is_within_data_scope, require_admin_permission
from ..schemas import (
LeadQuery,
LeadStatus,
@@ -434,8 +436,8 @@ def wanfa_route_dict(route: WanfaRoute) -> dict:
}
def wanfa_category_dict(category: WanfaCategory) -> dict:
routes = sorted(category.routes or [], key=lambda item: item.sortOrder)
def wanfa_category_dict(category: WanfaCategory, scoped_routes=None) -> dict:
routes = sorted(category.routes if scoped_routes is None else scoped_routes or [], key=lambda item: item.sortOrder)
return {
"id": category.id,
"label": category.label,
@@ -490,6 +492,16 @@ def home_error(status_code: int, message: str, code: str, details: dict | None =
raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}})
def assign_created_by(item, user_id: str | None) -> None:
if user_id and hasattr(item, "createdById") and not getattr(item, "createdById", None):
item.createdById = user_id
def ensure_data_scope(item, user: AdminUser, db: Session, label: str) -> None:
if not is_within_data_scope(item, user, db):
raise HTTPException(status_code=404, detail=f"{label}不存在")
def home_item_or_error(db: Session, model, item_id: str, label: str, code: str):
item = db.get(model, item_id)
if not item:
@@ -501,6 +513,10 @@ def home_items(db: Session, model) -> list:
return db.scalars(select(model).order_by(model.sortOrder.asc())).all()
def scoped_items(db: Session, model, user: AdminUser, order_column) -> list:
return db.scalars(apply_data_scope(select(model).order_by(order_column), model, user, db)).all()
def validate_home_order(item_ids: list[str], current_ids: list[str]) -> None:
if len(item_ids) != len(current_ids) or len(set(item_ids)) != len(item_ids) or set(item_ids) != set(current_ids):
home_error(400, "排序项目必须完整且不能重复", "HOME_REORDER_INVALID")
@@ -519,6 +535,7 @@ def create_home_item(db: Session, request: Request, body, model, entity: str):
if body.sortOrder is None:
payload["sortOrder"] = next_site_sort_order(db, model)
item = model(**payload)
assign_created_by(item, get_actor_id(request))
db.add(item)
db.flush()
after = model_dict(item)
@@ -752,6 +769,7 @@ def create_home_wanfa_recommendation(
isActive=body.isActive,
sortOrder=body.sortOrder if body.sortOrder is not None else ((next_sort + 1) if next_sort is not None else 0),
)
assign_created_by(recommendation, get_actor_id(request))
db.add(recommendation)
db.flush()
after = home_wanfa_recommendation_dict(recommendation)
@@ -878,8 +896,8 @@ def validate_detail_order(item_ids: list[str], current_ids: list[str]) -> None:
@router.get("/details")
def list_details(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
details = db.scalars(select(DetailRecord).order_by(DetailRecord.sortOrder.asc())).all()
def list_details(_user: AdminUser = Depends(require_admin_permission("admin:wanfa:read")), db: Session = Depends(get_db)):
details = db.scalars(apply_data_scope(select(DetailRecord), DetailRecord, _user, db).order_by(DetailRecord.sortOrder.asc())).all()
return success_response({"details": [detail_record_dict(detail) for detail in details]})
@@ -901,8 +919,11 @@ def reorder_details(
@router.get("/details/{detail_id}")
def get_detail(detail_id: str, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
return success_response(detail_record_dict(detail_or_error(db, detail_id)))
def get_detail(detail_id: str, _user: AdminUser = Depends(require_admin_permission("admin:wanfa:read")), db: Session = Depends(get_db)):
detail = detail_or_error(db, detail_id)
if not is_within_data_scope(detail, _user, db):
raise HTTPException(status_code=404, detail="详情不存在")
return success_response(detail_record_dict(detail))
@router.post("/details", status_code=status.HTTP_201_CREATED)
@@ -918,6 +939,7 @@ def create_detail(
**body.model_dump(exclude={"sortOrder"}),
sortOrder=body.sortOrder if body.sortOrder is not None else next_site_sort_order(db, DetailRecord),
)
assign_created_by(detail, get_actor_id(request))
db.add(detail)
db.flush()
after = detail_record_dict(detail)
@@ -935,6 +957,7 @@ def update_detail(
db: Session = Depends(get_db),
):
detail = detail_or_error(db, detail_id)
ensure_data_scope(detail, _user, db, "详情")
before = detail_record_dict(detail)
if "key" in body.model_fields_set:
detail_key_conflict(db, body.key or "", detail.id)
@@ -957,6 +980,7 @@ def delete_detail(
db: Session = Depends(get_db),
):
detail = detail_or_error(db, detail_id)
ensure_data_scope(detail, _user, db, "详情")
before = detail_record_dict(detail)
db.delete(detail)
remaining = db.scalars(select(DetailRecord).where(DetailRecord.id != detail.id).order_by(DetailRecord.sortOrder.asc())).all()
@@ -968,11 +992,21 @@ def delete_detail(
@router.get("/wanfa/categories")
def list_wanfa_categories(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
def list_wanfa_categories(_user: AdminUser = Depends(require_admin_permission("admin:wanfa:read")), db: Session = Depends(get_db)):
categories = db.scalars(
select(WanfaCategory).options(selectinload(WanfaCategory.routes)).order_by(WanfaCategory.sortOrder.asc())
apply_data_scope(select(WanfaCategory), WanfaCategory, _user, db).options(selectinload(WanfaCategory.routes)).order_by(WanfaCategory.sortOrder.asc())
).all()
return success_response({"categories": [wanfa_category_dict(category) for category in categories]})
return success_response(
{
"categories": [
wanfa_category_dict(
category,
[route for route in category.routes or [] if is_within_data_scope(route, _user, db)],
)
for category in categories
]
}
)
@router.post("/wanfa/categories", status_code=status.HTTP_201_CREATED)
@@ -983,6 +1017,7 @@ def create_wanfa_category(
db: Session = Depends(get_db),
):
category = WanfaCategory(label=body.label, sortOrder=next_site_sort_order(db, WanfaCategory))
assign_created_by(category, get_actor_id(request))
db.add(category)
db.flush()
after = wanfa_category_dict(category)
@@ -1017,6 +1052,7 @@ def update_wanfa_category(
db: Session = Depends(get_db),
):
category = wanfa_category_or_error(db, category_id)
ensure_data_scope(category, _user, db, "玩法分类")
before = wanfa_category_dict(category)
if "label" in body.model_fields_set:
category.label = body.label
@@ -1035,6 +1071,7 @@ def delete_wanfa_category(
db: Session = Depends(get_db),
):
category = wanfa_category_or_error(db, category_id)
ensure_data_scope(category, _user, db, "玩法分类")
if category.routes:
site_config_error(409, "玩法分类下仍有路线,不能删除", "WANFA_CATEGORY_NOT_EMPTY", {"categoryId": category.id})
recommendation_count = db.scalar(
@@ -1065,6 +1102,7 @@ def create_wanfa_route(
db: Session = Depends(get_db),
):
category = wanfa_category_or_error(db, category_id)
ensure_data_scope(category, _user, db, "玩法分类")
next_sort = db.scalar(
select(func.max(WanfaRoute.sortOrder)).where(WanfaRoute.categoryId == category.id)
)
@@ -1077,6 +1115,7 @@ def create_wanfa_route(
demandKeyword=body.demandKeyword,
sortOrder=(next_sort + 1) if next_sort is not None else 0,
)
assign_created_by(route, get_actor_id(request))
db.add(route)
db.flush()
after = wanfa_route_dict(route)
@@ -1114,7 +1153,9 @@ def update_wanfa_route(
db: Session = Depends(get_db),
):
category = wanfa_category_or_error(db, category_id)
ensure_data_scope(category, _user, db, "玩法分类")
route = wanfa_route_or_error(db, category, route_id)
ensure_data_scope(route, _user, db, "玩法路线")
before = wanfa_route_dict(route)
for field in ("title", "subtitle", "image", "routeCount", "demandKeyword"):
if field in body.model_fields_set:
@@ -1135,7 +1176,9 @@ def delete_wanfa_route(
db: Session = Depends(get_db),
):
category = wanfa_category_or_error(db, category_id)
ensure_data_scope(category, _user, db, "玩法分类")
route = wanfa_route_or_error(db, category, route_id)
ensure_data_scope(route, _user, db, "玩法路线")
before = wanfa_route_dict(route)
db.delete(route)
audit(db, get_actor_id(request), "delete", "wanfa_route", route.id, before=before)
@@ -1144,8 +1187,8 @@ def delete_wanfa_route(
@router.get("/concierge/advisors")
def list_concierge_advisors(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
advisors = db.scalars(select(ConciergeAdvisor).order_by(ConciergeAdvisor.sortOrder.asc())).all()
def list_concierge_advisors(_user: AdminUser = Depends(require_admin_permission("admin:concierge:read")), db: Session = Depends(get_db)):
advisors = db.scalars(apply_data_scope(select(ConciergeAdvisor), ConciergeAdvisor, _user, db).order_by(ConciergeAdvisor.sortOrder.asc())).all()
return success_response({"advisors": [concierge_advisor_dict(advisor) for advisor in advisors]})
@@ -1161,6 +1204,7 @@ def create_concierge_advisor(
if body.sortOrder is None:
payload["sortOrder"] = next_site_sort_order(db, ConciergeAdvisor)
advisor = ConciergeAdvisor(**payload)
assign_created_by(advisor, get_actor_id(request))
db.add(advisor)
db.flush()
after = concierge_advisor_dict(advisor)
@@ -1195,6 +1239,7 @@ def update_concierge_advisor(
db: Session = Depends(get_db),
):
advisor = concierge_advisor_or_error(db, advisor_id)
ensure_data_scope(advisor, _user, db, "管家顾问")
before = concierge_advisor_dict(advisor)
payload = body.model_dump(exclude_unset=True)
if "details" in payload:
@@ -1216,6 +1261,7 @@ def delete_concierge_advisor(
db: Session = Depends(get_db),
):
advisor = concierge_advisor_or_error(db, advisor_id)
ensure_data_scope(advisor, _user, db, "管家顾问")
before = concierge_advisor_dict(advisor)
db.delete(advisor)
db.flush()
@@ -1229,66 +1275,175 @@ def delete_concierge_advisor(
@router.post("/auth/login")
def login(body: LoginIn, db: Session = Depends(get_db)):
def login(
body: LoginIn,
request: Request,
response: Response,
db: Session = Depends(get_db),
store: AdminSessionStore = Depends(get_admin_session_store),
):
settings = get_settings()
client_host = request.client.host if request.client else "unknown"
identity = f"{client_host}:{body.email.strip().lower()}"
try:
if not store.allow_login_attempt(identity, settings.admin_login_rate_limit, settings.admin_login_rate_window_seconds):
raise HTTPException(status_code=429, detail="登录尝试过于频繁,请稍后再试", headers={"Retry-After": str(settings.admin_login_rate_window_seconds)})
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
user = db.scalar(select(AdminUser).where(AdminUser.email == body.email))
if not user or not user.isActive or not verify_password(body.password, user.passwordHash):
raise HTTPException(status_code=401, detail="账号或密码错误")
try:
access_token, refresh_token, expires_in = issue_admin_session(user, store)
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
result = success_response(
{
"token": access_token,
"accessToken": access_token,
"expiresIn": expires_in,
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
}
)
result.set_cookie(
key=settings.admin_refresh_cookie_name,
value=refresh_token,
httponly=True,
secure=settings.admin_refresh_cookie_secure,
samesite="lax",
path=settings.admin_refresh_cookie_path,
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60,
)
return result
@router.post("/auth/refresh")
def refresh_admin_session(
request: Request,
db: Session = Depends(get_db),
store: AdminSessionStore = Depends(get_admin_session_store),
):
settings = get_settings()
refresh_token = request.cookies.get(settings.admin_refresh_cookie_name)
if not refresh_token:
raise HTTPException(status_code=401, detail="请先登录后台")
try:
record = store.get_by_refresh_hash(hash_refresh_token(refresh_token))
if not record:
raise HTTPException(status_code=401, detail="请先登录后台")
user = db.get(AdminUser, record.user_id)
if not user or not user.isActive:
store.revoke(record.session_id, record.refresh_hash)
raise HTTPException(status_code=401, detail="请先登录后台")
tokens = rotate_admin_session(user, record, store)
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
if not tokens:
raise HTTPException(status_code=401, detail="刷新令牌已失效")
access_token, next_refresh_token, expires_in = tokens
result = success_response(
{
"token": access_token,
"accessToken": access_token,
"expiresIn": expires_in,
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
}
)
result.set_cookie(
key=settings.admin_refresh_cookie_name,
value=next_refresh_token,
httponly=True,
secure=settings.admin_refresh_cookie_secure,
samesite="lax",
path=settings.admin_refresh_cookie_path,
max_age=settings.admin_refresh_expires_days * 24 * 60 * 60,
)
return result
@router.post("/auth/logout")
def logout_admin_session(
request: Request,
store: AdminSessionStore = Depends(get_admin_session_store),
):
settings = get_settings()
refresh_token = request.cookies.get(settings.admin_refresh_cookie_name)
try:
if refresh_token:
record = store.get_by_refresh_hash(hash_refresh_token(refresh_token))
if record:
store.revoke(record.session_id, record.refresh_hash)
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
result = success_response({"ok": True})
result.delete_cookie(key=settings.admin_refresh_cookie_name, path=settings.admin_refresh_cookie_path)
return result
@router.get("/me")
def me(
user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
store: AdminSessionStore = Depends(get_admin_session_store),
):
return success_response(
{
"token": create_token(user),
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
**build_admin_permission_context(user, db, store),
}
)
@router.get("/me")
def me(user: AdminUser = Depends(require_admin)):
return success_response({"id": user.id, "email": user.email, "name": user.name, "role": user.role})
@router.get("/dashboard")
def dashboard(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
def dashboard(_user: AdminUser = Depends(require_admin_permission("admin:dashboard")), db: Session = Depends(get_db)):
lead_query = apply_data_scope(select(Lead), Lead, _user, db)
stats = {
"newLeadCount": db.scalar(select(func.count()).select_from(Lead).where(Lead.status == "new")),
"leadCount": db.scalar(select(func.count()).select_from(Lead)),
"newLeadCount": db.scalar(lead_query.where(Lead.status == "new").with_only_columns(func.count())),
"leadCount": db.scalar(lead_query.with_only_columns(func.count())),
}
recent = db.scalars(
select(Lead).options(selectinload(Lead.assignedUser)).order_by(Lead.createdAt.desc()).limit(5)
apply_data_scope(select(Lead), Lead, _user, db)
.options(selectinload(Lead.assignedUser))
.order_by(Lead.createdAt.desc())
.limit(5)
).all()
return success_response({"stats": stats, "recentLeads": [lead_dict(lead) for lead in recent]})
@router.get("/site-config")
def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
def admin_site_config(_user: AdminUser = Depends(require_admin_permission("admin:site-config")), db: Session = Depends(get_db)):
return success_response(
{
"heroSlides": [
hero_slide_admin_dict(item)
for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all()
for item in scoped_items(db, HeroSlide, _user, HeroSlide.sortOrder.asc())
],
"destinationHero": [
model_dict(item)
for item in db.scalars(select(DestinationHero).order_by(DestinationHero.sortOrder.asc())).all()
for item in scoped_items(db, DestinationHero, _user, DestinationHero.sortOrder.asc())
],
"vehicleOptions": [
model_dict(item)
for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all()
for item in scoped_items(db, VehicleOption, _user, VehicleOption.sortOrder.asc())
],
"vehicleService": [
model_dict(item)
for item in db.scalars(select(VehicleServiceConfig).order_by(VehicleServiceConfig.createdAt.asc())).all()
for item in scoped_items(db, VehicleServiceConfig, _user, VehicleServiceConfig.createdAt.asc())
],
"demandHero": [
model_dict(item)
for item in db.scalars(select(DemandHero).order_by(DemandHero.sortOrder.asc())).all()
for item in scoped_items(db, DemandHero, _user, DemandHero.sortOrder.asc())
],
"demandFeatureCards": [
model_dict(item)
for item in db.scalars(select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc())).all()
for item in scoped_items(db, DemandFeatureCard, _user, DemandFeatureCard.sortOrder.asc())
],
"demandForm": [
model_dict(item)
for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all()
for item in scoped_items(db, DemandForm, _user, DemandForm.createdAt.asc())
],
}
)
@@ -1308,6 +1463,7 @@ def create_site_config(
if config.get("singleton") and module_items(db, config):
site_config_error(409, "单例配置已存在", "MODULE_CONFIG_SINGLETON_EXISTS", {"module": module})
item = config["model"](**site_create_payload(module, config, body, db))
assign_created_by(item, get_actor_id(request))
db.add(item)
db.flush()
after = site_item_dict(module, item)
@@ -1403,7 +1559,7 @@ def list_leads(
createdFrom: str | None = None,
createdTo: str | None = None,
take: int = Query(default=100, ge=1, le=200),
_user: AdminUser = Depends(require_admin),
_user: AdminUser = Depends(require_admin_permission("admin:leads:read")),
db: Session = Depends(get_db),
):
query = LeadQuery(
@@ -1440,14 +1596,14 @@ def list_leads(
Lead.note.ilike(pattern),
)
).distinct()
leads = db.scalars(stmt).all()
leads = db.scalars(apply_data_scope(stmt, Lead, _user, db)).all()
return success_response({"items": [lead_dict(lead) for lead in leads]})
@router.patch("/leads/{lead_id}/status")
def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user: AdminUser = Depends(require_admin_permission("admin:leads:update")), db: Session = Depends(get_db)):
lead = db.get(Lead, lead_id)
if not lead:
if not lead or not is_within_data_scope(lead, _user, db):
raise HTTPException(status_code=404, detail="线索不存在")
before = model_dict(lead)
lead.status = body.status
@@ -1458,8 +1614,8 @@ def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user
@router.get("/media-assets")
def list_media_assets(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
assets = db.scalars(select(MediaAsset).order_by(MediaAsset.createdAt.desc()).limit(200)).all()
def list_media_assets(_user: AdminUser = Depends(require_admin_permission("admin:media:read")), db: Session = Depends(get_db)):
assets = db.scalars(apply_data_scope(select(MediaAsset), MediaAsset, _user, db).order_by(MediaAsset.createdAt.desc()).limit(200)).all()
return success_response({"items": [model_dict(asset) for asset in assets]})
@@ -1482,6 +1638,7 @@ def upload_media_asset(
sizeBytes=size_bytes,
group=safe_group,
)
assign_created_by(asset, get_actor_id(request))
db.add(asset)
db.flush()
after = model_dict(asset)