- 新增 PaginatedResult 泛型类型统一分页返回数据结构 - 重构后端媒体素材列表接口,支持分页参数并返回完整分页信息 - 前端媒体素材页面添加分页控件,适配分页加载逻辑 - 上传素材后自动重置页码至第一页并重新加载列表 - 更新接口文档和用户文档,补充分页API使用规范 - 新增对应测试用例覆盖分页接口与前端调用逻辑
1555 lines
58 KiB
Python
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)
|
|
|
|
|