528 lines
21 KiB
Python
528 lines
21 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.dependencies import get_admin_user, get_db
|
||
from app.enums.home_material import (
|
||
HomeMaterialAssetStatus,
|
||
HomeMaterialMediaType,
|
||
HomeMaterialOperationEnum,
|
||
HomeMaterialWatermarkPosition,
|
||
HomeMaterialWatermarkSizeMode,
|
||
HomeMaterialWatermarkType,
|
||
)
|
||
from app.models.user import User
|
||
from app.schemas.home_material import (
|
||
HomeMaterialAssetListOut,
|
||
HomeMaterialAssetOut,
|
||
HomeMaterialAssetStatusOut,
|
||
HomeMaterialAssetUpdate,
|
||
HomeMaterialCategoryCreate,
|
||
HomeMaterialCategoryListOut,
|
||
HomeMaterialCategoryOut,
|
||
HomeMaterialCategoryUpdate,
|
||
HomeMaterialConfigOut,
|
||
HomeMaterialConfigUpdate,
|
||
HomeMaterialRegenerateWatermarkRequest,
|
||
HomeMaterialTextWatermarkConfig,
|
||
HomeMaterialTextWatermarkPreviewRequest,
|
||
HomeMaterialTextWatermarkPreviewResponse,
|
||
HomeMaterialUploadResultOut,
|
||
HomeMaterialWatermarkConfig,
|
||
HomeMaterialWatermarkListOut,
|
||
HomeMaterialWatermarkOut,
|
||
HomeMaterialWatermarkUpdate,
|
||
)
|
||
from app.services.home_material import home_material_service
|
||
from app.services.operation_log import log_operation
|
||
|
||
router = APIRouter(prefix="/admin/home-material", tags=["admin-home-material"])
|
||
|
||
|
||
def _detail(**kwargs) -> str:
|
||
return json.dumps(kwargs, ensure_ascii=False, default=str)
|
||
|
||
|
||
@router.get(
|
||
"/config",
|
||
response_model=HomeMaterialConfigOut,
|
||
summary="获取首页素材展示配置",
|
||
description="获取首页素材行业装修展示配置。配置存储于 system_configs,key=home_material_showcase_config。enabled=false 时前台不展示该模块。",
|
||
)
|
||
async def get_home_material_config(
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_ = admin
|
||
return await home_material_service.get_config(db)
|
||
|
||
|
||
@router.put(
|
||
"/config",
|
||
response_model=HomeMaterialConfigOut,
|
||
summary="保存首页素材展示配置",
|
||
description="保存首页素材展示开关、标题、副标题、后台是否展示原素材配置,并写入操作日志。",
|
||
)
|
||
async def save_home_material_config(
|
||
req: HomeMaterialConfigUpdate,
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result, before, after = await home_material_service.save_config(db, req)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.CONFIG_UPDATE.value,
|
||
"PUT",
|
||
"/admin/home-material/config",
|
||
detail=_detail(before=before, after=after),
|
||
)
|
||
await db.commit()
|
||
return result
|
||
|
||
|
||
@router.get(
|
||
"/categories",
|
||
response_model=HomeMaterialCategoryListOut,
|
||
summary="后台首页素材行业列表",
|
||
description="分页查询首页素材行业。列表不会连表,会先查行业列表,再按 category_id 批量 group by 查询素材数量后 map 回填。",
|
||
)
|
||
async def list_home_material_categories(
|
||
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||
keyword: str | None = Query(None, description="搜索行业名称或 key。"),
|
||
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_ = admin
|
||
return await home_material_service.list_categories(db, page=page, page_size=page_size, keyword=keyword, is_active=is_active)
|
||
|
||
|
||
@router.post(
|
||
"/categories",
|
||
response_model=HomeMaterialCategoryOut,
|
||
summary="新增首页素材行业",
|
||
description="新增首页素材行业类别。key 只允许字母、数字、下划线、中划线,且未软删记录内唯一。",
|
||
)
|
||
async def create_home_material_category(
|
||
req: HomeMaterialCategoryCreate,
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result, after = await home_material_service.create_category(db, req, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.CATEGORY_CREATE.value,
|
||
"POST",
|
||
"/admin/home-material/categories",
|
||
detail=_detail(after=after),
|
||
)
|
||
await db.commit()
|
||
return result
|
||
|
||
|
||
@router.put(
|
||
"/categories/{category_id}",
|
||
response_model=HomeMaterialCategoryOut,
|
||
summary="修改首页素材行业",
|
||
description="修改首页素材行业名称、key、描述、图标、启用状态和排序,并记录修改前后快照。",
|
||
)
|
||
async def update_home_material_category(
|
||
req: HomeMaterialCategoryUpdate,
|
||
category_id: str = Path(..., description="行业ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result, before, after = await home_material_service.update_category(db, category_id, req, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.CATEGORY_UPDATE.value,
|
||
"PUT",
|
||
f"/admin/home-material/categories/{category_id}",
|
||
detail=_detail(before=before, after=after),
|
||
)
|
||
await db.commit()
|
||
return result
|
||
|
||
|
||
@router.delete(
|
||
"/categories/{category_id}",
|
||
summary="删除首页素材行业",
|
||
description="软删首页素材行业。若行业下还有未删除素材,接口会拒绝删除,建议改为禁用。",
|
||
)
|
||
async def delete_home_material_category(
|
||
category_id: str = Path(..., description="行业ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
category, before = await home_material_service.delete_category(db, category_id, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.CATEGORY_DELETE.value,
|
||
"DELETE",
|
||
f"/admin/home-material/categories/{category_id}",
|
||
detail=_detail(before=before, after={"deleted_at": category.deleted_at}),
|
||
)
|
||
await db.commit()
|
||
return {"message": "ok"}
|
||
|
||
|
||
@router.get(
|
||
"/watermarks",
|
||
response_model=HomeMaterialWatermarkListOut,
|
||
summary="后台水印图片列表",
|
||
description="分页查询首页素材水印图片库。",
|
||
)
|
||
async def list_home_material_watermarks(
|
||
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_ = admin
|
||
return await home_material_service.list_watermarks(db, page=page, page_size=page_size, is_active=is_active)
|
||
|
||
|
||
@router.post(
|
||
"/watermarks",
|
||
response_model=HomeMaterialWatermarkOut,
|
||
summary="上传水印图片",
|
||
description="上传水印图片到水印库。支持 png/webp/jpg/jpeg。is_default=true 时自动取消其他默认水印。",
|
||
)
|
||
async def upload_home_material_watermark(
|
||
file: UploadFile = File(..., description="水印图片文件,建议 png 或 webp。"),
|
||
name: str | None = Form(None, description="水印名称,不传使用文件名。"),
|
||
is_default: bool = Form(False, description="是否设为默认水印。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result, after = await home_material_service.upload_watermark(db, file=file, name=name, is_default=is_default, admin_id=admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.WATERMARK_UPLOAD.value,
|
||
"POST",
|
||
"/admin/home-material/watermarks",
|
||
detail=_detail(after=after),
|
||
)
|
||
await db.commit()
|
||
return result
|
||
|
||
|
||
@router.put(
|
||
"/watermarks/{watermark_id}",
|
||
response_model=HomeMaterialWatermarkOut,
|
||
summary="修改水印图片信息",
|
||
description="修改水印名称、默认状态、启用状态。设为默认水印时自动取消其他默认水印。",
|
||
)
|
||
async def update_home_material_watermark(
|
||
req: HomeMaterialWatermarkUpdate,
|
||
watermark_id: str = Path(..., description="水印ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result, before, after = await home_material_service.update_watermark(db, watermark_id, req, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.WATERMARK_UPDATE.value,
|
||
"PUT",
|
||
f"/admin/home-material/watermarks/{watermark_id}",
|
||
detail=_detail(before=before, after=after),
|
||
)
|
||
await db.commit()
|
||
return result
|
||
|
||
|
||
@router.delete(
|
||
"/watermarks/{watermark_id}",
|
||
summary="删除水印图片",
|
||
description="软删水印图片记录,不物理删除文件;历史素材仍保留已生成的水印素材。",
|
||
)
|
||
async def delete_home_material_watermark(
|
||
watermark_id: str = Path(..., description="水印ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
watermark, before = await home_material_service.delete_watermark(db, watermark_id, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.WATERMARK_DELETE.value,
|
||
"DELETE",
|
||
f"/admin/home-material/watermarks/{watermark_id}",
|
||
detail=_detail(before=before, after={"deleted_at": watermark.deleted_at}),
|
||
)
|
||
await db.commit()
|
||
return {"message": "ok"}
|
||
|
||
|
||
@router.post(
|
||
"/text-watermark-preview",
|
||
response_model=HomeMaterialTextWatermarkPreviewResponse,
|
||
summary="生成重复文字水印精准预览层",
|
||
description=(
|
||
"根据素材真实宽高和重复文字水印配置,使用后端固定开源字体生成透明 PNG 预览层 data URL。"
|
||
"该接口和最终 FFmpeg 叠加前的文字水印层共用同一套渲染逻辑,用于保证预览和实际效果一致。"
|
||
),
|
||
)
|
||
async def preview_home_material_text_watermark(
|
||
req: HomeMaterialTextWatermarkPreviewRequest,
|
||
admin: User = Depends(get_admin_user),
|
||
):
|
||
_ = admin
|
||
return await home_material_service.preview_text_watermark_layer(req)
|
||
|
||
|
||
@router.get(
|
||
"/assets",
|
||
response_model=HomeMaterialAssetListOut,
|
||
summary="后台首页素材列表",
|
||
description="分页查询首页素材。不会连表,先查素材列表,再批量查询行业和水印 map 后回填 category_name/category_key/watermark_name。",
|
||
)
|
||
async def list_home_material_assets(
|
||
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||
category_id: str | None = Query(None, description="行业ID。"),
|
||
media_type: HomeMaterialMediaType | None = Query(None, description="素材类型:image图片,video视频。"),
|
||
status: HomeMaterialAssetStatus | None = Query(None, description="处理状态:draft草稿,processing处理中,success成功,failed失败。"),
|
||
is_active: bool | None = Query(None, description="是否前台展示。"),
|
||
keyword: str | None = Query(None, description="素材标题搜索。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_ = admin
|
||
return await home_material_service.list_assets(
|
||
db,
|
||
page=page,
|
||
page_size=page_size,
|
||
category_id=category_id,
|
||
media_type=media_type,
|
||
status=status,
|
||
is_active=is_active,
|
||
keyword=keyword,
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/assets",
|
||
response_model=HomeMaterialUploadResultOut,
|
||
summary="上传首页素材并生成水印",
|
||
description=(
|
||
"上传图片或视频素材,并使用水印图片生成水印版本。默认 wait=false 立即返回 processing,前端轮询 /assets/{asset_id}/status。"
|
||
"可传 watermark_id 使用水印库,也可传 watermark_file 临时上传水印;二者都不传时使用默认水印。"
|
||
"水印透明度 opacity_level 为 1-10;position 支持九宫格和 custom;custom 需要 custom_x_ratio/custom_y_ratio;"
|
||
"size_mode=ratio 使用 width_ratio,size_mode=px 使用 width_px。"
|
||
),
|
||
)
|
||
async def upload_home_material_asset(
|
||
category_id: str = Form(..., description="行业ID。"),
|
||
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
||
watermark_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
|
||
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
||
watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。watermark_type=image 时使用。"),
|
||
opacity_level: int = Form(6, ge=1, le=10, description="图片水印透明度档位,1-10。"),
|
||
position: HomeMaterialWatermarkPosition = Form(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="水印位置。"),
|
||
custom_x_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置X比例,position=custom时必填。"),
|
||
custom_y_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置Y比例,position=custom时必填。"),
|
||
size_mode: HomeMaterialWatermarkSizeMode = Form(HomeMaterialWatermarkSizeMode.RATIO, description="水印尺寸模式:ratio/px。"),
|
||
width_ratio: float | None = Form(0.18, ge=0.01, le=1, description="size_mode=ratio时使用,水印宽度占素材宽度比例。"),
|
||
width_px: int | None = Form(None, ge=1, le=10000, description="size_mode=px时使用,固定水印宽度。"),
|
||
margin_x: int = Form(24, ge=0, le=2000, description="图片水印横向边距。"),
|
||
margin_y: int = Form(24, ge=0, le=2000, description="图片水印纵向边距。"),
|
||
text_watermark_text: str | None = Form(None, description="重复文字水印内容。watermark_type=repeated_text 时必填。"),
|
||
text_watermark_opacity_level: int = Form(2, ge=1, le=10, description="重复文字透明度档位,1-10。"),
|
||
text_watermark_font_size_px: int = Form(28, ge=8, le=160, description="重复文字字号 px。"),
|
||
text_watermark_color: str = Form("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="重复文字颜色,#RRGGBB。"),
|
||
text_watermark_rotate_deg: int = Form(-30, ge=-90, le=90, description="重复文字旋转角度。"),
|
||
text_watermark_gap_x: int = Form(220, ge=20, le=2000, description="重复文字横向间距。"),
|
||
text_watermark_gap_y: int = Form(140, ge=20, le=2000, description="重复文字纵向间距。"),
|
||
text_watermark_staggered: bool = Form(True, description="重复文字是否交错排列。"),
|
||
is_active: bool = Form(True, description="是否前台展示。"),
|
||
sort_order: int = Form(0, ge=0, le=999999, description="排序值。"),
|
||
wait: bool = Form(False, description="是否等待处理完成。"),
|
||
wait_timeout_seconds: int = Form(30, ge=1, le=60, description="wait=true时最长等待秒数。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
text_config = None
|
||
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
|
||
text_config = HomeMaterialTextWatermarkConfig(
|
||
text=text_watermark_text or "",
|
||
opacity_level=text_watermark_opacity_level,
|
||
font_size_px=text_watermark_font_size_px,
|
||
color=text_watermark_color,
|
||
rotate_deg=text_watermark_rotate_deg,
|
||
gap_x=text_watermark_gap_x,
|
||
gap_y=text_watermark_gap_y,
|
||
staggered=text_watermark_staggered,
|
||
)
|
||
config = HomeMaterialWatermarkConfig(
|
||
watermark_type=watermark_type,
|
||
watermark_id=watermark_id,
|
||
opacity_level=opacity_level,
|
||
position=position,
|
||
custom_x_ratio=custom_x_ratio,
|
||
custom_y_ratio=custom_y_ratio,
|
||
size_mode=size_mode,
|
||
width_ratio=width_ratio,
|
||
width_px=width_px,
|
||
margin_x=margin_x,
|
||
margin_y=margin_y,
|
||
text_watermark=text_config,
|
||
)
|
||
result, after = await home_material_service.upload_asset(
|
||
db,
|
||
category_id=category_id,
|
||
file=file,
|
||
media_type=media_type,
|
||
title=title,
|
||
watermark_id=watermark_id,
|
||
watermark_file=watermark_file,
|
||
watermark_config=config,
|
||
is_active=is_active,
|
||
sort_order=sort_order,
|
||
admin_id=admin.id,
|
||
)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.ASSET_UPLOAD.value,
|
||
"POST",
|
||
"/admin/home-material/assets",
|
||
detail=_detail(after=after),
|
||
)
|
||
await db.commit()
|
||
home_material_service.start_watermark_task(result.id)
|
||
if wait:
|
||
return await home_material_service.wait_for_asset_result(result.id, wait_timeout_seconds)
|
||
return result
|
||
|
||
|
||
@router.get(
|
||
"/assets/{asset_id}",
|
||
response_model=HomeMaterialAssetOut,
|
||
summary="后台首页素材详情",
|
||
description="查询单个素材详情。详情仍不使用 ORM relationship,会按 category_id/watermark_id 批量查询 map 后组装。",
|
||
)
|
||
async def get_home_material_asset(
|
||
asset_id: str = Path(..., description="素材ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_ = admin
|
||
return await home_material_service.get_asset_detail(db, asset_id)
|
||
|
||
|
||
@router.get(
|
||
"/assets/{asset_id}/status",
|
||
response_model=HomeMaterialAssetStatusOut,
|
||
summary="查询首页素材水印处理状态",
|
||
description="前端上传或重新生成后轮询本接口,status=success 时展示 watermarked_url,status=failed 时展示 error_message 并允许重新生成。",
|
||
)
|
||
async def get_home_material_asset_status(
|
||
asset_id: str = Path(..., description="素材ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
_ = admin
|
||
return await home_material_service.get_asset_status(db, asset_id)
|
||
|
||
|
||
@router.put(
|
||
"/assets/{asset_id}",
|
||
response_model=HomeMaterialAssetOut,
|
||
summary="修改首页素材展示信息",
|
||
description="只修改行业、标题、启用状态、排序,不重新生成水印。",
|
||
)
|
||
async def update_home_material_asset(
|
||
req: HomeMaterialAssetUpdate,
|
||
asset_id: str = Path(..., description="素材ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result, before, after = await home_material_service.update_asset(db, asset_id, req, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.ASSET_UPDATE.value,
|
||
"PUT",
|
||
f"/admin/home-material/assets/{asset_id}",
|
||
detail=_detail(before=before, after=after),
|
||
)
|
||
await db.commit()
|
||
return result
|
||
|
||
|
||
@router.post(
|
||
"/assets/{asset_id}/regenerate-watermark",
|
||
response_model=HomeMaterialUploadResultOut,
|
||
summary="重新生成首页素材水印",
|
||
description="基于原始素材和新的水印配置重新生成水印文件。默认 wait=false,前端轮询状态接口。失败时保留旧 watermarked_url,但 status=failed。",
|
||
)
|
||
async def regenerate_home_material_watermark(
|
||
req: HomeMaterialRegenerateWatermarkRequest,
|
||
asset_id: str = Path(..., description="素材ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
result, before, after = await home_material_service.prepare_regenerate(db, asset_id, req, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.ASSET_REGENERATE_WATERMARK.value,
|
||
"POST",
|
||
f"/admin/home-material/assets/{asset_id}/regenerate-watermark",
|
||
detail=_detail(before=before, after=after),
|
||
)
|
||
await db.commit()
|
||
home_material_service.start_watermark_task(asset_id)
|
||
if req.wait:
|
||
return await home_material_service.wait_for_asset_result(asset_id, req.wait_timeout_seconds)
|
||
return result
|
||
|
||
|
||
@router.delete(
|
||
"/assets/{asset_id}",
|
||
summary="删除首页素材",
|
||
description="软删素材记录,不物理删除原文件和水印文件,避免误删历史展示资源。",
|
||
)
|
||
async def delete_home_material_asset(
|
||
asset_id: str = Path(..., description="素材ID。"),
|
||
admin: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
asset, before = await home_material_service.delete_asset(db, asset_id, admin.id)
|
||
await log_operation(
|
||
db,
|
||
admin.id,
|
||
admin.username,
|
||
HomeMaterialOperationEnum.ASSET_DELETE.value,
|
||
"DELETE",
|
||
f"/admin/home-material/assets/{asset_id}",
|
||
detail=_detail(before=before, after={"deleted_at": asset.deleted_at}),
|
||
)
|
||
await db.commit()
|
||
return {"message": "ok"}
|