@@ -0,0 +1,527 @@
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 " }