Просмотр исходного кода

chore: 增加协作规范与提交辅助脚本

Yi Jiarui 2 месяцев назад
Родитель
Сommit
f18b7b6779

+ 14 - 0
.githooks/pre-commit

@@ -0,0 +1,14 @@
+#!/bin/sh
+
+# 核心代码变更没有同时暂存协作文档时给出提醒,但不阻止提交。
+staged_files="$(git -c core.quotepath=false diff --cached --name-only --diff-filter=ACMR)"
+
+if printf '%s\n' "$staged_files" | grep -Eq '^(src/app/services/|server/routes/|cloud-functions/)'; then
+  if ! printf '%s\n' "$staged_files" | grep -Eq '^(AGENTS\.md|docs/AI助手必读规范/.*\.md)$'; then
+    printf '\n%s\n%s\n\n' \
+      '============================================================' \
+      '⚠️ 你改了核心代码但没更新协作文档,请确认 AGENTS.md 或 docs/AI助手必读规范/ 是否需要同步。'
+  fi
+fi
+
+exit 0

+ 3 - 2
.gitignore

@@ -59,8 +59,9 @@ Thumbs.db
 /data/
 /data/
 !/data/.gitkeep
 !/data/.gitkeep
 
 
-# 项目内部方案、调研和参考资料(本地保留,不入库)
-/docs/
+# 项目内部方案、调研、参考资料和 AI 助手协作文档默认仅本地保留。
+/docs/*
+/项目参考/
 
 
 # 本地测试、调试和个人资料
 # 本地测试、调试和个人资料
 /testAudioLocal.js
 /testAudioLocal.js

+ 114 - 0
AGENTS.md

@@ -0,0 +1,114 @@
+# AI 助手项目说明
+
+本文件是 AI 助手改动本仓库前必读的约定。与代码/真实结果冲突时以代码为准;不确定先问,不要臆造。
+
+## 开工前必读
+
+- 开始任何任务前,必须依次阅读:
+  1. 本文件 `AGENTS.md`(约定与铁律)
+  2. `docs/长期规范/AI助手必读规范/系统架构.md`(运行边界、数据流、模块依赖、设计决策)
+  3. `docs/长期规范/AI助手必读规范/IP操盘三大核心路线.md`(IP 操盘主线、验收标准和防跑偏规则)
+  4. `docs/长期规范/AI助手必读规范/项目术语表.md`(遇到不懂的专有名词在此查证,不要臆测含义)
+  5. `docs/长期规范/AI助手必读规范/项目状态.md`(当前进度;注意区分“代码已完成”与“线上已验收”)
+- 读完后、动手前,必须用几句话向用户复述:项目定位、相关的运行边界与职责、本次任务要遵守的铁律和要避开的坑,以及打算如何改动;经用户确认后再开始写代码。
+- 不确定的概念先查 `项目术语表.md` 或询问用户,禁止臆造。
+
+## 项目定位
+
+- 公司内部的抖音内容运营与创作工作台。产品重心已由“AI 视频生成”转向“IP 操盘、选题、脚本和辅助运营”;生成能力仍是内容执行层,不是唯一主线。
+- IP 操盘目标是证据驱动的半自动闭环:真实账号数据 -> 定位诊断 -> 内容方向/选题 -> 分镜脚本 -> 日历/发布包 -> 数据复盘。中高风险动作由用户确认,不建设批量评论、Cookie 池、代理池或全自动发布。
+
+## 技术栈与架构
+
+- Angular 21 standalone + Signals;严格 TypeScript/模板检查。Express 仅承接本地代理、媒体处理和 FFmpeg/Whisper 等能力;长期业务数据经 fmode 云函数写入 Parse 项目命名空间 Class,文件本体进入七牛。
+- 主产品界面主要由 `App` 的 `currentTab` 单壳切换驱动,不是常规 Angular Router 多页面应用;`app.routes.ts` 只保留少量旧入口/调试入口。
+- `app.ts` 是历史主壳且体积很大。新业务逻辑优先放服务或独立 standalone 页面组件,不继续把流程堆进主壳。
+- 登录用户的数据归属只能由服务端根据 Parse `sessionToken` 解析的用户决定;不得信任前端传入的 `userId`。
+- 长期业务主数据写 Parse 项目命名空间 Class:`VideoWorkflowEntity`、`VideoWorkflowAudit`、`VideoWorkflowMigration` 或后续确认的 `VideoWorkflow*` 专用 Class;图片/视频/音频写七牛并登记 `VideoWorkflowFileAsset`。`localStorage` 仅允许轻量偏好和可重建缓存,短期试用/草稿使用 `sessionStorage` 或 IndexedDB。
+- 数据库清理、迁移、测试数据重置或批量修改只能操作 `VideoWorkflowEntity`、`VideoWorkflowAudit`、`VideoWorkflowMigration`、`VideoWorkflowFileAsset` 四个项目 Class;`_User`、`APIG`、`APIGAuth`、`APIGOrder` 以及其他共享/平台 Class 绝对不能动。
+- PSQL 不再作为本项目业务主存储;旧 PSQL 数据因尚未正式使用,本阶段不迁移、不删除。Parse Class 和字段结构不得擅自删除、重建或新增,新增字段必须先确认。
+
+## 目录约定
+
+- 新页面:`src/app/pages/<feature>/`;页面专属样式和模板与组件同目录。
+- 可复用 UI:`src/app/components/`;跨页面业务逻辑、API、存储适配:`src/app/services/`;共享业务类型:`src/app/models/`。
+- 新生成模式:放入 `src/app/pages/pipelines/<mode>/`,并同步登记 `pipeline-registry.ts`、`AppTab`/Tab 校验和主壳模板入口。
+- Express 新接口:实现于 `server/routes/`,运行时配置集中在 `server/config/runtime-config.js`;避免继续膨胀 `server.js`。
+- fmode 云函数源码:`cloud-functions/`;共用登录态逻辑放 `_session.js`。`cloud-functions/deployable/` 是构建产物,不手改、不提交。
+- 工程校验/迁移脚本:`scripts/validation/`;Playwright 场景:`e2e/`;单元测试与被测文件同目录,使用 `*.spec.ts`。
+- `docs/` 默认仅在本机保留并整体忽略,不作为提交或 CI 输入;AI 助手长期协作文档统一放在 `docs/长期规范/AI助手必读规范/`,仅作为本地开工读取与协作记录使用。`data/`、测试结果、本地部署脚本和个人资料不得作为 CI 或运行时输入。
+
+## 工作流程铁律
+
+- 修改前先读现有调用链和相邻测试;保留现有服务边界,不为单点需求另建一套账号、积分、存储或任务体系。
+- 改前端/服务后至少运行相关 `*.spec.ts` 与 `npm run build`;改 IP 操盘主流程再运行 `npm run e2e:ip-operator`。
+- 改云函数后运行 `npm run build:cloud-functions`、`npm run validate:cloud-functions`、`npm run smoke:cloud`;真实登录态、扣费或隔离测试需用户明确提供本地临时环境变量。
+- 改计费链路额外运行 `npm run validate:jimeng-billing`;改存储边界额外运行 `npm run validate:storage-policy`。
+- 提交前运行 `npm run review:guard` 和 `git status`。提交应原子化,说明改动范围与已运行的验证;仓库没有强制 Conventional Commits 格式。
+- 每个任务结束前必须按类型回写文档:约定、目录、命令或已知坑变化更新 `AGENTS.md`;阶段进度更新 `docs/长期规范/AI助手必读规范/项目状态.md`;技术决策在 `docs/长期规范/AI助手必读规范/架构决策记录/` 新增 ADR;架构变化更新 `docs/长期规范/AI助手必读规范/系统架构.md`。没有需要更新时,必须显式说明“无需更新”。
+- 运行边界、关键数据流、模块依赖方向、数据存储模型或关键设计决策发生变化时,必须在同一任务内同步 `docs/长期规范/AI助手必读规范/系统架构.md`;不能只更新项目状态或普通说明文档代替架构回写。
+- 禁止提交 `.env`、session token、用户数据、媒体产物、测试结果、`deployable/` 或本地方案资料。禁止用真实生成/充值接口做无授权冒烟测试。
+- 不直接修改或删除用户已有改动;不使用 `git reset --hard`、`git checkout --` 等破坏性命令。
+
+## 常用命令
+
+```bash
+# 首次克隆仓库后启用版本化 pre-commit 提醒钩子;每个工作副本执行一次。
+git config core.hooksPath .githooks
+
+# 同时启动 Angular 前端和本地 Express 后端,进行日常联调。
+npm run dev
+
+# 一次性运行 Angular/Vitest 单元测试,适合提交前或修改服务后执行。
+npm test -- --watch=false
+
+# 执行生产构建,验证严格类型、模板和构建预算。
+npm run build
+
+# 修改 IP 操盘主流程或页面交互后运行对应 Playwright 回归。
+npm run e2e:ip-operator
+
+# 修改依赖 _session.js 的云函数后,重新生成 fmode 单文件部署包。
+npm run build:cloud-functions
+
+# 修改云函数源码、函数清单或部署约定后检查部署就绪状态。
+npm run validate:cloud-functions
+
+# 修改余额、预扣、确认、退款或即梦计费逻辑后执行。
+npm run validate:jimeng-billing
+
+# 修改浏览器存储、Parse 云端实体、评论/脚本等数据边界后执行。
+npm run validate:storage-policy
+
+# 只读预览 IP 操盘测试垃圾清理候选;需要先设置 SMOKE_SESSION_TOKEN,不会删除或 purge。
+npm run inspect:ip-operator-cleanup
+
+# 按 dry-run 报告软删除 IP 操盘测试垃圾候选;必须显式传入 --confirm SOFT_DELETE_IP_OPERATOR_CANDIDATES。
+npm run cleanup:ip-operator:soft -- --input tmp/<dry-run-report>.json --confirm SOFT_DELETE_IP_OPERATOR_CANDIDATES
+
+# 按清理报告解析/物理删除 IP 操盘测试垃圾;默认只解析 objectId,真正删除必须显式传入 --confirm PHYSICAL_DELETE_IP_OPERATOR_CANDIDATES。
+npm run cleanup:ip-operator:physical -- --input tmp/<cleanup-report>.json
+
+# 只读预览当前登录用户在四个 VideoWorkflow 白名单 Class 下的测试数据;必须先设置 SMOKE_SESSION_TOKEN,不会删除。
+npm run inspect:videoworkflow-cleanup
+
+# 物理删除当前登录用户在四个 VideoWorkflow 白名单 Class 下的测试数据;必须显式传入 --confirm DELETE_VIDEO_WORKFLOW_TEST_DATA。
+npm run cleanup:videoworkflow:parse -- --confirm DELETE_VIDEO_WORKFLOW_TEST_DATA
+
+# 云函数部署或函数 ID 变更后检查线上连通性与权限拦截。
+npm run smoke:cloud
+
+# 提交前检查疑似凭证和已知大文件治理阈值。
+npm run review:guard
+```
+
+## 已知坑
+
+- `App` 使用 `ViewEncapsulation.None` 让全局 `dh-*`、`pl-*`、`pipeline-*` 样式作用于子组件;改回默认封装会导致多个生成页面失样。
+- fmode 运行环境不支持源码中的 `require('./_session')`。部署依赖它的函数前必须生成并复制 `cloud-functions/deployable/*.js`。
+- `cloud-functions.ts` 是云函数 ID 的唯一前端登记点;部署说明中的状态可能滞后,以配置和真实 smoke 结果为准。
+- fmode 网关偶发 `fetch failed` 或“必须提供 id”;只对只读/幂等请求做有限重试,充值、扣费、生成提交不得盲目重放。
+- Playwright 固定使用 Edge、端口 `4300` 且串行运行;真实 LLM/E2E 会调用外部服务并可能产生费用。
+- 前端生产包仍偏大,initial 预算当前为 warning `2.7MB`、error `3.0MB`;`app.ts`、IP 操盘组件和部分服务已很大,新增功能优先拆分,避免继续扩大单文件。
+- 开发代理中的 `/api` 指向 TikHub,`/backend` 才指向本地 Express;生产环境没有本地 Express 抖音代理,抖音账号采集必须走 `CLOUD_FN.douyin` 云函数,否则 `https://app.fmode.cn/backend/api/douyin/call` 会返回 405。
+

+ 432 - 127
README.md

@@ -1,175 +1,480 @@
-# video-workflow · 抖音 AI 视频生成系统
+# video-workflow
 
 
-一个基于 **Angular 21 + Express** 的 AI 视频创作平台,集成数字人合成、图生视频、动作迁移、素材合成、主题生视频、语音合成(含音色复刻 / 音色选择弹窗)等多条生成 Pipeline
+公司内部使用的 AI 短视频创作与运营工作台,面向抖音内容生产场景,把选题分析、脚本与素材生成、视频合成、账号运营和内容资产管理集中到一套工作流中
 
 
-> 项目名:`tik-tok`(package.json)/ 仓库名:`video-workflow`
+> `package.json` 中的包名仍为 `tik-tok`,本文统一使用仓库与产品名 `video-workflow`。
 
 
-当前试运营版本已接入账号系统与积分框架:用户登录后可查看个人创作、素材库、视频管理、博主监测和积分流水;生成、合成、训练等任务在提交前进行登录与积分校验。
+## 功能特性
 
 
----
+### 内容生成
 
 
-## ✨ 功能总览
+- 数字人合成:形象素材、脚本与音色生成口播视频。
+- 图片生成:根据提示词、风格和比例生成图片素材。
+- 文生视频:根据提示词、比例和时长生成视频。
+- 图生视频:使用图片、提示词和时长生成动态视频。
+- 动作迁移:使用角色图片和参考动作视频生成角色动画。
+- 素材合成视频:将多张图片、主题和音色组合为讲解视频。
+- 主题生视频:从主题出发,完成脚本、配图、配音和视频合成。
+- 批量生产:基于主题生视频模板串行执行多个生产任务。
 
 
-按"Pipeline 注册中心"模式组织(见 `src/app/pipelines/pipeline-registry.ts`),目前已上线/在迭代的 Pipeline:
+### 内容运营与资产
 
 
-| Pipeline | 路由 | 输入 | 状态 |
-|---|---|---|---|
-| **数字人合成** | `digital-human` | 参考视频 / 形象图 + 脚本 + 音色 | ✅ stable |
-| **标准视频生成** | `video-generation` | 原始视频 + 改造指令 | 🟡 beta |
-| **图生视频** | `image-to-video` | 图片 + 提示词 + 时长 | 🟡 beta |
-| **动作迁移** | `action-transfer` | 角色图 + 参考动作视频(即梦 Actor v2) | 🟡 beta |
-| **素材合成视频** | `asset-remix` | 3-10 张图片 + 主题 + 音色 | 🟡 beta |
-| **主题生视频** | `topic-to-video` | 一句话主题(LLM 拆分镜 → 文生图 → TTS → ffmpeg 全自动拼接) | 🟡 beta |
+- 抖音视频搜索、链接分析、爆款分析和逐字稿处理。
+- 博主监测、选题池、日报、分析历史和发布复盘。
+- IP 操盘工作台:账号定位、证据分析、内容方向、脚本和发布闭环。
+- 我的创作、素材库、模板库、任务管理和视频管理。
+- AI 助手、语音合成、官方音色选择和音色复刻。
 
 
-**辅助模块**
+### 账号与数据
 
 
-- 抖音视频搜索 / 监控 / 任务管理 / 历史 / 结果库
-- 语音合成(音色复刻 + 官方音色库选择弹窗 + 试听)
-- 数字人形象准备(normalize / benchmark)
-- 账号系统(Parse `_User` 登录、用户中心、管理员账号页、积分流水、充值框架)
+- 使用手机号验证码自动登录或注册。
+- 生成、合成和训练类操作可接入积分预扣、确认和失败退款流程。
+- 用户数据按 Parse 用户 `objectId` 隔离。
+- 草稿和部分浏览器数据使用 IndexedDB,并保留 localStorage 回退。
+- 业务主数据和文件资产可通过 fmode 云函数持久化。
 
 
-**账号与隔离**
+## 技术栈
 
 
-- 试运营阶段关闭公开注册,账号由管理员统一发放。
-- 管理员身份读取 `_User.role === 'admin'` 或 `_User.isAdmin === true`。
-- 积分规则:`1 元 = 10 积分`;新账号赠送 10 积分;任务提交前预扣,明确失败时退回。
-- 个人内容页包括「我的创作」「素材库」「视频管理」「博主监测」,未登录时跳转登录页。
-- 草稿/素材库和博主监测使用当前用户 `objectId` 做本地存储隔离,避免同一浏览器多账号串数据。
+### 前端
 
 
----
+- Angular `21.2`
+- TypeScript `5.9`
+- RxJS `7.8`
+- Standalone Components、Signals
+- FFmpeg WebAssembly:`@ffmpeg/ffmpeg`、`@ffmpeg/core`
+- Vitest、Playwright
 
 
-## 🏗️ 技术栈
+### 本地后端
 
 
-**前端**
+- Node.js
+- Express `4.21`
+- multer、cors、axios、undici
+- 系统 FFmpeg / ffprobe
+- 可选 OpenAI Whisper CLI
 
 
-- Angular 21(Standalone Components + Signals)
-- TypeScript 5.9 / RxJS 7.8
-- 设计系统:自研 `dh-*` 组件类(深色 / 浅色双主题)
+### 外部服务
 
 
-**后端**
+- fmode Parse、云函数和 APIG
+- 即梦 / 火山引擎视频、图片与语音能力
+- 抖音数据网关 / TikHub
+- 七牛云对象存储
+- LLM / Gemini 代理
+- Quickly 一键成片
 
 
-- Node.js + Express 4
-- multer(文件上传)、cors、axios
-- 数据:本地 JSON 文件(`data/*.json`)+ ffmpeg 处理视频
+## 环境要求
 
 
-**外部服务**
+必需:
 
 
-- 火山引擎(豆包 TTS、即梦文生图、即梦 Actor v2 动作迁移)
-- TikHub(抖音数据 API)
-- fmode 后端代理服务(`server.fmode.cn`)
+- Node.js:仓库暂未在 `package.json` 中限定最低版本。
+- npm:仓库声明的包管理器版本为 `11.6.1`。
 
 
----
+按功能需要:
 
 
-## 🚀 快速开始
+- FFmpeg 和 ffprobe:本地音频提取、视频下载处理及服务端视频合成需要,并且命令必须已加入 `PATH`。
+- Whisper CLI:仅本地 Whisper 转写功能需要。
+- Microsoft Edge:当前 Playwright 配置使用 `msedge` 通道。
+- PowerShell、华为云 `obsutil` 和 `hcloud`:仅执行内部部署脚本时需要。
 
 
-### 1. 安装依赖
+检查本机环境:
 
 
 ```bash
 ```bash
-npm install
+node --version
+npm --version
+ffmpeg -version
+ffprobe -version
 ```
 ```
 
 
-### 2. 启动开发环境(同时启动前后端)
+如需本地 Whisper:
+
+```bash
+pip install openai-whisper
+whisper --help
+```
+
+## 安装步骤
+
+1. 进入项目目录。
+
+   ```bash
+   cd video-workflow
+   ```
+
+2. 安装依赖。
+
+   ```bash
+   npm install
+   ```
+
+3. 创建本地环境变量文件。
+
+   PowerShell:
+
+   ```powershell
+   Copy-Item .env.example .env
+   ```
+
+   Bash:
+
+   ```bash
+   cp .env.example .env
+   ```
+
+4. 根据需要填写 `.env`。不要提交真实 token、密钥或 session token。
+
+5. 同时启动 Angular 前端和 Express 后端。
+
+   ```bash
+   npm run dev
+   ```
+
+6. 打开 <http://localhost:4200/>。
+
+可使用后端健康检查确认本地服务是否启动:
+
+```text
+http://localhost:3000/api/health
+```
+
+## 配置说明
+
+`server.js` 启动时会读取仓库根目录的 `.env`。已有系统环境变量优先,不会被 `.env` 覆盖。
+
+完整模板见 [`.env.example`](./.env.example)。以下是主要配置:
+
+### 基础服务
+
+| 变量 | 默认值/用途 |
+|---|---|
+| `PORT` | Express 端口,模板值为 `3000`。 |
+| `PARSE_API_HOST` | Parse 与 fmode API 主机。 |
+| `PARSE_APP_ID` | Parse Application ID。 |
+| `PARSE_BASE_URL` | fmode 云函数调用地址。 |
+
+### 即梦生成
+
+| 变量 | 用途 |
+|---|---|
+| `JIMENG_BASE_URL` | 即梦生成代理地址。 |
+| `JIMENG_TOKEN` | 图片、视频、数字人和动作迁移等生成请求的凭证。 |
+
+### 抖音数据与转写
+
+| 变量 | 用途 |
+|---|---|
+| `DOUYIN_API_BASE_URL` | 抖音数据网关地址。 |
+| `DOUYIN_API_TOKEN` | 抖音数据网关 token。 |
+| `VOC_TOKEN` / `VOC_SOCIAL_TOKEN` | 抖音数据网关的兼容 token。 |
+| `TIKHUB_BASE_URL` / `TIKHUB_TOKEN` | 明确直连 TikHub 时使用。 |
+| `IFLYTEK_GATEWAY_BASE_URL` | 逐字稿/转写网关地址。 |
+| `TRANSCRIPTION_VOC_TOKEN` | 转写网关 token。 |
+| `VOICE_TOKEN` / `OPENCLAW_VOC_TOKEN` | 语音及转写链路使用的兼容 token。 |
+
+### LLM
+
+| 变量 | 用途 |
+|---|---|
+| `LLM_BASE_URL` | OpenAI 兼容的 LLM 代理地址。 |
+| `LLM_API_KEY` | AI 助手、脚本生成和媒体理解使用的密钥。 |
+
+### 七牛云
+
+| 变量 | 用途 |
+|---|---|
+| `QINIU_AK` / `QINIU_SK` | 七牛 Access Key 和 Secret Key。 |
+| `QINIU_BUCKET` | 存储空间名称。 |
+| `QINIU_DOMAIN` / `QINIU_CDN_DOMAIN` | 文件访问域名。 |
+| `QINIU_CDN_PREFIX` | 上传文件的路径前缀。 |
+| `QINIU_UPLOAD_URL` | 七牛上传地址。 |
+
+### 语音合成
+
+| 变量 | 用途 |
+|---|---|
+| `VOLC_TTS_TOKEN` | 火山 TTS token。 |
+| `VOLC_SPEECH_API_KEY` | 火山语音 API Key 鉴权。 |
+| `VOLC_SPEECH_APP_KEY` / `VOLC_SPEECH_ACCESS_KEY` | 火山语音 App Key 鉴权。 |
+| `VOICE_TTS_BASE_URL` | 云函数语音代理地址。 |
+
+### Quickly
+
+| 变量 | 用途 |
+|---|---|
+| `QUICKLY_APP_KEY` / `QUICKLY_APP_SECRET` | Quickly 应用凭证。 |
+| `QUICKLY_ACCOUNT_ID` | Quickly 账号 ID。 |
+| `QUICKLY_CALLBACK_URL` | 任务回调地址。 |
+| `QUICKLY_RELAY_URL` | 云函数中继地址。 |
+| `QUICKLY_UPSTREAM_FN_ID` | 上游一键成片函数 ID。 |
+
+多数变量只在调用对应真实服务时需要。只查看界面或开发不依赖外部服务的功能时,可以先保留为空。
+
+云函数 ID 维护在:
+
+```text
+src/app/services/cloud-functions.ts
+```
+
+## 本地运行
+
+### 同时启动前后端
 
 
 ```bash
 ```bash
 npm run dev
 npm run dev
 ```
 ```
 
 
-`concurrently` 会同时启动:
+- Angular:<http://localhost:4200/>
+- Express:<http://localhost:3000/>
+
+### 仅启动前端
+
+```bash
+npm start
+```
+
+该命令会使用 `proxy.conf.json`。主要代理关系:
+
+| 前端路径 | 目标 |
+|---|---|
+| `/api` | `https://api.tikhub.io` |
+| `/jimeng` | `https://server.fmode.cn/api/volcengine/jimeng` |
+| `/parse` | `https://server.fmode.cn/parse` |
+| `/functions` | `https://server.fmode.cn/api/functions` |
+| `/backend` | `http://localhost:3000` |
+
+### 允许局域网访问前端
+
+```bash
+npm run start:dev
+```
+
+该命令监听 `0.0.0.0`。请确认所在网络可信,并避免在前端或 URL 中暴露凭证。
+
+### 仅启动本地后端
+
+```bash
+npm run server
+```
+
+修改 `PORT` 后,需同步调整 `proxy.conf.json` 中 `/backend` 的目标地址。
+
+### 开发模式持续构建
+
+```bash
+npm run watch
+```
+
+## 测试
+
+### 启用提交前文档提醒
+
+仓库提供 `.githooks/pre-commit`:当核心服务、Express 路由或云函数发生改动但没有同步协作文档时,会打印警告,但不会阻断提交。每个新工作副本首次使用时执行:
+
+```bash
+git config core.hooksPath .githooks
+```
+
+### Angular 单元测试
+
+```bash
+npm test
+```
+
+执行一次后退出:
+
+```bash
+npm test -- --watch=false
+```
+
+### IP 操盘 Playwright E2E
+
+```bash
+npm run e2e:ip-operator
+```
+
+脚本会在 `127.0.0.1:4300` 启动 Angular 开发服务器,并使用 Playwright 的 Edge 通道运行测试。
+
+运行真实 LLM 用例:
+
+```bash
+npm run e2e:ip-operator:real
+```
+
+真实用例需要对应服务可访问并已配置凭证,可能产生外部调用。
+
+### 云函数与治理校验
+
+```bash
+npm run build:cloud-functions
+npm run validate:cloud-functions
+npm run validate:jimeng-billing
+npm run validate:storage-policy
+npm run smoke:cloud
+npm run review:guard
+```
+
+- `build:cloud-functions`:生成可直接部署的单文件云函数到 `cloud-functions/deployable/`。
+- `smoke:cloud`:检查已配置云函数的连通性;空函数 ID 会被跳过。
+- `review:guard`:检查疑似凭证和部分代码体积治理规则。
 
 
-- 后端:`node server.js`(默认 7337 端口)
-- 前端:`ng serve --proxy-config proxy.conf.json`(默认 4200 端口)
+需要登录态的云函数或账号隔离验收还会使用 `SMOKE_SESSION_TOKEN`、`STORAGE_GOVERNANCE_SESSION_A` 等测试环境变量。它们只能临时设置在本地终端中,不能写入源码或文档。
 
 
-打开浏览器:<http://localhost:4200/>
+## 构建与部署
 
 
-### 3. 分开启动(可选)
+### 生产构建
 
 
 ```bash
 ```bash
-npm run server      # 仅后端
-npm start           # 仅前端
-npm run start:dev   # 前端 + 监听 0.0.0.0(局域网调试)
+npm run build
 ```
 ```
 
 
-### 4. 构建生产包
+Angular 项目名为 `Tik-tok`,默认构建输出位于:
+
+```text
+dist/Tik-tok/
+```
+
+### 前端内部部署
+
+仓库使用根目录的 `deploy.ps1` 发布前端:
+
+```powershell
+.\deploy.ps1
+```
+
+脚本会:
+
+1. 使用 `/dev/video-workflow/` 作为 `base-href` 构建前端。
+2. 将 `dist/video-workflow/browser` 同步到华为云 OBS。
+3. 设置公开读取权限。
+4. 刷新对应 CDN 目录。
+
+部署地址由脚本配置为:
+
+```text
+https://app.fmode.cn/dev/video-workflow/
+```
+
+执行前需检查 `deploy.ps1` 中的 `obsutil`、`hcloud` 本机路径及部署配置。该脚本被 `.gitignore` 忽略,属于内部本地部署配置,不应提交或对外分享其中的凭证。
+
+### 云函数部署
+
+云函数源码位于 `cloud-functions/`。依赖 `_session.js` 的函数不能直接复制源码部署,应先运行:
 
 
 ```bash
 ```bash
-npm run build       # 输出到 dist/
+npm run build:cloud-functions
 ```
 ```
 
 
----
+然后将 `cloud-functions/deployable/` 下的对应单文件版本部署到 fmode 平台,并把函数 ID 更新到:
+
+```text
+src/app/services/cloud-functions.ts
+```
 
 
-## 📁 目录结构
+部署后执行:
 
 
+```bash
+npm run smoke:cloud
+npm run build
 ```
 ```
+
+## 项目结构简述
+
+```text
 video-workflow/
 video-workflow/
-├── server.js                    # Express 后端主文件
-├── proxy.conf.json              # ng serve 代理配置(/api → tikhub、/backend → 本地后端等)
-├── data/                        # 后端运行时数据(history.json / tasks.json / videos/ ...)
 ├── src/
 ├── src/
+│   ├── main.ts                     # Angular 启动入口
+│   ├── environments/               # 开发/生产环境编译配置
 │   └── app/
 │   └── app/
-│       ├── app.ts               # 主组件(包含全局 UI 状态、tab 切换、各模块入口)
-│       ├── app.html / app.css   # 主模板与全局样式
-│       ├── components/          # 可复用组件
-│       │   ├── app-sidebar/
-│       │   ├── digital-human-form/
-│       │   ├── portrait-prepare/
-│       │   ├── recent-task-pill/
-│       │   ├── app-assistant/
-│       │   ├── auth-required-modal/ # 登录保护提示弹窗
-│       │   └── voice-picker/    # 声音模型选择弹窗(声音库 + 我的声音 + 试听)
-│       ├── pages/
-│       │   ├── home/
-│       │   ├── account/         # 登录、用户中心、管理员账号页
-│       │   └── pipelines/       # 各 Pipeline 独立页面
-│       │       ├── digital-human/
-│       │       ├── image-to-video/
-│       │       ├── action-transfer/
-│       │       ├── asset-remix/
-│       │       └── topic-to-video/
-│       ├── pipelines/
-│       │   ├── pipeline-registry.ts   # Pipeline 注册中心(添加新模式只需改这里)
-│       │   └── composite-sse.ts
-│       ├── services/            # HTTP / 业务服务(含账号积分、云函数拦截、草稿存储)
-│       └── pipes/               # 时间、文件大小等格式化管道
-└── public/                      # 静态资源
-```
-
----
-
-## 🔌 代理 / 上游服务(`proxy.conf.json`)
-
-| 前端路径 | 上游 | 用途 |
-|---|---|---|
-| `/api` | `https://api.tikhub.io` | 抖音数据 API |
-| `/jimeng` | `https://server.fmode.cn/api/volcengine/jimeng` | 即梦(图生视频 / 动作迁移) |
-| `/parse` | `https://server.fmode.cn/parse` | Parse 后端(音色档案等) |
-| `/functions` | `https://server.fmode.cn/api/functions` | fmode Functions |
-| `/backend` | `http://localhost:3000` | 本地后端(如有需要) |
-
----
-
-## 🎙️ 语音合成模块说明
-
-- **声音训练**:上传一段参考音频 → 调火山 voice_clone → 获得可复用的声音模型
-- **声音模型选择弹窗**(`components/voice-picker`):
-  - **声音库 Tab**:豆包 2.0 模型 25 个精选官方声音,按场景分组(通用/角色扮演/有声阅读/视频配音/客服/多语种),支持关键词搜索
-  - **我的声音 Tab**:用户训练成功的声音模型
-  - **试听**:点 ▶ 实时调 TTS 合成固定文本,内存缓存避免重复消耗
-- **合成接口**:根据声音来源自动切换参数
-  - 训练声音:`timbreId`
-  - 官方声音:`volcengine_voice_type` + `x_api_resource_id: seed-tts-2.0`(`_uranus_bigtts` 后缀必须用 2.0 资源)
-
-参考文档:`docs/语音合成/tts.md`、`docs/语音合成/音色列表.md`(本地参考,已加入 `.gitignore` 不入库)
-
----
-
-## 📦 npm scripts
-
-| 脚本 | 说明 |
-|---|---|
-| `npm run dev` | 同时启动前后端(推荐开发) |
-| `npm run server` | 仅启动后端 (`node server.js`) |
-| `npm start` | 仅启动前端 (`ng serve`) |
-| `npm run start:dev` | 前端 + 监听 0.0.0.0(局域网) |
-| `npm run build` | 生产构建 → `dist/` |
-| `npm run watch` | 开发模式 watch 构建 |
-| `npm test` | 运行测试(vitest) |
+│       ├── app.ts                  # 应用主壳与全局工作台逻辑
+│       ├── components/             # 通用 UI 组件
+│       ├── models/                 # 业务模型
+│       ├── pages/                  # 页面与各生成 Pipeline
+│       ├── pipelines/              # Pipeline 注册中心
+│       ├── pipes/                  # 展示格式化管道
+│       └── services/               # API、存储和业务服务
+├── server.js                       # Express 启动入口
+├── server/
+│   ├── config/                     # 后端运行时配置
+│   └── routes/                     # Express 路由模块
+├── cloud-functions/                # fmode 云函数源码与部署说明
+├── scripts/                        # 构建、冒烟和治理脚本
+├── e2e/                            # Playwright 端到端测试
+├── public/                         # 静态资源
+├── data/                           # 本地运行数据,不进入版本库
+├── proxy.conf.json                 # Angular 开发代理
+├── angular.json                    # Angular 构建配置
+├── playwright.config.ts            # E2E 配置
+├── .env.example                    # 本地配置模板
+└── deploy.ps1                      # 内部前端部署脚本,本地保留
+```
+
+生成模式由 `src/app/pipelines/pipeline-registry.ts` 统一登记,但新增模式还需要同步接入页面组件、Tab 类型和主模板。
+
+## 常见问题
+
+### 页面能打开,但调用 `/backend` 失败
+
+确认 Express 已启动:
+
+```bash
+npm run server
+```
+
+然后访问:
+
+```text
+http://localhost:3000/api/health
+```
+
+如果修改了 `PORT`,还需更新 `proxy.conf.json`。
+
+### 提示未检测到 FFmpeg 或 ffprobe
+
+安装 FFmpeg,并确保下面两个命令可在当前终端执行:
+
+```bash
+ffmpeg -version
+ffprobe -version
+```
+
+重启终端和本地后端后再试。
+
+### Whisper 转写不可用
+
+确认 CLI 已安装:
+
+```bash
+whisper --help
+```
+
+也可以访问:
+
+```text
+http://localhost:3000/api/whisper/status
+```
+
+### 生成、LLM、抖音或上传功能提示未配置
+
+1. 检查 `.env` 中对应能力的 URL 和 token。
+2. 检查 `src/app/services/cloud-functions.ts` 中对应函数 ID。
+3. 重新启动 `npm run server`,使后端重新加载 `.env`。
+4. 对云函数执行 `npm run smoke:cloud`。
+
+### 登录后数据为空或不同账号数据不同
+
+这是账号隔离行为。项目使用当前 Parse 用户的 `objectId` 区分云端业务数据和浏览器本地数据。
+
+### Playwright 找不到浏览器
+
+当前配置使用 Microsoft Edge。先确认本机已安装 Edge;必要时安装 Playwright 浏览器依赖:
+
+```bash
+npx playwright install msedge
+```
+
+### `deploy.ps1` 提示找不到工具
+
+检查脚本顶部配置的 `obsutil` 和 `hcloud` 路径是否与本机一致,并确认部署账号具备 OBS 同步、ACL 修改和 CDN 刷新权限。
+
+## 内部使用说明
+
+本仓库为公司私有项目。源码、业务文档、云函数 ID、服务地址、用户数据和访问凭证不得对外分发。
+

+ 2 - 2
angular.json

@@ -54,8 +54,8 @@
               "budgets": [
               "budgets": [
                 {
                 {
                   "type": "initial",
                   "type": "initial",
-                  "maximumWarning": "1.5MB",
-                  "maximumError": "2.14MB"
+                  "maximumWarning": "2.7MB",
+                  "maximumError": "3.0MB"
                 },
                 },
                 {
                 {
                   "type": "anyComponentStyle",
                   "type": "anyComponentStyle",

+ 61 - 9
cloud-functions/DEPLOY.md

@@ -2,10 +2,24 @@
 
 
 本目录是 fmode 云函数源码。前端通过 `src/app/services/cloud-functions.ts` 里的函数 ID 调用这些函数。
 本目录是 fmode 云函数源码。前端通过 `src/app/services/cloud-functions.ts` 里的函数 ID 调用这些函数。
 
 
+后续新增的 `authCredit`、`systemStorage`、`fileAsset` 与既有 `manifest/task/history/result` 等函数使用同一套 fmode 云函数部署和调用机制:上传或更新对应 `.js` 文件,获得函数 ID,再回填到 `src/app/services/cloud-functions.ts`。它们不是新的后端体系。
+
 ## 部署清单
 ## 部署清单
 
 
+`_session.js` 是用户私有数据云函数的共享源码依赖,作用是统一解析 Parse `sessionToken` 和校验管理员权限。当前 fmode 云函数运行环境不支持直接 `require('./_session')`,因此不要直接复制带 `require` 的源码文件部署。
+
+部署前必须先执行:
+
+```bash
+npm run build:cloud-functions
+```
+
+然后复制 `cloud-functions/deployable/` 下对应编号的单文件版本到 fmode 平台。`deployable` 文件会内联 `_session.js`,不再依赖 `require`。源码文件仍保留共享模块写法,方便后续维护;真实部署以 `cloud-functions/deployable/*.js` 为准。
+
 | 字段 | 文件 | 状态 | 主要 action / 能力 |
 | 字段 | 文件 | 状态 | 主要 action / 能力 |
 |---|---|---|---|
 |---|---|---|---|
+| session helper | `_session.js` | 源码依赖 | requireSession / optionalSession / requireAdmin / assertRequestedUserMatchesSession |
+| Parse Class helper | `_parseClassStore.js` | 源码依赖 | Parse REST / VideoWorkflow* Class / projectKey / owner Pointer |
 | `manifest` | `01-manifestManager.js` | 已配置 | list / get / create / update / delete |
 | `manifest` | `01-manifestManager.js` | 已配置 | list / get / create / update / delete |
 | `task` | `02-taskManager.js` | 已配置 | list / get / create / update / delete |
 | `task` | `02-taskManager.js` | 已配置 | list / get / create / update / delete |
 | `history` | `03-historyManager.js` | 已配置 | list / create / delete / clearAll |
 | `history` | `03-historyManager.js` | 已配置 | list / create / delete / clearAll |
@@ -15,20 +29,22 @@
 | `quickly` | `07-quicklyVideo.js` | 待部署 | create / query |
 | `quickly` | `07-quicklyVideo.js` | 待部署 | create / query |
 | `proxy` | `08-proxyHub.js` | 已配置 | llmChat / geminiChat / llmChatStream |
 | `proxy` | `08-proxyHub.js` | 已配置 | llmChat / geminiChat / llmChatStream |
 | `upload` | `09-uploadManager.js` | 已配置 | createUploadToken |
 | `upload` | `09-uploadManager.js` | 已配置 | createUploadToken |
-| `authCredit` | `10-authCreditManager.js` | 待部署/暂未启用 | register / login / me / reserve / commit / refund / ledger |
+| `authCredit` | `10-authCreditManager.js` | 已配置 ID,按需更新验收 | me / balance / rechargeContext / reserve / commitReservation / refundReservation / ledger / createRechargeOrder / saveRecharge |
 | `jimeng` | `11-jimengManager.js` | 已配置 | 即梦图片、视频、数字人、动作迁移代理 |
 | `jimeng` | `11-jimengManager.js` | 已配置 | 即梦图片、视频、数字人、动作迁移代理 |
 | `douyin` | `12-douyinManager.js` | 已配置 | TikHub 抖音搜索、详情、评论、博主数据代理 |
 | `douyin` | `12-douyinManager.js` | 已配置 | TikHub 抖音搜索、详情、评论、博主数据代理 |
 | `douyinInsight` | `13-douyinInsightManager.js` | 已配置 | 爆款分析、选题池、日报资产持久化、逐字稿任务 |
 | `douyinInsight` | `13-douyinInsightManager.js` | 已配置 | 爆款分析、选题池、日报资产持久化、逐字稿任务 |
+| `systemStorage` | `14-systemStorageManager.js` | 已配置 ID,本次需用最新 deployable 更新线上内容并验收 | list / get / upsert / patch / delete / purge / audit / auditList / stats / migrationGet / migrationSet / adminInspect |
+| `fileAsset` | `15-fileAssetManager.js` | 已配置 ID,本次需用最新 deployable 更新线上内容并验收 | register / list / get / stats / bind / delete / purge / adminInspect |
 
 
 ## 部署步骤
 ## 部署步骤
 
 
 1. 打开 fmode 云函数管理平台。
 1. 打开 fmode 云函数管理平台。
 2. 新建或更新云函数,名称建议与文件名一致。
 2. 新建或更新云函数,名称建议与文件名一致。
-3. 粘贴对应 `.js` 文件完整内容。
+3. 对于 `01/02/03/04/05/06/09/13/14/15`,粘贴 `cloud-functions/deployable/` 下对应 `.js` 文件完整内容;对于不依赖 `_session.js` 的 `10/11/12/08` 等单文件函数,可直接粘贴 `cloud-functions/` 下源码
 4. 在平台配置该函数需要的环境变量。
 4. 在平台配置该函数需要的环境变量。
 5. 保存并部署,复制返回的 10 位函数 ID。
 5. 保存并部署,复制返回的 10 位函数 ID。
 6. 更新 `src/app/services/cloud-functions.ts` 对应字段。
 6. 更新 `src/app/services/cloud-functions.ts` 对应字段。
-7. 执行 `npm run smoke:cloud` 验证已配置函数是否可调用。
+7. 执行 `npm run smoke:cloud` 验证已配置函数是否可调用。部署需要登录态的函数后,再带 `SMOKE_SESSION_TOKEN` 执行严格验收。
 8. 执行 `npm run build` 验证前端编译。
 8. 执行 `npm run build` 验证前端编译。
 
 
 ## 外部凭证清单
 ## 外部凭证清单
@@ -39,10 +55,11 @@
 | `07-quicklyVideo.js` | `QUICKLY_APP_KEY`, `QUICKLY_APP_SECRET`, `QUICKLY_ACCOUNT_ID`, `QUICKLY_CALLBACK_URL`, `QUICKLY_RELAY_URL`, `QUICKLY_UPSTREAM_FN_ID` | 一键成片。如不用该功能可保持 `quickly` 为空。 |
 | `07-quicklyVideo.js` | `QUICKLY_APP_KEY`, `QUICKLY_APP_SECRET`, `QUICKLY_ACCOUNT_ID`, `QUICKLY_CALLBACK_URL`, `QUICKLY_RELAY_URL`, `QUICKLY_UPSTREAM_FN_ID` | 一键成片。如不用该功能可保持 `quickly` 为空。 |
 | `08-proxyHub.js` | `LLM_BASE_URL`, `LLM_API_KEY` | LLM/Gemini 代理。 |
 | `08-proxyHub.js` | `LLM_BASE_URL`, `LLM_API_KEY` | LLM/Gemini 代理。 |
 | `09-uploadManager.js` | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 七牛直传 token。 |
 | `09-uploadManager.js` | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 七牛直传 token。 |
-| `10-authCreditManager.js` | Parse session 可用即可;无第三方密钥 | 账号、积分、充值框架。商业化恢复前再启用。 |
+| `10-authCreditManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID`, `VIDEO_WORKFLOW_APIG_ID` | 当前项目的 Parse/APIG 账号积分网关,不再自建 AppUser/UserCreditAccount。 |
 | `11-jimengManager.js` | `JIMENG_TOKEN`, `JIMENG_BASE_URL`, `PARSE_BASE_URL`, `PARSE_APP_ID` | 即梦真实生成代理。为保留公司计费,线上 `JIMENG_BASE_URL` 应指向 `https://server.fmode.cn/api/volcengine/jimeng`;云函数会依次尝试 `fetch`、Node HTTPS、XMLHttpRequest 访问同一公司接口。 |
 | `11-jimengManager.js` | `JIMENG_TOKEN`, `JIMENG_BASE_URL`, `PARSE_BASE_URL`, `PARSE_APP_ID` | 即梦真实生成代理。为保留公司计费,线上 `JIMENG_BASE_URL` 应指向 `https://server.fmode.cn/api/volcengine/jimeng`;云函数会依次尝试 `fetch`、Node HTTPS、XMLHttpRequest 访问同一公司接口。 |
 | `12-douyinManager.js` | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`(可复用 `VOC_TOKEN` / `VOICE_TOKEN`) | 抖音真实平台数据抓取。默认走 `https://server.fmode.cn/api/voc-social`。 |
 | `12-douyinManager.js` | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`(可复用 `VOC_TOKEN` / `VOICE_TOKEN`) | 抖音真实平台数据抓取。默认走 `https://server.fmode.cn/api/voc-social`。 |
-| `13-douyinInsightManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID`, `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `VOC_TOKEN`(或 `TRANSCRIPTION_VOC_TOKEN` / `VOICE_TOKEN`), `IFLYTEK_GATEWAY_BASE_URL` | 业务资产持久化和用户隔离;逐字稿任务会通过抖音数据网关获取视频详情并调用讯飞网关转写。 |
+| `13-douyinInsightManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID`, `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `IFLYTEK_GATEWAY_BASE_URL` | 业务资产持久化和用户隔离;逐字稿任务通过抖音数据网关获取视频详情,转写网关鉴权优先使用当前登录用户的 Parse `sessionToken`,与 `douyin-speaking-daily` 的 VOC token 机制一致。 |
+| `14-systemStorageManager.js` | `PARSE_API_HOST`, `PARSE_APP_ID` | 通用业务云端主存储,承接暂未独立建表的用户私有业务实体、轻量审计和迁移状态。 |
 
 
 以上变量均已在云函数源码中改为“环境变量优先、当前兼容值兜底”。正式部署时应在 fmode 云函数平台配置环境变量,并在确认新变量生效后轮换旧凭证。
 以上变量均已在云函数源码中改为“环境变量优先、当前兼容值兜底”。正式部署时应在 fmode 云函数平台配置环境变量,并在确认新变量生效后轮换旧凭证。
 
 
@@ -51,7 +68,9 @@
 见 `src/app/services/cloud-functions.ts`。当前仍为空的字段:
 见 `src/app/services/cloud-functions.ts`。当前仍为空的字段:
 
 
 - `quickly`: 一键成片云函数未启用。
 - `quickly`: 一键成片云函数未启用。
-- `authCredit`: 账号/积分云函数未启用;试运营阶段积分扣费仍关闭。
+- `authCredit`: 账号/积分云函数已有函数 ID;本次 Parse 主存储迁移不改 APIG 计费链路,只需确认余额仍可读。
+- `systemStorage`: 通用业务云端主存储已有函数 ID;本次需把最新 `cloud-functions/deployable/14-systemStorageManager.js` 更新到该函数并验收。
+- `fileAsset`: 七牛云文件资产治理已有函数 ID;本次需把最新 `cloud-functions/deployable/15-fileAssetManager.js` 更新到该函数并验收。
 
 
 ## 验证命令
 ## 验证命令
 
 
@@ -60,14 +79,47 @@ npm run smoke:cloud
 npm run build
 npm run build
 ```
 ```
 
 
-`smoke:cloud` 会跳过函数 ID 为空的项目;对于 `jimeng`、`douyin`、`proxy` 会用预期失败请求验证函数可达和路由校验是否生效,避免触发真实生成或真实扣费。
+`smoke:cloud` 会从 `src/app/services/cloud-functions.ts` 自动读取函数 ID,避免部署后需要在脚本里维护第二份 ID。函数 ID 为空的项目会跳过;对于 `jimeng`、`douyin`、`proxy` 会用预期失败请求验证函数可达和路由校验是否生效,避免触发真实生成或真实扣费。
+
+部署 `authCredit`、`systemStorage`、`fileAsset`、新版 `upload` 后,建议执行:
+
+```bash
+npm run smoke:cloud
+SMOKE_STRICT_PROTECTED=1 npm run smoke:cloud
+SMOKE_SESSION_TOKEN=你的_Parse_Session_Token npm run smoke:cloud
+SMOKE_SESSION_TOKEN=你的_Parse_Session_Token npm run validate:parse-storage:postdeploy
+```
+
+- 不带 session token:验证公开函数可达,并在严格模式下验证需要登录的函数会拒绝匿名访问。
+- 带 `SMOKE_SESSION_TOKEN`:验证 `authCredit.balance`、`systemStorage.stats`、`fileAsset.stats` 和新版 `upload.token` 能用真实登录态成功返回。
+- `validate:parse-storage:postdeploy`:用单个测试账号实际写入、读回、审计并软删除一条 `VideoWorkflowEntity`,同时注册、读回并软删除一条 `VideoWorkflowFileAsset` 元数据。
+- 新版 `upload.token` 的成功验收会检查七牛 key 是否进入 `users/{userId}/...` 分区;如果仍返回旧的 `x/openclaw-skills/...`,说明线上 `09-uploadManager.js` 还没有更新到账号隔离版本。
+
+完成 `authCredit/systemStorage/fileAsset/upload` 部署并回填函数 ID 后,再用两个不同测试账号执行账号隔离验收:
+
+```bash
+STORAGE_GOVERNANCE_SESSION_A=用户A_Parse_Session_Token \
+STORAGE_GOVERNANCE_SESSION_B=用户B_Parse_Session_Token \
+npm run validate:storage-governance:postdeploy
+```
+
+该命令会:
+- 验证用户 A 能读取 `authCredit.balance`。
+- 用用户 A 在 `VideoWorkflowEntity` 写入一条 `governanceProbe` 临时实体并读回。
+- 用用户 B 尝试读取用户 A 的同一 `governanceProbe`,必须读不到。
+- 验证新版 `upload` 返回的七牛 key 进入 `users/{userId}/...` 分区。
+- 用 upload 返回的用户分区 key 注册一条临时 `VideoWorkflowFileAsset` 元数据,读回后软删除。
+- 验证用户 A 可读取 `fileAsset.stats`。
+- 最后清理用户 A 的临时 `governanceProbe`。
+
+注意:两个 session token 都是敏感信息,只能在本地命令行临时使用,不要写入文档、源码或提交记录。
 
 
 2026-06-01 联网冒烟结果:
 2026-06-01 联网冒烟结果:
 
 
 - 最终通过:`manifest.list`、`task.list`、`history.list`、`result.list`、`remix.listAll`、`voice.listProfiles`、`upload.token.probe`、`jimeng.unknownAction`、`douyin.unknownRoute`、`douyinInsight.authGuard`、`proxy.unknownAction`。
 - 最终通过:`manifest.list`、`task.list`、`history.list`、`result.list`、`remix.listAll`、`voice.listProfiles`、`upload.token.probe`、`jimeng.unknownAction`、`douyin.unknownRoute`、`douyinInsight.authGuard`、`proxy.unknownAction`。
 - 权限保护符合预期:`douyinInsight.listTopics` 未带 session token 时返回“请先登录”。
 - 权限保护符合预期:`douyinInsight.listTopics` 未带 session token 时返回“请先登录”。
 - 首轮曾出现 `manifest.list`、`task.list` 偶发 `fetch failed`,复跑通过;后续生产运维需要继续观察函数平台网络稳定性。
 - 首轮曾出现 `manifest.list`、`task.list` 偶发 `fetch failed`,复跑通过;后续生产运维需要继续观察函数平台网络稳定性。
-- 待部署填 ID:`quickly`、`authCredit`。
+- 未启用:`quickly`。`authCredit`、`systemStorage`、`fileAsset` 当前已有函数 ID;如果在 fmode 平台重建函数并产生新 ID,需要同步回填 `src/app/services/cloud-functions.ts`。
 
 
 ## 首次部署后的特殊步骤
 ## 首次部署后的特殊步骤
 
 
@@ -110,7 +162,7 @@ await fetch('https://server.fmode.cn/api/functions', {
 | 即梦生成 | `JIMENG_TOKEN`, `JIMENG_BASE_URL` | 文生视频、图生视频、图片生成、数字人、动作迁移。为保留公司计费,生产建议使用 `https://server.fmode.cn/api/volcengine/jimeng`。 |
 | 即梦生成 | `JIMENG_TOKEN`, `JIMENG_BASE_URL` | 文生视频、图生视频、图片生成、数字人、动作迁移。为保留公司计费,生产建议使用 `https://server.fmode.cn/api/volcengine/jimeng`。 |
 | 抖音数据 | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `VOC_TOKEN`, `VOC_SOCIAL_TOKEN` | 视频详情、评论、博主作品、爆款分析证据抓取 |
 | 抖音数据 | `DOUYIN_API_BASE_URL`, `DOUYIN_API_TOKEN`, `VOC_TOKEN`, `VOC_SOCIAL_TOKEN` | 视频详情、评论、博主作品、爆款分析证据抓取 |
 | TikHub 直连 | `TIKHUB_BASE_URL`, `TIKHUB_TOKEN` | 仅在明确直连 TikHub 时使用 |
 | TikHub 直连 | `TIKHUB_BASE_URL`, `TIKHUB_TOKEN` | 仅在明确直连 TikHub 时使用 |
-| 逐字稿 | `IFLYTEK_GATEWAY_BASE_URL`, `TRANSCRIPTION_VOC_TOKEN`, `VOICE_TOKEN`, `OPENCLAW_VOC_TOKEN` | 爆款分析补逐字稿、音频转写 |
+| 逐字稿 | `IFLYTEK_GATEWAY_BASE_URL` | 爆款分析补逐字稿、音频转写;云函数入口通过 Parse `sessionToken` 做用户鉴权和数据隔离,并优先把同一个当前用户 session token 作为转写网关 VOC token 使用。`TRANSCRIPTION_VOC_TOKEN` / `VOICE_TOKEN` / `OPENCLAW_VOC_TOKEN` / `VOC_TOKEN` 仅保留为诊断或兼容兜底,不作为线上多用户主路径。 |
 | LLM/Gemini | `LLM_BASE_URL`, `LLM_API_KEY` | AI 助手、脚本生成、素材理解 |
 | LLM/Gemini | `LLM_BASE_URL`, `LLM_API_KEY` | AI 助手、脚本生成、素材理解 |
 | 七牛上传 | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_DOMAIN`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 上传 token 和素材归档 |
 | 七牛上传 | `QINIU_AK`, `QINIU_SK`, `QINIU_BUCKET`, `QINIU_DOMAIN`, `QINIU_CDN_DOMAIN`, `QINIU_CDN_PREFIX`, `QINIU_UPLOAD_URL` | 上传 token 和素材归档 |
 | 语音合成 | `VOLC_TTS_TOKEN`, `VOLC_SPEECH_API_KEY`, `VOLC_SPEECH_APP_KEY`, `VOLC_SPEECH_ACCESS_KEY`, `VOICE_TTS_BASE_URL` | TTS、音色训练、语音合成 |
 | 语音合成 | `VOLC_TTS_TOKEN`, `VOLC_SPEECH_API_KEY`, `VOLC_SPEECH_APP_KEY`, `VOLC_SPEECH_ACCESS_KEY`, `VOICE_TTS_BASE_URL` | TTS、音色训练、语音合成 |

+ 10 - 0
package.json

@@ -8,7 +8,17 @@
     "server": "node server.js",
     "server": "node server.js",
     "dev": "concurrently \"npm run server\" \"npm start\"",
     "dev": "concurrently \"npm run server\" \"npm start\"",
     "build": "ng build",
     "build": "ng build",
+    "build:cloud-functions": "node scripts/build-cloud-functions.mjs",
     "validate:jimeng-billing": "node scripts/validation/jimeng-billing-policy.mjs",
     "validate:jimeng-billing": "node scripts/validation/jimeng-billing-policy.mjs",
+    "validate:cloud-functions": "node scripts/validation/cloud-functions-readiness.mjs",
+    "validate:storage-policy": "node scripts/validation/storage-sensitive-policy.mjs",
+    "validate:parse-storage:postdeploy": "node scripts/validation/parse-storage-postdeploy.mjs",
+    "validate:storage-governance:postdeploy": "node scripts/validation/storage-governance-postdeploy.mjs",
+    "inspect:ip-operator-cleanup": "node scripts/validation/ip-operator-cleanup-dry-run.mjs",
+    "cleanup:ip-operator:soft": "node scripts/validation/ip-operator-cleanup-soft-delete.mjs",
+    "cleanup:ip-operator:physical": "node scripts/validation/ip-operator-cleanup-physical-delete.mjs",
+    "inspect:videoworkflow-cleanup": "node scripts/validation/videoworkflow-parse-cleanup.mjs",
+    "cleanup:videoworkflow:parse": "node scripts/validation/videoworkflow-parse-cleanup.mjs",
     "e2e:ip-operator": "node scripts/validation/run-ip-operator-e2e.mjs",
     "e2e:ip-operator": "node scripts/validation/run-ip-operator-e2e.mjs",
     "e2e:ip-operator:real": "node scripts/validation/run-ip-operator-e2e.mjs e2e/ip-operator-real-llm.spec.ts",
     "e2e:ip-operator:real": "node scripts/validation/run-ip-operator-e2e.mjs e2e/ip-operator-real-llm.spec.ts",
     "smoke:cloud": "node scripts/smoke-cloud-functions.mjs",
     "smoke:cloud": "node scripts/smoke-cloud-functions.mjs",

+ 180 - 0
scripts/git-stage-safe.ps1

@@ -0,0 +1,180 @@
+param(
+  [ValidateSet('ip', 'storage', 'billing', 'governance', 'all')]
+  [string]$Group = 'all',
+
+  [switch]$Apply
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Write-Section([string]$Title) {
+  Write-Host ""
+  Write-Host "==== $Title ====" -ForegroundColor Cyan
+}
+
+function Invoke-GitAdd([string]$Name, [string[]]$Paths) {
+  Write-Section "stage group: $Name"
+
+  if (-not $Paths -or $Paths.Count -eq 0) {
+    Write-Host "No paths configured. Skip."
+    return
+  }
+
+  if (-not $Apply) {
+    Write-Host "Dry run only. These paths would be staged:"
+    $Paths | ForEach-Object { Write-Host "  $_" }
+    return
+  }
+
+  git add -- @Paths
+  if ($LASTEXITCODE -ne 0) {
+    throw "git add failed: $Name"
+  }
+}
+
+function Test-ForbiddenStagedFiles {
+  $forbiddenPatterns = @(
+    '^docs/',
+    '^data/',
+    '^tmp/',
+    '^test-results/',
+    '^playwright-report/',
+    '^blob-report/',
+    '^coverage/',
+    '^cloud-functions/deployable/',
+    '^\.env($|\.)',
+    '^deploy\.ps1$',
+    '^src/video/',
+    '\.xlsx$',
+    '\.xls$'
+  )
+
+  $staged = git diff --cached --name-only
+  $bad = @()
+  foreach ($file in $staged) {
+    foreach ($pattern in $forbiddenPatterns) {
+      if ($file -match $pattern) {
+        $bad += $file
+        break
+      }
+    }
+  }
+
+  if ($bad.Count -gt 0) {
+    Write-Section "forbidden staged files"
+    $bad | Sort-Object -Unique | ForEach-Object { Write-Host "  $_" -ForegroundColor Red }
+    Write-Host ""
+    Write-Host "Abort. Run: git restore --staged <path>" -ForegroundColor Yellow
+    exit 1
+  }
+}
+
+function Show-CredentialReminder {
+  Write-Section "credential reminder"
+  Write-Host "Before commit, manually verify there are no real tokens/API keys in:"
+  Write-Host "  cloud-functions/06-voiceManager.js"
+  Write-Host "  cloud-functions/11-jimengManager.js"
+  Write-Host "  cloud-functions/13-douyinInsightManager.js"
+  Write-Host "  cloud-functions/08-proxyHub.js"
+  Write-Host "  cloud-functions/12-douyinManager.js"
+}
+
+$groups = @{
+  ip = @(
+    'src/app/pages/ip-operator',
+    'src/app/models/ip-operator.model.ts',
+    'src/app/services/ip-account-*',
+    'src/app/services/ip-operator-*',
+    'src/app/services/ip-content-production-flow*',
+    'src/app/services/ip-publish-*',
+    'src/app/services/ip-script-*',
+    'src/app/services/ip-topic-script-generator*',
+    'src/app/services/reference-video-prompt-planner*',
+    'src/app/services/retrospective.service*',
+    'src/app/services/viral-analysis.service*',
+    'e2e/ip-operator-*',
+    'src/app/app.ts',
+    'src/app/app.html',
+    'src/app/app.spec.ts',
+    'src/app/components/app-sidebar/app-sidebar.component.ts',
+    'src/app/models/app-tab.model.ts'
+  )
+  storage = @(
+    'cloud-functions/_session.js',
+    'cloud-functions/_session.spec.js',
+    'cloud-functions/_parseClassStore.js',
+    'cloud-functions/13-douyinInsightManager.js',
+    'cloud-functions/13-douyinInsightManager.spec.js',
+    'cloud-functions/14-systemStorageManager.js',
+    'cloud-functions/15-fileAssetManager.js',
+    'cloud-functions/09-uploadManager.js',
+    'scripts/build-cloud-functions.mjs',
+    'scripts/smoke-cloud-functions.mjs',
+    'scripts/validation/cloud-functions-readiness.mjs',
+    'scripts/validation/parse-storage-postdeploy.mjs',
+    'scripts/validation/storage-governance-postdeploy.mjs',
+    'scripts/validation/storage-sensitive-policy.mjs',
+    'src/app/services/cloud-session-storage*',
+    'src/app/services/file-asset*',
+    'src/app/services/storage-governance*',
+    'src/app/services/system-storage-migration*',
+    'src/app/services/douyin-insight*',
+    'src/app/services/douyin-api*'
+  )
+  billing = @(
+    'cloud-functions/10-authCreditManager.js',
+    'cloud-functions/11-jimengManager.js',
+    'cloud-functions/auth-credit-deploy.md',
+    'src/app/services/auth-credit*',
+    'src/app/services/jimeng*',
+    'src/app/services/cost-estimator*',
+    'src/app/pages/account/user-center.component*',
+    'src/app/pages/pipelines/image-to-video',
+    'src/app/pages/pipelines/image-generation/image-generation.component.ts',
+    'src/app/pages/pipelines/action-transfer/action-transfer.component.ts',
+    'src/app/pages/pipelines/asset-remix/asset-remix.component.ts',
+    'src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts'
+  )
+  governance = @(
+    'AGENTS.md',
+    '.githooks/pre-commit',
+    '.gitignore',
+    'README.md',
+    'package.json',
+    'angular.json',
+    'cloud-functions/DEPLOY.md',
+    'scripts/git-stage-safe.ps1',
+    'scripts/validation/ip-operator-cleanup-dry-run.mjs',
+    'scripts/validation/ip-operator-cleanup-soft-delete.mjs',
+    'scripts/validation/ip-operator-cleanup-physical-delete.mjs',
+    'scripts/validation/videoworkflow-parse-cleanup.mjs',
+    'scripts/validation/ip-operator-scenario-seed.js',
+    'scripts/validation/jimeng-billing-policy.mjs'
+  )
+}
+
+Write-Section "current git status"
+git status --short
+
+if ($Group -eq 'all') {
+  Invoke-GitAdd 'ip' $groups.ip
+  Invoke-GitAdd 'storage' $groups.storage
+  Invoke-GitAdd 'billing' $groups.billing
+  Invoke-GitAdd 'governance' $groups.governance
+} else {
+  Invoke-GitAdd $Group $groups[$Group]
+}
+
+if ($Apply) {
+  Test-ForbiddenStagedFiles
+
+  Write-Section "staged files"
+  git diff --cached --name-status
+} else {
+  Write-Section "usage"
+  Write-Host "Dry run group: powershell -ExecutionPolicy Bypass -File scripts/git-stage-safe.ps1 -Group ip"
+  Write-Host "Stage group:   powershell -ExecutionPolicy Bypass -File scripts/git-stage-safe.ps1 -Group ip -Apply"
+  Write-Host "Stage all:     powershell -ExecutionPolicy Bypass -File scripts/git-stage-safe.ps1 -Group all -Apply"
+}
+
+Show-CredentialReminder

+ 464 - 0
scripts/validation/ip-operator-cleanup-dry-run.mjs

@@ -0,0 +1,464 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
+const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
+const ACCOUNT_FILTER = String(process.env.IP_CLEANUP_ACCOUNT_ID || '').trim();
+const INCLUDE_ALL_ACTIVE = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_INCLUDE_ALL_ACTIVE || ''));
+const PRINT_CANDIDATES = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_PRINT_CANDIDATES || ''));
+const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
+  ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
+  : path.join(process.cwd(), 'tmp');
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..', '..');
+const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
+const fn = readCloudFunctionIds(cloudFunctionsPath);
+
+const entityTypes = [
+  'ipOperator.account',
+  'ipOperator.profile',
+  'ipOperator.plan',
+  'ipOperator.accountSnapshot',
+  'ipOperator.accountSnapshotWork',
+  'ipOperator.accountSnapshotComment',
+  'ipOperator.accountDiagnosis',
+  'ipOperator.positioningProposal',
+  'ipOperator.positioningVersion',
+  'ipOperator.contentDirection',
+  'ipOperator.operationTask',
+  'ipOperator.publishBinding',
+  'ipOperator.planEvidenceItem',
+  'ipOperator.planCommentPainInsight',
+  'ipOperator.planContentCalendarItem',
+  'ipOperator.planPublishPackage',
+  'ipOperator.planScriptBody',
+  'ipOperator.planGenerationPreview',
+];
+
+if (!SESSION_TOKEN) {
+  console.error('Missing SMOKE_SESSION_TOKEN. This dry-run only reads the current Parse user namespace.');
+  process.exit(1);
+}
+if (!fn.systemStorage) {
+  console.error('CLOUD_FN.systemStorage is empty. Check src/app/services/cloud-functions.ts.');
+  process.exit(1);
+}
+
+const startedAt = new Date().toISOString();
+const rowsByType = new Map();
+const allRows = [];
+
+for (const entityType of entityTypes) {
+  const rows = await listRows(entityType);
+  rowsByType.set(entityType, rows);
+  allRows.push(...rows);
+}
+
+const stats = await call(fn.systemStorage, { action: 'stats' }).catch(() => null);
+const accounts = rowsByType.get('ipOperator.account') || [];
+const plans = rowsByType.get('ipOperator.plan') || [];
+const snapshots = rowsByType.get('ipOperator.accountSnapshot') || [];
+const works = rowsByType.get('ipOperator.accountSnapshotWork') || [];
+const comments = rowsByType.get('ipOperator.accountSnapshotComment') || [];
+const evidenceRows = rowsByType.get('ipOperator.planEvidenceItem') || [];
+const painRows = rowsByType.get('ipOperator.planCommentPainInsight') || [];
+
+const accountIds = new Set();
+for (const account of accounts) accountIds.add(account.entityId);
+for (const row of [...snapshots, ...works, ...comments, ...evidenceRows, ...painRows]) {
+  const accountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
+  if (accountId) accountIds.add(accountId);
+}
+const targetAccountIds = [...accountIds].filter((id) => !ACCOUNT_FILTER || id === ACCOUNT_FILTER);
+
+const candidates = [];
+for (const accountId of targetAccountIds) {
+  collectAccountCandidates(accountId, candidates);
+}
+
+const typeStatus = summarizeByTypeAndStatus(allRows);
+const candidateSummary = summarizeByTypeAndStatus(candidates);
+const reportBase = `ip-operator-cleanup-dry-run-${safeTimestamp(startedAt)}`;
+const jsonPath = path.join(OUTPUT_DIR, `${reportBase}.json`);
+const mdPath = path.join(OUTPUT_DIR, `${reportBase}.md`);
+const report = buildReport({
+  generatedAt: startedAt,
+  accountFilter: ACCOUNT_FILTER || '',
+  stats: stats?.success ? stats.data : null,
+  fetchedSummary: summaryToObject(typeStatus),
+  candidateSummary: summaryToObject(candidateSummary),
+  fetchedRowCount: allRows.length,
+  candidateCount: candidates.length,
+  candidates,
+});
+fs.mkdirSync(OUTPUT_DIR, { recursive: true });
+fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
+fs.writeFileSync(mdPath, renderMarkdownReport(report), 'utf8');
+
+console.log(`IP operator cleanup dry-run generatedAt=${startedAt}`);
+console.log(`scope=current Parse user; accountFilter=${ACCOUNT_FILTER || '(all detected accounts)'}`);
+console.log('');
+console.log('Current fetched rows by entityType/status:');
+printSummary(typeStatus);
+if (stats?.success && stats.data?.totalEntities !== undefined) {
+  console.log('');
+  console.log(`systemStorage.stats totalEntities=${stats.data.totalEntities}`);
+}
+console.log('');
+console.log('Cleanup candidates by entityType/status:');
+printSummary(candidateSummary);
+console.log('');
+console.log(`Cleanup candidate rows: ${candidates.length}`);
+console.log('');
+console.log(`Full JSON report: ${jsonPath}`);
+console.log(`Full Markdown report: ${mdPath}`);
+if (PRINT_CANDIDATES) {
+  console.log('');
+  printCandidates(candidates);
+}
+console.log('');
+console.log('DRY-RUN ONLY: no delete/purge/physical removal was executed.');
+console.log('Next step: review candidate reasons. Only after confirmation should a separate execution script be used.');
+
+async function listRows(entityType) {
+  const statuses = INCLUDE_ALL_ACTIVE
+    ? ['active', 'deleted', 'purged', 'archived']
+    : ['active', 'deleted', 'purged'];
+  const merged = [];
+  const seen = new Set();
+  for (const status of statuses) {
+    const result = await call(fn.systemStorage, { action: 'list', entityType, status, limit: 1000 });
+    if (!result?.success) {
+      console.warn(`WARN list failed entityType=${entityType} status=${status}: ${result?.error || JSON.stringify(result)}`);
+      continue;
+    }
+    for (const row of result.data || []) {
+      const key = row.objectId || `${row.entityType}:${row.entityId}:${row.status}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      merged.push(normalizeRow(row));
+    }
+  }
+  return merged;
+}
+
+function collectAccountCandidates(accountId, output) {
+  const currentSnapshotId = `snapshot_${accountId}_current`;
+  const currentPlanId = `ip_account_plan_${accountId}`;
+  const plan = plans.find((row) => row.entityId === currentPlanId || row.data?.id === currentPlanId);
+  const currentRefs = new Set([
+    ...toArray(plan?.data?.externalizedCollections?.evidenceItems),
+    ...toArray(plan?.data?.evidenceItems).map((item) => item?.id).filter(Boolean),
+  ]);
+  const currentPainRefs = new Set([
+    ...toArray(plan?.data?.externalizedCollections?.commentPainInsights),
+    ...toArray(plan?.data?.commentPainInsights).map((item) => item?.id).filter(Boolean),
+  ]);
+
+  const staleSnapshotIds = new Set();
+  for (const row of snapshots) {
+    const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.entityId);
+    if (rowAccountId !== accountId) continue;
+    if (row.entityId === currentSnapshotId) continue;
+    if (/^snapshot_.+_(current)$/.test(row.entityId)) continue;
+    staleSnapshotIds.add(row.entityId);
+    pushCandidate(output, row, accountId, `old account snapshot; keep ${currentSnapshotId}`);
+  }
+
+  for (const row of works) {
+    const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
+    const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
+    if (rowAccountId !== accountId) continue;
+    if (staleSnapshotIds.has(snapshotId)) {
+      pushCandidate(output, row, accountId, `work belongs to old snapshot ${snapshotId}`);
+    }
+  }
+
+  for (const row of comments) {
+    const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
+    const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
+    if (rowAccountId !== accountId) continue;
+    if (staleSnapshotIds.has(snapshotId)) {
+      pushCandidate(output, row, accountId, `comment belongs to old snapshot ${snapshotId}`);
+    }
+  }
+
+  for (const row of evidenceRows) {
+    const data = row.data || {};
+    if (data.accountId !== accountId) continue;
+    const evidenceId = data.id || row.entityId.split('__').pop();
+    const snapshotId = data.snapshotId || '';
+    if (staleSnapshotIds.has(snapshotId)) {
+      pushCandidate(output, row, accountId, `evidence belongs to old snapshot ${snapshotId}`);
+      continue;
+    }
+    if (plan && currentRefs.size && !currentRefs.has(evidenceId)) {
+      pushCandidate(output, row, accountId, `evidence is not referenced by current plan ${currentPlanId}`);
+    }
+  }
+
+  for (const row of painRows) {
+    const data = row.data || {};
+    const planId = data.planId || row.entityId.split('__')[0];
+    if (planId !== currentPlanId) continue;
+    const painId = data.id || row.entityId.split('__').pop();
+    const evidenceIds = toArray(data.evidenceItemIds);
+    const referencesStaleEvidence = evidenceIds.some((id) => candidatesEvidenceIdsForAccount(output, accountId).has(id));
+    if (referencesStaleEvidence) {
+      pushCandidate(output, row, accountId, 'pain insight references stale evidence');
+      continue;
+    }
+    if (plan && currentPainRefs.size && !currentPainRefs.has(painId)) {
+      pushCandidate(output, row, accountId, `pain insight is not referenced by current plan ${currentPlanId}`);
+    }
+  }
+
+  for (const row of allRows) {
+    if ((row.status === 'deleted' || row.status === 'purged') && row.entityType.startsWith('ipOperator.')) {
+      const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
+      if (rowAccountId === accountId) pushCandidate(output, row, accountId, `already ${row.status}; physical cleanup candidate only after review`);
+    }
+  }
+}
+
+function candidatesEvidenceIdsForAccount(output, accountId) {
+  return new Set(output
+    .filter((row) => row.accountId === accountId && row.entityType === 'ipOperator.planEvidenceItem')
+    .map((row) => row.data?.id || row.entityId.split('__').pop())
+    .filter(Boolean));
+}
+
+function pushCandidate(output, row, accountId, reason) {
+  const key = row.objectId || `${row.entityType}:${row.entityId}`;
+  if (output.some((item) => (item.objectId || `${item.entityType}:${item.entityId}`) === key)) return;
+  output.push({ ...row, accountId, reason });
+}
+
+async function call(id, body) {
+  let lastResult = null;
+  let lastError = null;
+  for (let attempt = 1; attempt <= 4; attempt += 1) {
+    try {
+      const response = await fetch(FN_URL, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': APP_ID,
+        },
+        body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
+      });
+      const text = await response.text();
+      let result;
+      try {
+        result = JSON.parse(text);
+      } catch {
+        result = { code: response.status, success: false, error: text };
+      }
+      lastResult = result;
+      if (attempt < 4 && isRetryableResult(result)) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      return result;
+    } catch (error) {
+      lastError = error;
+      const message = `${error?.message || ''} ${error?.cause?.code || ''}`;
+      if (attempt < 4 && isRetryableMessage(message)) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      return { code: 500, success: false, error: error?.message || 'fetch failed' };
+    }
+  }
+  if (lastResult) return lastResult;
+  return { code: 500, success: false, error: lastError?.message || 'fetch failed' };
+}
+
+function isRetryableResult(result) {
+  const message = `${result?.error || ''} ${result?.message || ''}`;
+  return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
+}
+
+function isRetryableMessage(message) {
+  return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function readCloudFunctionIds(filePath) {
+  const text = fs.readFileSync(filePath, 'utf8');
+  const ids = {};
+  const re = /(\w+):\s*'([^']*)'/g;
+  let match;
+  while ((match = re.exec(text))) ids[match[1]] = match[2];
+  return ids;
+}
+
+function normalizeRow(row) {
+  return {
+    objectId: row.objectId || '',
+    entityType: row.entityType || row.type || '',
+    entityId: row.entityId || row.id || '',
+    status: row.status || 'active',
+    createdAt: row.createdAt || '',
+    updatedAt: row.updatedAt || '',
+    data: row.data && typeof row.data === 'object' ? row.data : {},
+  };
+}
+
+function summarizeByTypeAndStatus(rows) {
+  const summary = new Map();
+  for (const row of rows) {
+    const type = row.entityType || '(unknown)';
+    const status = row.status || '(unknown)';
+    if (!summary.has(type)) summary.set(type, {});
+    summary.get(type)[status] = (summary.get(type)[status] || 0) + 1;
+  }
+  return summary;
+}
+
+function printSummary(summary) {
+  const types = [...summary.keys()].sort();
+  if (!types.length) {
+    console.log('  (none)');
+    return;
+  }
+  for (const type of types) {
+    const statuses = Object.entries(summary.get(type))
+      .sort(([a], [b]) => a.localeCompare(b))
+      .map(([status, count]) => `${status}:${count}`)
+      .join(', ');
+    console.log(`  ${type} -> ${statuses}`);
+  }
+}
+
+function printCandidates(rows) {
+  if (!rows.length) {
+    console.log('  (none)');
+    return;
+  }
+  const sorted = [...rows].sort((a, b) =>
+    a.accountId.localeCompare(b.accountId)
+    || a.entityType.localeCompare(b.entityType)
+    || a.entityId.localeCompare(b.entityId));
+  for (const row of sorted) {
+    console.log([
+      `  account=${row.accountId}`,
+      `type=${row.entityType}`,
+      `status=${row.status}`,
+      `entityId=${row.entityId}`,
+      row.objectId ? `objectId=${row.objectId}` : '',
+      `reason=${row.reason}`,
+    ].filter(Boolean).join(' | '));
+  }
+}
+
+function buildReport(input) {
+  return {
+    generatedAt: input.generatedAt,
+    scope: {
+      parseUser: 'current sessionToken user',
+      accountFilter: input.accountFilter,
+      includeArchived: INCLUDE_ALL_ACTIVE,
+    },
+    stats: input.stats,
+    fetchedRowCount: input.fetchedRowCount,
+    fetchedSummary: input.fetchedSummary,
+    candidateCount: input.candidateCount,
+    candidateSummary: input.candidateSummary,
+    candidates: input.candidates.map((row) => ({
+      accountId: row.accountId,
+      objectId: row.objectId,
+      entityType: row.entityType,
+      entityId: row.entityId,
+      status: row.status,
+      createdAt: row.createdAt,
+      updatedAt: row.updatedAt,
+      reason: row.reason,
+      dataId: row.data?.id || '',
+      snapshotId: row.data?.snapshotId || '',
+      planId: row.data?.planId || '',
+      workId: row.data?.workId || '',
+    })),
+  };
+}
+
+function renderMarkdownReport(report) {
+  const lines = [
+    '# IP Operator Cleanup Dry Run',
+    '',
+    `- generatedAt: ${report.generatedAt}`,
+    `- scope: ${report.scope.parseUser}`,
+    `- accountFilter: ${report.scope.accountFilter || '(all detected accounts)'}`,
+    `- fetchedRowCount: ${report.fetchedRowCount}`,
+    `- candidateCount: ${report.candidateCount}`,
+  ];
+  if (report.stats?.totalEntities !== undefined) {
+    lines.push(`- systemStorage.stats totalEntities: ${report.stats.totalEntities}`);
+  }
+  lines.push('', '## Current fetched rows by entityType/status', '');
+  lines.push(...summaryMarkdownLines(report.fetchedSummary));
+  lines.push('', '## Cleanup candidates by entityType/status', '');
+  lines.push(...summaryMarkdownLines(report.candidateSummary));
+  lines.push('', '## Candidate rows', '');
+  if (!report.candidates.length) {
+    lines.push('(none)');
+  } else {
+    lines.push('| accountId | entityType | status | entityId | objectId | reason |');
+    lines.push('|---|---|---|---|---|---|');
+    for (const row of report.candidates) {
+      lines.push(`| ${escapeMd(row.accountId)} | ${escapeMd(row.entityType)} | ${escapeMd(row.status)} | ${escapeMd(row.entityId)} | ${escapeMd(row.objectId)} | ${escapeMd(row.reason)} |`);
+    }
+  }
+  lines.push('', '> DRY-RUN ONLY: no delete/purge/physical removal was executed.', '');
+  return `${lines.join('\n')}\n`;
+}
+
+function summaryMarkdownLines(summary) {
+  const types = Object.keys(summary).sort();
+  if (!types.length) return ['(none)'];
+  return types.map((type) => {
+    const statuses = Object.entries(summary[type])
+      .sort(([a], [b]) => a.localeCompare(b))
+      .map(([status, count]) => `${status}:${count}`)
+      .join(', ');
+    return `- ${type}: ${statuses}`;
+  });
+}
+
+function summaryToObject(summary) {
+  const result = {};
+  for (const [type, statuses] of summary.entries()) {
+    result[type] = { ...statuses };
+  }
+  return result;
+}
+
+function safeTimestamp(value) {
+  return String(value).replace(/[:.]/g, '-');
+}
+
+function escapeMd(value) {
+  return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
+}
+
+function parseSnapshotIdFromChildId(value) {
+  const text = String(value || '');
+  const index = text.indexOf('__');
+  return index >= 0 ? text.slice(0, index) : '';
+}
+
+function parseAccountIdFromSnapshotId(value) {
+  const text = String(value || '');
+  const match = text.match(/^snapshot_(.+?)_(?:current|\d{10,}.*)$/);
+  return match?.[1] || '';
+}
+
+function toArray(value) {
+  return Array.isArray(value) ? value : [];
+}

+ 258 - 0
scripts/validation/ip-operator-cleanup-physical-delete.mjs

@@ -0,0 +1,258 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const PARSE_HOST = normalizeParseHost(process.env.SMOKE_PARSE_HOST || 'https://server.fmode.cn');
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
+const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
+const CONFIRM_TEXT = 'PHYSICAL_DELETE_IP_OPERATOR_CANDIDATES';
+const CONFIRM = readArg('--confirm') || process.env.IP_CLEANUP_CONFIRM || '';
+const INPUT = readArg('--input') || process.env.IP_CLEANUP_REPORT || '';
+const EXECUTE = CONFIRM === CONFIRM_TEXT;
+const CONCURRENCY = clamp(Number(process.env.IP_CLEANUP_CONCURRENCY || readArg('--concurrency') || 2), 1, 4);
+const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
+  ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
+  : path.join(process.cwd(), 'tmp');
+
+const PROJECT_KEY = 'video-workflow';
+const CLASS_NAME = 'VideoWorkflowEntity';
+const allowedEntityTypes = new Set([
+  'ipOperator.accountSnapshot',
+  'ipOperator.accountSnapshotWork',
+  'ipOperator.accountSnapshotComment',
+  'ipOperator.planEvidenceItem',
+  'ipOperator.planCommentPainInsight',
+]);
+const protectedEntityTypes = new Set([
+  'ipOperator.account',
+  'ipOperator.profile',
+  'ipOperator.plan',
+]);
+
+if (!SESSION_TOKEN) {
+  console.error('Missing SMOKE_SESSION_TOKEN. Refusing to access Parse.');
+  process.exit(1);
+}
+if (!INPUT) {
+  console.error('Missing --input <cleanup-report.json> or IP_CLEANUP_REPORT.');
+  process.exit(1);
+}
+
+const inputPath = path.resolve(INPUT);
+const report = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
+const sourceCandidates = Array.isArray(report.candidates)
+  ? report.candidates
+  : [
+    ...toArray(report.results).map((item) => ({ ...item, status: item.status || 'deleted' })),
+    ...toArray(report.skipped).map((item) => ({ ...item, status: item.status || 'deleted' })),
+  ];
+const candidates = normalizeCandidates(sourceCandidates);
+
+console.log(`IP operator physical cleanup input=${inputPath}`);
+console.log(`mode=${EXECUTE ? 'EXECUTE_PHYSICAL_DELETE' : 'RESOLVE_ONLY_DRY_RUN'}`);
+console.log(`candidateRows=${sourceCandidates.length}`);
+console.log(`allowlistedTargets=${candidates.length}`);
+console.log(`concurrency=${CONCURRENCY}`);
+console.log('');
+
+const me = await parseRequest('GET', '/users/me');
+const ownerId = me?.objectId || '';
+if (!ownerId) {
+  console.error('Could not resolve current Parse user from SMOKE_SESSION_TOKEN.');
+  process.exit(1);
+}
+
+const startedAt = new Date().toISOString();
+const resolved = [];
+let resolveIndex = 0;
+await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
+  while (resolveIndex < candidates.length) {
+    const currentIndex = resolveIndex;
+    resolveIndex += 1;
+    const target = candidates[currentIndex];
+    resolved[currentIndex] = await resolveTarget(target, ownerId);
+    if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === candidates.length) {
+      console.log(`resolve progress ${currentIndex + 1}/${candidates.length}`);
+    }
+  }
+}));
+
+const resolvable = resolved.filter((item) => item.objectId);
+const unresolved = resolved.filter((item) => !item.objectId);
+console.log('');
+console.log(`resolvedObjectIds=${resolvable.length}`);
+console.log(`unresolved=${unresolved.length}`);
+
+let deleteResults = [];
+if (!EXECUTE) {
+  console.log('');
+  console.log('No physical delete executed.');
+  console.log(`To execute irreversible physical delete, rerun with: --confirm ${CONFIRM_TEXT}`);
+} else {
+  deleteResults = [];
+  let deleteIndex = 0;
+  await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
+    while (deleteIndex < resolvable.length) {
+      const currentIndex = deleteIndex;
+      deleteIndex += 1;
+      const target = resolvable[currentIndex];
+      deleteResults[currentIndex] = await deleteTarget(target);
+      if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === resolvable.length) {
+        console.log(`delete progress ${currentIndex + 1}/${resolvable.length}`);
+      }
+    }
+  }));
+}
+
+const completedAt = new Date().toISOString();
+const deleteFailures = deleteResults.filter((item) => !item.ok);
+const output = {
+  startedAt,
+  completedAt,
+  mode: EXECUTE ? 'execute' : 'dry-run',
+  inputPath,
+  ownerId,
+  sourceCandidateRows: sourceCandidates.length,
+  allowlistedTargets: candidates.length,
+  resolvedObjectIds: resolvable.length,
+  unresolvedCount: unresolved.length,
+  deleteAttempted: deleteResults.length,
+  deleteSuccess: deleteResults.filter((item) => item.ok).length,
+  deleteFailures: deleteFailures.length,
+  resolved,
+  unresolved,
+  deleteResults,
+};
+
+fs.mkdirSync(OUTPUT_DIR, { recursive: true });
+const outputPath = path.join(
+  OUTPUT_DIR,
+  `ip-operator-cleanup-physical-${EXECUTE ? 'delete' : 'dry-run'}-${safeTimestamp(completedAt)}.json`,
+);
+fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
+
+console.log('');
+console.log(`Physical cleanup ${EXECUTE ? 'execute' : 'dry-run'} complete.`);
+console.log(`Result report: ${outputPath}`);
+if (deleteFailures.length) process.exit(1);
+
+function normalizeCandidates(rows) {
+  const seen = new Set();
+  const output = [];
+  for (const row of rows) {
+    const entityType = String(row.entityType || '');
+    const entityId = String(row.entityId || '');
+    if (!entityType || !entityId) continue;
+    if (protectedEntityTypes.has(entityType)) continue;
+    if (!allowedEntityTypes.has(entityType)) continue;
+    const key = `${entityType}:${entityId}`;
+    if (seen.has(key)) continue;
+    seen.add(key);
+    output.push({
+      accountId: row.accountId || '',
+      entityType,
+      entityId,
+      status: row.status || '',
+      reason: row.reason || '',
+    });
+  }
+  return output;
+}
+
+async function resolveTarget(target, ownerId) {
+  const where = {
+    projectKey: PROJECT_KEY,
+    owner: { __type: 'Pointer', className: '_User', objectId: ownerId },
+    entityType: target.entityType,
+    entityId: target.entityId,
+  };
+  const query = new URLSearchParams();
+  query.set('where', JSON.stringify(where));
+  query.set('limit', '2');
+  query.set('keys', 'objectId,entityType,entityId,status,ownerId,projectKey,createdAt,updatedAt');
+  const result = await parseRequest('GET', `/classes/${encodeURIComponent(CLASS_NAME)}?${query.toString()}`);
+  const rows = Array.isArray(result.results) ? result.results : [];
+  return {
+    ...target,
+    objectId: rows.length === 1 ? rows[0].objectId : '',
+    matchedRows: rows.length,
+    currentStatus: rows[0]?.status || '',
+    createdAt: rows[0]?.createdAt || '',
+    updatedAt: rows[0]?.updatedAt || '',
+    resolveError: rows.length > 1 ? 'multiple rows matched; refusing to delete' : rows.length === 0 ? 'not found' : '',
+  };
+}
+
+async function deleteTarget(target) {
+  try {
+    await parseRequest('DELETE', `/classes/${encodeURIComponent(CLASS_NAME)}/${encodeURIComponent(target.objectId)}`);
+    return { ...target, ok: true, error: '' };
+  } catch (error) {
+    return { ...target, ok: false, error: error?.message || 'delete failed' };
+  }
+}
+
+async function parseRequest(method, parsePath) {
+  const headers = {
+    Accept: 'application/json',
+    'X-Parse-Application-Id': APP_ID,
+    'X-Parse-Session-Token': SESSION_TOKEN,
+  };
+  let lastError = null;
+  for (let attempt = 1; attempt <= 4; attempt += 1) {
+    try {
+      const response = await fetch(`${PARSE_HOST}/parse${parsePath}`, { method, headers });
+      const data = await response.json().catch(() => ({}));
+      if (!response.ok || data.error) {
+        const error = new Error(data.error || data.message || `Parse ${method} ${parsePath} failed`);
+        error.status = response.status || 500;
+        error.detail = data;
+        if (attempt < 4 && isRetryableError(error)) {
+          await sleep(500 * attempt);
+          continue;
+        }
+        throw error;
+      }
+      return data;
+    } catch (error) {
+      lastError = error;
+      if (attempt < 4 && isRetryableError(error)) {
+        await sleep(500 * attempt);
+        continue;
+      }
+      throw error;
+    }
+  }
+  throw lastError || new Error(`Parse ${method} ${parsePath} failed`);
+}
+
+function isRetryableError(error) {
+  const status = Number(error?.status || 0);
+  const message = `${error?.message || ''} ${error?.cause?.code || ''} ${error?.detail?.error || ''}`;
+  return status >= 500 || /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
+}
+
+function readArg(name) {
+  const index = process.argv.indexOf(name);
+  return index >= 0 ? process.argv[index + 1] || '' : '';
+}
+
+function toArray(value) {
+  return Array.isArray(value) ? value : [];
+}
+
+function normalizeParseHost(value) {
+  return String(value || 'https://server.fmode.cn').replace(/\/+$/, '').replace(/\/parse$/i, '');
+}
+
+function clamp(value, min, max) {
+  if (!Number.isFinite(value)) return min;
+  return Math.max(min, Math.min(max, Math.floor(value)));
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function safeTimestamp(value) {
+  return String(value).replace(/[:.]/g, '-');
+}

+ 227 - 0
scripts/validation/ip-operator-cleanup-soft-delete.mjs

@@ -0,0 +1,227 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
+const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
+const CONFIRM_TEXT = 'SOFT_DELETE_IP_OPERATOR_CANDIDATES';
+const CONFIRM = readArg('--confirm') || process.env.IP_CLEANUP_CONFIRM || '';
+const INPUT = readArg('--input') || process.env.IP_CLEANUP_REPORT || '';
+const CONCURRENCY = clamp(Number(process.env.IP_CLEANUP_CONCURRENCY || readArg('--concurrency') || 3), 1, 5);
+const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
+  ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
+  : path.join(process.cwd(), 'tmp');
+
+const allowedEntityTypes = new Set([
+  'ipOperator.accountSnapshot',
+  'ipOperator.accountSnapshotWork',
+  'ipOperator.accountSnapshotComment',
+  'ipOperator.planEvidenceItem',
+  'ipOperator.planCommentPainInsight',
+]);
+
+const protectedEntityTypes = new Set([
+  'ipOperator.account',
+  'ipOperator.profile',
+  'ipOperator.plan',
+]);
+
+if (!SESSION_TOKEN) {
+  console.error('Missing SMOKE_SESSION_TOKEN. Refusing to modify cloud data.');
+  process.exit(1);
+}
+if (!INPUT) {
+  console.error('Missing --input <dry-run-report.json> or IP_CLEANUP_REPORT.');
+  process.exit(1);
+}
+
+const inputPath = path.resolve(INPUT);
+const report = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
+const candidates = Array.isArray(report.candidates) ? report.candidates : [];
+const activeTargets = [];
+const skipped = [];
+
+for (const row of candidates) {
+  const entityType = String(row.entityType || '');
+  const entityId = String(row.entityId || '');
+  const status = String(row.status || 'active');
+  if (!entityType || !entityId) {
+    skipped.push({ ...row, skippedReason: 'missing entityType/entityId' });
+    continue;
+  }
+  if (protectedEntityTypes.has(entityType)) {
+    skipped.push({ ...row, skippedReason: 'protected entity type' });
+    continue;
+  }
+  if (!allowedEntityTypes.has(entityType)) {
+    skipped.push({ ...row, skippedReason: 'not in cleanup allowlist' });
+    continue;
+  }
+  if (status !== 'active') {
+    skipped.push({ ...row, skippedReason: `status is ${status}; soft delete not needed` });
+    continue;
+  }
+  activeTargets.push({
+    accountId: row.accountId || '',
+    entityType,
+    entityId,
+    reason: row.reason || '',
+    status,
+  });
+}
+
+console.log(`IP operator soft cleanup input=${inputPath}`);
+console.log(`dryRunGeneratedAt=${report.generatedAt || '(unknown)'}`);
+console.log(`candidateRows=${candidates.length}`);
+console.log(`activeTargets=${activeTargets.length}`);
+console.log(`skipped=${skipped.length}`);
+console.log(`concurrency=${CONCURRENCY}`);
+console.log('');
+
+if (CONFIRM !== CONFIRM_TEXT) {
+  console.log('No cleanup executed.');
+  console.log(`To execute soft delete, rerun with: --confirm ${CONFIRM_TEXT}`);
+  console.log('This will call systemStorage.delete for active allowlisted candidate rows only.');
+  process.exit(2);
+}
+
+const cloudFunctionsPath = path.join(process.cwd(), 'src', 'app', 'services', 'cloud-functions.ts');
+const fn = readCloudFunctionIds(cloudFunctionsPath);
+if (!fn.systemStorage) {
+  console.error('CLOUD_FN.systemStorage is empty. Check src/app/services/cloud-functions.ts.');
+  process.exit(1);
+}
+
+const startedAt = new Date().toISOString();
+const results = [];
+let index = 0;
+
+await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
+  while (index < activeTargets.length) {
+    const currentIndex = index;
+    index += 1;
+    const target = activeTargets[currentIndex];
+    const result = await softDelete(target);
+    results[currentIndex] = result;
+    if ((currentIndex + 1) % 50 === 0 || currentIndex + 1 === activeTargets.length) {
+      console.log(`progress ${currentIndex + 1}/${activeTargets.length}`);
+    }
+  }
+}));
+
+const completedAt = new Date().toISOString();
+const failures = results.filter((item) => !item.ok);
+const successCount = results.length - failures.length;
+const output = {
+  startedAt,
+  completedAt,
+  inputPath,
+  dryRunGeneratedAt: report.generatedAt || '',
+  activeTargets: activeTargets.length,
+  successCount,
+  failureCount: failures.length,
+  skippedCount: skipped.length,
+  skipped,
+  failures,
+  results,
+};
+
+fs.mkdirSync(OUTPUT_DIR, { recursive: true });
+const outputPath = path.join(OUTPUT_DIR, `ip-operator-cleanup-soft-delete-${safeTimestamp(completedAt)}.json`);
+fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
+
+console.log('');
+console.log(`Soft cleanup complete. success=${successCount} failures=${failures.length} skipped=${skipped.length}`);
+console.log(`Result report: ${outputPath}`);
+if (failures.length) process.exit(1);
+
+async function softDelete(target) {
+  const result = await call(fn.systemStorage, {
+    action: 'delete',
+    entityType: target.entityType,
+    entityId: target.entityId,
+  });
+  const ok = Number(result?.code || 0) === 200 && result?.success !== false;
+  return {
+    ...target,
+    ok,
+    responseCode: result?.code,
+    error: ok ? '' : result?.error || result?.message || JSON.stringify(result).slice(0, 500),
+  };
+}
+
+async function call(id, body) {
+  let lastResult = null;
+  let lastError = null;
+  for (let attempt = 1; attempt <= 4; attempt += 1) {
+    try {
+      const response = await fetch(FN_URL, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': APP_ID,
+        },
+        body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
+      });
+      const text = await response.text();
+      let result;
+      try {
+        result = JSON.parse(text);
+      } catch {
+        result = { code: response.status, success: false, error: text };
+      }
+      lastResult = result;
+      if (attempt < 4 && isRetryableResult(result)) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      return result;
+    } catch (error) {
+      lastError = error;
+      const message = `${error?.message || ''} ${error?.cause?.code || ''}`;
+      if (attempt < 4 && isRetryableMessage(message)) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      return { code: 500, success: false, error: error?.message || 'fetch failed' };
+    }
+  }
+  if (lastResult) return lastResult;
+  return { code: 500, success: false, error: lastError?.message || 'fetch failed' };
+}
+
+function readArg(name) {
+  const index = process.argv.indexOf(name);
+  return index >= 0 ? process.argv[index + 1] || '' : '';
+}
+
+function readCloudFunctionIds(filePath) {
+  const text = fs.readFileSync(filePath, 'utf8');
+  const ids = {};
+  const re = /(\w+):\s*'([^']*)'/g;
+  let match;
+  while ((match = re.exec(text))) ids[match[1]] = match[2];
+  return ids;
+}
+
+function isRetryableResult(result) {
+  const message = `${result?.error || ''} ${result?.message || ''}`;
+  return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
+}
+
+function isRetryableMessage(message) {
+  return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function clamp(value, min, max) {
+  if (!Number.isFinite(value)) return min;
+  return Math.max(min, Math.min(max, Math.floor(value)));
+}
+
+function safeTimestamp(value) {
+  return String(value).replace(/[:.]/g, '-');
+}

+ 419 - 0
scripts/validation/ip-operator-scenario-seed.js

@@ -37,10 +37,247 @@
       createdAt: now,
       createdAt: now,
       summary: `Switch current IP profile: ${profileId}`,
       summary: `Switch current IP profile: ${profileId}`,
     }]));
     }]));
+    seedAccountWorkbench({ now, profileId, planId, scenario });
 
 
     return { scenario, profileId, planId };
     return { scenario, profileId, planId };
   }
   }
 
 
+  function seedAccountWorkbench({ now, profileId, planId, scenario }) {
+    const accountId = `ip_account_e2e_${scenario}`;
+    const snapshotId = `ip_snapshot_e2e_${scenario}`;
+    const proposalId = `ip_positioning_proposal_e2e_${scenario}`;
+    const versionId = `ip_positioning_version_e2e_${scenario}`;
+    const account = {
+      id: accountId,
+      userId: scope,
+      platform: 'douyin',
+      role: 'owned',
+      displayName: '林川老板增长笔记',
+      profileId,
+      planId,
+      homepageUrl: 'https://www.douyin.com/user/MS4wLjABAAAA_e2e',
+      secUserId: 'MS4wLjABAAAA_e2e',
+      accountId: 'MS4wLjABAAAA_e2e',
+      intendedTrack: '传统行业老板 IP',
+      intendedPersona: '增长顾问',
+      intendedAudience: '传统行业中小企业老板',
+      enabled: true,
+      lastRefreshStatus: 'completed',
+      lastRefreshedAt: now,
+      createdAt: now,
+      updatedAt: now,
+    };
+    const works = Array.from({ length: 10 }, (_, index) => {
+      const awemeId = `738000000${index}`;
+      const deep = index < 3;
+      return {
+        id: `work_${accountId}_${awemeId}`,
+        accountId,
+        awemeId,
+        title: index === 0 ? '老板 IP 如何避免成为广告号' : `老板增长内容复盘 ${index + 1}`,
+        desc: index === 0 ? '老板 IP 如何避免成为广告号,先讲信任再讲产品。' : `传统老板账号第 ${index + 1} 条内容复盘`,
+        coverUrl: index === 0 ? 'http://127.0.0.1:9/expired-douyin-cover.jpg' : '',
+        coverCandidates: index === 0
+          ? [
+              'http://127.0.0.1:9/expired-douyin-cover.jpg',
+              'data:image/svg+xml;charset=UTF-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2272%22 height=%2296%22%3E%3Crect width=%2272%22 height=%2296%22 fill=%22%230f766e%22/%3E%3C/svg%3E',
+            ]
+          : [],
+        publishTime: now,
+        url: `https://www.douyin.com/video/${awemeId}`,
+        metrics: {
+          playCount: 10000 - index * 600,
+          likeCount: 320 - index * 12,
+          commentCount: 46 - index * 3,
+          collectCount: 80 - index * 4,
+          shareCount: 20 - index,
+        },
+        interactionScore: 320 - index * 12 + (46 - index * 3) * 4 + (80 - index * 4) * 3 + (20 - index) * 5,
+        isDeepSampled: deep,
+        structure: {
+          hook: '为什么你的老板号越发越像广告?',
+          topic: '老板 IP 信任表达',
+          style: '问题/避坑表达',
+          cta: '评论区留下最卡的问题',
+        },
+        comments: deep ? Array.from({ length: 30 }, (_, commentIndex) => ({
+          id: `comment_${index + 1}_${commentIndex + 1}`,
+          workId: `work_${accountId}_${awemeId}`,
+          text: commentIndex % 3 === 0
+            ? '我们传统行业老板确实不知道怎么讲案例'
+            : commentIndex % 3 === 1
+              ? '想知道怎么把客户问题变成选题'
+              : '不要讲太虚,最好给具体脚本结构',
+          likeCount: 12 - (commentIndex % 5),
+          replyCount: commentIndex % 4,
+          authorName: `用户${commentIndex + 1}`,
+          capturedAt: now,
+        })) : [],
+        capturedAt: now,
+      };
+    });
+    const snapshot = {
+      id: snapshotId,
+      accountId,
+      profile: {
+        nickname: '林川老板增长笔记',
+        signature: '传统行业老板 IP 增长与短视频内容转化',
+        avatarUrl: '',
+        followerCount: 2860,
+        followingCount: 38,
+        totalFavorited: 15800,
+        awemeCount: 42,
+      },
+      works,
+      dataMode: 'data_diagnosis',
+      evidenceItemIds: [],
+      capturedAt: now,
+      warnings: [],
+    };
+    const proposedVersion = {
+      accountId,
+      targetAudience: '传统行业中小企业老板',
+      persona: '传统行业老板增长顾问',
+      followReason: '用真实案例和短视频结构帮助老板解决表达、信任和转化问题。',
+      contentPillars: ['老板 IP 定位与认知纠偏', '老板表达案例与完整方法', '真实项目复盘与评论答疑'],
+      expressionStyle: ['口语化', '案例优先', '前 3 秒直接给判断'],
+      boundaries: ['不夸大收益', '不照搬对标账号话术', '不做无关泛流量内容'],
+      suitableViralPatterns: ['反常识开头', '问题-方案结构', '评论问题反推选题'],
+      evidenceItemIds: [],
+      assumptionsToValidate: ['关注理由是否清晰', '评论痛点能否反推选题'],
+      observeMetrics: ['播放量', '评论数', '收藏数', '涨粉'],
+    };
+    const proposal = {
+      id: proposalId,
+      accountId,
+      snapshotId,
+      status: 'proposed',
+      reason: '基于最近作品表现、重点评论和对标证据生成的定位提案。',
+      proposedVersion,
+      evidenceItemIds: [],
+      createdAt: now,
+      updatedAt: now,
+    };
+    const diagnosis = {
+      id: `ip_account_diagnosis_e2e_${scenario}`,
+      accountId,
+      snapshotId,
+      mode: 'data_diagnosis',
+      reportMarkdown: [
+        '# 账号诊断报告',
+        '',
+        '## 当前账号像什么 IP',
+        '当前更像传统行业老板增长顾问,内容需要继续收窄到老板表达、信任和转化。',
+        '',
+        '## 高互动作品共性',
+        '问题开头、具体场景、明确判断。',
+        '',
+        '## 评论区用户关心的问题',
+        '用户集中关心如何讲案例、如何把客户问题变成选题。',
+        '',
+        '## 当前最大增长障碍',
+        '内容主题仍有分散,用户关注理由需要稳定。',
+        '',
+        '## 未来 7 天优先动作',
+        '确认定位版本,分别测试涨粉、信任、互动三个方向。',
+      ].join('\n'),
+      scores: {
+        positioningClarity: 82,
+        contentStructure: 78,
+        interactionConversion: 75,
+        recognizability: 80,
+        sustainability: 76,
+      },
+      evidenceItemIds: [],
+      topWorkIds: works.slice(0, 3).map((item) => item.id),
+      commentSampleCount: 90,
+      createdAt: now,
+      updatedAt: now,
+    };
+    const version = {
+      id: versionId,
+      ...proposedVersion,
+      version: 1,
+      status: 'active',
+      createdAt: now,
+      updatedAt: now,
+    };
+    const directions = [
+      createDirection(accountId, versionId, 'growth', '老板 IP 定位与认知纠偏', '围绕老板做内容时的常见误区和判断标准,帮助新用户快速建立正确认知。', ['播放量', '分享数', '新粉'], now),
+      createDirection(accountId, versionId, 'trust', '老板表达案例与完整方法', '通过真实案例、表达框架和执行过程建立专业信任。', ['收藏数', '咨询意向', '关注率'], now),
+      createDirection(accountId, versionId, 'interaction', '真实项目复盘与评论答疑', '把高互动评论归纳成问题类型,用项目复盘和集中答疑形成持续栏目。', ['评论数', '评论率', '高频问题'], now),
+    ];
+    const tasks = [
+      createTask(accountId, 'today', 'positioning', '确认账号当前定位提案', '定位版本会约束后续方向、选题、脚本和发布包。', 95, now),
+      createTask(accountId, 'today', 'direction', '确认涨粉破圈方向', '先稳定最影响播放和涨粉的内容方向。', 90, now),
+      createTask(accountId, 'this_week', 'pain', '复核重点作品评论痛点', '评论问题能反推互动型选题和脚本切入点。', 82, now),
+      createTask(accountId, 'later', 'publish_pack', '绑定发布包与真实作品', '发布后绑定作品才能形成复盘闭环。', 72, now),
+    ];
+    const binding = {
+      id: `publish_binding_${planId}_1`,
+      publishPackageId: `${planId}_publish_1`,
+      accountId,
+      awemeId: works[0].awemeId,
+      workUrl: works[0].url,
+      status: 'auto_matched',
+      matchScore: 86,
+      matchReason: '根据标题/文案相似度自动匹配,请人工复核。',
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    saveEntity('tiktok.ipOperator.accounts', 'tiktok.ipOperator.account.', account);
+    saveEntity('tiktok.ipOperator.accountSnapshots', 'tiktok.ipOperator.accountSnapshot.', snapshot);
+    saveEntity('tiktok.ipOperator.accountDiagnoses', 'tiktok.ipOperator.accountDiagnosis.', diagnosis);
+    saveEntity('tiktok.ipOperator.positioningProposals', 'tiktok.ipOperator.positioningProposal.', proposal);
+    saveEntity('tiktok.ipOperator.positioningVersions', 'tiktok.ipOperator.positioningVersion.', version);
+    directions.forEach((item) => saveEntity('tiktok.ipOperator.contentDirections', 'tiktok.ipOperator.contentDirection.', item));
+    tasks.forEach((item) => saveEntity('tiktok.ipOperator.operationTasks', 'tiktok.ipOperator.operationTask.', item));
+    saveEntity('tiktok.ipOperator.publishBindings', 'tiktok.ipOperator.publishBinding.', binding);
+  }
+
+  function createDirection(accountId, positioningVersionId, role, title, purpose, observeMetrics, now) {
+    return {
+      id: `direction_${accountId}_${role}`,
+      accountId,
+      positioningVersionId,
+      role,
+      title,
+      targetAudience: role === 'growth' ? '尚未关注账号的新用户' : role === 'trust' ? '正在判断账号是否可靠的用户' : '有具体问题并愿意评论追问的用户',
+      purpose,
+      sourceEvidenceIds: [],
+      viralPatternRefs: ['反常识开头', '问题-方案结构'],
+      commentPainRefs: [],
+      topicIds: [],
+      cadenceSuggestion: role === 'growth' ? '每周 2-3 条' : '每周 1-2 条',
+      observeMetrics,
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  function createTask(accountId, column, type, title, reason, growthImpactScore, now) {
+    return {
+      id: `task_${accountId}_${type}_${column}`,
+      accountId,
+      column,
+      type,
+      title,
+      reason,
+      growthImpactScore,
+      relatedEvidenceIds: [],
+      createdAt: now,
+      updatedAt: now,
+    };
+  }
+
+  function saveEntity(indexKey, prefix, entity) {
+    const scopedIndex = `${indexKey}.${scope}`;
+    const ids = JSON.parse(localStorage.getItem(scopedIndex) || '[]');
+    localStorage.setItem(scopedIndex, JSON.stringify(Array.from(new Set([entity.id, ...ids]))));
+    localStorage.setItem(`${prefix}${entity.id}.${scope}`, JSON.stringify(entity));
+  }
+
   function clearIpOperatorData() {
   function clearIpOperatorData() {
     for (const key of Object.keys(localStorage)) {
     for (const key of Object.keys(localStorage)) {
       if (
       if (
@@ -168,6 +405,16 @@
         materialDirections: ['Customer doubt list', 'Before-after cases', 'Service process screenshots'],
         materialDirections: ['Customer doubt list', 'Before-after cases', 'Service process screenshots'],
         iterationPath: ['Keep high-comment questions', 'Rewrite weak hooks', 'Promote proven topics'],
         iterationPath: ['Keep high-comment questions', 'Rewrite weak hooks', 'Promote proven topics'],
       },
       },
+      evidenceItems: [],
+      commentPainInsights: [],
+      accountStrategyReports: failed ? [] : createAccountStrategyReports({ scenario, planId, now }),
+      calibrationRecords: failed ? [] : createCalibrationRecords({ scenario, planId, now }),
+      publishRetrospectives: failed ? [] : createPublishRetrospectives({ scenario, planId, now }),
+      commentLabelSystems: [],
+      contentCalendar: failed ? [] : createContentCalendar({ planId, topics, now }),
+      publishPackages: failed ? [] : createPublishPackages({ planId, topics, scripts, now }),
+      retrospectiveFollowUpTopics: [],
+      matrixAccounts: [],
       missingInputs: needsInput ? [{ type: 'case', description: 'Add real client before-after cases', priority: 'high' }] : [],
       missingInputs: needsInput ? [{ type: 'case', description: 'Add real client before-after cases', priority: 'high' }] : [],
       errorMessage: failed ? 'Generation stopped because diagnosis JSON was malformed.' : undefined,
       errorMessage: failed ? 'Generation stopped because diagnosis JSON was malformed.' : undefined,
       createdAt: now,
       createdAt: now,
@@ -175,6 +422,98 @@
     };
     };
   }
   }
 
 
+  function createAccountStrategyReports({ scenario, planId, now }) {
+    const accountId = `ip_account_e2e_${scenario}`;
+    const snapshotId = `ip_snapshot_e2e_${scenario}`;
+    const workId = `work_${accountId}_7380000000`;
+    return [{
+      id: `ip_strategy_report_e2e_${scenario}`,
+      accountId,
+      snapshotId,
+      positioningProposalId: `ip_positioning_proposal_e2e_${scenario}`,
+      sourceMode: 'llm',
+      sourceLabel: 'LLM 证据诊断',
+      diagnosisSummary: {
+        accountSnapshot: '账号已读取最近作品和重点评论样本。',
+        currentIpGuess: '传统行业老板增长顾问',
+        actualAudience: '传统行业中小企业老板',
+        positioningMismatch: '需要减少泛工具话题,强化经营问题和真实案例。',
+        strongestFollowReason: '能把老板经营问题拆成短视频表达和执行步骤。',
+        biggestGrowthBlocker: '定位校准和发布复盘还需要持续继承。',
+        highInteractionPattern: '先指出老板号误区,再给可执行步骤。',
+        commentPainSummary: '用户集中关心案例怎么讲、问题怎么变选题。',
+        sevenDayPriority: '围绕经营问题继续测试 3 条内容。',
+      },
+      evidenceRefs: [{
+        id: `strategy_ref_${scenario}_work_1`,
+        sourceType: 'owned_work',
+        sourceId: workId,
+        label: '高互动经营问题作品',
+        quote: '收藏和评论集中说明用户需要可执行的老板号内容顺序。',
+        reason: '支持继续测试“经营问题拆解”方向,而不是复制原作品简介。',
+        confidence: 'medium',
+        gaps: ['仍需发布后转化表现'],
+        analyzed: true,
+      }],
+      directions: [],
+      taskSuggestions: [],
+      appliedCalibrationIds: [`calibration_${planId}_1`],
+      appliedCalibrationSummary: ['已采用用户校准:减少纯工具合集,强调老板经营问题。'],
+      confidenceLevel: 'medium',
+      evidenceGaps: ['仍缺发布后复盘数据的长期对比'],
+      dataScopeSummary: {
+        workCount: 10,
+        commentCount: 90,
+        hasPositioningVersion: true,
+        calibrationCount: 1,
+        publishBindingCount: 1,
+      },
+      createdAt: now,
+      updatedAt: now,
+    }];
+  }
+
+  function createCalibrationRecords({ scenario, planId, now }) {
+    const accountId = `ip_account_e2e_${scenario}`;
+    return [{
+      id: `calibration_${planId}_1`,
+      accountId,
+      reportId: `ip_strategy_report_e2e_${scenario}`,
+      source: 'manual_calibration',
+      accuracy: 'partial',
+      targetAudienceNotes: '更偏传统行业中小企业老板。',
+      positioningNotes: '减少纯工具合集,强调老板经营问题。',
+      personaNotes: '懂业务的增长顾问。',
+      forbiddenTopics: ['纯工具合集'],
+      forbiddenExpressions: ['保姆级万能'],
+      confirmedDirectionIds: [`direction_${accountId}_trust`],
+      rejectedDirectionIds: [],
+      rejectedEvidenceIds: [],
+      operatorExperienceNotes: '历史内容里案例拆解更容易带来收藏和有效评论。',
+      createdAt: now,
+      updatedAt: now,
+    }];
+  }
+
+  function createPublishRetrospectives({ scenario, planId, now }) {
+    const accountId = `ip_account_e2e_${scenario}`;
+    return [{
+      id: `publish_review_${planId}_1`,
+      accountId,
+      publishPackageId: `${planId}_publish_1`,
+      bindingId: `publish_binding_${planId}_1`,
+      workId: `work_${accountId}_7380000000`,
+      outcome: 'met_expectation',
+      expectedGoal: ['验证信任表达是否能带来评论和关注'],
+      actualSignals: ['收藏和评论达到预期', '评论集中在案例怎么讲'],
+      diagnosis: '信任表达方向可继续测试,但需要增加真实案例材料。',
+      nextAction: 'continue',
+      calibrationRecordId: `calibration_${planId}_1`,
+      createdAt: now,
+      updatedAt: now,
+    }];
+  }
+
   function createTopics() {
   function createTopics() {
     return Array.from({ length: 12 }, (_, index) => ({
     return Array.from({ length: 12 }, (_, index) => ({
       id: `ip_topic_e2e_${index + 1}`,
       id: `ip_topic_e2e_${index + 1}`,
@@ -183,6 +522,7 @@
       trafficLayer: index < 4 ? 'vertical' : index < 8 ? 'conversion' : 'broad',
       trafficLayer: index < 4 ? 'vertical' : index < 8 ? 'conversion' : 'broad',
       contentGoal: 'Help the target user understand the trust problem and next action',
       contentGoal: 'Help the target user understand the trust problem and next action',
       priority: index < 4 ? 'high' : index < 9 ? 'medium' : 'low',
       priority: index < 4 ? 'high' : index < 9 ? 'medium' : 'low',
+      qualityLevel: index < 4 ? 'make_now' : index < 8 ? 'polish_first' : 'needs_material',
       fitReason: 'Derived from benchmark hooks and adapted to this IP positioning',
       fitReason: 'Derived from benchmark hooks and adapted to this IP positioning',
       source: 'Benchmark migration',
       source: 'Benchmark migration',
       requiredMaterials: ['Real case', 'Service before-after comparison'],
       requiredMaterials: ['Real case', 'Service before-after comparison'],
@@ -213,5 +553,84 @@
     }));
     }));
   }
   }
 
 
+  function createContentCalendar({ planId, topics, now }) {
+    return [{
+      id: `${planId}_calendar_1`,
+      planId,
+      topicId: topics[0]?.id,
+      scriptId: 'ip_script_e2e_1',
+      date: now.slice(0, 10),
+      timeSlot: '19:30',
+      platform: 'douyin',
+      accountName: '林川老板增长笔记',
+      pillar: '信任建立内容',
+      title: '老板 IP 如何避免成为广告号',
+      contentGoal: '验证信任表达是否能带来评论和关注',
+      materialNeeds: ['真实账号截图', '客户问题截图'],
+      evidenceItemIds: [],
+      painInsightIds: [],
+      status: 'ready_for_review',
+      publishPackageId: `${planId}_publish_1`,
+      createdAt: now,
+      updatedAt: now,
+    }];
+  }
+
+  function createPublishPackages({ planId, topics, scripts, now }) {
+    return [{
+      id: `${planId}_publish_1`,
+      calendarItemId: `${planId}_calendar_1`,
+      topicId: topics[0]?.id || '',
+      scriptId: scripts[0]?.id || '',
+      platform: 'douyin',
+      accountName: '林川老板增长笔记',
+      titleOptions: ['老板 IP 如何避免成为广告号', '为什么你的老板号越发越不被信任'],
+      coverSuggestions: ['左侧:广告号,右侧:信任号', '老板别再只讲产品'],
+      caption: '老板 IP 如何避免成为广告号,先讲信任再讲产品。',
+      scriptText: scripts[0]?.fullScript || scripts[0]?.hook || '',
+      hashtags: ['老板IP', '短视频运营', '账号定位'],
+      materialFiles: [
+        { id: 'material_1', name: '账号主页截图', status: 'missing' },
+        { id: 'material_2', name: '客户问题截图', status: 'missing' },
+      ],
+      scheduleSuggestion: '今天 19:30 人工发布,发布后回填作品链接。',
+      isOriginal: true,
+      isDraft: true,
+      materialChecklist: [
+        { id: 'material_check_1', label: '确认案例素材可公开', checked: false, required: true },
+        { id: 'material_check_2', label: '检查封面文字不夸张', checked: true, required: true },
+      ],
+      riskChecklist: [
+        { id: 'risk_check_1', label: '不承诺具体收益', checked: true, required: true },
+        { id: 'risk_check_2', label: '不使用诱导互动话术', checked: true, required: true },
+      ],
+      platformVariants: {
+        xiaohongshu: {
+          title: '老板 IP 别再拍成广告号',
+          coverText: '先讲信任,再讲产品',
+          imageNoteOutline: ['问题', '原因', '改法'],
+          hashtags: ['老板IP', '内容运营'],
+        },
+        douyin: {
+          firstThreeSecondsHook: '为什么你的老板号越发越像广告?',
+          shotList: ['正面口播', '账号截图', '标题对比'],
+          spokenScript: scripts[0]?.fullScript || '',
+          subtitleEmphasis: ['别急着卖产品', '先给关注理由'],
+        },
+        wechat: {
+          calmerTitle: '老板内容如何建立信任',
+          intro: '很多老板号的问题不是不专业,而是太像广告。',
+          body: '先讲清楚用户问题,再放服务能力。',
+          endingCta: '欢迎把你的账号问题发来一起看。',
+        },
+      },
+      evidenceItemIds: [],
+      painInsightIds: [],
+      status: 'ready_for_review',
+      createdAt: now,
+      updatedAt: now,
+    }];
+  }
+
   window.__seedIpOperatorScenario = seedIpOperatorScenario;
   window.__seedIpOperatorScenario = seedIpOperatorScenario;
 })();
 })();

+ 2 - 2
scripts/validation/jimeng-billing-policy.mjs

@@ -1,6 +1,6 @@
 import assert from 'node:assert/strict';
 import assert from 'node:assert/strict';
 
 
-const APIG_ID = '6pF6EAdKT';
+const APIG_ID = '6pFf6EAdKT';
 const APIG_PRICE_CNY = 0.1;
 const APIG_PRICE_CNY = 0.1;
 
 
 const BILLABLE_ENDPOINTS = {
 const BILLABLE_ENDPOINTS = {
@@ -47,7 +47,7 @@ function assertNonBillingEndpoint(endpoint) {
 }
 }
 
 
 function main() {
 function main() {
-  assert.equal(APIG_ID, '6pF6EAdKT', 'APIG id must remain 6pF6EAdKT');
+  assert.equal(APIG_ID, '6pFf6EAdKT', 'APIG id must remain 6pFf6EAdKT');
   assert.equal(APIG_PRICE_CNY, 0.1, 'APIG price must remain 0.1 CNY per credit');
   assert.equal(APIG_PRICE_CNY, 0.1, 'APIG price must remain 0.1 CNY per credit');
 
 
   assertBillingCase('getImgV4', 1, 3);
   assertBillingCase('getImgV4', 1, 3);

+ 229 - 0
scripts/validation/videoworkflow-parse-cleanup.mjs

@@ -0,0 +1,229 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const PARSE_HOST = normalizeParseHost(process.env.SMOKE_PARSE_HOST || 'https://server.fmode.cn');
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
+const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
+const CONFIRM_TEXT = 'DELETE_VIDEO_WORKFLOW_TEST_DATA';
+const CONFIRM = readArg('--confirm') || process.env.VIDEO_WORKFLOW_CLEANUP_CONFIRM || '';
+const EXECUTE = CONFIRM === CONFIRM_TEXT;
+const CONCURRENCY = clamp(Number(readArg('--concurrency') || process.env.VIDEO_WORKFLOW_CLEANUP_CONCURRENCY || 2), 1, 4);
+const OUTPUT_DIR = process.env.VIDEO_WORKFLOW_CLEANUP_OUTPUT_DIR
+  ? path.resolve(process.env.VIDEO_WORKFLOW_CLEANUP_OUTPUT_DIR)
+  : path.join(process.cwd(), 'tmp');
+
+const PROJECT_KEY = 'video-workflow';
+const ALLOWED_CLASSES = [
+  'VideoWorkflowEntity',
+  'VideoWorkflowAudit',
+  'VideoWorkflowMigration',
+  'VideoWorkflowFileAsset',
+];
+const requestedClasses = parseRequestedClasses(readArg('--classes') || process.env.VIDEO_WORKFLOW_CLEANUP_CLASSES || '');
+const targetClasses = requestedClasses.length ? requestedClasses : ALLOWED_CLASSES;
+
+if (!SESSION_TOKEN) {
+  console.error('Missing SMOKE_SESSION_TOKEN. Refusing to inspect or delete Parse data.');
+  process.exit(1);
+}
+
+const invalidClasses = targetClasses.filter((className) => !ALLOWED_CLASSES.includes(className));
+if (invalidClasses.length) {
+  console.error(`Refusing to continue. Classes outside allowlist: ${invalidClasses.join(', ')}`);
+  console.error(`Allowed classes: ${ALLOWED_CLASSES.join(', ')}`);
+  process.exit(1);
+}
+
+const startedAt = new Date().toISOString();
+const me = await parseRequest('GET', '/users/me');
+const ownerId = me?.objectId || '';
+if (!ownerId) {
+  console.error('Could not resolve current Parse user from SMOKE_SESSION_TOKEN.');
+  process.exit(1);
+}
+
+const rowsByClass = {};
+for (const className of targetClasses) {
+  rowsByClass[className] = await listRows(className, ownerId);
+}
+
+const allTargets = Object.entries(rowsByClass).flatMap(([className, rows]) =>
+  rows.map((row) => ({
+    className,
+    objectId: row.objectId,
+    ownerId: row.ownerId || '',
+    projectKey: row.projectKey || '',
+    entityType: row.entityType || '',
+    entityId: row.entityId || '',
+    status: row.status || '',
+    createdAt: row.createdAt || '',
+    updatedAt: row.updatedAt || '',
+  })),
+);
+
+console.log(`VideoWorkflow Parse cleanup generatedAt=${startedAt}`);
+console.log(`mode=${EXECUTE ? 'EXECUTE_DELETE' : 'DRY_RUN_ONLY'}`);
+console.log(`ownerId=${ownerId}`);
+console.log(`classes=${targetClasses.join(', ')}`);
+console.log('');
+console.log('Rows by class:');
+for (const className of targetClasses) {
+  console.log(`- ${className}: ${rowsByClass[className].length}`);
+}
+console.log('');
+console.log(`Total target rows: ${allTargets.length}`);
+
+let deleteResults = [];
+if (!EXECUTE) {
+  console.log('');
+  console.log('No data was deleted.');
+  console.log(`To execute physical delete, rerun with: --confirm ${CONFIRM_TEXT}`);
+} else {
+  deleteResults = await deleteTargets(allTargets);
+}
+
+const completedAt = new Date().toISOString();
+const failures = deleteResults.filter((item) => !item.ok);
+const report = {
+  startedAt,
+  completedAt,
+  mode: EXECUTE ? 'execute' : 'dry-run',
+  ownerId,
+  allowedClasses: ALLOWED_CLASSES,
+  targetClasses,
+  totalTargets: allTargets.length,
+  countsByClass: Object.fromEntries(targetClasses.map((className) => [className, rowsByClass[className].length])),
+  deleteAttempted: deleteResults.length,
+  deleteSuccess: deleteResults.filter((item) => item.ok).length,
+  deleteFailures: failures.length,
+  targets: allTargets,
+  deleteResults,
+};
+
+fs.mkdirSync(OUTPUT_DIR, { recursive: true });
+const reportPath = path.join(
+  OUTPUT_DIR,
+  `videoworkflow-parse-cleanup-${EXECUTE ? 'delete' : 'dry-run'}-${safeTimestamp(completedAt)}.json`,
+);
+fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
+
+console.log('');
+console.log(`Cleanup ${EXECUTE ? 'execute' : 'dry-run'} complete.`);
+console.log(`Report: ${reportPath}`);
+if (failures.length) process.exit(1);
+
+async function listRows(className, ownerId) {
+  const output = [];
+  const pageSize = 1000;
+  for (let skip = 0; ; skip += pageSize) {
+    const query = new URLSearchParams();
+    query.set('where', JSON.stringify({ projectKey: PROJECT_KEY, ownerId }));
+    query.set('limit', String(pageSize));
+    query.set('skip', String(skip));
+    query.set('order', 'createdAt');
+    query.set('keys', 'objectId,projectKey,ownerId,entityType,entityId,status,createdAt,updatedAt');
+    const data = await parseRequest('GET', `/classes/${encodeURIComponent(className)}?${query.toString()}`);
+    const rows = Array.isArray(data.results) ? data.results : [];
+    output.push(...rows);
+    if (rows.length < pageSize) break;
+  }
+  return output;
+}
+
+async function deleteTargets(targets) {
+  const results = [];
+  let index = 0;
+  await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
+    while (index < targets.length) {
+      const currentIndex = index;
+      index += 1;
+      const target = targets[currentIndex];
+      results[currentIndex] = await deleteTarget(target);
+      if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === targets.length) {
+        console.log(`delete progress ${currentIndex + 1}/${targets.length}`);
+      }
+    }
+  }));
+  const failures = results.filter((item) => !item.ok);
+  console.log('');
+  console.log(`Delete complete. success=${results.length - failures.length} failures=${failures.length}`);
+  return results;
+}
+
+async function deleteTarget(target) {
+  try {
+    await parseRequest('DELETE', `/classes/${encodeURIComponent(target.className)}/${encodeURIComponent(target.objectId)}`);
+    return { ...target, ok: true, error: '' };
+  } catch (error) {
+    return { ...target, ok: false, error: error?.message || 'delete failed' };
+  }
+}
+
+async function parseRequest(method, parsePath) {
+  const headers = {
+    Accept: 'application/json',
+    'X-Parse-Application-Id': APP_ID,
+    'X-Parse-Session-Token': SESSION_TOKEN,
+  };
+  let lastError = null;
+  for (let attempt = 1; attempt <= 4; attempt += 1) {
+    try {
+      const response = await fetch(`${PARSE_HOST}/parse${parsePath}`, { method, headers });
+      const data = await response.json().catch(() => ({}));
+      if (!response.ok || data.error) {
+        const error = new Error(data.error || data.message || `Parse ${method} ${parsePath} failed`);
+        error.status = response.status || 500;
+        error.detail = data;
+        if (attempt < 4 && isRetryableError(error)) {
+          await sleep(500 * attempt);
+          continue;
+        }
+        throw error;
+      }
+      return data;
+    } catch (error) {
+      lastError = error;
+      if (attempt < 4 && isRetryableError(error)) {
+        await sleep(500 * attempt);
+        continue;
+      }
+      throw error;
+    }
+  }
+  throw lastError || new Error(`Parse ${method} ${parsePath} failed`);
+}
+
+function parseRequestedClasses(value) {
+  return String(value || '')
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean);
+}
+
+function readArg(name) {
+  const index = process.argv.indexOf(name);
+  return index >= 0 ? process.argv[index + 1] || '' : '';
+}
+
+function normalizeParseHost(value) {
+  return String(value || 'https://server.fmode.cn').replace(/\/+$/, '').replace(/\/parse$/i, '');
+}
+
+function isRetryableError(error) {
+  const status = Number(error?.status || 0);
+  const message = `${error?.message || ''} ${error?.cause?.code || ''} ${error?.detail?.error || ''}`;
+  return status >= 500 || /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
+}
+
+function clamp(value, min, max) {
+  if (!Number.isFinite(value)) return min;
+  return Math.max(min, Math.min(max, Math.floor(value)));
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function safeTimestamp(value) {
+  return String(value).replace(/[:.]/g, '-');
+}