Files
video-gen/video-gen-api/app/api/admin/home_material.py
T
2026-06-30 17:06:45 +08:00

528 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_configskey=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-10position 支持九宫格和 customcustom 需要 custom_x_ratio/custom_y_ratio"
"size_mode=ratio 使用 width_ratiosize_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_urlstatus=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"}