Files
video-gen/video-gen-api/app/services/home_material/service.py
T

913 lines
42 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 asyncio
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException, UploadFile, status
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.home_material import (
HOME_MATERIAL_DEFAULT_CONFIG,
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN,
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
HomeMaterialAssetStatus,
HomeMaterialConfigKeyEnum,
HomeMaterialLogEventEnum,
HomeMaterialMediaType,
HomeMaterialPublicResponseMode,
HomeMaterialWatermarkPosition,
HomeMaterialWatermarkSizeMode,
HomeMaterialWatermarkType,
)
from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
from app.models.base import async_session
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.system_config import SystemConfig
from app.schemas.home_material import (
HomeMaterialAssetListOut,
HomeMaterialAssetOut,
HomeMaterialAssetStatusOut,
HomeMaterialAssetUpdate,
HomeMaterialCategoryCreate,
HomeMaterialCategoryListOut,
HomeMaterialCategoryOut,
HomeMaterialCategoryUpdate,
HomeMaterialConfigOut,
HomeMaterialConfigUpdate,
HomeMaterialPublicCategoryListOut,
HomeMaterialPublicFlatOut,
HomeMaterialMediaReference,
HomeMaterialPublicGroupedOut,
HomeMaterialRegenerateWatermarkRequest,
HomeMaterialTextWatermarkPreviewRequest,
HomeMaterialTextWatermarkPreviewResponse,
HomeMaterialUploadResultOut,
HomeMaterialWatermarkConfig,
HomeMaterialWatermarkListOut,
HomeMaterialWatermarkOut,
HomeMaterialWatermarkUpdate,
)
from app.services.home_material.query import query_service
from app.services.home_material.storage import storage_service
from app.services.home_material.watermark_processor import watermark_processor
from app.services.operation_log_service import build_exception_detail, log_operation_event
from app.services.upload_resource.bind_service import bind_upload_resources
from app.utils.id_gen import generate_id
def _json_dumps(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
def _json_loads(value: str | None) -> dict[str, Any] | None:
if not value:
return None
try:
data = json.loads(value)
return data if isinstance(data, dict) else None
except Exception:
return None
def _json_loads_list(value: str | None) -> list[Any]:
if not value:
return []
try:
data = json.loads(value)
return data if isinstance(data, list) else []
except Exception as exc:
log_operation_event(
domain="home_material",
module="home_material_asset",
event_type=HomeMaterialLogEventEnum.MEDIA_REFERENCES_PARSE_FAILED.value,
event_status=LogEventStatusEnum.WARNING.value,
source=LogSourceEnum.SERVICE.value,
detail={"raw_prefix": value[:500]},
error=str(exc),
)
return []
def _clean_title(value: str | None) -> str | None:
value = (value or "").strip()
return value or None
def _clean_generation_prompt(value: str | None) -> str | None:
text = (value or "").strip()
if not text:
return None
if len(text) > HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"生成提词不能超过 {HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN} 个字符")
return text
def _media_references_from_json(value: str | None) -> list[HomeMaterialMediaReference]:
if not value or not value.strip():
return []
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
try:
raw = json.loads(value)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件JSON格式错误") from exc
if raw is None:
return []
if not isinstance(raw, list):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件必须是数组格式")
return _normalize_media_references(raw)
def _normalize_media_references(value: list[Any] | None) -> list[HomeMaterialMediaReference]:
if not value:
return []
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件最多 {HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT} 个")
refs: list[HomeMaterialMediaReference] = []
for item in value:
try:
ref = item if isinstance(item, HomeMaterialMediaReference) else HomeMaterialMediaReference(**item)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件格式错误:{exc}") from exc
refs.append(ref)
return refs
def _dump_media_references_json(refs: list[HomeMaterialMediaReference] | None) -> str | None:
items = [r.model_dump(mode="json", exclude_none=True) for r in (refs or [])]
if not items:
return None
text = _json_dumps(items)
if len(text) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
return text
def _media_references_out(value: str | None) -> list[HomeMaterialMediaReference]:
return _normalize_media_references(_json_loads_list(value))
def _media_reference_resource_ids(refs: list[HomeMaterialMediaReference]) -> list[str]:
return [str(r.upload_resource_id).strip() for r in refs if r.upload_resource_id and str(r.upload_resource_id).strip()]
def _media_reference_urls(refs: list[HomeMaterialMediaReference]) -> list[str]:
return [str(r.url).strip() for r in refs if r.url and str(r.url).strip()]
def _media_reference_type_counts(refs: list[HomeMaterialMediaReference]) -> dict[str, int]:
counts: dict[str, int] = {}
for ref in refs:
counts[ref.type] = counts.get(ref.type, 0) + 1
return counts
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
data = dict(value or {})
watermark_type = data.get("watermark_type") or HomeMaterialWatermarkType.IMAGE.value
data["watermark_type"] = watermark_type
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
data["watermark_id"] = None
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
if fallback_watermark_id and not data.get("watermark_id"):
data["watermark_id"] = fallback_watermark_id
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
def _config_to_out(data: dict[str, Any] | None) -> HomeMaterialConfigOut:
raw = {**HOME_MATERIAL_DEFAULT_CONFIG, **(data or {})}
return HomeMaterialConfigOut(
enabled=bool(raw.get("enabled", False)),
title=str(raw.get("title") or HOME_MATERIAL_DEFAULT_CONFIG["title"]),
subtitle=str(raw.get("subtitle") or HOME_MATERIAL_DEFAULT_CONFIG["subtitle"]),
show_original_in_admin=bool(raw.get("show_original_in_admin", True)),
)
def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
if not asset:
return None
return {
"id": asset.id,
"category_id": asset.category_id,
"title": asset.title,
"media_type": asset.media_type,
"status": asset.status,
"original_url": asset.original_url,
"watermarked_url": asset.watermarked_url,
"cover_url": asset.cover_url,
"watermark_id": asset.watermark_id,
"watermark_config": _json_loads(asset.watermark_config_json),
"generation_prompt": asset.generation_prompt,
"media_references_count": len(_media_references_out(asset.media_references_json)),
"is_active": asset.is_active,
"sort_order": asset.sort_order,
"deleted_at": asset.deleted_at,
}
def _category_snapshot(category: HomeMaterialCategory | None) -> dict[str, Any] | None:
if not category:
return None
return {
"id": category.id,
"name": category.name,
"key": category.key,
"description": category.description,
"icon": category.icon,
"is_active": category.is_active,
"sort_order": category.sort_order,
"deleted_at": category.deleted_at,
}
def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, Any] | None:
if not watermark:
return None
return {
"id": watermark.id,
"name": watermark.name,
"file_url": watermark.file_url,
"is_default": watermark.is_default,
"is_active": watermark.is_active,
"deleted_at": watermark.deleted_at,
}
def _log_generation_config_event(
event_type: str,
*,
admin_id: str | None,
asset_id: str | None = None,
event_status: str = LogEventStatusEnum.SUCCESS.value,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
log_operation_event(
domain="home_material",
module="home_material_asset",
event_type=event_type,
event_status=event_status,
source=LogSourceEnum.API.value,
user_id=admin_id,
asset_id=asset_id,
message=message,
detail=detail or {},
error=error,
)
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
"""
写入后立即返回 ORM 对象前必须显式刷新。
本模块模型继承 TimestampMixinupdated_at 使用 onupdate=func.now()。
AsyncSession 下 flush 后直接访问 updated_at/created_at 可能触发隐式 IO
导致 MissingGreenlet。统一 flush + refresh,避免响应组装阶段懒加载。
"""
await db.flush()
await db.refresh(obj)
class HomeMaterialService:
"""首页素材装修主业务服务。API 层只调用本服务,内部再委托 query/storage/processor。"""
async def get_config(self, db: AsyncSession) -> HomeMaterialConfigOut:
result = await db.execute(
select(SystemConfig.value)
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
.limit(1)
)
value = result.scalar_one_or_none()
if not value:
return _config_to_out(None)
return _config_to_out(_json_loads(value))
async def save_config(self, db: AsyncSession, req: HomeMaterialConfigUpdate) -> tuple[HomeMaterialConfigOut, dict[str, Any], dict[str, Any]]:
before = await self.get_config(db)
after = HomeMaterialConfigOut(**req.model_dump())
result = await db.execute(
select(SystemConfig)
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
.limit(1)
)
config = result.scalar_one_or_none()
payload = after.model_dump()
if config:
config.value = _json_dumps(payload)
config.description = "首页素材行业装修展示配置"
db.add(config)
else:
db.add(
SystemConfig(
id=generate_id(),
key=HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value,
value=_json_dumps(payload),
description="首页素材行业装修展示配置",
)
)
return after, before.model_dump(), after.model_dump()
async def list_categories(
self,
db: AsyncSession,
*,
page: int,
page_size: int,
keyword: str | None = None,
is_active: bool | None = None,
) -> HomeMaterialCategoryListOut:
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None))
count_stmt = select(func.count(HomeMaterialCategory.id)).where(HomeMaterialCategory.deleted_at.is_(None))
conditions = []
if keyword:
like = f"%{keyword}%"
conditions.append(or_(HomeMaterialCategory.name.ilike(like), HomeMaterialCategory.key.ilike(like)))
if is_active is not None:
conditions.append(HomeMaterialCategory.is_active.is_(is_active))
for cond in conditions:
stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond)
total = int((await db.execute(count_stmt)).scalar_one() or 0)
result = await db.execute(
stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
categories = result.scalars().all()
counts = await query_service.asset_counts_map(db, [c.id for c in categories])
return HomeMaterialCategoryListOut(
items=[query_service.category_to_out(c, counts.get(c.id)) for c in categories],
total=total,
)
async def create_category(self, db: AsyncSession, req: HomeMaterialCategoryCreate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any]]:
await self._ensure_category_key_available(db, req.key)
category = HomeMaterialCategory(
id=generate_id(),
name=req.name,
key=req.key,
description=req.description,
icon=req.icon,
is_active=req.is_active,
sort_order=req.sort_order,
created_by=admin_id,
updated_by=admin_id,
)
db.add(category)
await _flush_refresh(db, category)
return query_service.category_to_out(category), _category_snapshot(category) or {}
async def update_category(self, db: AsyncSession, category_id: str, req: HomeMaterialCategoryUpdate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any], dict[str, Any]]:
category = await self._get_category(db, category_id)
before = _category_snapshot(category) or {}
if req.key != category.key:
await self._ensure_category_key_available(db, req.key, exclude_id=category_id)
category.name = req.name
category.key = req.key
category.description = req.description
category.icon = req.icon
category.is_active = req.is_active
category.sort_order = req.sort_order
category.updated_by = admin_id
db.add(category)
await _flush_refresh(db, category)
after = _category_snapshot(category) or {}
return query_service.category_to_out(category), before, after
async def delete_category(self, db: AsyncSession, category_id: str, admin_id: str | None) -> tuple[HomeMaterialCategory, dict[str, Any]]:
category = await self._get_category(db, category_id)
count = int((await db.execute(select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.category_id == category_id, HomeMaterialAsset.deleted_at.is_(None)))).scalar_one() or 0)
if count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该行业下仍有素材,请先删除素材或禁用行业")
before = _category_snapshot(category) or {}
category.deleted_at = datetime.now(timezone.utc)
category.updated_by = admin_id
db.add(category)
return category, before
async def _ensure_category_key_available(self, db: AsyncSession, key: str, exclude_id: str | None = None) -> None:
stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.key == key, HomeMaterialCategory.deleted_at.is_(None))
if exclude_id:
stmt = stmt.where(HomeMaterialCategory.id != exclude_id)
exists = (await db.execute(stmt.limit(1))).scalar_one_or_none()
if exists:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="行业 key 已存在")
async def _get_category(self, db: AsyncSession, category_id: str, *, active_only: bool = False) -> HomeMaterialCategory:
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.id == category_id, HomeMaterialCategory.deleted_at.is_(None))
if active_only:
stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
result = await db.execute(stmt.limit(1))
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="行业不存在或已删除")
return category
async def upload_watermark(self, db: AsyncSession, *, file: UploadFile, name: str | None, is_default: bool, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any]]:
stored = await storage_service.save_watermark_file(file)
info = await watermark_processor.probe(stored.storage_path)
if is_default:
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None)).values(is_default=False))
watermark = HomeMaterialWatermark(
id=generate_id(),
name=name or stored.file_name,
file_url=stored.file_url,
storage_path=stored.storage_path,
file_name=stored.file_name,
file_size_bytes=stored.file_size_bytes,
width=info.width,
height=info.height,
is_default=is_default,
is_active=True,
created_by=admin_id,
updated_by=admin_id,
)
db.add(watermark)
await _flush_refresh(db, watermark)
return query_service.watermark_to_out(watermark), _watermark_snapshot(watermark) or {}
async def list_watermarks(self, db: AsyncSession, *, page: int, page_size: int, is_active: bool | None = None) -> HomeMaterialWatermarkListOut:
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None))
count_stmt = select(func.count(HomeMaterialWatermark.id)).where(HomeMaterialWatermark.deleted_at.is_(None))
if is_active is not None:
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
count_stmt = count_stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
total = int((await db.execute(count_stmt)).scalar_one() or 0)
result = await db.execute(
stmt.order_by(HomeMaterialWatermark.is_default.desc(), HomeMaterialWatermark.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return HomeMaterialWatermarkListOut(items=[query_service.watermark_to_out(w) for w in result.scalars().all()], total=total)
async def update_watermark(self, db: AsyncSession, watermark_id: str, req: HomeMaterialWatermarkUpdate, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any], dict[str, Any]]:
watermark = await self._get_watermark(db, watermark_id)
before = _watermark_snapshot(watermark) or {}
if req.is_default:
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.id != watermark_id).values(is_default=False))
watermark.name = req.name
watermark.is_default = req.is_default
watermark.is_active = req.is_active
watermark.updated_by = admin_id
db.add(watermark)
await _flush_refresh(db, watermark)
return query_service.watermark_to_out(watermark), before, _watermark_snapshot(watermark) or {}
async def delete_watermark(self, db: AsyncSession, watermark_id: str, admin_id: str | None) -> tuple[HomeMaterialWatermark, dict[str, Any]]:
watermark = await self._get_watermark(db, watermark_id)
before = _watermark_snapshot(watermark) or {}
watermark.deleted_at = datetime.now(timezone.utc)
watermark.is_active = False
watermark.is_default = False
watermark.updated_by = admin_id
db.add(watermark)
return watermark, before
async def _get_watermark(self, db: AsyncSession, watermark_id: str, *, active_only: bool = False) -> HomeMaterialWatermark:
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.id == watermark_id, HomeMaterialWatermark.deleted_at.is_(None))
if active_only:
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(True))
result = await db.execute(stmt.limit(1))
watermark = result.scalar_one_or_none()
if not watermark:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="水印不存在或已删除")
return watermark
async def upload_asset(
self,
db: AsyncSession,
*,
category_id: str,
file: UploadFile,
media_type: HomeMaterialMediaType,
title: str | None,
watermark_id: str | None,
watermark_file: UploadFile | None,
watermark_config: HomeMaterialWatermarkConfig,
is_active: bool,
sort_order: int,
generation_prompt: str | None = None,
media_references_json: str | None = None,
admin_id: str | None,
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
await self._get_category(db, category_id, active_only=False)
clean_title = _clean_title(title)
clean_prompt = _clean_generation_prompt(generation_prompt)
refs = _media_references_from_json(media_references_json)
refs_json = _dump_media_references_json(refs)
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
admin_id=admin_id,
detail={
"generation_prompt_length": len(clean_prompt or ""),
"media_references_count": len(refs),
"media_reference_types": _media_reference_type_counts(refs),
},
)
watermark_type = watermark_config.watermark_type
final_watermark_id: str | None = None
if watermark_type == HomeMaterialWatermarkType.IMAGE:
final_watermark_id = watermark_id
if watermark_file is not None:
watermark_out, _ = await self.upload_watermark(db, file=watermark_file, name=f"临时水印-{clean_title or '首页素材'}", is_default=False, admin_id=admin_id)
final_watermark_id = watermark_out.id
if not final_watermark_id:
default_wm = await db.execute(
select(HomeMaterialWatermark)
.where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.is_active.is_(True), HomeMaterialWatermark.is_default.is_(True))
.limit(1)
)
wm = default_wm.scalar_one_or_none()
final_watermark_id = wm.id if wm else None
if not final_watermark_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先选择或上传水印图片")
await self._get_watermark(db, final_watermark_id, active_only=True)
elif watermark_file is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印模式不允许上传图片水印文件")
stored = await storage_service.save_asset_file(file, media_type)
probe = await watermark_processor.probe(stored.storage_path)
if media_type == HomeMaterialMediaType.VIDEO:
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
if probe.duration_seconds is not None and probe.duration_seconds > max_duration:
storage_service.safe_remove(stored.storage_path)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
cfg = watermark_config.model_copy(update={"watermark_id": final_watermark_id, "opacity": round(watermark_config.opacity_level / 10, 2)})
asset = HomeMaterialAsset(
id=generate_id(),
category_id=category_id,
title=clean_title,
media_type=media_type.value,
original_url=stored.file_url,
original_storage_path=stored.storage_path,
watermark_id=final_watermark_id,
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
generation_prompt=clean_prompt,
media_references_json=refs_json,
status=HomeMaterialAssetStatus.PROCESSING.value,
width=probe.width,
height=probe.height,
duration_seconds=probe.duration_seconds,
file_size_bytes=stored.file_size_bytes,
is_active=is_active,
sort_order=sort_order,
created_by=admin_id,
updated_by=admin_id,
)
db.add(asset)
await _flush_refresh(db, asset)
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
after = _asset_snapshot(asset) or {}
after["media_reference_bind_stats"] = bind_stats
return self._upload_result(asset, message="素材已上传,水印处理中"), after
async def list_assets(
self,
db: AsyncSession,
*,
page: int,
page_size: int,
category_id: str | None,
media_type: HomeMaterialMediaType | str | None,
status: HomeMaterialAssetStatus | str | None,
is_active: bool | None,
keyword: str | None,
) -> HomeMaterialAssetListOut:
config = await self.get_config(db)
items, total = await query_service.list_admin_assets(
db,
page=page,
page_size=page_size,
category_id=category_id,
media_type=media_type,
status=status,
is_active=is_active,
keyword=keyword,
include_original=config.show_original_in_admin,
)
return HomeMaterialAssetListOut(items=items, total=total)
async def get_asset_detail(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetOut:
asset = await self._get_asset(db, asset_id)
category_map = await query_service.batch_categories_map(db, [asset.category_id])
watermark_map = await query_service.batch_watermarks_map(db, [asset.watermark_id])
config = await self.get_config(db)
return query_service.asset_to_out(asset, category_map=category_map, watermark_map=watermark_map, include_original=config.show_original_in_admin)
async def update_asset(self, db: AsyncSession, asset_id: str, req: HomeMaterialAssetUpdate, admin_id: str | None) -> tuple[HomeMaterialAssetOut, dict[str, Any], dict[str, Any]]:
asset = await self._get_asset(db, asset_id)
await self._get_category(db, req.category_id)
before = _asset_snapshot(asset) or {}
try:
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_STARTED.value,
admin_id=admin_id,
asset_id=asset_id,
event_status=LogEventStatusEnum.STARTED.value,
)
clean_prompt = _clean_generation_prompt(req.generation_prompt)
refs = _normalize_media_references(req.media_references)
refs_json = _dump_media_references_json(refs)
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
admin_id=admin_id,
asset_id=asset_id,
detail={
"generation_prompt_length": len(clean_prompt or ""),
"media_references_count": len(refs),
"media_reference_types": _media_reference_type_counts(refs),
},
)
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_STARTED.value,
admin_id=admin_id,
asset_id=asset_id,
event_status=LogEventStatusEnum.STARTED.value,
)
asset.category_id = req.category_id
asset.title = req.title
asset.is_active = req.is_active
asset.sort_order = req.sort_order
asset.generation_prompt = clean_prompt
asset.media_references_json = refs_json
asset.updated_by = admin_id
db.add(asset)
await _flush_refresh(db, asset)
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
after = _asset_snapshot(asset) or {}
after["media_reference_bind_stats"] = bind_stats
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_SUCCESS.value,
admin_id=admin_id,
asset_id=asset_id,
detail={
"changed_fields": [k for k in ("generation_prompt", "media_references_count") if before.get(k) != after.get(k)],
"generation_prompt_length": len(clean_prompt or ""),
"media_references_count": len(refs),
"media_reference_types": _media_reference_type_counts(refs),
"bind_stats": bind_stats,
},
)
return await self.get_asset_detail(db, asset_id), before, after
except Exception as exc:
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_FAILED.value,
admin_id=admin_id,
asset_id=asset_id,
event_status=LogEventStatusEnum.FAILED.value,
detail=build_exception_detail(exc, {"before": before}),
error=str(exc),
)
raise
async def prepare_regenerate(
self,
db: AsyncSession,
asset_id: str,
req: HomeMaterialRegenerateWatermarkRequest,
admin_id: str | None,
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any], dict[str, Any]]:
asset = await self._get_asset(db, asset_id)
before = _asset_snapshot(asset) or {}
req_data = req.model_dump(exclude={"wait", "wait_timeout_seconds"})
if req.watermark_type == HomeMaterialWatermarkType.IMAGE:
final_watermark_id = req.watermark_id or asset.watermark_id
await self._get_watermark(db, final_watermark_id or "", active_only=True)
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": final_watermark_id})
else:
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": None})
asset.watermark_id = cfg.watermark_id
asset.watermark_config_json = _json_dumps(cfg.model_dump(mode="json"))
asset.status = HomeMaterialAssetStatus.PROCESSING.value
asset.error_message = None
asset.updated_by = admin_id
db.add(asset)
await _flush_refresh(db, asset)
return self._upload_result(asset, message="水印重新生成中"), before, _asset_snapshot(asset) or {}
async def delete_asset(self, db: AsyncSession, asset_id: str, admin_id: str | None) -> tuple[HomeMaterialAsset, dict[str, Any]]:
asset = await self._get_asset(db, asset_id)
before = _asset_snapshot(asset) or {}
asset.deleted_at = datetime.now(timezone.utc)
asset.updated_by = admin_id
db.add(asset)
return asset, before
async def _bind_media_reference_resources(
self,
db: AsyncSession,
*,
asset_id: str,
admin_id: str | None,
refs: list[HomeMaterialMediaReference],
) -> dict[str, int]:
if not admin_id or not refs:
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
return await bind_upload_resources(
db,
user_id=admin_id,
module=UploadResourceModuleEnum.HOME_MATERIAL.value,
source_model=UploadResourceSourceModelEnum.HOME_MATERIAL_ASSET.value,
source_id=asset_id,
resource_ids=_media_reference_resource_ids(refs),
urls=_media_reference_urls(refs),
allow_common_migrate=False,
)
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
asset = await self._get_asset(db, asset_id)
return HomeMaterialAssetStatusOut(
id=asset.id,
status=HomeMaterialAssetStatus(asset.status),
error_message=asset.error_message,
original_url=asset.original_url,
watermarked_url=asset.watermarked_url,
cover_url=asset.cover_url,
processed_at=asset.processed_at,
)
async def _get_asset(self, db: AsyncSession, asset_id: str) -> HomeMaterialAsset:
result = await db.execute(
select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id, HomeMaterialAsset.deleted_at.is_(None)).limit(1)
)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在或已删除")
return asset
def _upload_result(self, asset: HomeMaterialAsset, *, message: str) -> HomeMaterialUploadResultOut:
return HomeMaterialUploadResultOut(
id=asset.id,
category_id=asset.category_id,
title=asset.title,
media_type=HomeMaterialMediaType(asset.media_type),
status=HomeMaterialAssetStatus(asset.status),
original_url=asset.original_url,
watermarked_url=asset.watermarked_url,
cover_url=asset.cover_url,
watermark_config=_json_loads(asset.watermark_config_json),
generation_prompt=asset.generation_prompt,
media_references=_media_references_out(asset.media_references_json),
message=message,
)
async def wait_for_asset_result(self, asset_id: str, timeout_seconds: int) -> HomeMaterialUploadResultOut:
deadline = asyncio.get_running_loop().time() + max(1, min(timeout_seconds, 60))
while True:
async with async_session() as db:
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在")
if asset.status in (HomeMaterialAssetStatus.SUCCESS.value, HomeMaterialAssetStatus.FAILED.value):
return self._upload_result(asset, message="水印处理完成" if asset.status == HomeMaterialAssetStatus.SUCCESS.value else "水印处理失败")
if asyncio.get_running_loop().time() >= deadline:
async with async_session() as db:
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
asset = result.scalar_one()
return self._upload_result(asset, message="水印仍在处理中,请继续轮询状态")
await asyncio.sleep(0.5)
def start_watermark_task(self, asset_id: str) -> None:
asyncio.create_task(self._run_watermark_task(asset_id))
async def _run_watermark_task(self, asset_id: str) -> None:
try:
async with async_session() as db:
asset = await self._get_asset(db, asset_id)
cfg = _normalize_watermark_config_dict(_json_loads(asset.watermark_config_json), asset.watermark_id)
watermark_path: str | None = None
if cfg.get("watermark_type") == HomeMaterialWatermarkType.IMAGE.value:
watermark = await self._get_watermark(db, cfg.get("watermark_id") or asset.watermark_id or "", active_only=True)
watermark_path = watermark.storage_path
output_path, output_url = storage_service.build_watermarked_target(asset.id, asset.media_type)
cover_path = cover_url = None
if asset.media_type == HomeMaterialMediaType.VIDEO.value:
cover_path, cover_url = storage_service.build_cover_target(asset.id)
result = await watermark_processor.apply_watermark(
media_type=HomeMaterialMediaType(asset.media_type),
source_path=asset.original_storage_path,
watermark_path=watermark_path,
output_path=output_path,
config=cfg,
cover_path=cover_path,
)
asset.watermarked_storage_path = result.output_path
asset.watermarked_url = output_url
asset.cover_storage_path = result.cover_path
asset.cover_url = cover_url if result.cover_path else None
asset.width = result.width
asset.height = result.height
asset.duration_seconds = result.duration_seconds
asset.watermarked_file_size_bytes = result.file_size_bytes
asset.status = HomeMaterialAssetStatus.SUCCESS.value
asset.error_message = None
asset.processed_at = datetime.now(timezone.utc)
db.add(asset)
await db.commit()
except Exception as exc:
async with async_session() as db:
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
asset = result.scalar_one_or_none()
if asset:
asset.status = HomeMaterialAssetStatus.FAILED.value
asset.error_message = str(exc)[:4000]
db.add(asset)
await db.commit()
async def preview_text_watermark_layer(self, req: HomeMaterialTextWatermarkPreviewRequest) -> HomeMaterialTextWatermarkPreviewResponse:
data_url = await asyncio.to_thread(
watermark_processor.generate_repeated_text_layer_data_url,
width=req.width,
height=req.height,
text_config=req.text_watermark,
)
return HomeMaterialTextWatermarkPreviewResponse(
width=req.width,
height=req.height,
preview_layer_data_url=data_url,
)
async def mark_stale_processing_failed(self, db: AsyncSession) -> int:
minutes = int(getattr(settings, "HOME_MATERIAL_PROCESSING_STALE_MINUTES", 30))
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
result = await db.execute(
update(HomeMaterialAsset)
.where(
HomeMaterialAsset.deleted_at.is_(None),
HomeMaterialAsset.status == HomeMaterialAssetStatus.PROCESSING.value,
HomeMaterialAsset.updated_at < cutoff,
)
.values(status=HomeMaterialAssetStatus.FAILED.value, error_message="处理任务超时或服务重启,请重新生成水印")
)
return int(result.rowcount or 0)
async def get_public_categories(
self,
db: AsyncSession,
*,
with_asset_count: bool,
media_type: HomeMaterialMediaType | str | None,
only_has_assets: bool,
) -> HomeMaterialPublicCategoryListOut:
config = await self.get_config(db)
if not config.enabled:
return HomeMaterialPublicCategoryListOut(enabled=False, title=config.title, subtitle=config.subtitle, items=[])
items = await query_service.list_public_categories(db, with_asset_count=with_asset_count, media_type=media_type, only_has_assets=only_has_assets)
return HomeMaterialPublicCategoryListOut(enabled=True, title=config.title, subtitle=config.subtitle, items=items)
async def get_public_home_materials(
self,
db: AsyncSession,
*,
category_id: str | None,
category_key: str | None,
category_ids: str | None,
category_keys: str | None,
media_type: HomeMaterialMediaType | str | None,
limit_per_category: int,
include_empty_categories: bool,
response_mode: HomeMaterialPublicResponseMode,
page: int,
page_size: int,
) -> HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut:
config = await self.get_config(db)
if not config.enabled:
if response_mode == HomeMaterialPublicResponseMode.FLAT:
return HomeMaterialPublicFlatOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=[], total=0)
return HomeMaterialPublicGroupedOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=[])
resolved_ids = await query_service.resolve_category_ids(
db,
category_id=category_id,
category_key=category_key,
category_ids=category_ids,
category_keys=category_keys,
active_only=True,
)
if response_mode == HomeMaterialPublicResponseMode.FLAT:
items, total = await query_service.list_public_flat(db, category_ids=resolved_ids, media_type=media_type, page=page, page_size=page_size)
return HomeMaterialPublicFlatOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=items, total=total)
categories = await query_service.list_public_grouped(
db,
category_ids=resolved_ids,
media_type=media_type,
limit_per_category=limit_per_category,
include_empty_categories=include_empty_categories,
)
return HomeMaterialPublicGroupedOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=categories)
home_material_service = HomeMaterialService()