From ac6a8a82df6b9f94548abd21d039a57c75daed4b Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 20 Mar 2026 14:10:30 +0800 Subject: [PATCH] feat: add account sync entry and cleanup legacy runtime --- .../app/storyforge/StoryForgeRepository.kt | 26 ++++++ .../app/storyforge/StoryForgeScreen.kt | 89 +++++++++++++++++++ .../app/storyforge/StoryForgeViewModel.kt | 74 +++++++++++++++ deploy/cleanup_legacy_fastgpt_runtime.sh | 65 ++++++++++++++ docs/AUDIT_2026-03-18.md | 4 +- docs/LAN_E2E_GUIDE_2026-03-18.md | 15 ++++ docs/MVP_STATUS_2026-03-18.md | 3 + 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100755 deploy/cleanup_legacy_fastgpt_runtime.sh diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt index 0b902b2..bc54d9e 100644 --- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt +++ b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeRepository.kt @@ -147,6 +147,32 @@ class StoryForgeRepository(private val context: Context) { ) ) + suspend fun createContentSourceSyncJob( + platform: String, + handle: String, + sourceUrl: String, + title: String, + knowledgeBaseId: String, + assistantId: String, + analysisModelProfileId: String, + maxItems: Int, + skipExisting: Boolean, + autoTriggerAnalysis: Boolean + ): JobDto = api().createContentSourceSyncJob( + ContentSourceSyncRequest( + knowledge_base_id = knowledgeBaseId, + assistant_id = assistantId, + platform = platform, + handle = handle, + source_url = sourceUrl, + title = title, + analysis_model_profile_id = analysisModelProfileId, + max_items = maxItems, + skip_existing = skipExisting, + auto_trigger_analysis = autoTriggerAnalysis + ) + ) + suspend fun uploadVideo( uri: Uri, title: String, diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt index 45068dc..c2dd881 100644 --- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt +++ b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeScreen.kt @@ -293,6 +293,7 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") { ChoiceRow( options = listOf( + "账号同步" to (state.exploreInputMode == ExploreInputMode.ContentSource), "视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink), "上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo), "输入文字" to (state.exploreInputMode == ExploreInputMode.Text) @@ -300,6 +301,7 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick onSelect = { label -> vm.setExploreInputMode( when (label) { + "账号同步" -> ExploreInputMode.ContentSource "视频链接" -> ExploreInputMode.VideoLink "上传视频" -> ExploreInputMode.UploadVideo else -> ExploreInputMode.Text @@ -319,6 +321,93 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick ) Spacer(modifier = Modifier.height(12.dp)) when (state.exploreInputMode) { + ExploreInputMode.ContentSource -> { + Text( + text = "适合导入抖音、B 站、小红书创作者账号主页。抖音 public 页面抓不到时,也可以把分享页链接和账号标识手工填进来。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(10.dp)) + ChoiceRow( + options = listOf( + "抖音" to (state.accountSyncPlatform == "抖音"), + "B站" to (state.accountSyncPlatform == "bilibili"), + "小红书" to (state.accountSyncPlatform == "小红书") + ), + onSelect = { label -> + vm.updateAccountSyncPlatform( + when (label) { + "B站" -> "bilibili" + else -> label + } + ) + } + ) + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + value = state.accountSyncUrl, + onValueChange = vm::updateAccountSyncUrl, + modifier = Modifier.fillMaxWidth(), + label = { Text("账号主页或分享页链接") }, + minLines = 2 + ) + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + value = state.accountSyncHandle, + onValueChange = vm::updateAccountSyncHandle, + modifier = Modifier.fillMaxWidth(), + label = { Text("账号标识(可选)") }, + singleLine = true + ) + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + value = state.accountSyncTitle, + onValueChange = vm::updateAccountSyncTitle, + modifier = Modifier.fillMaxWidth(), + label = { Text("任务标题(可选)") }, + singleLine = true + ) + Spacer(modifier = Modifier.height(10.dp)) + OutlinedTextField( + value = state.accountSyncMaxItems, + onValueChange = vm::updateAccountSyncMaxItems, + modifier = Modifier.fillMaxWidth(), + label = { Text("最多拉取视频数(1-20)") }, + singleLine = true + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("跳过已存在视频", style = MaterialTheme.typography.bodySmall) + Switch( + checked = state.accountSyncSkipExisting, + onCheckedChange = vm::setAccountSyncSkipExisting + ) + } + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("自动触发分析", style = MaterialTheme.typography.bodySmall) + Switch( + checked = state.accountSyncAutoTriggerAnalysis, + onCheckedChange = vm::setAccountSyncAutoTriggerAnalysis + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + Button(onClick = vm::submitContentSourceSync, enabled = !state.busy) { + Text("同步账号内容") + } + } ExploreInputMode.VideoLink -> { OutlinedTextField( value = state.videoUrl, diff --git a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt index ebb7c6b..ce60245 100644 --- a/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt +++ b/android-app/app/src/main/java/com/aiglasses/app/storyforge/StoryForgeViewModel.kt @@ -26,6 +26,7 @@ enum class StoryForgeAuthMode { } enum class ExploreInputMode { + ContentSource, VideoLink, UploadVideo, Text @@ -72,6 +73,13 @@ data class StoryForgeUiState( val createKnowledgeBaseName: String = "", val createKnowledgeBaseDescription: String = "", val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink, + val accountSyncPlatform: String = "抖音", + val accountSyncHandle: String = "", + val accountSyncUrl: String = "", + val accountSyncTitle: String = "", + val accountSyncMaxItems: String = "5", + val accountSyncSkipExisting: Boolean = true, + val accountSyncAutoTriggerAnalysis: Boolean = true, val videoUrl: String = "", val videoTitle: String = "", val textTitle: String = "", @@ -155,6 +163,35 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati _state.value = _state.value.copy(videoUrl = value) } + fun updateAccountSyncPlatform(value: String) { + _state.value = _state.value.copy(accountSyncPlatform = value) + } + + fun updateAccountSyncHandle(value: String) { + _state.value = _state.value.copy(accountSyncHandle = value) + } + + fun updateAccountSyncUrl(value: String) { + _state.value = _state.value.copy(accountSyncUrl = value) + } + + fun updateAccountSyncTitle(value: String) { + _state.value = _state.value.copy(accountSyncTitle = value) + } + + fun updateAccountSyncMaxItems(value: String) { + val digits = value.filter { it.isDigit() } + _state.value = _state.value.copy(accountSyncMaxItems = digits) + } + + fun setAccountSyncSkipExisting(value: Boolean) { + _state.value = _state.value.copy(accountSyncSkipExisting = value) + } + + fun setAccountSyncAutoTriggerAnalysis(value: Boolean) { + _state.value = _state.value.copy(accountSyncAutoTriggerAnalysis = value) + } + fun updateVideoTitle(value: String) { _state.value = _state.value.copy(videoTitle = value) } @@ -463,6 +500,43 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati } } + fun submitContentSourceSync() { + val current = state.value + if (current.accountSyncUrl.isBlank()) { + setError("请先输入账号主页链接") + return + } + val knowledgeBaseId = selectedKnowledgeBaseIdOrFallback() + if (knowledgeBaseId.isBlank()) { + setError("请先选择知识库") + return + } + val maxItems = current.accountSyncMaxItems.toIntOrNull()?.coerceIn(1, 20) ?: 5 + runBusy(message = "正在创建账号同步任务...", task = { + repository.createContentSourceSyncJob( + platform = current.accountSyncPlatform.trim(), + handle = current.accountSyncHandle.trim(), + sourceUrl = current.accountSyncUrl.trim(), + title = current.accountSyncTitle.trim(), + knowledgeBaseId = knowledgeBaseId, + assistantId = current.selectedAssistantId, + analysisModelProfileId = preferredModelId(), + maxItems = maxItems, + skipExisting = current.accountSyncSkipExisting, + autoTriggerAnalysis = current.accountSyncAutoTriggerAnalysis + ) + }) { job -> + appendTimeline("账号同步任务已创建: ${job.title}") + _state.value = state.value.copy( + accountSyncHandle = "", + accountSyncUrl = "", + accountSyncTitle = "", + accountSyncMaxItems = maxItems.toString() + ) + afterJobCreated(job) + } + } + fun submitText() { val current = state.value if (current.textTitle.isBlank() || current.textContent.isBlank()) { diff --git a/deploy/cleanup_legacy_fastgpt_runtime.sh b/deploy/cleanup_legacy_fastgpt_runtime.sh new file mode 100755 index 0000000..7e9d2cf --- /dev/null +++ b/deploy/cleanup_legacy_fastgpt_runtime.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +LEGACY_CONTAINERS=( + storyforge-fastgpt-plugin + storyforge-sandbox + storyforge-pg + storyforge-minio + storyforge-redis + storyforge-mongo +) + +LEGACY_NETWORK="storyforge-net" +COLLECTOR_HEALTH_URL="${COLLECTOR_HEALTH_URL:-http://127.0.0.1:8081/healthz}" +N8N_HEALTH_URL="${N8N_HEALTH_URL:-http://127.0.0.1:5670/healthz}" +APPLY="${APPLY:-0}" + +log() { + printf '[cleanup] %s\n' "$*" +} + +check_url() { + local url="$1" + curl -fsS "$url" >/dev/null +} + +log "preflight: verifying StoryForge collector and n8n" +check_url "$COLLECTOR_HEALTH_URL" +check_url "$N8N_HEALTH_URL" + +log "legacy containers:" +for container in "${LEGACY_CONTAINERS[@]}"; do + if docker ps -a --format '{{.Names}}' | grep -Fxq "$container"; then + status="$(docker inspect --format '{{.State.Status}}' "$container")" + printf ' - %s (%s)\n' "$container" "$status" + else + printf ' - %s (missing)\n' "$container" + fi +done + +if [[ "$APPLY" != "1" ]]; then + log "dry run complete. Re-run with APPLY=1 to stop and remove legacy containers." + exit 0 +fi + +for container in "${LEGACY_CONTAINERS[@]}"; do + if docker ps -a --format '{{.Names}}' | grep -Fxq "$container"; then + log "removing $container" + docker rm -f "$container" >/dev/null + fi +done + +if docker network inspect "$LEGACY_NETWORK" >/dev/null 2>&1; then + if [[ "$(docker network inspect "$LEGACY_NETWORK" --format '{{len .Containers}}')" == "0" ]]; then + log "removing empty network $LEGACY_NETWORK" + docker network rm "$LEGACY_NETWORK" >/dev/null + else + log "network $LEGACY_NETWORK still has attached containers; leaving it in place" + fi +fi + +log "post-check: verifying StoryForge collector and n8n" +check_url "$COLLECTOR_HEALTH_URL" +check_url "$N8N_HEALTH_URL" +log "legacy FastGPT runtime cleanup completed" diff --git a/docs/AUDIT_2026-03-18.md b/docs/AUDIT_2026-03-18.md index 7082d11..0c3191d 100644 --- a/docs/AUDIT_2026-03-18.md +++ b/docs/AUDIT_2026-03-18.md @@ -163,6 +163,7 @@ ## 当前已完成迁移面 - FastGPT 运行时依赖已从 `collector-service` 主代码中剥离 +- 旧 FastGPT 运行残留容器 `storyforge-fastgpt-plugin / sandbox / pg / minio / redis / mongo` 已于 2026-03-20 实际下线并清理 - 数据库已支持 `project/content_source/job_events` - `collector-service` 已增加: - `n8n` 触发 @@ -173,10 +174,11 @@ - `n8n` 工作流导出文件已纳入仓库 - `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖 `/Users/kris/code/Fastgpt/collector-service/app` 的临时 bind mount - `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由 +- Android Explore 页已补上“账号同步”入口,可直接创建内容源账号同步任务,并支持平台、主页链接、账号标识、最大抓取条数、跳过已存在、自动触发分析等参数 ## 当前主要风险 1. 小红书账号级内容源还未做真实平台验证 2. `douyin` public 直抓仍受反爬限制,生产落地还需要补 cookie 或人工页面采集协作链 3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞 -4. `douyin` 新接口已上线 live,但还需要补一轮真实账号级回归,确认手工 payload 和相似账号分析都稳定 +4. Android 端完整编译目前仍被既有 `MainViewModel` 缺失依赖阻塞,本轮新增账号同步入口未触发新的 Kotlin 编译错误,但无法在现有工作区拿到全量 APK 构建通过结论 diff --git a/docs/LAN_E2E_GUIDE_2026-03-18.md b/docs/LAN_E2E_GUIDE_2026-03-18.md index 8527ca9..7e983a3 100644 --- a/docs/LAN_E2E_GUIDE_2026-03-18.md +++ b/docs/LAN_E2E_GUIDE_2026-03-18.md @@ -226,3 +226,18 @@ docker compose up -d --build - 抖音 public 页面直抓会命中反爬挑战;生产接入仍需要 cookie 或人工页面采集协助 - 小红书账号级内容源还未做真实平台验证 - `huobao-drama-upstream` 代码已迁移完成,但 fresh 生成仍受外部图片/视频凭证 `403 invalid user` 阻塞 + +## 10. 旧 FastGPT 残留清理 + +- 旧 FastGPT runtime 容器已在 2026-03-20 实际清理完成: + - `storyforge-fastgpt-plugin` + - `storyforge-sandbox` + - `storyforge-pg` + - `storyforge-minio` + - `storyforge-redis` + - `storyforge-mongo` +- 清理脚本已纳入仓库: + - `/Users/kris/code/StoryForge-gitea/deploy/cleanup_legacy_fastgpt_runtime.sh` +- 脚本会在清理前后校验: + - `http://127.0.0.1:8081/healthz` + - `http://127.0.0.1:5670/healthz` diff --git a/docs/MVP_STATUS_2026-03-18.md b/docs/MVP_STATUS_2026-03-18.md index a709062..372e3fa 100644 --- a/docs/MVP_STATUS_2026-03-18.md +++ b/docs/MVP_STATUS_2026-03-18.md @@ -10,6 +10,7 @@ - `user -> project -> assistant / knowledge base / job / content source` 数据模型 - 文本 / 视频链接 / 上传视频 三类分析任务创建 - 内容源账号同步任务创建与子任务派发 +- Android Explore 页已补上内容源账号同步入口 - `n8n` 工作流导入、激活与触发接口 - 本地下载器调用 - 本地 `ffmpeg` / `whisper` 风格入口封装 @@ -23,6 +24,7 @@ - `douyin` 手工 payload 导入与账号分析链路已跑通 - 本机 `huobao-drama` API 调度、首尾帧生成、视频生成与结果回写接口 - FastGPT 运行时依赖删除 +- 旧 FastGPT 运行残留容器已实际下线 ## 已验证的真实任务 @@ -47,6 +49,7 @@ - `douyin` public 主页直抓会命中 `public_profile_anti_bot_challenge`;当前已验证手工 payload 导入、分析、相似账号搜索和对标关系可作为可用兜底路径 - `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user` - `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置 +- Android 整体 `compileDebugKotlin` 目前仍被工作区既有 `MainViewModel` 缺失依赖阻塞,暂时无法给出 APK 级构建通过结论 ## 下一步优先级