from __future__ import annotations from decimal import Decimal from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.enums.home_material import ( HomeMaterialAssetStatus, HomeMaterialMediaType, HomeMaterialPublicResponseMode, HomeMaterialWatermarkPosition, HomeMaterialWatermarkSizeMode, HomeMaterialWatermarkType, ) from app.schemas.common import NaiveDatetimeOptional class HomeMaterialConfigOut(BaseModel): enabled: bool = Field(False, description="首页是否展示素材装修模块。false 时前台行业和素材接口返回 enabled=false 且列表为空。") title: str = Field("行业素材案例", max_length=64, description="首页素材模块标题。") subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。") show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。前台接口永远不返回原始素材链接。") class HomeMaterialConfigUpdate(BaseModel): enabled: bool = Field(False, description="首页是否展示素材装修模块。") title: str = Field("行业素材案例", min_length=1, max_length=64, description="首页素材模块标题。") subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。") show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。") class HomeMaterialCategoryCreate(BaseModel): name: str = Field(..., min_length=1, max_length=64, description="行业名称,例如:美妆护肤、食品生鲜。") key: str = Field(..., min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$", description="行业唯一标识,只允许字母、数字、下划线、中划线;前台可通过 category_key 查询。") description: str | None = Field(None, max_length=255, description="行业描述。") icon: str | None = Field(None, max_length=64, description="前端图标名称,例如 PictureOutlined、VideoCameraOutlined。") is_active: bool = Field(True, description="是否启用。禁用后前台不展示。") sort_order: int = Field(0, ge=0, le=999999, description="排序值,越小越靠前。") class HomeMaterialCategoryUpdate(HomeMaterialCategoryCreate): pass class HomeMaterialCategoryOut(BaseModel): id: str = Field(..., description="行业ID。") name: str = Field(..., description="行业名称。") key: str = Field(..., description="行业唯一标识。") description: str | None = Field(None, description="行业描述。") icon: str | None = Field(None, description="前端图标名称。") is_active: bool = Field(True, description="是否启用。") sort_order: int = Field(0, description="排序值。") asset_count: int = Field(0, description="可展示素材总数。后台列表按未删除素材统计,前台按 success + is_active 统计。") image_count: int = Field(0, description="图片素材数量。") video_count: int = Field(0, description="视频素材数量。") created_at: NaiveDatetimeOptional = Field(None, description="创建时间。") updated_at: NaiveDatetimeOptional = Field(None, description="更新时间。") model_config = ConfigDict(from_attributes=True) class HomeMaterialCategoryListOut(BaseModel): items: list[HomeMaterialCategoryOut] = Field(default_factory=list, description="行业列表。") total: int = Field(0, description="符合条件的行业总数。") class HomeMaterialWatermarkOut(BaseModel): id: str = Field(..., description="水印ID。") name: str = Field(..., description="水印名称。") file_url: str = Field(..., description="水印图片URL。") file_name: str | None = Field(None, description="上传文件名。") file_size_bytes: int = Field(0, description="文件大小,单位字节。") width: int | None = Field(None, description="水印图片宽度。") height: int | None = Field(None, description="水印图片高度。") is_default: bool = Field(False, description="是否默认水印。") is_active: bool = Field(True, description="是否启用。") created_at: NaiveDatetimeOptional = None updated_at: NaiveDatetimeOptional = None model_config = ConfigDict(from_attributes=True) class HomeMaterialWatermarkUpdate(BaseModel): name: str = Field(..., min_length=1, max_length=128, description="水印名称。") is_default: bool = Field(False, description="是否设为默认水印。设为 true 时会自动取消其他默认水印。") is_active: bool = Field(True, description="是否启用。") class HomeMaterialWatermarkListOut(BaseModel): items: list[HomeMaterialWatermarkOut] = Field(default_factory=list, description="水印列表。") total: int = Field(0, description="符合条件的水印总数。") class HomeMaterialTextWatermarkConfig(BaseModel): """重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。""" text: str = Field(..., min_length=1, max_length=64, description="重复水印文字。") opacity_level: int = Field(2, ge=1, le=10, description="文字透明度档位,1-10;1=0.1,10=1.0。") opacity: float = Field(0.2, ge=0.1, le=1.0, description="实际透明度,后端根据 opacity_level 计算。") font_size_px: int = Field(28, ge=8, le=160, description="字号,单位 px。") color: str = Field("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="文字颜色,#RRGGBB。") rotate_deg: int = Field(-30, ge=-90, le=90, description="文字旋转角度,单位度。") gap_x: int = Field(220, ge=20, le=2000, description="横向平铺间距。") gap_y: int = Field(140, ge=20, le=2000, description="纵向平铺间距。") staggered: bool = Field(True, description="是否交错排列。") @field_validator("text") @classmethod def validate_text(cls, value: str) -> str: value = (value or "").strip() if not value: raise ValueError("重复文字水印内容不能为空") return value @model_validator(mode="after") def fill_opacity(self) -> "HomeMaterialTextWatermarkConfig": self.opacity = round(self.opacity_level / 10, 2) return self class HomeMaterialWatermarkConfig(BaseModel): watermark_type: HomeMaterialWatermarkType = Field(HomeMaterialWatermarkType.IMAGE, description="水印类型:image=图片水印;repeated_text=重复文字水印。旧数据无该字段时默认 image。") watermark_id: str | None = Field(None, description="图片水印ID。watermark_type=image 时使用。上传素材时可由 watermark_id 指定,也可通过临时 watermark_file 生成。") opacity_level: int = Field(6, ge=1, le=10, description="图片水印透明度档位,1-10;1=0.1,10=1.0。") opacity: float = Field(0.6, ge=0.1, le=1.0, description="图片水印实际透明度,后端根据 opacity_level 计算。") position: HomeMaterialWatermarkPosition = Field(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="图片水印位置枚举。") custom_x_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 X 比例,position=custom 时使用,0-1。") custom_y_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 Y 比例,position=custom 时使用,0-1。") size_mode: HomeMaterialWatermarkSizeMode = Field(HomeMaterialWatermarkSizeMode.RATIO, description="图片水印尺寸模式:ratio=按素材宽度比例;px=固定像素宽度。") width_ratio: float | None = Field(0.18, ge=0.01, le=1, description="图片水印 size_mode=ratio 时使用,水印宽度占素材宽度比例,例如 0.18。") width_px: int | None = Field(None, ge=1, le=10000, description="图片水印 size_mode=px 时使用,固定水印宽度。") margin_x: int = Field(24, ge=0, le=2000, description="图片水印横向边距,九宫格位置使用。") margin_y: int = Field(24, ge=0, le=2000, description="图片水印纵向边距,九宫格位置使用。") text_watermark: HomeMaterialTextWatermarkConfig | None = Field(None, description="重复文字水印配置。watermark_type=repeated_text 时必填。") @model_validator(mode="after") def validate_by_type(self) -> "HomeMaterialWatermarkConfig": self.opacity = round(self.opacity_level / 10, 2) if self.watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT: if self.text_watermark is None: raise ValueError("watermark_type=repeated_text 时 text_watermark 必填") self.watermark_id = None return self if self.position == HomeMaterialWatermarkPosition.CUSTOM: if self.custom_x_ratio is None or self.custom_y_ratio is None: raise ValueError("position=custom 时 custom_x_ratio 和 custom_y_ratio 必填") if self.size_mode == HomeMaterialWatermarkSizeMode.RATIO and self.width_ratio is None: raise ValueError("size_mode=ratio 时 width_ratio 必填") if self.size_mode == HomeMaterialWatermarkSizeMode.PX and self.width_px is None: raise ValueError("size_mode=px 时 width_px 必填") self.text_watermark = None return self class HomeMaterialTextWatermarkPreviewRequest(BaseModel): width: int = Field(..., ge=1, le=8192, description="预览层宽度,建议传素材真实宽度。") height: int = Field(..., ge=1, le=8192, description="预览层高度,建议传素材真实高度。") text_watermark: HomeMaterialTextWatermarkConfig = Field(..., description="重复文字水印配置。") class HomeMaterialTextWatermarkPreviewResponse(BaseModel): width: int = Field(..., description="预览层宽度。") height: int = Field(..., description="预览层高度。") preview_layer_data_url: str = Field(..., description="透明 PNG 预览层 data URL。") class HomeMaterialAssetOut(BaseModel): id: str = Field(..., description="素材ID。") category_id: str = Field(..., description="行业ID。") category_name: str | None = Field(None, description="行业名称,列表接口批量查询回填。") category_key: str | None = Field(None, description="行业key,列表接口批量查询回填。") title: str | None = Field(None, description="素材标题。") media_type: HomeMaterialMediaType = Field(..., description="素材类型:image=图片,video=视频。") status: HomeMaterialAssetStatus = Field(..., description="处理状态:draft草稿,processing处理中,success成功,failed失败。") original_url: str | None = Field(None, description="原始素材URL,仅后台返回;前台不返回。") watermarked_url: str | None = Field(None, description="水印素材URL。") cover_url: str | None = Field(None, description="视频封面URL。") watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。") watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。") watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。") width: int | None = Field(None, description="素材宽度。") height: int | None = Field(None, description="素材高度。") duration_seconds: Decimal | float | None = Field(None, description="视频时长,图片为空。") file_size_bytes: int = Field(0, description="原始文件大小。") watermarked_file_size_bytes: int | None = Field(None, description="水印后文件大小。") is_active: bool = Field(True, description="是否前台展示。") sort_order: int = Field(0, description="排序值。") error_message: str | None = Field(None, description="失败原因。") processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。") created_at: NaiveDatetimeOptional = None updated_at: NaiveDatetimeOptional = None model_config = ConfigDict(from_attributes=True) class HomeMaterialAssetListOut(BaseModel): items: list[HomeMaterialAssetOut] = Field(default_factory=list, description="素材列表。") total: int = Field(0, description="符合条件的素材总数。") class HomeMaterialAssetUpdate(BaseModel): category_id: str = Field(..., description="行业ID。") title: str | None = Field(None, max_length=128, description="素材标题。") is_active: bool = Field(True, description="是否前台展示。") sort_order: int = Field(0, ge=0, le=999999, description="排序值。") @field_validator("title") @classmethod def clean_title(cls, value: str | None) -> str | None: value = (value or "").strip() return value or None class HomeMaterialRegenerateWatermarkRequest(HomeMaterialWatermarkConfig): wait: bool = Field(False, description="是否等待水印处理完成。默认 false,前端轮询状态。") wait_timeout_seconds: int = Field(30, ge=1, le=60, description="wait=true 时最长等待秒数,最大 60 秒。") class HomeMaterialAssetStatusOut(BaseModel): id: str = Field(..., description="素材ID。") status: HomeMaterialAssetStatus = Field(..., description="处理状态。") error_message: str | None = Field(None, description="失败原因。") original_url: str | None = Field(None, description="原始素材URL,仅后台返回。") watermarked_url: str | None = Field(None, description="水印素材URL。") cover_url: str | None = Field(None, description="视频封面URL。") processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。") class HomeMaterialPublicCategoryOut(BaseModel): id: str = Field(..., description="行业ID。") name: str = Field(..., description="行业名称。") key: str = Field(..., description="行业key。") icon: str | None = Field(None, description="前端图标名。") description: str | None = Field(None, description="行业描述。") sort_order: int = Field(0, description="排序。") asset_count: int = Field(0, description="可展示素材总数。") image_count: int = Field(0, description="图片素材数量。") video_count: int = Field(0, description="视频素材数量。") class HomeMaterialPublicCategoryListOut(BaseModel): enabled: bool = Field(False, description="首页素材展示开关。false 时 items 为空。") title: str = Field("行业素材案例", description="首页素材模块标题。") subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。") items: list[HomeMaterialPublicCategoryOut] = Field(default_factory=list, description="行业列表。") class HomeMaterialPublicAssetOut(BaseModel): id: str = Field(..., description="素材ID。") title: str | None = Field(None, description="素材标题。") media_type: HomeMaterialMediaType = Field(..., description="素材类型。") url: str = Field(..., description="前台展示URL,永远返回 watermarked_url。") cover_url: str | None = Field(None, description="视频封面URL。") width: int | None = Field(None, description="宽度。") height: int | None = Field(None, description="高度。") duration_seconds: Decimal | float | None = Field(None, description="视频时长。") sort_order: int = Field(0, description="排序。") class HomeMaterialPublicCategoryGroupOut(BaseModel): category: HomeMaterialPublicCategoryOut assets: list[HomeMaterialPublicAssetOut] = Field(default_factory=list) class HomeMaterialPublicGroupedOut(BaseModel): enabled: bool = Field(True, description="首页素材展示开关。") title: str = Field("行业素材案例", description="首页素材模块标题。") subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。") response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.GROUPED, description="返回模式。") categories: list[HomeMaterialPublicCategoryGroupOut] = Field(default_factory=list, description="行业分组素材。") class HomeMaterialPublicFlatItemOut(HomeMaterialPublicAssetOut): category_id: str = Field(..., description="行业ID。") category_name: str = Field(..., description="行业名称。") category_key: str = Field(..., description="行业key。") category_icon: str | None = Field(None, description="行业图标。") class HomeMaterialPublicFlatOut(BaseModel): enabled: bool = Field(True, description="首页素材展示开关。") title: str = Field("行业素材案例", description="首页素材模块标题。") subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。") response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.FLAT, description="返回模式。") items: list[HomeMaterialPublicFlatItemOut] = Field(default_factory=list, description="平铺素材列表。") total: int = Field(0, description="总数。") class HomeMaterialUploadResultOut(BaseModel): id: str = Field(..., description="素材ID。") category_id: str = Field(..., description="行业ID。") title: str | None = Field(None, description="素材标题。") media_type: HomeMaterialMediaType = Field(..., description="素材类型。") status: HomeMaterialAssetStatus = Field(..., description="处理状态。") original_url: str | None = Field(None, description="原始素材URL。") watermarked_url: str | None = Field(None, description="水印素材URL。") cover_url: str | None = Field(None, description="视频封面URL。") watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。") message: str = Field("", description="提示消息。")