feat: add account sync entry and cleanup legacy runtime

This commit is contained in:
kris
2026-03-20 14:10:30 +08:00
parent 98722a580a
commit ac6a8a82df
7 changed files with 275 additions and 1 deletions

View File

@@ -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( suspend fun uploadVideo(
uri: Uri, uri: Uri,
title: String, title: String,

View File

@@ -293,6 +293,7 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick
SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") { SectionCard(title = "素材入口", subtitle = "视频链接、上传视频、输入文字都会转成文本并做风格分析") {
ChoiceRow( ChoiceRow(
options = listOf( options = listOf(
"账号同步" to (state.exploreInputMode == ExploreInputMode.ContentSource),
"视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink), "视频链接" to (state.exploreInputMode == ExploreInputMode.VideoLink),
"上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo), "上传视频" to (state.exploreInputMode == ExploreInputMode.UploadVideo),
"输入文字" to (state.exploreInputMode == ExploreInputMode.Text) "输入文字" to (state.exploreInputMode == ExploreInputMode.Text)
@@ -300,6 +301,7 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick
onSelect = { label -> onSelect = { label ->
vm.setExploreInputMode( vm.setExploreInputMode(
when (label) { when (label) {
"账号同步" -> ExploreInputMode.ContentSource
"视频链接" -> ExploreInputMode.VideoLink "视频链接" -> ExploreInputMode.VideoLink
"上传视频" -> ExploreInputMode.UploadVideo "上传视频" -> ExploreInputMode.UploadVideo
else -> ExploreInputMode.Text else -> ExploreInputMode.Text
@@ -319,6 +321,93 @@ private fun ExploreTab(state: StoryForgeUiState, vm: StoryForgeViewModel, onPick
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
when (state.exploreInputMode) { 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 -> { ExploreInputMode.VideoLink -> {
OutlinedTextField( OutlinedTextField(
value = state.videoUrl, value = state.videoUrl,

View File

@@ -26,6 +26,7 @@ enum class StoryForgeAuthMode {
} }
enum class ExploreInputMode { enum class ExploreInputMode {
ContentSource,
VideoLink, VideoLink,
UploadVideo, UploadVideo,
Text Text
@@ -72,6 +73,13 @@ data class StoryForgeUiState(
val createKnowledgeBaseName: String = "", val createKnowledgeBaseName: String = "",
val createKnowledgeBaseDescription: String = "", val createKnowledgeBaseDescription: String = "",
val exploreInputMode: ExploreInputMode = ExploreInputMode.VideoLink, 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 videoUrl: String = "",
val videoTitle: String = "", val videoTitle: String = "",
val textTitle: String = "", val textTitle: String = "",
@@ -155,6 +163,35 @@ class StoryForgeViewModel(application: Application) : AndroidViewModel(applicati
_state.value = _state.value.copy(videoUrl = value) _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) { fun updateVideoTitle(value: String) {
_state.value = _state.value.copy(videoTitle = value) _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() { fun submitText() {
val current = state.value val current = state.value
if (current.textTitle.isBlank() || current.textContent.isBlank()) { if (current.textTitle.isBlank() || current.textContent.isBlank()) {

View File

@@ -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"

View File

@@ -163,6 +163,7 @@
## 当前已完成迁移面 ## 当前已完成迁移面
- FastGPT 运行时依赖已从 `collector-service` 主代码中剥离 - FastGPT 运行时依赖已从 `collector-service` 主代码中剥离
- 旧 FastGPT 运行残留容器 `storyforge-fastgpt-plugin / sandbox / pg / minio / redis / mongo` 已于 2026-03-20 实际下线并清理
- 数据库已支持 `project/content_source/job_events` - 数据库已支持 `project/content_source/job_events`
- `collector-service` 已增加: - `collector-service` 已增加:
- `n8n` 触发 - `n8n` 触发
@@ -173,10 +174,11 @@
- `n8n` 工作流导出文件已纳入仓库 - `n8n` 工作流导出文件已纳入仓库
- `collector-service` 的 live 运行态已回归到 `StoryForge-gitea` 自身源码构建,不再依赖 `/Users/kris/code/Fastgpt/collector-service/app` 的临时 bind mount - `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` 路由 - `collector-service` 现已在 live `8081` 提供 `/v2/douyin/*` 接口,并保留原有 `real-cut / ai-video / content-source-sync` 路由
- Android Explore 页已补上“账号同步”入口,可直接创建内容源账号同步任务,并支持平台、主页链接、账号标识、最大抓取条数、跳过已存在、自动触发分析等参数
## 当前主要风险 ## 当前主要风险
1. 小红书账号级内容源还未做真实平台验证 1. 小红书账号级内容源还未做真实平台验证
2. `douyin` public 直抓仍受反爬限制,生产落地还需要补 cookie 或人工页面采集协作链 2. `douyin` public 直抓仍受反爬限制,生产落地还需要补 cookie 或人工页面采集协作链
3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞 3. `huobao-drama-upstream` 已完成代码迁移并可编译,但 fresh smoke 受外部图片/视频凭证 `403 invalid user` 阻塞
4. `douyin` 新接口已上线 live但还需要补一轮真实账号级回归确认手工 payload 和相似账号分析都稳定 4. Android 端完整编译目前仍被既有 `MainViewModel` 缺失依赖阻塞,本轮新增账号同步入口未触发新的 Kotlin 编译错误,但无法在现有工作区拿到全量 APK 构建通过结论

View File

@@ -226,3 +226,18 @@ docker compose up -d --build
- 抖音 public 页面直抓会命中反爬挑战;生产接入仍需要 cookie 或人工页面采集协助 - 抖音 public 页面直抓会命中反爬挑战;生产接入仍需要 cookie 或人工页面采集协助
- 小红书账号级内容源还未做真实平台验证 - 小红书账号级内容源还未做真实平台验证
- `huobao-drama-upstream` 代码已迁移完成,但 fresh 生成仍受外部图片/视频凭证 `403 invalid user` 阻塞 - `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`

View File

@@ -10,6 +10,7 @@
- `user -> project -> assistant / knowledge base / job / content source` 数据模型 - `user -> project -> assistant / knowledge base / job / content source` 数据模型
- 文本 / 视频链接 / 上传视频 三类分析任务创建 - 文本 / 视频链接 / 上传视频 三类分析任务创建
- 内容源账号同步任务创建与子任务派发 - 内容源账号同步任务创建与子任务派发
- Android Explore 页已补上内容源账号同步入口
- `n8n` 工作流导入、激活与触发接口 - `n8n` 工作流导入、激活与触发接口
- 本地下载器调用 - 本地下载器调用
- 本地 `ffmpeg` / `whisper` 风格入口封装 - 本地 `ffmpeg` / `whisper` 风格入口封装
@@ -23,6 +24,7 @@
- `douyin` 手工 payload 导入与账号分析链路已跑通 - `douyin` 手工 payload 导入与账号分析链路已跑通
- 本机 `huobao-drama` API 调度、首尾帧生成、视频生成与结果回写接口 - 本机 `huobao-drama` API 调度、首尾帧生成、视频生成与结果回写接口
- FastGPT 运行时依赖删除 - FastGPT 运行时依赖删除
- 旧 FastGPT 运行残留容器已实际下线
## 已验证的真实任务 ## 已验证的真实任务
@@ -47,6 +49,7 @@
- `douyin` public 主页直抓会命中 `public_profile_anti_bot_challenge`;当前已验证手工 payload 导入、分析、相似账号搜索和对标关系可作为可用兜底路径 - `douyin` public 主页直抓会命中 `public_profile_anti_bot_challenge`;当前已验证手工 payload 导入、分析、相似账号搜索和对标关系可作为可用兜底路径
- `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user` - `huobao-upstream` 已能全量编译;并且旧改版隔离实例也已重放确认,当前 fresh 生成被外部图片/视频凭证统一返回 `403 invalid user`
- `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置 - `huobao-upstream` 已新增 `HUOBAO_TEXT_* / HUOBAO_IMAGE_* / HUOBAO_VIDEO_*` 运行时覆盖能力,后续补新 key 可直接接管数据库配置
- Android 整体 `compileDebugKotlin` 目前仍被工作区既有 `MainViewModel` 缺失依赖阻塞,暂时无法给出 APK 级构建通过结论
## 下一步优先级 ## 下一步优先级