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="素材标题。为空时不再自动回填文件名。"), generation_prompt: str | None = Form(None, description="生成提词,可为空。"), media_references_json: str | None = Form(None, description="附件/参考素材JSON字符串,格式参考 generation_ai.create_task 的 media_references。"), 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, generation_prompt=generation_prompt, media_references_json=media_references_json, 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="修改行业、标题、启用状态、排序、生成提词和附件JSON,不重新生成水印。", ) 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_GENERATION_CONFIG_UPDATE.value if (before.get("generation_prompt") != after.get("generation_prompt") or before.get("media_references_count") != after.get("media_references_count")) else 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"}