智剧通(ZJT)文档中心 测试模式说明

E2E 测试模式(Mock)实施方案

本文档自包含,可直接据此实施。系统提供测试模式,可在配置中开启(test_mode.enabled):开启后端到端链路(剧本生成 → 分镜 → 图片/音频/视频生成 → 四宫格/多角度 → 人脸遮盖 → 时间轴合成)中除 LLM 外的所有外部付费生成 API 调用与外部下载都被挡板拦截,返回预设本地媒体资源。算力扣费、CDN 上传等副作用保留真实执行(靠独立隔离环境保证可控,见 §6);测试数据采用幂等播种、不做清理(见 §14)。

关键约束

  1. web 接口与内部函数必须非阻塞;禁止在异步函数中调用同步阻塞 IO(如 requests)。
  2. 功能改动同步 docs/
  3. 兼容 Windows / Linux / macOS(路径与编码)。
  4. 改表结构需同步迁移脚本与模型 SQL。

1. 背景与现状

1.1 现有测试模式(仅覆盖个别链路)

默认配置中已定义 test_mode.enabled 及若干 mock URL 配置项,但此前主链路的挡板基本未实现(仅个别链路有 mock 或日志输出)。本方案从零搭建统一拦截层。

1.2 覆盖范围

挡板需覆盖 E2E 链路中所有涉及外部生成调用的任务通道(视觉异步、视觉同步、TTS 音频、第三方服务异步、ComfyUI 工具直调、四宫格、多角度、人脸遮盖 pipeline 等);时间轴合成与世界导入/导出两条链路靠上游本地路径/纯本地处理跑真实代码,无需 mock

此外有若干副作用需隔离/可控:第三方服务槽位由 mock 短路天然不占用;算力扣费、LLM token 计费、CDN 上传在独立测试环境真实执行(不绕过,见 §6),靠环境隔离保证可控。


2. 总体架构

                         ┌──────────────────────────────────────────┐
                         │   mock 拦截器(纯逻辑层)                  │
                         │  · is_mock_enabled / mock_task_id 生成     │
                         │  · 按 category 解析 mock URL               │
                         │  · 构造各通道响应结构 / 命中计数             │
                         └───────────────┬──────────────────────────┘
                                         │ 被各生成任务的提交/轮询层调用
                                         │ (时间轴合成/世界导入导出无需 mock)
        ┌────────────┬────────────┬──────┴───────┬────────────┬──────────────┐
        ▼            ▼            ▼              ▼            ▼              ▼
   视觉异步      视觉同步        音频           第三方服务     ComfyUI工具/   人脸遮盖
   (调度层)      (子进程)       (TTS)           异步          四宫格/多角度
                                         │
                         ┌───────────────┴──────────────────────────┐
                         │  副作用策略(不绕过,靠隔离环境)            │
                         │  · 算力:扣费链路真实执行,测试前余额充足     │
                         │  · CDN:保留上传,由测试环境承载             │
                         │  · 槽位:mock 短路天然不占用                │
                         └──────────────────────────────────────────┘

2.1 三条铁律

  1. 写库留在各通道调度器内,复用现有成功处理函数;拦截器只负责"返回什么",不直接写 DB。
  2. mock 任务 ID 统一前缀 mock_task_;异步任务的外部任务 ID 同前缀。所有轮询/重试调度器据此识别并短路。
  3. mock 资源一律本地 /upload/mock/... 真实文件(绝不使用远程 URL),避免触发外网下载与远程生成 API(CDN 上传在隔离环境中保留,见 §6)。

3. 核心模块:mock 拦截器(新建)

仅含纯逻辑,无 DB 写入。 核心代码如下:

"""
E2E 测试模式拦截器(纯逻辑层)。
当 test_mode.enabled=True 时,拦截所有外部媒体生成调用,返回预设本地媒体。
本模块只负责"判断与构造响应",写库由各通道调度器复用现有成功处理函数完成。
"""
import logging
import threading
import uuid
from typing import Any, Dict, Optional

from config.config_util import get_dynamic_config_value
from config.unified_config import UnifiedConfigRegistry, TaskCategory

logger = logging.getLogger(__name__)

MOCK_PROJECT_PREFIX = "mock_task_"

# ============ 进程内命中计数(可观测性) ============
_counters_lock = threading.Lock()
_counters: Dict[str, int] = {}


