from __future__ import annotations import copy import json from typing import Any from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.enums.common import ( VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE, VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE, VIDEO_SCHEMA_CONFIG_DESCRIPTION, VIDEO_SCHEMA_CONFIG_KEY, VIDEO_SCHEMA_CONFIG_VERSION, VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN, VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN, VIDEO_SCHEMA_CONFIG_DESC_MAX_LEN, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION, VIDEO_SCHEMA_MAX_SECTION_COUNT, VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE, VIDEO_SCHEMA_MAX_TIME_RULE_COUNT, VIDEO_SCHEMA_TIME_DESC_MAX_LEN, VIDEO_SCHEMA_TIME_STAGE_MAX_LEN, ) from app.models.system_config import SystemConfig from app.services.hot_opening_video_prompt_service import ( build_dynamic_schema, build_schema_config_snapshot, default_video_prompt_schema_config, normalize_video_prompt_schema_config, ) from app.utils.id_gen import generate_id def _json_dumps(data: Any) -> str: return json.dumps(data, ensure_ascii=False, separators=(",", ":")) def _json_loads(value: str | None) -> dict[str, Any] | None: if not value or not str(value).strip(): return None try: data = json.loads(value) except Exception as exc: raise HTTPException(status_code=400, detail=f"视频提词 Schema 配置不是合法 JSON: {exc}") from exc if not isinstance(data, dict): raise HTTPException(status_code=400, detail="视频提词 Schema 配置必须是 JSON 对象") return data async def _get_record(db: AsyncSession) -> SystemConfig | None: result = await db.execute(select(SystemConfig).where(SystemConfig.key == VIDEO_SCHEMA_CONFIG_KEY).limit(1)) return result.scalar_one_or_none() def _validate_text_length(path: str, value: Any, max_length: int) -> None: text = str(value or "") if len(text) > max_length: raise HTTPException(status_code=400, detail=f"字段【{path}】长度不能超过 {max_length} 个字符") def validate_video_prompt_schema_config(data: dict[str, Any]) -> dict[str, Any]: normalized = normalize_video_prompt_schema_config(data) sections = normalized.get("sections") if isinstance(normalized.get("sections"), list) else [] if len(sections) > VIDEO_SCHEMA_MAX_SECTION_COUNT: raise HTTPException(status_code=400, detail=f"最多允许配置 {VIDEO_SCHEMA_MAX_SECTION_COUNT} 个一级分组") seen_sections: set[str] = set() for section in sections: key = str(section.get("key") or "") if not key: raise HTTPException(status_code=400, detail="一级分组 key 不能为空") if key in seen_sections: raise HTTPException(status_code=400, detail=f"一级分组 key 重复: {key}") seen_sections.add(key) _validate_text_length(f"{key}.key", key, VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN) _validate_text_length(f"{key}.label", section.get("label"), VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN) _validate_text_length(f"{key}.description", section.get("description"), VIDEO_SCHEMA_CONFIG_DESC_MAX_LEN) section_type = str(section.get("type") or "object") if section_type == "object": children = section.get("children") if isinstance(section.get("children"), list) else [] if len(children) > VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION: raise HTTPException(status_code=400, detail=f"分组【{key}】最多允许 {VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION} 个字段") seen_children: set[str] = set() for child in children: child_key = str(child.get("key") or "") if not child_key: raise HTTPException(status_code=400, detail=f"分组【{key}】存在空字段 key") if child_key in seen_children: raise HTTPException(status_code=400, detail=f"分组【{key}】字段 key 重复: {child_key}") seen_children.add(child_key) _validate_text_length(f"{key}.{child_key}.key", child_key, VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN) _validate_text_length(f"{key}.{child_key}.label", child.get("label"), VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN) if child.get("editable"): max_length = int(child.get("max_length") or VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN) _validate_text_length(f"{key}.{child_key}.value", child.get("value"), max_length) elif section_type == "flow": item_fields = section.get("item_fields") if isinstance(section.get("item_fields"), list) else [] if len(item_fields) > VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION: raise HTTPException(status_code=400, detail=f"流程【{key}】最多允许 {VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION} 个对象字段") seen_flow_fields: set[str] = set() for field in item_fields: field_key = str(field.get("key") or "") if not field_key: raise HTTPException(status_code=400, detail=f"流程【{key}】存在空字段 key") if field_key == "时间段": raise HTTPException(status_code=400, detail=f"流程【{key}】字段 key 不能使用锁定字段 时间段") if field_key in seen_flow_fields: raise HTTPException(status_code=400, detail=f"流程【{key}】字段 key 重复: {field_key}") seen_flow_fields.add(field_key) _validate_text_length(f"{key}.{field_key}.key", field_key, VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN) _validate_text_length(f"{key}.{field_key}.label", field.get("label"), VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN) if field.get("editable"): max_length = int(field.get("max_length") or VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN) _validate_text_length(f"{key}.{field_key}.value", field.get("value"), max_length) else: if section.get("editable"): max_length = int(section.get("max_length") or VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN) _validate_text_length(f"{key}.value", section.get("value"), max_length) rules = normalized.get("time_plan_rules") if isinstance(normalized.get("time_plan_rules"), list) else [] if len(rules) > VIDEO_SCHEMA_MAX_TIME_RULE_COUNT: raise HTTPException(status_code=400, detail=f"最多允许配置 {VIDEO_SCHEMA_MAX_TIME_RULE_COUNT} 条秒数切片规则") for index, rule in enumerate(rules): min_duration = int(rule.get("min_duration") or 0) max_duration = int(rule.get("max_duration") or 0) if min_duration <= 0 or max_duration <= 0 or min_duration > max_duration: raise HTTPException(status_code=400, detail=f"第 {index + 1} 条秒数切片规则区间不合法") segments = rule.get("segments") if isinstance(rule.get("segments"), list) else [] ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else [] if not segments: raise HTTPException(status_code=400, detail=f"第 {index + 1} 条秒数切片规则必须至少包含一个片段") if len(segments) > VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE: raise HTTPException(status_code=400, detail=f"第 {index + 1} 条秒数切片规则最多允许 {VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE} 个片段") if len(ratios) != len(segments): raise HTTPException(status_code=400, detail=f"第 {index + 1} 条秒数切片规则 ratios 数量必须和 segments 数量一致") for segment_index, segment in enumerate(segments): _validate_text_length(f"time_plan_rules[{index}].segments[{segment_index}].stage", segment.get("stage"), VIDEO_SCHEMA_TIME_STAGE_MAX_LEN) _validate_text_length(f"time_plan_rules[{index}].segments[{segment_index}].description", segment.get("description"), VIDEO_SCHEMA_TIME_DESC_MAX_LEN) return normalized async def get_video_prompt_schema_config(db: AsyncSession) -> dict[str, Any]: default_data = default_video_prompt_schema_config() record = await _get_record(db) if not record: return { "id": None, "key": VIDEO_SCHEMA_CONFIG_KEY, "description": VIDEO_SCHEMA_CONFIG_DESCRIPTION, "is_enabled": False, "using_default": True, "data": copy.deepcopy(default_data), "default_data": default_data, "created_at": None, "updated_at": None, } data = _json_loads(record.value) if not data: data = copy.deepcopy(default_data) using_default = True else: data = normalize_video_prompt_schema_config(data) using_default = False return { "id": record.id, "key": record.key, "description": record.description, "is_enabled": bool(data.get("enabled", True)) and not using_default, "using_default": using_default, "data": data, "default_data": default_data, "created_at": record.created_at, "updated_at": record.updated_at, } async def save_video_prompt_schema_config(db: AsyncSession, *, data: dict[str, Any], is_enabled: bool = True) -> dict[str, Any]: normalized = validate_video_prompt_schema_config(data) normalized["enabled"] = bool(is_enabled) record = await _get_record(db) if not record: record = SystemConfig( id=generate_id(), key=VIDEO_SCHEMA_CONFIG_KEY, value=_json_dumps(normalized), description=VIDEO_SCHEMA_CONFIG_DESCRIPTION, ) db.add(record) else: record.value = _json_dumps(normalized) record.description = VIDEO_SCHEMA_CONFIG_DESCRIPTION await db.flush() return await get_video_prompt_schema_config(db) async def reset_video_prompt_schema_config(db: AsyncSession) -> dict[str, Any]: return await save_video_prompt_schema_config(db, data=default_video_prompt_schema_config(), is_enabled=True) async def import_video_prompt_schema_config(db: AsyncSession, *, data: dict[str, Any], is_enabled: bool = True) -> dict[str, Any]: return await save_video_prompt_schema_config(db, data=data, is_enabled=is_enabled) async def export_video_prompt_schema_config(db: AsyncSession) -> dict[str, Any]: current = await get_video_prompt_schema_config(db) return { "version": VIDEO_SCHEMA_CONFIG_VERSION, "key": VIDEO_SCHEMA_CONFIG_KEY, "is_enabled": current["is_enabled"], "data": current["data"], } async def get_runtime_schema_snapshot(db: AsyncSession) -> dict[str, Any]: record = await _get_record(db) if not record: return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE) data = _json_loads(record.value) if not data: return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE) normalized = normalize_video_prompt_schema_config(data) if not normalized.get("enabled", True): return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE) return build_schema_config_snapshot(normalized, source=VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE) def fallback_runtime_schema_snapshot(snapshot: dict[str, Any] | None = None) -> dict[str, Any]: if isinstance(snapshot, dict) and isinstance(snapshot.get("data"), dict): return snapshot return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE) def preview_runtime_schema(*, data: dict[str, Any], is_enabled: bool, video_config: dict[str, Any]) -> dict[str, Any]: normalized = validate_video_prompt_schema_config(data) normalized["enabled"] = bool(is_enabled) snapshot = build_schema_config_snapshot( normalized if normalized.get("enabled", True) else default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE if normalized.get("enabled", True) else VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE, ) runtime_schema = build_dynamic_schema(video_config, snapshot) return { "schema_config_snapshot": snapshot, "runtime_schema": runtime_schema, "time_plan": runtime_schema.get("动态时间规划") or [], }