From a5f82bd0aa2b4cea46397ff4ecd2e5d26e02eb10 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 23 Mar 2026 09:59:22 +0800 Subject: [PATCH] feat: lock live recorder ui to tenant proxy --- web/storyforge-web-v4/README.md | 5 ++ web/storyforge-web-v4/assets/app.js | 129 +++++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/web/storyforge-web-v4/README.md b/web/storyforge-web-v4/README.md index 7db353d..1e4b06b 100644 --- a/web/storyforge-web-v4/README.md +++ b/web/storyforge-web-v4/README.md @@ -68,6 +68,10 @@ - 在生产中心 / 发布与复盘常驻最近一次任务详情摘要 - 在 Web 中直接创建和编辑复盘 - 在页面里直接看到 `本机模型 / cutvideo / huobao / n8n / ASR` 的真实健康状态 +- 直播录制已切成租户隔离模式: + - 录制源按当前账号和项目归属保存 + - 录像文件只通过当前租户的后端代理访问 + - 前端不再直接暴露 NAS 全局配置和下载根地址 - 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404 - 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因 - 使用 Agent 生成文案 @@ -99,5 +103,6 @@ python3 -m http.server 3918 - 把跟踪日报从 Douyin 扩到多平台统一模型,并接入真正的定时调度 - 把全局搜索和页内搜索合并成统一搜索体验 - 为 `生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象 +- 如果后续要开放外网多租户录像访问,继续沿用 collector 的鉴权代理,不要把 NAS 下载目录直接暴露给浏览器 - 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs` - 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index be1836c..df59abb 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -24,6 +24,9 @@ const appState = { trackingAccounts: [], trackingDigest: null, reviews: [], + liveRecorderSources: [], + liveRecorderStatus: null, + liveRecorderFiles: [], integrationHealth: null, localModelCatalog: null, backendCapabilities: null, @@ -669,6 +672,30 @@ async function storyforgeFetch(path, options = {}) { return payload; } +async function storyforgeFetchBlob(path, options = {}) { + const backendUrl = (options.backendUrl || appState.session?.backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, ""); + const headers = { ...(options.headers || {}) }; + const useAuth = options.auth !== false; + const token = options.token || appState.session?.token; + if (useAuth && token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(`${backendUrl}${path}`, { + method: options.method || "GET", + headers, + body: options.body, + cache: "no-store" + }); + if (!response.ok) { + const payload = (response.headers.get("content-type") || "").includes("application/json") + ? await response.json().catch(() => null) + : await response.text().catch(() => ""); + const detail = typeof payload === "object" && payload + ? payload.detail || payload.message || JSON.stringify(payload) + : String(payload || response.statusText); + throw new Error(detail); + } + return response.blob(); +} + async function loadBackendCapabilities(backendUrl) { const normalizedUrl = (backendUrl || DEFAULT_BACKEND_URL).replace(/\/$/, ""); const response = await fetch(`${normalizedUrl}/openapi.json`); @@ -825,14 +852,20 @@ async function bootstrap() { const supportsReviews = backendSupports("/v2/reviews"); const supportsIntegrationHealth = backendSupports("/v2/integrations/health"); const supportsLocalModels = backendSupports("/v2/integrations/local-models"); - const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([ + const supportsLiveRecorderSources = backendSupports("/v2/live-recorder/sources"); + const supportsLiveRecorderStatus = backendSupports("/v2/live-recorder/status"); + const supportsLiveRecorderFiles = backendSupports("/v2/live-recorder/files"); + const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog, liveRecorderSourcesPayload, liveRecorderStatus, liveRecorderFilesPayload] = await Promise.all([ storyforgeFetch("/v2/me/dashboard"), storyforgeFetch("/v2/content-sources").catch(() => []), accountListPath ? storyforgeFetch(accountListPath).catch(() => []) : Promise.resolve([]), trackingAccountsPath ? storyforgeFetch(trackingAccountsPath).catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }), supportsReviews ? storyforgeFetch("/v2/reviews").catch(() => []) : Promise.resolve([]), supportsIntegrationHealth ? storyforgeFetch("/v2/integrations/health").catch(() => null) : Promise.resolve(null), - supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null) + supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null), + supportsLiveRecorderSources ? storyforgeFetch("/v2/live-recorder/sources").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), + supportsLiveRecorderStatus ? storyforgeFetch("/v2/live-recorder/status").catch(() => null) : Promise.resolve(null), + supportsLiveRecorderFiles ? storyforgeFetch("/v2/live-recorder/files?limit=16").catch(() => ({ items: [] })) : Promise.resolve({ items: [] }) ]); const trackingCursorLastSeenAt = trackingAccountsPayload?.cursor_last_seen_at || ""; if (trackingCursorLastSeenAt) { @@ -856,6 +889,9 @@ async function bootstrap() { appState.trackingAccounts = safeArray(trackingAccountsPayload.items || trackingAccountsPayload); appState.trackingDigest = trackingDigest; appState.reviews = safeArray(reviews); + appState.liveRecorderSources = safeArray(liveRecorderSourcesPayload?.items || liveRecorderSourcesPayload); + appState.liveRecorderStatus = liveRecorderStatus; + appState.liveRecorderFiles = safeArray(liveRecorderFilesPayload?.items || liveRecorderFilesPayload); appState.integrationHealth = integrationHealth; appState.localModelCatalog = localModelCatalog; appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases); @@ -1261,7 +1297,12 @@ function getIntegrationCards() { ].filter(Boolean).join(""); } if (key === "live_recorder") { - extra = detail.baseUrl ? `服务地址:${detail.baseUrl}` : "当前未配置 NAS 录制服务"; + const ownedSources = safeArray(appState.liveRecorderSources); + const ownedFiles = safeArray(appState.liveRecorderFiles); + const activeCount = Number(appState.liveRecorderStatus?.recording_count || 0); + extra = ownedSources.length + ? `我的录制源 ${ownedSources.length} · 录像 ${ownedFiles.length} · 正在录制 ${activeCount}` + : "当前还没有你的录制源"; actions = `录制控制`; } return { @@ -1276,6 +1317,41 @@ function getIntegrationCards() { }); } +function renderLiveRecorderSummaryHtml() { + const sources = safeArray(appState.liveRecorderSources); + const files = safeArray(appState.liveRecorderFiles); + const status = appState.liveRecorderStatus || {}; + const activeItems = safeArray(status.active_recordings); + const sourceHtml = sources.slice(0, 4).map((item) => ` +
+

${escapeHtml(item.title || item.remote_name || item.source_url || "录制源")}

+

${escapeHtml(platformLabel(item.platform))} · ${escapeHtml(item.quality || "原画")} · ${escapeHtml(item.enabled ? "启用中" : "已停用")}

+
+ `).join(""); + const fileHtml = files.slice(0, 4).map((item) => ` +
+

${escapeHtml(item.title || item.name || "录像文件")}

+

${escapeHtml(item.mtime || "-")} · ${escapeHtml(item.name || item.relative_path || "-")}

+
+ 打开录像 +
+
+ `).join(""); + return ` +
+

租户隔离状态

+

当前只展示你自己名下的录制源、活动录制和录像文件。全局 NAS 配置不会直接暴露给前端。

+
+ ${escapeHtml(`录制源 ${sources.length}`)} + ${escapeHtml(`活动 ${activeItems.length}`)} + ${escapeHtml(`文件 ${files.length}`)} +
+
+ ${sourceHtml || `

还没有录制源

新增直播源后会自动挂到你的租户空间下。

`} + ${fileHtml || `

还没有录像文件

录制完成后的文件会只出现在你的当前租户视图里。

`} + `; +} + function getIntegrationOverview() { const cards = getIntegrationCards(); const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length; @@ -1508,7 +1584,7 @@ function renderIntegrationOverviewPanel(options = {}) {
${escapeHtml(item.note)}
${item.extra ? `
${escapeHtml(item.extra)}
` : ""} -
${escapeHtml(item.detail.url || item.detail.baseUrl || "未提供探测地址")}
+
${escapeHtml(item.key === "live_recorder" ? "仅通过当前租户的后端代理访问" : (item.detail.url || item.detail.baseUrl || "未提供探测地址"))}
${item.actions ? `
${item.actions}
` : ""} `).join("")} @@ -3348,21 +3424,37 @@ function openCreateRealCutAction(defaults = {}) { function openLiveRecorderAction() { const status = getIntegrationDetail("live_recorder"); + const project = getSelectedProject() || appState.dashboard?.projects?.[0] || null; + const assistants = getAssistantOptions(project?.id || ""); openActionModal({ title: "直播录制控制", description: status.reachable - ? "把直播间链接导入到 NAS 录制服务,必要时直接触发开始录制。" + ? "新增的是你当前租户名下的录制源。文件访问和录制状态也只会回到你的账号视图里。" : "当前 NAS 录制服务不可达,先检查集成健康。", - submitLabel: "提交到录制服务", + submitLabel: "保存录制源", fields: [ - { name: "raw", label: "直播源", type: "textarea", rows: 4, value: "原画,https://live.kuaishou.com/u/storyforge_anchor", placeholder: "一行一条,例如:原画,https://live.kuaishou.com/u/anchor" }, + { type: "html", label: "当前租户", html: renderLiveRecorderSummaryHtml() }, + { name: "projectId", label: "归属项目", type: "select", value: project?.id || "", options: getProjectOptions() }, + { name: "assistantId", label: "关联 Agent", type: "select", value: assistants[0]?.value || "", options: [{ value: "", label: "暂不绑定" }, ...assistants] }, + { name: "platform", label: "平台", type: "select", value: "kuaishou", options: getPlatformOptions() }, + { name: "title", label: "录制名称", placeholder: "例如:A 类目直播跟踪" }, + { name: "quality", label: "清晰度", type: "select", value: "原画", options: ["原画", "蓝光", "超清", "高清", "标清", "流畅"].map((item) => ({ value: item, label: item })) }, + { name: "sourceUrl", label: "直播源", type: "url", placeholder: "https://..." }, { name: "autoStart", label: "导入后立即开始", type: "checkbox", value: true } ], onSubmit: async (values) => { - if (!values.raw?.trim()) throw new Error("请填写直播源链接"); - const imported = await storyforgeFetch("/v2/live-recorder/url-config/import", { + if (!values.sourceUrl?.trim()) throw new Error("请填写直播源链接"); + const saved = await storyforgeFetch("/v2/live-recorder/sources", { method: "POST", - body: { raw: values.raw.trim() } + body: { + project_id: values.projectId || project?.id || "", + assistant_id: values.assistantId || "", + platform: normalizePlatformValue(values.platform, "kuaishou"), + source_url: values.sourceUrl.trim(), + title: values.title || "", + quality: values.quality || "原画", + enabled: true + } }); let started = null; if (values.autoStart) { @@ -3372,12 +3464,23 @@ function openLiveRecorderAction() { started = { ok: false, message: error.message }; } } - rememberAction("直播录制已下发", "NAS 录制服务已接收最新直播源。", "green", { imported, started }); + rememberAction("直播录制已下发", "当前租户的直播源已经保存到服务端并同步到 NAS。", "green", { saved, started }); await bootstrap(); } }); } +async function openLiveRecorderFileAction(fileId) { + const target = safeArray(appState.liveRecorderFiles).find((item) => item.id === fileId); + if (!target?.content_url) { + throw new Error("当前录像文件不存在,可能已经被移除"); + } + const blob = await storyforgeFetchBlob(target.content_url); + const blobUrl = URL.createObjectURL(blob); + window.open(blobUrl, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); +} + function openReviewAction(defaults = {}) { const project = requireSelectedProject(); const assistants = getAssistantOptions(project.id); @@ -3498,6 +3601,10 @@ document.addEventListener("click", async (event) => { openLiveRecorderAction(); return; } + if (name === "open-live-recorder-file") { + await openLiveRecorderFileAction(action.dataset.fileId || ""); + return; + } if (name === "mark-tracking-read") { await markTrackingDigestRead(); rememberAction("日报已标记", "当前跟踪摘要已更新为已读,下次会从新的时间点继续汇总。", "green");