def _bump(channel: str) -> None:
    with _counters_lock:
        _counters[channel] = _counters.get(channel, 0) + 1


def mock_hit_summary() -> Dict[str, int]:
    with _counters_lock:
        return dict(_counters)


# ============ 开关与 ID ============
def is_mock_enabled() -> bool:
    """挡板总开关。注意:动态配置缓存 TTL=30s,跨进程有传播延迟(见 §7)。"""
    return bool(get_dynamic_config_value("test_mode", "enabled", default=False))


def generate_mock_project_id() -> str:
    return f"{MOCK_PROJECT_PREFIX}{uuid.uuid4().hex[:16]}"


def is_mock_id(task_id: Optional[str]) -> bool:
    return isinstance(task_id, str) and task_id.startswith(MOCK_PROJECT_PREFIX)


# ============ 按 category 解析 mock URL ============
# TaskCategory 是字符串常量类(非 Enum),取值如 'text_to_image'/'image_edit'/
# 'image_to_video'/'text_to_video'/'digital_human'/'audio' 等。
def _img(key: str) -> Optional[str]:
    return get_dynamic_config_value("test_mode", "mock_images", key, default=None)


def _vid(key: str) -> Optional[str]:
    return get_dynamic_config_value("test_mode", "mock_videos", key, default=None)


def _aud(key: str) -> Optional[str]:
    return get_dynamic_config_value("test_mode", "mock_audio", key, default=None)


def resolve_mock_url_for_visual(ai_tool_type: int) -> Optional[str]:
    """
    根据 AI 工具类型解析视觉任务的 mock URL。
    同时考虑主分类 category 与附加分类 categories(一个图编模型可能也支持文生图)。
    """
    try:
        cfg = UnifiedConfigRegistry.get_by_id(ai_tool_type)
        if not cfg:
            return None
        cats = [cfg.category] + list(cfg.categories or [])

        def pick(*candidates, getter):
            for c in candidates:
                if c in cats:
                    return getter(c)
            return None

        # 视频(数字人归视频桶)
        url = pick(TaskCategory.IMAGE_TO_VIDEO, TaskCategory.TEXT_TO_VIDEO,
                   TaskCategory.DIGITAL_HUMAN, getter=_vid)
        if url:
            return url
        # 图片:优先按主分类精确映射,再用附加分类兜底,
        # 否则图编模型(主分类=IMAGE_EDIT)会被误判为文生图。
        primary = cfg.category
        if primary == TaskCategory.IMAGE_EDIT:
            url = _img("image_edit")
        elif primary == TaskCategory.TEXT_TO_IMAGE:
            url = _img("text_to_image")
        else:
            url = pick(TaskCategory.TEXT_TO_IMAGE, TaskCategory.IMAGE_EDIT, getter=_img)
        if url:
            return url
        # 兜底
        return _vid("image_to_video") or _img("text_to_image")
    except Exception as e:
        logger.warning(f"[MOCK] resolve_mock_url_for_visual({ai_tool_type}) failed: {e}")
        return None


def mock_image(subkey: str = "text_to_image") -> Optional[str]:
    return _img(subkey)


def mock_video(subkey: str = "image_to_video") -> Optional[str]:
    return _vid(subkey)


def mock_audio(subkey: str = "tts") -> Optional[str]:
    return _aud(subkey)


# ============ 各通道响应结构构造 ============
def visual_async_submit_result(ai_tool_type: int) -> Dict[str, Any]:
    """视觉异步:submit 返回任务 ID,check_status 稍后返回 SUCCESS+url。"""
    _bump("visual_async_submit")
    pid = generate_mock_project_id()
    logger.info(f"[MOCK] channel=visual_async_submit type={ai_tool_type} project_id={pid}")
    return {"success": True, "project_id": pid}


def visual_async_status_result(ai_tool_type: int) -> Dict[str, Any]:
    """视觉异步:check_status 直接返回 SUCCESS。"""
    url = resolve_mock_url_for_visual(ai_tool_type)
    _bump("visual_async_poll")
    logger.info(f"[MOCK] channel=visual_async_poll type={ai_tool_type} url={url}")
    return {"status": "SUCCESS", "result_url": url}


def visual_sync_result(ai_tool_type: int) -> Dict[str, Any]:
    """视觉同步:返回同步模式结果。"""
    url = resolve_mock_url_for_visual(ai_tool_type)
    _bump("visual_sync_submit")
    logger.info(f"[MOCK] channel=visual_sync_submit type={ai_tool_type} url={url}")
    return {"success": True, "sync_mode": True, "result_url": url}


