Files
WonderQ-Project/WonderQ-Admin/app/routers/admin.py
duanshuwen c381eb8b38 feat(media): 为媒体素材库添加分页加载功能
- 新增 PaginatedResult 泛型类型统一分页返回数据结构
- 重构后端媒体素材列表接口,支持分页参数并返回完整分页信息
- 前端媒体素材页面添加分页控件,适配分页加载逻辑
- 上传素材后自动重置页码至第一页并重新加载列表
- 更新接口文档和用户文档,补充分页API使用规范
- 新增对应测试用例覆盖分页接口与前端调用逻辑
2026-08-27 15:45:46 +08:00

1555 lines
58 KiB
Python

import base64
import hashlib
import hmac
import http.client
import os
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 sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from ..auth import get_actor_id, issue_admin_session, require_admin, rotate_admin_session, verify_password
from ..api_response import success_response
from ..captcha import create_captcha, verify_captcha
from ..config import get_settings
from ..database import get_db
from ..models import (
AdminUser,
AuditLog,
ConciergeAdvisor,
DetailRecord,
HeroSlide,
HomeTeamBuilding,
HomeWanfaRecommendation,
HomeWildArchive,
Lead,
MediaAsset,
VehicleOption,
WanfaCategory,
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,
LeadStatusIn,
LoginIn,
HomeReorderIn,
HomeTeamBuildingCreate,
HomeTeamBuildingPatch,
HomeWanfaRecommendationCreate,
HomeWanfaRecommendationPatch,
HomeWildArchiveCreate,
HomeWildArchivePatch,
SiteConfigPatchIn,
SiteConfigReorderIn,
WanfaCategoryCreate,
WanfaCategoryPatch,
WanfaReorderIn,
WanfaRouteCreate,
WanfaRoutePatch,
ConciergeAdvisorCreate,
ConciergeAdvisorPatch,
DetailCreate,
DetailPatch,
)
from ..seed import create_media
from ..media_urls import resolve_media_url
from ..serializers import concierge_advisor_dict, detail_record_dict, encode_value, lead_dict, media_model_dict, model_dict
from .shared import site_config
router = APIRouter(prefix="/api/admin")
MEDIA_UPLOAD_MAX_BYTES = 5 * 1024 * 1024
MEDIA_ALLOWED_MIME_TYPES = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
}
SITE_CONFIG_MODULES = {
"heroSlides": {
"model": HeroSlide,
"entity": "hero_slide",
"primary": "title",
"fields": {"title", "kicker", "image", "isActive", "sortOrder"},
"none_to_empty": {"image"},
"create_defaults": {"kicker": "", "image": ""},
},
"vehicleOptions": {
"model": VehicleOption,
"entity": "vehicle_option",
"primary": "title",
"fields": {"title", "description", "image", "isActive", "sortOrder"},
"none_to_empty": set(),
"empty_to_none": {"description", "image"},
"create_defaults": {"description": None, "image": None},
},
}
def media_error(status_code: int, message: str, code: str, details: dict | None = None) -> None:
raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}})
def normalize_media_group(group: str | None) -> str:
value = (group or "general").strip() or "general"
if len(value) > 32 or not re.fullmatch(r"[A-Za-z0-9_-]+", value):
media_error(400, "素材分组只能包含字母、数字、下划线和中划线", "MEDIA_UPLOAD_INVALID_GROUP", {"group": group})
return value
def safe_media_name(filename: str | None) -> str:
name = os.path.basename((filename or "upload").replace("\\", "/")).strip()
return name[:120] or "upload"
def detect_image_mime(head: bytes) -> str | None:
if head.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if head.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if head.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "image/webp"
return None
def inspect_upload_image(file: UploadFile) -> tuple[str, int]:
content_type = (file.content_type or "").split(";", 1)[0].strip().lower()
if content_type == "image/jpg":
content_type = "image/jpeg"
if content_type and content_type not in MEDIA_ALLOWED_MIME_TYPES and content_type != "application/octet-stream":
media_error(400, "仅支持上传 JPG、PNG、WebP 或 GIF 图片", "MEDIA_UPLOAD_INVALID_TYPE", {"mimeType": content_type})
file.file.seek(0, os.SEEK_END)
size_bytes = file.file.tell()
file.file.seek(0)
if size_bytes <= 0:
media_error(400, "上传文件不能为空", "MEDIA_UPLOAD_EMPTY_FILE")
if size_bytes > MEDIA_UPLOAD_MAX_BYTES:
media_error(
413,
"图片大小不能超过 5MB",
"MEDIA_UPLOAD_TOO_LARGE",
{"maxBytes": MEDIA_UPLOAD_MAX_BYTES, "sizeBytes": size_bytes},
)
head = file.file.read(512)
file.file.seek(0)
detected = detect_image_mime(head)
if not detected:
media_error(400, "仅支持上传 JPG、PNG、WebP 或 GIF 图片", "MEDIA_UPLOAD_INVALID_TYPE")
if content_type in MEDIA_ALLOWED_MIME_TYPES and content_type != detected:
media_error(
400,
"图片内容与文件类型不一致",
"MEDIA_UPLOAD_TYPE_MISMATCH",
{"mimeType": content_type, "detectedMimeType": detected},
)
return detected, size_bytes
def media_object_key(group: str, mime_type: str) -> str:
extension = MEDIA_ALLOWED_MIME_TYPES[mime_type]
return f"admin/{group}/{utc_now().strftime('%Y/%m/%d')}/{uuid4().hex}.{extension}"
def oss_settings():
settings = get_settings()
values = {
"OSS_ACCESS_KEY_ID": settings.oss_access_key_id,
"OSS_ACCESS_KEY_SECRET": settings.oss_access_key_secret,
"OSS_ENDPOINT": settings.oss_endpoint,
"OSS_BUCKET_NAME": settings.oss_bucket_name,
}
missing = [key for key, value in values.items() if not (value and value.strip())]
if missing:
media_error(503, "OSS 存储配置不完整", "MEDIA_STORAGE_NOT_CONFIGURED", {"missing": missing})
return settings
def normalized_oss_host(endpoint: str, bucket: str) -> tuple[str, str]:
raw = endpoint.strip().rstrip("/")
if "://" not in raw:
raw = f"https://{raw}"
parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
media_error(503, "OSS Endpoint 配置不正确", "MEDIA_STORAGE_INVALID_ENDPOINT")
host = parsed.netloc
if not host.startswith(f"{bucket}."):
host = f"{bucket}.{host}"
return parsed.scheme, host
def upload_image_to_oss(file_obj, key: str, mime_type: str, size_bytes: int) -> str:
settings = oss_settings()
bucket = settings.oss_bucket_name.strip()
scheme, host = normalized_oss_host(settings.oss_endpoint, bucket)
date = formatdate(usegmt=True)
canonical_resource = f"/{bucket}/{key}"
string_to_sign = f"PUT\n\n{mime_type}\n{date}\n{canonical_resource}"
signature = base64.b64encode(
hmac.new(settings.oss_access_key_secret.strip().encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
quoted_key = quote(key, safe="/-_.~")
headers = {
"Authorization": f"OSS {settings.oss_access_key_id.strip()}:{signature}",
"Content-Type": mime_type,
"Content-Length": str(size_bytes),
"Date": date,
}
connection_class = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
connection = connection_class(host, timeout=20)
try:
file_obj.seek(0)
connection.request("PUT", f"/{quoted_key}", body=file_obj, headers=headers)
response = connection.getresponse()
response.read(2048)
if response.status < 200 or response.status >= 300:
media_error(502, "OSS 上传失败", "MEDIA_STORAGE_UPLOAD_FAILED", {"status": response.status})
except OSError:
media_error(502, "OSS 上传失败", "MEDIA_STORAGE_UPLOAD_FAILED")
finally:
connection.close()
return urlunsplit((scheme, host, f"/{quoted_key}", "", ""))
def audit(db: Session, actor_id: str | None, action: str, entity: str, entity_id: str | None = None, after=None, before=None) -> None:
db.add(
AuditLog(
actorId=actor_id,
action=action,
entity=entity,
entityId=entity_id,
before=encode_value(before) if before is not None else None,
after=encode_value(after) if after is not None else None,
)
)
def site_config_error(status_code: int, message: str, code: str, details: dict | None = None) -> None:
raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}})
def site_module(module: str) -> dict:
config = SITE_CONFIG_MODULES.get(module)
if not config:
site_config_error(400, "模块不存在或无权限操作", "MODULE_CONFIG_FORBIDDEN", {"module": module})
return config
def clean_site_value(value):
return value.strip() if isinstance(value, str) else value
def normalize_string_list(value) -> list[str]:
if value is None:
return []
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
def site_field_value(config: dict, field: str, value):
if field in {"steps", "chips", "advantages"}:
return normalize_string_list(value)
if field in {"serviceSections", "processSteps"}:
return [
{key: item_value.strip() for key, item_value in item.model_dump().items() if isinstance(item_value, str)}
if hasattr(item, "model_dump")
else {key: item_value.strip() for key, item_value in item.items() if isinstance(item_value, str)}
for item in (value or [])
if isinstance(item, dict) or hasattr(item, "model_dump")
]
value = clean_site_value(value)
if value == "" and field in config.get("empty_to_none", set()):
return None
if value is None and field in config["none_to_empty"]:
return ""
return value
def validate_site_primary(config: dict, body: SiteConfigPatchIn) -> str:
primary = config["primary"]
value = clean_site_value(getattr(body, primary))
if not value:
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": primary})
return value
def next_site_sort_order(db: Session, model) -> int:
highest = db.scalar(select(func.max(model.sortOrder)))
return (highest + 1) if highest is not None else 0
def hero_slide_admin_dict(item: HeroSlide) -> dict:
return {
"id": item.id,
"title": item.title,
"kicker": item.kicker or None,
"image": resolve_media_url(item.image) or None,
"isActive": item.isActive,
"sortOrder": item.sortOrder,
"createdAt": encode_value(item.createdAt),
"updatedAt": encode_value(item.updatedAt),
}
def site_item_dict(module: str, item) -> dict:
if module == "heroSlides":
return hero_slide_admin_dict(item)
return media_model_dict(item)
def module_items(db: Session, config: dict) -> list:
model = config["model"]
stmt = select(model)
if config.get("ordered", True):
stmt = stmt.order_by(model.sortOrder.asc())
else:
stmt = stmt.order_by(model.createdAt.asc())
return db.scalars(stmt).all()
def normalize_site_sort_orders(items: list) -> None:
for index, item in enumerate(items):
item.sortOrder = index
def site_create_payload(module: str, config: dict, body: SiteConfigPatchIn, db: Session) -> dict:
fields = body.model_fields_set
payload = {
field: site_field_value(config, field, getattr(body, field))
for field in config["fields"]
if field in fields
}
payload[config["primary"]] = validate_site_primary(config, body)
for field, value in config["create_defaults"].items():
payload.setdefault(field, value)
if "isActive" in config["fields"]:
payload["isActive"] = payload.get("isActive", True)
if config.get("ordered", True) and payload.get("sortOrder") is None:
payload["sortOrder"] = next_site_sort_order(db, config["model"])
return payload
def apply_site_patch(module: str, config: dict, item, body: SiteConfigPatchIn) -> None:
fields = body.model_fields_set
for field in config["fields"]:
if field not in fields:
continue
value = site_field_value(config, field, getattr(body, field))
if field == config["primary"] and not value:
site_config_error(422, "必填字段不能为空", "MODULE_CONFIG_VALIDATION_ERROR", {"field": field})
setattr(item, field, value)
def wanfa_route_dict(route: WanfaRoute) -> dict:
return {
"id": route.id,
"title": route.title,
"subtitle": route.subtitle,
"image": resolve_media_url(route.image),
"routeCount": route.routeCount,
"demandKeyword": route.demandKeyword,
}
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,
"routes": [wanfa_route_dict(route) for route in routes],
}
def home_wanfa_recommendation_dict(item: HomeWanfaRecommendation) -> dict:
category = item.category
return model_dict(
item,
{
"categoryLabel": category.label if category else "",
"routeCount": len(category.routes) if category else 0,
},
)
def wanfa_category_or_error(db: Session, category_id: str) -> WanfaCategory:
category = db.get(WanfaCategory, category_id)
if not category:
site_config_error(404, "玩法分类不存在", "WANFA_CATEGORY_NOT_FOUND", {"categoryId": category_id})
return category
def wanfa_route_or_error(db: Session, category: WanfaCategory, route_id: str) -> WanfaRoute:
route = db.get(WanfaRoute, route_id)
if not route or route.categoryId != category.id:
site_config_error(
404,
"玩法路线不存在",
"WANFA_ROUTE_NOT_FOUND",
{"categoryId": category.id, "routeId": route_id},
)
return route
def validate_wanfa_order(item_ids: list[str], current_ids: list[str], code: str) -> None:
if len(item_ids) != len(current_ids) or len(set(item_ids)) != len(item_ids) or set(item_ids) != set(current_ids):
site_config_error(400, "排序项目必须完整且不能重复", code)
def apply_wanfa_order(items: list, item_ids: list[str]) -> list:
by_id = {item.id: item for item in items}
ordered = [by_id[item_id] for item_id in item_ids]
for index, item in enumerate(ordered):
item.sortOrder = index
return ordered
def home_error(status_code: int, message: str, code: str, details: dict | None = None) -> 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:
home_error(404, f"{label}不存在", code, {"id": item_id})
return item
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")
def apply_home_order(items: list, item_ids: list[str]) -> list:
by_id = {item.id: item for item in items}
ordered = [by_id[item_id] for item_id in item_ids]
for index, item in enumerate(ordered):
item.sortOrder = index
return ordered
def create_home_item(db: Session, request: Request, body, model, entity: str):
payload = body.model_dump(exclude_none=True)
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)
audit(db, get_actor_id(request), "create", entity, item.id, after=after)
db.commit()
return success_response(media_model_dict(item), status_code=status.HTTP_201_CREATED)
def update_home_item(db: Session, request: Request, item_id: str, body, model, entity: str, label: str, code: str):
item = home_item_or_error(db, model, item_id, label, code)
before = model_dict(item)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(item, field, value)
db.flush()
after = model_dict(item)
audit(db, get_actor_id(request), "update", entity, item.id, after=after, before=before)
db.commit()
return success_response(media_model_dict(item))
def delete_home_item(db: Session, request: Request, item_id: str, model, entity: str, label: str, code: str):
item = home_item_or_error(db, model, item_id, label, code)
before = model_dict(item)
db.delete(item)
db.flush()
for index, remaining in enumerate(home_items(db, model)):
remaining.sortOrder = index
result = {"id": item_id}
audit(db, get_actor_id(request), "delete", entity, item_id, before=before, after=result)
db.commit()
return success_response(result)
def reorder_home_items(db: Session, request: Request, body: HomeReorderIn, model, entity: str):
items = home_items(db, model)
validate_home_order(body.itemIds, [item.id for item in items])
ordered = apply_home_order(items, body.itemIds)
db.flush()
after = [model_dict(item) for item in ordered]
audit(db, get_actor_id(request), "reorder", entity, after=after)
db.commit()
return success_response({"items": [media_model_dict(item) for item in ordered]})
@router.get("/home/team-buildings")
def list_home_team_buildings(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
return success_response({"items": [media_model_dict(item) for item in home_items(db, HomeTeamBuilding)]})
@router.post("/home/team-buildings", status_code=status.HTTP_201_CREATED)
def create_home_team_building(
body: HomeTeamBuildingCreate,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return create_home_item(db, request, body, HomeTeamBuilding, "home_team_building")
@router.patch("/home/team-buildings/reorder")
def reorder_home_team_buildings(
body: HomeReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return reorder_home_items(db, request, body, HomeTeamBuilding, "home_team_building")
@router.patch("/home/team-buildings/{team_building_id}")
def update_home_team_building(
team_building_id: str,
body: HomeTeamBuildingPatch,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return update_home_item(db, request, team_building_id, body, HomeTeamBuilding, "home_team_building", "团队共创", "HOME_TEAM_BUILDING_NOT_FOUND")
@router.delete("/home/team-buildings/{team_building_id}")
def delete_home_team_building(
team_building_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return delete_home_item(db, request, team_building_id, HomeTeamBuilding, "home_team_building", "团队共创", "HOME_TEAM_BUILDING_NOT_FOUND")
@router.get("/home/wild-archives")
def list_home_wild_archives(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
return success_response({"items": [media_model_dict(item) for item in home_items(db, HomeWildArchive)]})
@router.post("/home/wild-archives", status_code=status.HTTP_201_CREATED)
def create_home_wild_archive(
body: HomeWildArchiveCreate,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return create_home_item(db, request, body, HomeWildArchive, "home_wild_archive")
@router.patch("/home/wild-archives/reorder")
def reorder_home_wild_archives(
body: HomeReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return reorder_home_items(db, request, body, HomeWildArchive, "home_wild_archive")
@router.patch("/home/wild-archives/{archive_id}")
def update_home_wild_archive(
archive_id: str,
body: HomeWildArchivePatch,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return update_home_item(db, request, archive_id, body, HomeWildArchive, "home_wild_archive", "极境视界", "HOME_WILD_ARCHIVE_NOT_FOUND")
@router.delete("/home/wild-archives/{archive_id}")
def delete_home_wild_archive(
archive_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
return delete_home_item(db, request, archive_id, HomeWildArchive, "home_wild_archive", "极境视界", "HOME_WILD_ARCHIVE_NOT_FOUND")
def home_wanfa_recommendation_items(db: Session) -> list[HomeWanfaRecommendation]:
return db.scalars(
select(HomeWanfaRecommendation)
.options(selectinload(HomeWanfaRecommendation.category).selectinload(WanfaCategory.routes))
.order_by(HomeWanfaRecommendation.sortOrder.asc())
).all()
def home_wanfa_recommendation_or_error(db: Session, recommendation_id: str) -> HomeWanfaRecommendation:
recommendation = db.get(HomeWanfaRecommendation, recommendation_id)
if not recommendation:
home_error(404, "首页玩法推荐不存在", "HOME_WANFA_RECOMMENDATION_NOT_FOUND", {"id": recommendation_id})
return recommendation
def ensure_home_wanfa_category_available(
db: Session,
category_id: str,
recommendation_id: str | None = None,
) -> WanfaCategory:
category = wanfa_category_or_error(db, category_id)
query = select(func.count()).select_from(HomeWanfaRecommendation).where(
HomeWanfaRecommendation.categoryId == category.id
)
if recommendation_id:
query = query.where(HomeWanfaRecommendation.id != recommendation_id)
if db.scalar(query):
home_error(409, "该玩法分类已经关联到首页推荐", "HOME_WANFA_CATEGORY_DUPLICATE", {"categoryId": category.id})
return category
@router.get("/home/play-recommendations")
def list_home_wanfa_recommendations(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)):
return success_response({"items": [home_wanfa_recommendation_dict(item) for item in home_wanfa_recommendation_items(db)]})
@router.post("/home/play-recommendations", status_code=status.HTTP_201_CREATED)
def create_home_wanfa_recommendation(
body: HomeWanfaRecommendationCreate,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
category = ensure_home_wanfa_category_available(db, body.categoryId)
next_sort = db.scalar(select(func.max(HomeWanfaRecommendation.sortOrder)))
recommendation = HomeWanfaRecommendation(
categoryId=category.id,
category=category,
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)
audit(db, get_actor_id(request), "create", "home_wanfa_recommendation", recommendation.id, after=after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
@router.patch("/home/play-recommendations/reorder")
def reorder_home_wanfa_recommendations(
body: HomeReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
items = home_wanfa_recommendation_items(db)
validate_home_order(body.itemIds, [item.id for item in items])
ordered = apply_home_order(items, body.itemIds)
db.flush()
after = [home_wanfa_recommendation_dict(item) for item in ordered]
audit(db, get_actor_id(request), "reorder", "home_wanfa_recommendation", after=after)
db.commit()
return success_response({"items": after})
@router.patch("/home/play-recommendations/{recommendation_id}")
def update_home_wanfa_recommendation(
recommendation_id: str,
body: HomeWanfaRecommendationPatch,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
recommendation = home_wanfa_recommendation_or_error(db, recommendation_id)
before = home_wanfa_recommendation_dict(recommendation)
if "categoryId" in body.model_fields_set:
category = ensure_home_wanfa_category_available(db, body.categoryId, recommendation.id)
recommendation.categoryId = category.id
recommendation.category = category
for field in ("isActive", "sortOrder"):
if field in body.model_fields_set:
setattr(recommendation, field, getattr(body, field))
db.flush()
after = home_wanfa_recommendation_dict(recommendation)
audit(db, get_actor_id(request), "update", "home_wanfa_recommendation", recommendation.id, after=after, before=before)
db.commit()
return success_response(after)
@router.delete("/home/play-recommendations/{recommendation_id}")
def delete_home_wanfa_recommendation(
recommendation_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
recommendation = home_wanfa_recommendation_or_error(db, recommendation_id)
before = home_wanfa_recommendation_dict(recommendation)
db.delete(recommendation)
db.flush()
for index, remaining in enumerate(home_wanfa_recommendation_items(db)):
remaining.sortOrder = index
result = {"id": recommendation_id}
audit(db, get_actor_id(request), "delete", "home_wanfa_recommendation", recommendation_id, before=before, after=result)
db.commit()
return success_response(result)
def concierge_error(status_code: int, message: str, code: str, details: dict | None = None) -> None:
raise HTTPException(status_code=status_code, detail={"message": message, "code": code, "details": details or {}})
def concierge_advisor_or_error(db: Session, advisor_id: str) -> ConciergeAdvisor:
advisor = db.get(ConciergeAdvisor, advisor_id)
if not advisor:
concierge_error(404, "管家顾问不存在", "CONCIERGE_ADVISOR_NOT_FOUND", {"advisorId": advisor_id})
return advisor
def validate_concierge_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):
concierge_error(400, "排序项目必须完整且不能重复", "CONCIERGE_REORDER_INVALID")
def apply_concierge_order(items: list[ConciergeAdvisor], item_ids: list[str]) -> list[ConciergeAdvisor]:
by_id = {item.id: item for item in items}
ordered = [by_id[item_id] for item_id in item_ids]
for index, item in enumerate(ordered):
item.sortOrder = index
return ordered
def detail_error(status_code: int, message: str, code: str, details: dict | None = None) -> None:
site_config_error(status_code, message, code, details)
def detail_or_error(db: Session, detail_id: str) -> DetailRecord:
detail = db.get(DetailRecord, detail_id)
if not detail:
detail_error(404, "详情记录不存在", "DETAIL_NOT_FOUND", {"detailId": detail_id})
return detail
def detail_key_conflict(db: Session, key: str, current_id: str | None = None) -> None:
existing = db.scalar(select(DetailRecord).where(DetailRecord.key == key))
if existing and existing.id != current_id:
detail_error(409, "详情 key 已存在", "DETAIL_KEY_EXISTS", {"key": key})
def validate_detail_concierge_advisor(db: Session, advisor_id: str | None) -> None:
if not advisor_id:
return
if not db.get(ConciergeAdvisor, advisor_id):
detail_error(
422,
"关联的管家顾问不存在",
"DETAIL_CONCIERGE_ADVISOR_INVALID",
{"conciergeAdvisorId": advisor_id},
)
def validate_detail_order(item_ids: list[str], current_ids: list[str]) -> None:
validate_wanfa_order(item_ids, current_ids, "DETAIL_REORDER_INVALID")
@router.get("/details")
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]})
@router.patch("/details/reorder")
def reorder_details(
body: WanfaReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
details = db.scalars(select(DetailRecord).order_by(DetailRecord.sortOrder.asc())).all()
validate_detail_order(body.itemIds, [detail.id for detail in details])
ordered = apply_wanfa_order(details, body.itemIds)
db.flush()
after = [detail_record_dict(detail) for detail in ordered]
audit(db, get_actor_id(request), "reorder", "detail_record", after=after)
db.commit()
return success_response({"items": after})
@router.get("/details/{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)
def create_detail(
body: DetailCreate,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
detail_key_conflict(db, body.key)
validate_detail_concierge_advisor(db, body.conciergeAdvisorId)
detail = DetailRecord(
**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)
audit(db, get_actor_id(request), "create", "detail_record", detail.id, after=after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
@router.patch("/details/{detail_id}")
def update_detail(
detail_id: str,
body: DetailPatch,
request: Request,
_user: AdminUser = Depends(require_admin),
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)
if "conciergeAdvisorId" in body.model_fields_set:
validate_detail_concierge_advisor(db, body.conciergeAdvisorId)
for field in body.model_fields_set:
setattr(detail, field, getattr(body, field))
db.flush()
after = detail_record_dict(detail)
audit(db, get_actor_id(request), "update", "detail_record", detail.id, after=after, before=before)
db.commit()
return success_response(after)
@router.delete("/details/{detail_id}")
def delete_detail(
detail_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
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()
for index, item in enumerate(remaining):
item.sortOrder = index
audit(db, get_actor_id(request), "delete", "detail_record", detail.id, before=before)
db.commit()
return success_response({"id": detail.id})
@router.get("/wanfa/categories")
def list_wanfa_categories(_user: AdminUser = Depends(require_admin_permission("admin:wanfa:read")), db: Session = Depends(get_db)):
categories = db.scalars(
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,
[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)
def create_wanfa_category(
body: WanfaCategoryCreate,
request: Request,
_user: AdminUser = Depends(require_admin),
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)
audit(db, get_actor_id(request), "create", "wanfa_category", category.id, after=after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
@router.patch("/wanfa/categories/reorder")
def reorder_wanfa_categories(
body: WanfaReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
categories = db.scalars(select(WanfaCategory).order_by(WanfaCategory.sortOrder.asc())).all()
validate_wanfa_order(body.itemIds, [category.id for category in categories], "WANFA_CATEGORY_REORDER_INVALID")
ordered = apply_wanfa_order(categories, body.itemIds)
db.flush()
after = [wanfa_category_dict(category) for category in ordered]
audit(db, get_actor_id(request), "reorder", "wanfa_category", after=after)
db.commit()
return success_response({"items": after})
@router.patch("/wanfa/categories/{category_id}")
def update_wanfa_category(
category_id: str,
body: WanfaCategoryPatch,
request: Request,
_user: AdminUser = Depends(require_admin),
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
db.flush()
after = wanfa_category_dict(category)
audit(db, get_actor_id(request), "update", "wanfa_category", category.id, after=after, before=before)
db.commit()
return success_response(after)
@router.delete("/wanfa/categories/{category_id}")
def delete_wanfa_category(
category_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
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(
select(func.count())
.select_from(HomeWanfaRecommendation)
.where(HomeWanfaRecommendation.categoryId == category.id)
)
if recommendation_count:
site_config_error(
409,
"该玩法分类已被首页推荐,不能删除",
"WANFA_CATEGORY_RECOMMENDED",
{"categoryId": category.id},
)
before = wanfa_category_dict(category)
db.delete(category)
audit(db, get_actor_id(request), "delete", "wanfa_category", category.id, before=before)
db.commit()
return success_response({"id": category.id})
@router.post("/wanfa/categories/{category_id}/routes", status_code=status.HTTP_201_CREATED)
def create_wanfa_route(
category_id: str,
body: WanfaRouteCreate,
request: Request,
_user: AdminUser = Depends(require_admin),
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)
)
route = WanfaRoute(
categoryId=category.id,
title=body.title,
subtitle=body.subtitle,
image=body.image,
routeCount=body.routeCount,
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)
audit(db, get_actor_id(request), "create", "wanfa_route", route.id, after=after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
@router.patch("/wanfa/categories/{category_id}/routes/reorder")
def reorder_wanfa_routes(
category_id: str,
body: WanfaReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
category = wanfa_category_or_error(db, category_id)
routes = list(category.routes or [])
validate_wanfa_order(body.itemIds, [route.id for route in routes], "WANFA_ROUTE_REORDER_INVALID")
ordered = apply_wanfa_order(routes, body.itemIds)
db.flush()
after = [wanfa_route_dict(route) for route in ordered]
audit(db, get_actor_id(request), "reorder", "wanfa_route", category.id, after=after)
db.commit()
return success_response({"items": after})
@router.patch("/wanfa/categories/{category_id}/routes/{route_id}")
def update_wanfa_route(
category_id: str,
route_id: str,
body: WanfaRoutePatch,
request: Request,
_user: AdminUser = Depends(require_admin),
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:
setattr(route, field, getattr(body, field))
db.flush()
after = wanfa_route_dict(route)
audit(db, get_actor_id(request), "update", "wanfa_route", route.id, after=after, before=before)
db.commit()
return success_response(after)
@router.delete("/wanfa/categories/{category_id}/routes/{route_id}")
def delete_wanfa_route(
category_id: str,
route_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
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)
db.commit()
return success_response({"id": route.id})
@router.get("/concierge/advisors")
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]})
@router.post("/concierge/advisors", status_code=status.HTTP_201_CREATED)
def create_concierge_advisor(
body: ConciergeAdvisorCreate,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
payload = body.model_dump(exclude_none=True)
payload["details"] = body.details
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)
audit(db, get_actor_id(request), "create", "concierge_advisor", advisor.id, after=after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
@router.patch("/concierge/advisors/reorder")
def reorder_concierge_advisors(
body: WanfaReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
advisors = db.scalars(select(ConciergeAdvisor).order_by(ConciergeAdvisor.sortOrder.asc())).all()
validate_concierge_order(body.itemIds, [advisor.id for advisor in advisors])
ordered = apply_concierge_order(advisors, body.itemIds)
db.flush()
after = [concierge_advisor_dict(advisor) for advisor in ordered]
audit(db, get_actor_id(request), "reorder", "concierge_advisor", after=after)
db.commit()
return success_response({"items": after})
@router.patch("/concierge/advisors/{advisor_id}")
def update_concierge_advisor(
advisor_id: str,
body: ConciergeAdvisorPatch,
request: Request,
_user: AdminUser = Depends(require_admin),
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:
payload["details"] = body.details or []
for field, value in payload.items():
setattr(advisor, field, value)
db.flush()
after = concierge_advisor_dict(advisor)
audit(db, get_actor_id(request), "update", "concierge_advisor", advisor.id, after=after, before=before)
db.commit()
return success_response(after)
@router.delete("/concierge/advisors/{advisor_id}")
def delete_concierge_advisor(
advisor_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
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()
remaining = db.scalars(select(ConciergeAdvisor).order_by(ConciergeAdvisor.sortOrder.asc())).all()
for index, item in enumerate(remaining):
item.sortOrder = index
result = {"id": advisor_id}
audit(db, get_actor_id(request), "delete", "concierge_advisor", advisor_id, before=before, after=result)
db.commit()
return success_response(result)
@router.get("/auth/captcha")
def get_login_captcha(
store: AdminSessionStore = Depends(get_admin_session_store),
):
settings = get_settings()
try:
return success_response(create_captcha(store, settings.admin_captcha_expires_seconds))
except RedisUnavailableError as exc:
raise HTTPException(status_code=503, detail="后台会话服务暂时不可用") from exc
@router.post("/auth/login")
def login(
body: LoginIn,
request: Request,
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
try:
if not verify_captcha(store, body.captchaId, body.captchaCode):
raise HTTPException(status_code=401, detail="图形验证码错误或已过期")
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, remember_me=body.rememberMe)
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 if body.rememberMe else None,
)
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 if record.remember_me else None,
)
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(
{
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
**build_admin_permission_context(user, db, store),
}
)
@router.get("/dashboard")
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(lead_query.where(Lead.status == "new").with_only_columns(func.count())),
"leadCount": db.scalar(lead_query.with_only_columns(func.count())),
}
recent = db.scalars(
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_permission("admin:site-config")), db: Session = Depends(get_db)):
return success_response(
{
"heroSlides": [
hero_slide_admin_dict(item)
for item in scoped_items(db, HeroSlide, _user, HeroSlide.sortOrder.asc())
],
"vehicleOptions": [
media_model_dict(item)
for item in scoped_items(db, VehicleOption, _user, VehicleOption.sortOrder.asc())
],
}
)
@router.post("/site-config/{module}", status_code=status.HTTP_201_CREATED)
def create_site_config(
module: str,
body: SiteConfigPatchIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
config = site_module(module)
if config.get("fixed"):
site_config_error(405, "固定模块不支持新增", "MODULE_CONFIG_CREATE_UNSUPPORTED", {"module": module})
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)
audit(db, get_actor_id(request), "create", config["entity"], item.id, after)
db.commit()
return success_response(after, status_code=status.HTTP_201_CREATED)
@router.patch("/site-config/{module}/reorder")
def reorder_site_config(
module: str,
body: SiteConfigReorderIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
config = site_module(module)
if not config.get("ordered", True):
site_config_error(400, "模块不支持排序", "MODULE_CONFIG_REORDER_UNSUPPORTED", {"module": module})
items = module_items(db, config)
current_ids = [item.id for item in items]
requested_ids = body.itemIds
if len(set(requested_ids)) != len(requested_ids) or set(requested_ids) != set(current_ids):
site_config_error(
400,
"排序配置项必须完整且不能重复",
"MODULE_CONFIG_REORDER_INVALID",
{"module": module, "itemIds": requested_ids},
)
items_by_id = {item.id: item for item in items}
ordered_items = [items_by_id[item_id] for item_id in requested_ids]
before = [site_item_dict(module, item) for item in items]
normalize_site_sort_orders(ordered_items)
after = [site_item_dict(module, item) for item in ordered_items]
audit(db, get_actor_id(request), "reorder", config["entity"], module, {"items": after}, {"items": before})
db.commit()
return success_response({"items": after})
@router.patch("/site-config/{module}/{item_id}")
def update_site_config(
module: str,
item_id: str,
body: SiteConfigPatchIn,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
config = site_module(module)
item = db.get(config["model"], item_id)
if not item:
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
before = site_item_dict(module, item)
apply_site_patch(module, config, item, body)
db.flush()
after = site_item_dict(module, item)
audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before)
db.commit()
return success_response(after)
@router.delete("/site-config/{module}/{item_id}")
def delete_site_config(
module: str,
item_id: str,
request: Request,
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
config = site_module(module)
if config.get("fixed"):
site_config_error(405, "固定模块不支持删除", "MODULE_CONFIG_DELETE_UNSUPPORTED", {"module": module})
item = db.get(config["model"], item_id)
if not item:
site_config_error(404, "维护项不存在", "MODULE_CONFIG_NOT_FOUND", {"module": module, "id": item_id})
before = site_item_dict(module, item)
db.delete(item)
db.flush()
if config.get("ordered", True):
normalize_site_sort_orders(module_items(db, config))
result = {"id": item_id}
audit(db, get_actor_id(request), "delete", config["entity"], item_id, result, before)
db.commit()
return success_response(result)
@router.get("/leads")
def list_leads(
leadType: str | None = None,
status_value: LeadStatus | None = Query(default=None, alias="status"),
sourcePage: str | None = None,
keyword: str | None = None,
createdFrom: str | None = None,
createdTo: str | None = None,
offset: int = Query(default=0, ge=0),
take: int = Query(default=100, ge=1, le=200),
_user: AdminUser = Depends(require_admin_permission("admin:leads:read")),
db: Session = Depends(get_db),
):
query = LeadQuery(
leadType=leadType,
status=status_value,
sourcePage=sourcePage,
keyword=keyword,
createdFrom=createdFrom,
createdTo=createdTo,
take=take,
)
stmt = (
select(Lead)
.options(selectinload(Lead.assignedUser))
.order_by(Lead.createdAt.desc())
.offset(offset)
.limit(query.take)
)
if query.status:
stmt = stmt.where(Lead.status == query.status)
if query.leadType:
stmt = stmt.where(Lead.leadType == query.leadType)
if query.sourcePage:
stmt = stmt.where(Lead.sourcePage == query.sourcePage.strip())
if query.createdFrom:
stmt = stmt.where(Lead.createdAt >= query.createdFrom)
if query.createdTo:
stmt = stmt.where(Lead.createdAt < query.createdTo)
if query.keyword and query.keyword.strip():
pattern = f"%{query.keyword.strip()}%"
stmt = stmt.where(
or_(
Lead.phone.ilike(pattern),
Lead.destination.ilike(pattern),
Lead.note.ilike(pattern),
)
).distinct()
scoped_stmt = apply_data_scope(stmt, Lead, _user, db)
leads = db.scalars(scoped_stmt).all()
count_stmt = select(func.count()).select_from(Lead)
if query.status:
count_stmt = count_stmt.where(Lead.status == query.status)
if query.leadType:
count_stmt = count_stmt.where(Lead.leadType == query.leadType)
if query.sourcePage:
count_stmt = count_stmt.where(Lead.sourcePage == query.sourcePage.strip())
if query.createdFrom:
count_stmt = count_stmt.where(Lead.createdAt >= query.createdFrom)
if query.createdTo:
count_stmt = count_stmt.where(Lead.createdAt < query.createdTo)
if query.keyword and query.keyword.strip():
pattern = f"%{query.keyword.strip()}%"
count_stmt = count_stmt.where(or_(Lead.phone.ilike(pattern), Lead.destination.ilike(pattern), Lead.note.ilike(pattern)))
total = db.scalar(apply_data_scope(count_stmt, Lead, _user, db)) or 0
return success_response({"items": [lead_dict(lead) for lead in leads], "total": total, "offset": offset, "take": query.take})
@router.patch("/leads/{lead_id}/status")
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 or not is_within_data_scope(lead, _user, db):
raise HTTPException(status_code=404, detail="线索不存在")
before = model_dict(lead)
lead.status = body.status
db.flush()
audit(db, get_actor_id(request), "update_status", "lead", lead.id, model_dict(lead), before)
db.commit()
return success_response(model_dict(lead))
@router.get("/media-assets")
def list_media_assets(
pageNum: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=1, le=200),
_user: AdminUser = Depends(require_admin_permission("admin:media:read")),
db: Session = Depends(get_db),
):
offset = (pageNum - 1) * pageSize
assets_stmt = (
select(MediaAsset)
.order_by(MediaAsset.createdAt.desc())
.offset(offset)
.limit(pageSize)
)
assets = db.scalars(apply_data_scope(assets_stmt, MediaAsset, _user, db)).all()
total_stmt = apply_data_scope(select(func.count()).select_from(MediaAsset), MediaAsset, _user, db)
total = db.scalar(total_stmt) or 0
return success_response({
"items": [media_model_dict(asset) for asset in assets],
"total": total,
"pageNum": pageNum,
"pageSize": pageSize,
})
@router.post("/media-assets/upload", status_code=status.HTTP_201_CREATED)
def upload_media_asset(
request: Request,
file: UploadFile = File(...),
group: str | None = Form(default="general"),
_user: AdminUser = Depends(require_admin),
db: Session = Depends(get_db),
):
safe_group = normalize_media_group(group)
mime_type, size_bytes = inspect_upload_image(file)
key = media_object_key(safe_group, mime_type)
url = upload_image_to_oss(file.file, key, mime_type, size_bytes)
asset = MediaAsset(
url=url,
name=safe_media_name(file.filename),
mimeType=mime_type,
sizeBytes=size_bytes,
group=safe_group,
)
assign_created_by(asset, get_actor_id(request))
db.add(asset)
db.flush()
after = model_dict(asset)
audit(db, get_actor_id(request), "upload", "media_asset", asset.id, after)
db.commit()
return success_response(media_model_dict(asset), status_code=status.HTTP_201_CREATED)