def comfyui_submit_result(ai_tool_type: int) -> Dict[str, Any]:
    """ComfyUI 工具直调 submit:兼容 {project_ids, status} 结构。"""
    _bump("comfyui_submit")
    pid = generate_mock_project_id()
    logger.info(f"[MOCK] channel=comfyui_submit type={ai_tool_type} project_id={pid}")
    return {"status": "submitted", "project_ids": [pid]}


def comfyui_status_success(file_url: str) -> Dict[str, Any]:
    """ComfyUI 轮询成功:返回任务元素结构(含 results[0].file_url)。
    注意:成功处理函数接收的是任务元素本身,不是外层 envelope。"""
    _bump("comfyui_poll")
    logger.info(f"[MOCK] channel=comfyui_poll file_url={file_url}")
    return {
        "status": "SUCCESS",
        "results": [{"file_url": file_url, "result_url": file_url,
                     "cdn_status": "skip", "task_cost_time": 0}],
    }


def async_submit_result(impl_label: str) -> Dict[str, Any]:
    """第三方服务异步(音频/人脸遮盖):submit 返回 mock 任务 ID。"""
    _bump(f"async_submit:{impl_label}")
    pid = generate_mock_project_id()
    logger.info(f"[MOCK] channel=async_submit impl={impl_label} project_id={pid}")
    return {"success": True, "project_id": pid}

4. 配置项定义

4.1 扩展默认配置(test_mode 段)

在现有 test_mode 段后追加(保留原有 mock URL 项):

    # ---- test_mode 行为开关 ----
    {'key': 'test_mode.inject_failure_rate',     'value_type': 'int',    'description': '失败注入概率(0-100),0=不注入', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.inject_failure_scenario', 'value_type': 'string', 'description': '失败注入场景,如 submit/poll/timeout', 'editable': True, 'is_sensitive': False},

    # ---- mock_images ----
    {'key': 'test_mode.mock_images.comfyui_text_to_image', 'value_type': 'string', 'description': 'ComfyUI 文生图预设URL', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.mock_images.comfyui_image_edit',    'value_type': 'string', 'description': 'ComfyUI 图片编辑预设URL', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.mock_images.grid_image',            'value_type': 'string', 'description': '四宫格整图预设URL(2x2)', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.mock_images.multi_angle_front',     'value_type': 'string', 'description': '多角度-正面图', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.mock_images.multi_angle_side',      'value_type': 'string', 'description': '多角度-侧面图', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.mock_images.multi_angle_back',      'value_type': 'string', 'description': '多角度-背面图', 'editable': True, 'is_sensitive': False},

    # ---- mock_videos ----
    {'key': 'test_mode.mock_videos.digital_human', 'value_type': 'string', 'description': '数字人预设视频URL', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.mock_videos.face_mask',     'value_type': 'string', 'description': '人脸遮盖预设视频URL(必须本地真实mp4)', 'editable': True, 'is_sensitive': False},

    # ---- mock_audio ----
    {'key': 'test_mode.mock_audio.tts',            'value_type': 'string', 'description': 'TTS 预设音频URL', 'editable': True, 'is_sensitive': False},
    {'key': 'test_mode.mock_audio.character_audio','value_type': 'string', 'description': '角色音频预设URL', 'editable': True, 'is_sensitive': False},
说明:get_dynamic_config_value 支持多级 key 拼接(如 ("test_mode", "mock_images", "text_to_image") 拼成 test_mode.mock_images.text_to_image)。原有的 test_mode.mock_videos.image_to_video/text_to_videomock_images.image_edit/text_to_image 等保留不动,继续被 resolve_mock_url_for_visual 使用。

4.2 预设资源约定(必须本地落盘)

key文件要求
mock_images.text_to_imageupload/mock/e2e_text_to_image.png9:16,≤1MB
mock_images.image_editupload/mock/e2e_image_edit.png同上
mock_images.comfyui_text_to_imageupload/mock/e2e_comfyui_tti.png同上
mock_images.comfyui_image_editupload/mock/e2e_comfyui_ie.png同上
mock_images.grid_imageupload/mock/e2e_grid_2x2.png真实 2x2 拼图,1024×1024
mock_images.multi_angle_front/side/backupload/mock/e2e_ma_*.png场景图
mock_videos.image_to_videoupload/mock/e2e_i2v.mp49:16,≤5MB,≤5s,真实可播
mock_videos.text_to_videoupload/mock/e2e_t2v.mp4同上
mock_videos.digital_humanupload/mock/e2e_dh.mp4同上
mock_videos.face_maskupload/mock/e2e_face_mask.mp4真实 mp4(下游有文件存在性校验,见 §5.8)
mock_audio.ttsupload/mock/e2e_tts.mp3≤10s
mock_audio.character_audioupload/mock/e2e_char.mp3≤10s
(测试资产)世界导出包upload/mock/world_export_sample.zip由 §14 播种世界导出生成,结构见 §5.10,供导入测试用

资源准备脚本:见 §12。所有 mock 文件放 upload/mock/(该目录已挂载为静态资源,/upload/mock/x.png 可直接 HTTP 访问)。


5. 分链路实施要点

挡板统一嵌入各生成任务的提交层轮询层。以下按链路给出关键要点,具体插入点以对应模块代码中的"创建驱动/发起外部调用"位置为准(统一原则:在真正发起外部调用之前短路)。

5.1 视觉异步

    ai_tool_type = ai_tool.type
    task_id = ai_tool.id

    # ===== 测试模式短路(必须在 param_prepare / 模型服务记录 / sync 分流之前)=====
    from task.mock_interceptor import is_mock_enabled, visual_async_submit_result
    if is_mock_enabled():
        mock = visual_async_submit_result(ai_tool_type)
        project_id = mock["project_id"]
        # 写回视觉任务与任务表状态为 PROCESSING(复用现有更新函数)
        return True
    # =====================================================================

5.2 视觉同步(子进程池)

5.3 TTS 音频

    task_id = ai_audio.id

    # ===== 测试模式短路 =====
    from task.mock_interceptor import is_mock_enabled, mock_audio
    if is_mock_enabled():
        url = mock_audio("tts")
        if url:
            # 写回音频记录为 COMPLETED 并写 result_url(复用现有更新函数)
            return True
    # =========================

5.4 第三方服务异步(角色音频 + 人脸遮盖,统一拦截点)

音频与人脸遮盖两条异步链路都汇聚到异步驱动基类的"带槽位管理的提交"方法,是唯一统一拦截点

另一条"可重试任务"轮询流程按 status=QUEUED AND next_retry_at IS NOT NULL 过滤,而 mock 分支创建记录时不设重试时间、且提前 return 不会触发重试调度,普通 mock 任务不会进入该流程,无需在此拦截;仅在任务内做防御:若外部任务 ID 是 mock 前缀(理论上不出现),跳过真实提交直接标记完成。

5.5 ComfyUI 工具直调(Agent 工具)

        # ===== 测试模式短路:仅替换 project_ids 的获取,保留后续创建逻辑 =====
        from task.mock_interceptor import is_mock_enabled, generate_mock_project_id
        if is_mock_enabled():
            project_id = generate_mock_project_id()
            result_data = {'project_ids': [project_id]}
        else:
            response = httpx.post(api_url, data=request_data, timeout=30, verify=False)
            response.raise_for_status()
            result_data = response.json()
        # =================================================================
        # 后续创建任务记录逻辑原样保留

5.6 四宫格

5.7 多角度

def _apply_mock_angle(task_key: str, comfyui_base_url: str):
    """测试模式推进一个角度:选图→落盘→写场景引用→递增索引,末角度置完成。"""
    task = get_latest_multi_angle_task(task_key)   # 读取最新任务状态
    angles = task.get_angles_list()
    idx = task.current_angle_index or 0
    generated = task.get_generated_images_list() or []
    if idx >= len(angles):
        update_status(task_key, COMPLETED, generated_images=generated)
        return

    angle = angles[idx].get('angle', 0)
    angle_key = angles[idx].get('angleKey', 'unknown')
    label = angles[idx].get('label', f'{angle}°')

    # 角度→mock 图映射(front≈0°,back≈180°,其余 side)
    if angle >= 337.5 or angle < 22.5:
        file_url = _img('multi_angle_front') or '/upload/mock/e2e_ma_front.png'
    elif 157.5 <= angle < 202.5:
        file_url = _img('multi_angle_back') or '/upload/mock/e2e_ma_back.png'
    else:
        file_url = _img('multi_angle_side') or '/upload/mock/e2e_ma_side.png'

    # 本地 mock 文件直接复制落盘(改动 C 的本地路径短路,不外网下载)
    local_image_url, local_file_path = _download_and_store_image(file_url, comfyui_base_url)

    new_image = {'angle': angle_key, 'label': label,
                 'url': local_image_url, 'local_file_path': local_file_path}
    generated.append(new_image)
    _update_reference_images_to_staging(task, [new_image])   # 写入场景 JSON reference_images

    next_idx = idx + 1
    if next_idx >= len(angles):
        update_status(task_key, COMPLETED, generated_images=generated)
    else:
        update_status(task_key, PROCESSING,
                      current_angle_index=next_idx, generated_images=generated)

5.8 人脸遮盖 pipeline(param_prepare 产物)

5.9 时间轴合成(E2E 终点)—— 无需 mock

5.10 世界导入 / 导出 —— 无需 mock

测试资产:预置世界导出 zip,结构须与导出产出一致:

world_export_sample.zip
├── metadata.json              # {export_version, world_id, user_id, image_count, audio_count, subdirs:[...]}
├── image_mapping.json         # {filename: "/upload/{type}/pic/{filename}"}   (有图片时)
├── audio_mapping.json         # {filename: "/upload/character/voice/{filename}"} (有音频时)
├── characters/<name>.json     # 角色 JSON(reference_image 用 images/<file> 相对路径)
├── locations/<name>.json
├── props/<name>.json
├── scripts/<name>.json
├── worlds/<name>.json
├── images/<file>.png          # 与 image_mapping 对应的真实图片(可复用 §4.2 的 mock 图)
└── audios/<file>.mp3          # 与 audio_mapping 对应(可复用 mock 音频)

生成方式(二选一,推荐 1)

  1. 先按 §14 幂等播种一个已知世界(含若干角色/场景/道具 + mock 图片),调一次真实导出接口得到 zip,固化为测试资产。格式天然与代码一致,最稳。
  2. 手工按上面结构打包(适合最小用例)。
导入测试建议指向专用测试 world_id,避免覆盖其它测试数据:导入会覆盖目标世界目录下的同名 JSON;图片在目标已存在时跳过(天然幂等)。

E2E 用例要点


6. 副作用处理策略(不绕过,靠隔离环境)

决策:算力扣减与 CDN 上传均不绕过——它们是必须覆盖的重要链路,绕过会导致大量测试不完整。E2E 在独立隔离环境运行,副作用影响可控:

副作用策略说明
媒体算力扣费/退款保留真实执行扣费/退款链路正常跑,覆盖计费链路
LLM token 计费保留真实执行token 扣减正常执行,覆盖 Agent 计费
测试账号算力测试前保证充足隔离环境内独立维护测试账户余额,保证运行期间不被扣穿
CDN/对象存储上传保留上传由测试环境的隔离 bucket 承载,不碰生产
第三方服务槽位天然不占mock 短路在获取槽位之前 return,mock 任务不占槽位

6.1 算力扣费/退款:保留 + 测试前余额充足

扣费链路保持原样,不拦截:媒体扣费/退款经计费服务(与 token 计费分属两套独立后端)正常执行,幂等键为交易 ID。

测试前需保证测试账户余额充足(隔离环境内独立维护,每次运行前对齐到固定高值),"验收"不再是"算力不变",而是"扣费/退款链路被实际执行(日志可见扣减/增加)+ 测试前余额已对齐 + 不影响生产账户"。

6.2 CDN / 对象存储上传:保留,不改动

自动上传开关保持不动(由测试环境配置文件决定,建议测试环境配置独立 bucket 或本地存储)。各真实上传链路全部保留——CDN 上传也被 E2E 覆盖,且无需任何代码改动,降低复杂度与线上风险。

mock 产出的 /upload/mock/ 结果若被上传到测试 bucket 是预期行为;测试环境与生产 CDN 隔离即可。无需给上传入口加测试模式短路。

6.3 第三方服务槽位:天然不占

§5.4 的 mock 短路在获取槽位之前 return,mock 任务根本不获取槽位,槽位数据中无 mock 记录;轮询调度器 mock 分支的释放槽位调用仅作保险。无需任何 bypass 开关。


7. 配置缓存一致性(critical)

事实:动态配置缓存是进程级内存字典,TTL 30 秒;配置失效/重载接口只清当前进程缓存。

受影响

应对(必须执行其一)

  1. 主进程预判(推荐):视觉同步任务的 mock 判定放在主进程的同步分流之前。若 mock,则提交子进程,直接在主进程写 mock 结果。这样子进程缓存不影响 mock 判定。
  2. fixture 操作时序:开 test_mode 后调用配置重载接口,并重启同步任务进程池(shutdown + start,让 worker 重新派生),或等待 >30s 再提交任务。
  3. 批量配置接口注意:批量更新接口更新后自动刷缓存(单条更新会刷)。用批量更新后必须再调重载接口。
主进程的后台调度器是线程非进程,与主进程共享缓存,30s TTL 对其可接受;问题主要在同步任务子进程。故方案 1 优先。

8. 任务清理豁免(防止 mock 任务被误杀)

任务清理逻辑不区分 mock 任务:

逻辑对 mock 的影响
过期清理(默认 7 天)历史残留 mock 任务可能过期失败
最大重试(默认 30 次)失败注入会累加尝试次数,30 次后强制失败
孤儿重置(外部任务 ID 为空)mock 同步任务子进程异常会被重置回待处理,丢尝试次数一致性

应对

  1. E2E fixture 创建任务时刷新创建时间为当前时间,避免误过期。
  2. 在过期检查与最大重试检查开头加 mock 任务 ID 豁免:
from task.mock_interceptor import is_mock_id
if is_mock_id(getattr(task, 'project_id', None)) or is_mock_id(getattr(ai_tool, 'project_id', None)):
    return False
  1. 失败注入路径建议不累加尝试次数(mock 失败是受控注入,不应消耗重试额度)。

9. PARAM_PREPARE 死锁与 pipeline 时序(critical)

事实:提交入口最开头就检查是否存在待处理的参数准备步骤(param_prepare),有则推进"等待参数准备"状态并返回——mock 短路必须在此之前。

因此 §5.1 的短路必须在 param_prepare 检查之前

额外要求:当任务类型带人脸遮盖(param_prepare)时,mock 主流程短路后直接成功,不进 pipeline,因此人脸遮盖步骤不会创建。这是期望行为(E2E 不需要真实遮罩)。若 E2E 需测人脸遮盖链路本身,则该任务类型的 mock 应在参数准备之前短路,而是让人脸遮盖步骤走 §5.4/§5.8 的 mock,再让主任务 mock——此时需确保主任务在结果应用后回到待处理时仍命中 §5.1 短路(基于 test_mode 判断,与模型服务无关,满足)。


10. 重试机制 / 重试尝试记录

链路:重试 pipeline 驱动切换模型服务并设回待处理重新提交。

要求

  1. mock 短路必须基于 test_mode 开关判断,不基于模型服务名——这样重试切换模型服务后第二次提交仍命中 §5.1 短路。
  2. 重试尝试记录在提交/同步成功/过期/最大重试/收尾等多处读写。mock 任务可创建尝试记录但不应影响判定。建议 mock 成功时正常标记当前尝试为完成,保持数据一致性;无需冻结。

11. 前端 / 后端开关同步

前端 TEST_MODE(URL ?test=1)与后端 test_mode.enabled两套独立机制,且 mock 任务 ID 前缀不同(前端 mock_project_、后端 mock_task_)。

E2E fixture 要求同时开启——


12. 预设资源落地脚本

新建 scripts/prepare_mock_assets.py(或手动放置):

"""
准备 E2E mock 预设资源到 upload/mock/。
需人工提供真实样本文件(视频/音频/2x2图),脚本仅做拷贝与校验。
"""
import os, shutil, sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DST = os.path.join(ROOT, "upload", "mock")

# (源文件, 目标相对路径) —— 源文件由实施者准备
ASSETS = [
    ("auto_test/samples/e2e_text_to_image.png", "e2e_text_to_image.png"),
    ("auto_test/samples/e2e_image_edit.png",    "e2e_image_edit.png"),
    ("auto_test/samples/e2e_grid_2x2.png",      "e2e_grid_2x2.png"),
    ("auto_test/samples/e2e_i2v.mp4",           "e2e_i2v.mp4"),
    ("auto_test/samples/e2e_t2v.mp4",           "e2e_t2v.mp4"),
    ("auto_test/samples/e2e_dh.mp4",            "e2e_dh.mp4"),
    ("auto_test/samples/e2e_face_mask.mp4",     "e2e_face_mask.mp4"),
    ("auto_test/samples/e2e_tts.mp3",           "e2e_tts.mp3"),
    ("auto_test/samples/e2e_char.mp3",          "e2e_char.mp3"),
    ("auto_test/samples/e2e_ma_front.png",      "e2e_ma_front.png"),
    ("auto_test/samples/e2e_ma_side.png",       "e2e_ma_side.png"),
    ("auto_test/samples/e2e_ma_back.png",       "e2e_ma_back.png"),
    # 世界导入测试资产(由 §5.10 方式 1 从已播种世界导出生成后放入 auto_test/samples/)
    ("auto_test/samples/world_export_sample.zip", "world_export_sample.zip"),
]

def main():
    os.makedirs(DST, exist_ok=True)
    missing = []
    for src, name in ASSETS:
        s = os.path.join(ROOT, src)
        d = os.path.join(DST, name)
        if not os.path.exists(s):
            missing.append(src); continue
        shutil.copy2(s, d)
        print(f"OK  {d}")
    if missing:
        print(f"\n缺失样本(请准备后重跑): {missing}", file=sys.stderr); sys.exit(1)

if __name__ == "__main__":
    main()

配置写入脚本 scripts/enable_test_mode.py:直接调 config.config_util.set_dynamic_config_value(用配置模型 upsert,可自动新建配置项;而单条更新接口在配置不存在时报 404)。

import os
from config.config_util import set_dynamic_config_value, invalidate_dynamic_cache

# ⚠️ bool 值必须传 Python bool(True/False),不能传字符串 "false"——
# 内部按"非空即真"序列化,非空字符串 "false" 会被当 True 写成 'true'。
KV = {
    ("test_mode", "enabled"): (True, "bool"),
    ("test_mode", "mock_images", "text_to_image"): ("/upload/mock/e2e_text_to_image.png", "string"),
    ("test_mode", "mock_images", "image_edit"):    ("/upload/mock/e2e_image_edit.png", "string"),
    ("test_mode", "mock_images", "comfyui_text_to_image"): ("/upload/mock/e2e_comfyui_tti.png", "string"),
    ("test_mode", "mock_images", "comfyui_image_edit"):    ("/upload/mock/e2e_comfyui_ie.png", "string"),
    ("test_mode", "mock_images", "grid_image"):    ("/upload/mock/e2e_grid_2x2.png", "string"),
    ("test_mode", "mock_videos", "image_to_video"):("/upload/mock/e2e_i2v.mp4", "string"),
    ("test_mode", "mock_videos", "text_to_video"): ("/upload/mock/e2e_t2v.mp4", "string"),
    ("test_mode", "mock_videos", "digital_human"): ("/upload/mock/e2e_dh.mp4", "string"),
    ("test_mode", "mock_videos", "face_mask"):     ("/upload/mock/e2e_face_mask.mp4", "string"),
    ("test_mode", "mock_audio", "tts"):            ("/upload/mock/e2e_tts.mp3", "string"),
    ("test_mode", "mock_audio", "character_audio"):("/upload/mock/e2e_char.mp3", "string"),
    # multi_angle_* 按需补充
}

for keys, (val, vtype) in KV.items():
    set_dynamic_config_value(*keys, value=val, value_type=vtype)

invalidate_dynamic_cache()  # 清当前进程缓存;跨进程见 §7

# 测试账户余额在隔离环境内独立维护(见 §6.1),此处不做重置操作
print("test_mode enabled & mock URLs written.")

13. E2E fixture 模板(conftest.py)

import os
import pytest
from config.config_util import (
    set_dynamic_config_value,
    get_dynamic_config_value,
    invalidate_dynamic_cache,   # 签名 invalidate_dynamic_cache(config_key: str = None)
)

# ⚠️ bool 必须传 Python True/False,不能传 "false" 字符串(见 §12)。
# ⚠️ 算力与 CDN 均不绕过(见 §6):不关自动上传开关,不设扣费旁路。
TEST_USER_ID = int(os.environ.get("E2E_TEST_USER_ID", "0"))


def _invalidate(k):
    """动态配置用点分字符串做缓存 key。"""
    invalidate_dynamic_cache(".".join(k))


@pytest.fixture(scope="session", autouse=True)
def e2e_mock_setup():
    # 仅保存/恢复 test_mode.enabled(mock URL 留着无害)
    saved_enabled = get_dynamic_config_value("test_mode", "enabled", default=False)

    # 1) 开启挡板(传 Python bool)
    set_dynamic_config_value("test_mode", "enabled", value=True, value_type="bool")
    _invalidate(("test_mode", "enabled"))

    # 2) 测试账户余额在隔离环境内独立维护(§6.1),保证运行期间不被扣穿

    # 3) 重启同步任务进程池,使子进程重新派生并读到新 test_mode(§7)
    from task.sync_task_executor import get_sync_task_executor
    exe = get_sync_task_executor()
    if exe.is_running():
        exe.shutdown(wait=False)
        exe.start()

    yield

    # teardown:仅恢复 test_mode.enabled(不清理数据,见 §14)
    set_dynamic_config_value("test_mode", "enabled",
                             value=bool(saved_enabled), value_type="bool")
    _invalidate(("test_mode", "enabled"))
说明:

14. 测试数据:幂等播种,不做清理

决策:不做删除清理——担心破坏外键/业务关联性。基础数据改为幂等播种:播种前先查存在性,存在则跳过、不存在才新建。E2E 在隔离环境运行,累积的测试数据可接受。

原则

  1. 所有"准备基础数据"的步骤(测试用户、world、character、location、workflow、session 等)都必须先查后建(create-if-not-exists)。
  2. 业务任务数据(视觉任务/异步任务/四宫格任务等,均为相关数据表)由被测流程自然产生,无需预置、也不清理。
  3. 若需识别某次运行的产物用于排查,创建时在附加配置/消息字段打 is_e2e_test=true 标记(仅用于查询定位,不用于删除)。

幂等播种示例(伪代码,按各模型实际查询/创建方法实现):

def ensure_world(user_id, world_name):
    world = get_world_by_name(user_id, world_name)
    if world:
        return world.id                 # 已存在,直接复用
    return create_world(user_id=user_id, name=world_name, ...)

def ensure_character(world_id, name, **fields):
    char = get_character_by_name(world_id, name)
    if char:
        return char.id
    return create_character(world_id=world_id, name=name, **fields)
若未来确需清理某次失败的脏数据,应针对该次运行标记的记录定点删除,并严格遵守依赖顺序(先删子表再删父表),且仅在隔离环境执行。本期默认不提供批量清理脚本。

15. 可观测性

拦截器已内置进程内计数(mock_hit_summary())。E2E 结束时输出:

from task.mock_interceptor import mock_hit_summary
print("[MOCK SUMMARY]", mock_hit_summary())

预期每个被测通道至少命中 1 次;若某通道计数为 0,说明该路径漏到真实外部服务。channel 维度:visual_async_submit/pollvisual_sync_submitcomfyui_submit/pollasync_submit:<impl>


16. 验收清单(逐条可验证)

Phase 1:不真实调用外部 API

Phase 2:副作用可控(不绕过,靠隔离环境)

Phase 3(可选):错误注入


17. 实施分阶段

Phase 1(核心,防止真实外部调用)

  1. 新建 mock 拦截器模块(§3)
  2. 扩展默认配置(§4.1)
  3. 视觉异步链路:提交/轮询/成功路径三处短路(§5.1)
  4. 视觉同步链路短路(§5.2)
  5. 音频链路短路(§5.3)
  6. 第三方服务异步统一拦截点(§5.4)
  7. ComfyUI 工具直调短路(§5.5)
  8. 四宫格轮询/重试短路(§5.6)
  9. 多角度提交/下载短路 + mock 角度推进(§5.7)
  10. 时间轴导出——无需改动(§5.9,上游产出本地路径即可)
  11. 资源准备脚本 + 落盘样本 + 预置世界导出 zip(§5.10 方式 1)

Phase 2(副作用可控:不绕过,靠隔离环境)

  1. 测试模式开启脚本(§6.1 + §12)
  2. CDN/算力/token 不改动,保留真实链路(§6.1/§6.2)
  3. 任务清理豁免(§8,防 mock 任务被误杀)
  4. 缓存一致性处理(§7:主进程预判 + 重启进程池)
  5. fixture 模板(§13)+ 基础数据幂等播种(§14,无删除)

Phase 3(错误注入,可选)

  1. inject_failure_rate/scenario 状态机
  2. 重试链路 E2E 用例

18. 附录:关键事实速查


实施完成后,运行 §16 验收清单逐条勾选;任一未通过即对应链路拦截未生效。