Compare commits
7 Commits
642793500f
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
| 04a822ac79 | |||
| 90dde14ac3 | |||
| 3146266383 | |||
| 0e55945352 | |||
| d551c45006 | |||
| 0d5c32630f | |||
| 7ae92ae4d9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,7 @@ __pycache__/
|
||||
deploy/.env.production
|
||||
deploy/.htpasswd
|
||||
secrets/
|
||||
.backups/
|
||||
|
||||
# api
|
||||
api/.venv/
|
||||
@@ -26,6 +27,7 @@ asset_library/*
|
||||
prompt_library/*
|
||||
!prompt_library/.gitkeep
|
||||
_trash/
|
||||
output/
|
||||
|
||||
# web
|
||||
web/.next/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 项目接力
|
||||
|
||||
- 生成时间:May 21, 2026 at 17:15
|
||||
- 生成时间:May 23, 2026 at 23:37
|
||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 状态:active
|
||||
@@ -9,7 +9,7 @@
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e4913-d34e-7943-925e-ec1b60ddf937 · 时间未知
|
||||
- Codex:019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
@@ -92,51 +92,39 @@
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- Session ID:019e4913-d34e-7943-925e-ec1b60ddf937
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T13-48-16-019e4913-d34e-7943-925e-ec1b60ddf937.jsonl
|
||||
- Session ID:019e4d33-ad65-7673-934e-815226984ea6
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 分支:main
|
||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||
|
||||
### 最近用户要求
|
||||
|
||||
- 那我现在直接给你个服务器,你来弄好,api什么的都配好公司的 界面设计也弄好 你能行么
|
||||
- 上来就是1分钟的 没事
|
||||
- https://www.figma.com/design/petlyV2zio31BdZghC1alb/iOS-15-UI-Kit-for-Figma--Community-?node-id=5-54&p=f&t=l1NYiFeSZNk5bWtp-0 设计都在这里找,ssh root@2.24.28.41 根密码应该可以在记忆里看到
|
||||
- 音频解析 也需需要有中文识别 和多语言识别
|
||||
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
|
||||
- 分镜那边
|
||||
|
||||
### Codex 最近回复
|
||||
|
||||
> 推送第一次被沙箱拦住了,我按规则用提升权限重试 `git push origin main`。`.memory/worklog.json` 又被会话心跳改了一次,这类辅助日志我先不再反复追提交,避免变成无限心跳提交。
|
||||
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说。
|
||||
|
||||
> 已上线。页面我已经打开了,公网入口是:
|
||||
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本。
|
||||
>
|
||||
> `http://2.24.28.41:4290/agent/`
|
||||
> 当前链路是:
|
||||
>
|
||||
> 现在流程是你要的极简形态:左侧只放 TikTok 链接和产品图上传,中间是 Agent Terminal 实时日志,右侧是成片播放器、分镜 contact sheet 和下载入口。后端不是“裸 Codex 乱跑”,而是 `AgentRun` 状态机:下载、素材准备、12 段 x 5 秒规划、生成、失败重试、审片、合成,最终约 1 分钟。
|
||||
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像。
|
||||
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
|
||||
> 3. 每行会先自动套默认三字段:
|
||||
> - `文案`
|
||||
> - `场景一句话`
|
||||
> - `人物 + 产品 + 动作`
|
||||
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta,不是每句都先让大模型深度理解原文后重写。
|
||||
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
|
||||
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
|
||||
>
|
||||
> 已验证:
|
||||
> - 远端 `skg-agent-api` / `skg-agent-web` 容器正常运行
|
||||
> - 未登录 `/agent/` 正确跳 `/login/`
|
||||
> - 登录后 `/agent/` 返回 200
|
||||
> - `/api/agent-runs` 返回数组
|
||||
> - 容器内 `/health` 为 `ok:true` 且 `auth_configured:true`
|
||||
> - 本地 `python3 -m py_compile api/main.py` 通过
|
||||
> - 本地 `web/npm run build` 通过
|
||||
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
|
||||
>
|
||||
> 已推送到 Gitea:`77d23a0 feat: add one-click agent cut terminal`
|
||||
>
|
||||
> 最后状态:`git status -sb` 显示 `main...origin/main` 已同步;只剩 `.memory/worklog.json` 被会话心跳再次改动,我没有继续追提交这个自动日志,避免无限心跳提交。
|
||||
>
|
||||
> 我没有额外代你点一次真实 1 分钟生成,因为那会立刻消耗视频 API;现在页面已经可以你上传产品图后直接触发。
|
||||
>
|
||||
> <oai-mem-citation>
|
||||
> <citation_entries>
|
||||
> MEMORY.md:72-85|note=[used host context pointer for 2.24.28.41]
|
||||
> </citation_entries>
|
||||
> <rollout_ids>
|
||||
> 019e44c6-d8fe-7090-949d-f57d527a6f36
|
||||
> </rollout_ids>
|
||||
> </…
|
||||
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案,那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
|
||||
|
||||
## Cursor 最近会话
|
||||
|
||||
@@ -146,7 +134,7 @@
|
||||
|
||||
- 当前分支:main
|
||||
- 未提交变更:1 项
|
||||
- 最近提交:feat: add one-click agent cut terminal
|
||||
- 最近提交:docs: record multilingual asr deployment
|
||||
- 变更文件:
|
||||
- M .memory/worklog.json
|
||||
|
||||
|
||||
4420
.memory/worklog.json
4420
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
@@ -33,15 +33,21 @@
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产网页登录;用户名写 RULES.md,密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"description" : "生产网页登录备用账号;飞书免登录为主路径,备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name" : "WEB_LOGIN",
|
||||
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
|
||||
"type" : "web_login"
|
||||
},
|
||||
{
|
||||
"description" : "飞书免登录 OAuth 应用配置;App ID 和 App Secret 只放服务器 deploy\/.env.production,本地开发放 api\/.env,不入库;回调地址为 https:\/\/marketing.skg.com\/api\/auth\/feishu\/callback",
|
||||
"name" : "FEISHU_OAUTH",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production \/ 飞书开放平台",
|
||||
"type" : "oauth_app"
|
||||
}
|
||||
],
|
||||
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
|
||||
"kind" : "app",
|
||||
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"name" : "SKG 营销内容工作台",
|
||||
"ownership" : "company",
|
||||
"pin_order" : 1778664997,
|
||||
"pinned" : true,
|
||||
@@ -58,10 +64,10 @@
|
||||
}
|
||||
],
|
||||
"quick_login" : {
|
||||
"label" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"password" : "c413cdc5bbbf2ca042",
|
||||
"url" : "https:\/\/marketing.skg.com",
|
||||
"username" : "skg"
|
||||
"label" : "SKG 营销内容工作台",
|
||||
"password" : "",
|
||||
"url" : "https:\/\/marketing.skg.com\/login\/",
|
||||
"username" : "飞书免登录;备用账号见 credentials.WEB_LOGIN"
|
||||
},
|
||||
"stack" : [
|
||||
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SKG AI 素材管线 - TK 二创验证 Agent Rules
|
||||
# SKG 营销内容工作台 Agent Rules
|
||||
|
||||
## Must Read First
|
||||
|
||||
|
||||
19
RULES.md
19
RULES.md
@@ -1,4 +1,4 @@
|
||||
# SKG AI 素材管线 - TK 二创验证
|
||||
# SKG 营销内容工作台
|
||||
|
||||
## 启动
|
||||
- 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`)
|
||||
@@ -18,6 +18,7 @@
|
||||
- Agent Cut 独立预览服务器:`2.24.28.41`(Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`,Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
|
||||
- Agent Cut 独立预览验证(2026-05-21):已在 `2.24.28.41` 的 `/opt/skg-marketing-studio` 用 `docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200,`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true` 且 `auth_configured:true`。
|
||||
- 发布状态:已部署并验证(2026-05-20,主体元素按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- 最近部署验证(2026-05-22):`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API:`ASR_LANGUAGE=auto`、`FASTER_WHISPER_MODEL=base`、`ASR_REMOTE_ENABLED=false`、`ASR_LOCAL_FALLBACK_ENABLED=true`、`ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto`、`faster_whisper=base`。
|
||||
- 最近部署验证(2026-05-21):`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302`、`web:/login/ 200`、`web:/_next/does-not-exist.js 404`、`web:/api/health 401`、`api:health ok`、`api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台` 和 `信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
|
||||
- 最近部署验证(2026-05-20):`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]` 和 `max-w-[1920px]`,未再命中旧的 `h-[1000px]`、`w-[1800px]`、`BOARD_SCALE_PRESETS` 或 `boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
|
||||
- 最近部署验证(2026-05-20):`2b842fd` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520145223.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/743b82648dfa9db9.js` 已包含 `h-32`、`maxHeight:560`、`提示词就绪` 和 `确认生成`,且未再命中旧的 `height:640` / `h-40`;对应转换层取消固定长高,生成要求输入区回到 128px,底部仍由发送区主按钮确认生成。
|
||||
@@ -65,14 +66,15 @@
|
||||
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
|
||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash`
|
||||
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=`、`YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt`;`yt-dlp` 会在任务结束时回写 cookies,因此不要把该挂载设为只读;不要使用云端浏览器读取方案,也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`。
|
||||
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
|
||||
- 登录凭证:生产入口以飞书免登录为主;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。原账号密码登录保留为备用入口,用户名写下方快捷登录,密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,`WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务只对备用账号可见,飞书用户互不可见。
|
||||
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude:`.git`、`.memory`、`.logs`、`.pids`、`data`、`jobs`、`secrets`、`api/jobs`、`api/.env`、`api/.env.local`、`api/.env.production`、`deploy/.env.production`、`web/node_modules`、`web/.next`、`web/out`。不要把本地 `api/.env` 或 `deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
|
||||
|
||||
## 快捷登录
|
||||
- 登录地址:`https://marketing.skg.com/login/`
|
||||
- 用户名:`skg`
|
||||
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
||||
- 说明:当前是生产入口应用内登录页;数据库密码、API Key、服务器 root 密码不要写这里
|
||||
- 主路径:飞书免登录
|
||||
- 备用用户名:`skg`
|
||||
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
||||
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
|
||||
|
||||
## 元数据回写清单
|
||||
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
|
||||
@@ -120,7 +122,12 @@
|
||||
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
|
||||
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
|
||||
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
|
||||
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
|
||||
- `FEISHU_REDIRECT_URI`:飞书 OAuth 回调地址,生产固定为 `https://marketing.skg.com/api/auth/feishu/callback`。
|
||||
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。
|
||||
- `FEISHU_ALLOWED_EMAIL_DOMAINS` / `FEISHU_ALLOWED_EMAILS` / `FEISHU_ALLOWED_TENANT_KEYS`:可选飞书账号白名单;留空时由飞书应用可见范围控制。
|
||||
- `AUTH_DATA_ISOLATION_ENABLED`:多用户数据隔离开关,生产保持 `true`;新建 `Job` / `AgentRun` 会写入当前登录用户 owner,列表和详情访问只返回本人数据。
|
||||
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
|
||||
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
|
||||
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`;网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`
|
||||
|
||||
@@ -8,6 +8,16 @@ WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
WEB_AUTH_COOKIE_SECURE=false
|
||||
AUTH_DATA_ISOLATION_ENABLED=true
|
||||
|
||||
# 飞书免登录(OAuth)。生产回调地址需同步配置到飞书开放平台应用安全设置。
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
|
||||
FEISHU_OAUTH_SCOPE=
|
||||
FEISHU_ALLOWED_EMAIL_DOMAINS=
|
||||
FEISHU_ALLOWED_EMAILS=
|
||||
FEISHU_ALLOWED_TENANT_KEYS=
|
||||
|
||||
# 模型分工
|
||||
ASR_MODEL=whisper-1
|
||||
|
||||
667
api/main.py
667
api/main.py
@@ -4,6 +4,7 @@ import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -17,12 +18,13 @@ import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
load_dotenv()
|
||||
@@ -194,7 +196,30 @@ WEB_AUTH_PASSWORD = os.getenv("WEB_AUTH_PASSWORD", "").strip()
|
||||
WEB_AUTH_SESSION_SECRET = os.getenv("WEB_AUTH_SESSION_SECRET", "").strip()
|
||||
WEB_AUTH_COOKIE_NAME = os.getenv("WEB_AUTH_COOKIE_NAME", "skg_marketing_session").strip() or "skg_marketing_session"
|
||||
WEB_AUTH_COOKIE_SECURE = os.getenv("WEB_AUTH_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"}
|
||||
WEB_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET)
|
||||
FEISHU_APP_ID = (os.getenv("FEISHU_APP_ID") or os.getenv("FEISHU_CLIENT_ID") or "").strip()
|
||||
FEISHU_APP_SECRET = (os.getenv("FEISHU_APP_SECRET") or os.getenv("FEISHU_CLIENT_SECRET") or "").strip()
|
||||
FEISHU_REDIRECT_URI = os.getenv("FEISHU_REDIRECT_URI", "").strip()
|
||||
FEISHU_OAUTH_SCOPE = os.getenv("FEISHU_OAUTH_SCOPE", "").strip()
|
||||
FEISHU_AUTHORIZE_URL = os.getenv(
|
||||
"FEISHU_AUTHORIZE_URL",
|
||||
"https://accounts.feishu.cn/open-apis/authen/v1/authorize",
|
||||
).strip()
|
||||
FEISHU_TOKEN_URL = os.getenv(
|
||||
"FEISHU_TOKEN_URL",
|
||||
"https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
||||
).strip()
|
||||
FEISHU_USER_INFO_URL = os.getenv(
|
||||
"FEISHU_USER_INFO_URL",
|
||||
"https://open.feishu.cn/open-apis/authen/v1/user_info",
|
||||
).strip()
|
||||
FEISHU_STATE_COOKIE_NAME = os.getenv("FEISHU_STATE_COOKIE_NAME", "skg_feishu_oauth_state").strip() or "skg_feishu_oauth_state"
|
||||
FEISHU_ALLOWED_EMAIL_DOMAINS = os.getenv("FEISHU_ALLOWED_EMAIL_DOMAINS", "").strip()
|
||||
FEISHU_ALLOWED_EMAILS = os.getenv("FEISHU_ALLOWED_EMAILS", "").strip()
|
||||
FEISHU_ALLOWED_TENANT_KEYS = os.getenv("FEISHU_ALLOWED_TENANT_KEYS", "").strip()
|
||||
AUTH_DATA_ISOLATION_ENABLED = os.getenv("AUTH_DATA_ISOLATION_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
|
||||
PASSWORD_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET)
|
||||
FEISHU_AUTH_CONFIGURED = bool(FEISHU_APP_ID and FEISHU_APP_SECRET and WEB_AUTH_SESSION_SECRET)
|
||||
WEB_AUTH_CONFIGURED = bool(PASSWORD_AUTH_CONFIGURED or FEISHU_AUTH_CONFIGURED)
|
||||
|
||||
|
||||
def default_video_gateway_paths(base_url: str) -> tuple[str, str, str]:
|
||||
@@ -794,6 +819,11 @@ class SubjectAgentState(BaseModel):
|
||||
class Job(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
owner_id: str = ""
|
||||
owner_name: str = ""
|
||||
owner_email: str = ""
|
||||
owner_provider: str = ""
|
||||
tenant_key: str = ""
|
||||
status: JobStatus = "created"
|
||||
progress: int = 0
|
||||
message: str = ""
|
||||
@@ -827,7 +857,17 @@ AUDIO_WORKERS_LOCK = threading.Lock()
|
||||
|
||||
def ensure_auth_configured() -> None:
|
||||
if not WEB_AUTH_CONFIGURED:
|
||||
raise HTTPException(503, "WEB_AUTH_USERNAME、WEB_AUTH_PASSWORD 或 WEB_AUTH_SESSION_SECRET 未配置")
|
||||
raise HTTPException(503, "WEB_AUTH_SESSION_SECRET 以及账号密码或飞书 OAuth 未配置")
|
||||
|
||||
|
||||
def ensure_password_auth_configured() -> None:
|
||||
if not PASSWORD_AUTH_CONFIGURED:
|
||||
raise HTTPException(503, "账号密码登录未配置")
|
||||
|
||||
|
||||
def ensure_feishu_auth_configured() -> None:
|
||||
if not FEISHU_AUTH_CONFIGURED:
|
||||
raise HTTPException(503, "飞书免登录未配置")
|
||||
|
||||
|
||||
def _auth_signature(body: str) -> str:
|
||||
@@ -846,16 +886,80 @@ def _decode_auth_body(body: str) -> dict:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def make_auth_token(username: str, ttl_seconds: int) -> str:
|
||||
body = _encode_auth_body({
|
||||
"u": username,
|
||||
def _csv_values(raw: str) -> set[str]:
|
||||
return {item.strip().lower() for item in raw.split(",") if item.strip()}
|
||||
|
||||
|
||||
def _normalize_next_url(value: str | None) -> str:
|
||||
value = (value or "/").strip() or "/"
|
||||
if not value.startswith("/") or value.startswith("//"):
|
||||
return "/"
|
||||
return value
|
||||
|
||||
|
||||
def _public_base_url(request: Request) -> str:
|
||||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
host = request.headers.get("host") or request.url.netloc
|
||||
return f"{proto}://{host}".rstrip("/")
|
||||
|
||||
|
||||
def _feishu_redirect_uri(request: Request) -> str:
|
||||
if FEISHU_REDIRECT_URI:
|
||||
return FEISHU_REDIRECT_URI
|
||||
return f"{_public_base_url(request)}/api/auth/feishu/callback"
|
||||
|
||||
|
||||
def _session_user_id(payload: dict | None) -> str:
|
||||
payload = payload or {}
|
||||
explicit = str(payload.get("uid") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
provider = str(payload.get("provider") or "").strip().lower()
|
||||
if provider == "feishu":
|
||||
for key in ("open_id", "union_id", "email", "u"):
|
||||
value = str(payload.get(key) or "").strip()
|
||||
if value:
|
||||
return f"feishu:{value.lower() if key == 'email' else value}"
|
||||
username = str(payload.get("u") or "").strip() or "anonymous"
|
||||
return f"password:{username}"
|
||||
|
||||
|
||||
def _public_session(payload: dict) -> dict:
|
||||
return {
|
||||
"uid": _session_user_id(payload),
|
||||
"provider": str(payload.get("provider") or "password"),
|
||||
"username": str(payload.get("u") or payload.get("name") or ""),
|
||||
"name": str(payload.get("name") or payload.get("u") or ""),
|
||||
"email": str(payload.get("email") or ""),
|
||||
"open_id": str(payload.get("open_id") or ""),
|
||||
"union_id": str(payload.get("union_id") or ""),
|
||||
"tenant_key": str(payload.get("tenant_key") or ""),
|
||||
"avatar_url": str(payload.get("avatar_url") or ""),
|
||||
}
|
||||
|
||||
|
||||
def make_auth_token(user: str | dict, ttl_seconds: int) -> str:
|
||||
if isinstance(user, str):
|
||||
payload = {
|
||||
"u": user,
|
||||
"name": user,
|
||||
"provider": "password",
|
||||
"uid": f"password:{user}",
|
||||
}
|
||||
else:
|
||||
payload = dict(user)
|
||||
payload["uid"] = _session_user_id(payload)
|
||||
payload.setdefault("u", payload.get("name") or payload.get("email") or payload["uid"])
|
||||
payload.setdefault("name", payload.get("u") or payload["uid"])
|
||||
payload.update({
|
||||
"exp": int(time.time()) + ttl_seconds,
|
||||
"n": secrets.token_hex(8),
|
||||
})
|
||||
body = _encode_auth_body(payload)
|
||||
return f"{body}.{_auth_signature(body)}"
|
||||
|
||||
|
||||
def verify_auth_token(token: str) -> str | None:
|
||||
def verify_auth_token(token: str) -> dict | None:
|
||||
if not WEB_AUTH_CONFIGURED or "." not in token:
|
||||
return None
|
||||
body, supplied_sig = token.rsplit(".", 1)
|
||||
@@ -867,14 +971,228 @@ def verify_auth_token(token: str) -> str | None:
|
||||
expires_at = int(payload.get("exp") or 0)
|
||||
except Exception:
|
||||
return None
|
||||
if username != WEB_AUTH_USERNAME or expires_at < int(time.time()):
|
||||
if expires_at < int(time.time()):
|
||||
return None
|
||||
return username
|
||||
|
||||
provider = str(payload.get("provider") or "").strip().lower()
|
||||
if not provider:
|
||||
provider = "password" if username else ""
|
||||
|
||||
if provider == "password":
|
||||
if not PASSWORD_AUTH_CONFIGURED or username != WEB_AUTH_USERNAME:
|
||||
return None
|
||||
payload["provider"] = "password"
|
||||
payload["uid"] = f"password:{username}"
|
||||
payload.setdefault("name", username)
|
||||
return _public_session(payload)
|
||||
|
||||
if provider == "feishu":
|
||||
if not FEISHU_AUTH_CONFIGURED:
|
||||
return None
|
||||
payload["provider"] = "feishu"
|
||||
payload["uid"] = _session_user_id(payload)
|
||||
return _public_session(payload)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def auth_session_from_request(request: Request) -> dict | None:
|
||||
token = request.cookies.get(WEB_AUTH_COOKIE_NAME, "")
|
||||
return verify_auth_token(token)
|
||||
|
||||
|
||||
def auth_username_from_request(request: Request) -> str | None:
|
||||
token = request.cookies.get(WEB_AUTH_COOKIE_NAME, "")
|
||||
return verify_auth_token(token)
|
||||
session = auth_session_from_request(request)
|
||||
return str(session.get("username") or session.get("name") or session.get("uid")) if session else None
|
||||
|
||||
|
||||
def data_user_from_request(request: Request) -> dict:
|
||||
session = auth_session_from_request(request)
|
||||
if session:
|
||||
return session
|
||||
if not WEB_AUTH_CONFIGURED:
|
||||
return {"uid": "local:dev", "provider": "local", "username": "local-dev", "name": "local-dev", "email": "", "tenant_key": ""}
|
||||
raise HTTPException(401, "unauthorized")
|
||||
|
||||
|
||||
def _is_password_session(user: dict | None) -> bool:
|
||||
return bool(user and str(user.get("provider") or "") == "password")
|
||||
|
||||
|
||||
def assign_owner(model: Job | "AgentRun", user: dict) -> None:
|
||||
model.owner_id = _session_user_id(user)
|
||||
model.owner_name = str(user.get("name") or user.get("username") or model.owner_id)
|
||||
model.owner_email = str(user.get("email") or "")
|
||||
model.owner_provider = str(user.get("provider") or "")
|
||||
model.tenant_key = str(user.get("tenant_key") or "")
|
||||
|
||||
|
||||
def user_can_access_job(job: Job | None, user: dict | None) -> bool:
|
||||
if not job:
|
||||
return False
|
||||
if not AUTH_DATA_ISOLATION_ENABLED or not WEB_AUTH_CONFIGURED:
|
||||
return True
|
||||
owner_id = str(getattr(job, "owner_id", "") or "").strip()
|
||||
if owner_id:
|
||||
return bool(user and owner_id == _session_user_id(user))
|
||||
return _is_password_session(user)
|
||||
|
||||
|
||||
def _load_agent_run_for_access(run_id: str):
|
||||
run = AGENT_RUNS.get(run_id)
|
||||
if not run and agent_run_path(run_id).exists():
|
||||
try:
|
||||
run = AgentRun.model_validate_json(agent_run_path(run_id).read_text(encoding="utf-8"))
|
||||
AGENT_RUNS[run_id] = run
|
||||
except Exception:
|
||||
return None
|
||||
return run
|
||||
|
||||
|
||||
def user_can_access_agent_run(run_id: str, user: dict | None) -> bool:
|
||||
if not AUTH_DATA_ISOLATION_ENABLED or not WEB_AUTH_CONFIGURED:
|
||||
return True
|
||||
run = _load_agent_run_for_access(run_id)
|
||||
if not run:
|
||||
return False
|
||||
owner_id = str(getattr(run, "owner_id", "") or "").strip()
|
||||
if owner_id:
|
||||
return bool(user and owner_id == _session_user_id(user))
|
||||
return user_can_access_job(JOBS.get(run.job_id), user) or _is_password_session(user)
|
||||
|
||||
|
||||
JOB_PATH_RE = re.compile(r"^/jobs/([0-9a-f]{8,32})(?:/|$)")
|
||||
COPY_TO_JOB_PATH_RE = re.compile(r"^/asset-library/[^/]+/[^/]+/copy-to-job/([0-9a-f]{8,32})(?:/|$)")
|
||||
AGENT_RUN_PATH_RE = re.compile(r"^/agent-runs/([0-9a-f]{8,32})(?:/|$)")
|
||||
|
||||
|
||||
def _extract_protected_job_id(path: str) -> str:
|
||||
for pattern in (JOB_PATH_RE, COPY_TO_JOB_PATH_RE):
|
||||
match = pattern.match(path)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def _feishu_oauth_state(next_url: str) -> str:
|
||||
body = _encode_auth_body({
|
||||
"kind": "feishu_oauth_state",
|
||||
"next": _normalize_next_url(next_url),
|
||||
"exp": int(time.time()) + 600,
|
||||
"n": secrets.token_hex(12),
|
||||
})
|
||||
return f"{body}.{_auth_signature(body)}"
|
||||
|
||||
|
||||
def _verify_feishu_oauth_state(token: str) -> dict | None:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
body, supplied_sig = token.rsplit(".", 1)
|
||||
if not hmac.compare_digest(_auth_signature(body), supplied_sig):
|
||||
return None
|
||||
try:
|
||||
payload = _decode_auth_body(body)
|
||||
except Exception:
|
||||
return None
|
||||
if payload.get("kind") != "feishu_oauth_state" or int(payload.get("exp") or 0) < int(time.time()):
|
||||
return None
|
||||
payload["next"] = _normalize_next_url(str(payload.get("next") or "/"))
|
||||
return payload
|
||||
|
||||
|
||||
def _feishu_authorize_url(request: Request, state: str) -> str:
|
||||
params = {
|
||||
"client_id": FEISHU_APP_ID,
|
||||
"redirect_uri": _feishu_redirect_uri(request),
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
if FEISHU_OAUTH_SCOPE:
|
||||
params["scope"] = FEISHU_OAUTH_SCOPE
|
||||
return f"{FEISHU_AUTHORIZE_URL}?{urlencode(params)}"
|
||||
|
||||
|
||||
def _exchange_feishu_code(code: str, redirect_uri: str) -> str:
|
||||
payload = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": FEISHU_APP_ID,
|
||||
"client_secret": FEISHU_APP_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
with httpx.Client(timeout=20) as client:
|
||||
response = client.post(FEISHU_TOKEN_URL, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") not in (None, 0, "0"):
|
||||
raise HTTPException(401, f"飞书授权失败:{data.get('msg') or data.get('message') or data.get('code')}")
|
||||
token_data = data.get("data") if isinstance(data.get("data"), dict) else data
|
||||
token = str(
|
||||
token_data.get("access_token")
|
||||
or token_data.get("user_access_token")
|
||||
or token_data.get("accessToken")
|
||||
or ""
|
||||
).strip()
|
||||
if not token:
|
||||
raise HTTPException(401, "飞书授权未返回 user_access_token")
|
||||
return token
|
||||
|
||||
|
||||
def _fetch_feishu_user(access_token: str) -> dict:
|
||||
with httpx.Client(timeout=20) as client:
|
||||
response = client.get(FEISHU_USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") not in (None, 0, "0"):
|
||||
raise HTTPException(401, f"飞书用户信息获取失败:{data.get('msg') or data.get('message') or data.get('code')}")
|
||||
user = data.get("data") if isinstance(data.get("data"), dict) else data
|
||||
if not isinstance(user, dict):
|
||||
raise HTTPException(401, "飞书用户信息格式异常")
|
||||
return user
|
||||
|
||||
|
||||
def _build_feishu_session(user: dict) -> dict:
|
||||
email = str(user.get("email") or user.get("enterprise_email") or "").strip().lower()
|
||||
open_id = str(user.get("open_id") or "").strip()
|
||||
union_id = str(user.get("union_id") or "").strip()
|
||||
tenant_key = str(user.get("tenant_key") or "").strip()
|
||||
name = str(user.get("name") or user.get("en_name") or user.get("nickname") or email or open_id or union_id or "Feishu User").strip()
|
||||
avatar_url = str(
|
||||
user.get("avatar_url")
|
||||
or user.get("avatar_thumb")
|
||||
or user.get("avatar_middle")
|
||||
or user.get("avatar_big")
|
||||
or ""
|
||||
).strip()
|
||||
session = {
|
||||
"provider": "feishu",
|
||||
"u": name,
|
||||
"name": name,
|
||||
"email": email,
|
||||
"open_id": open_id,
|
||||
"union_id": union_id,
|
||||
"tenant_key": tenant_key,
|
||||
"avatar_url": avatar_url,
|
||||
}
|
||||
session["uid"] = _session_user_id(session)
|
||||
return session
|
||||
|
||||
|
||||
def _validate_feishu_session(session: dict) -> None:
|
||||
allowed_emails = _csv_values(FEISHU_ALLOWED_EMAILS)
|
||||
allowed_domains = {item.lstrip("@") for item in _csv_values(FEISHU_ALLOWED_EMAIL_DOMAINS)}
|
||||
allowed_tenants = _csv_values(FEISHU_ALLOWED_TENANT_KEYS)
|
||||
|
||||
email = str(session.get("email") or "").lower()
|
||||
domain = email.rsplit("@", 1)[1] if "@" in email else ""
|
||||
tenant_key = str(session.get("tenant_key") or "").lower()
|
||||
|
||||
if allowed_emails and email not in allowed_emails:
|
||||
raise HTTPException(403, "当前飞书账号不在允许登录名单")
|
||||
if allowed_domains and domain not in allowed_domains:
|
||||
raise HTTPException(403, "当前飞书账号邮箱域不允许登录")
|
||||
if allowed_tenants and tenant_key not in allowed_tenants:
|
||||
raise HTTPException(403, "当前飞书租户不允许登录")
|
||||
|
||||
|
||||
def job_dir(job_id: str) -> Path:
|
||||
@@ -1474,7 +1792,7 @@ async def lifespan(_: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="SKG TK 二创 API", lifespan=lifespan)
|
||||
app = FastAPI(title="SKG 营销内容工作台 API", lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=CORS_ORIGINS,
|
||||
@@ -1484,17 +1802,56 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def enforce_data_isolation(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if AUTH_DATA_ISOLATION_ENABLED and WEB_AUTH_CONFIGURED:
|
||||
try:
|
||||
user = data_user_from_request(request)
|
||||
except HTTPException:
|
||||
user = None
|
||||
|
||||
job_id = _extract_protected_job_id(path)
|
||||
if job_id and not user_can_access_job(JOBS.get(job_id), user):
|
||||
return JSONResponse({"detail": "job not found"}, status_code=404)
|
||||
|
||||
run_match = AGENT_RUN_PATH_RE.match(path)
|
||||
if run_match and not user_can_access_agent_run(run_match.group(1), user):
|
||||
return JSONResponse({"detail": "agent run not found"}, status_code=404)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.get("/auth/check")
|
||||
def auth_check(request: Request) -> Response:
|
||||
ensure_auth_configured()
|
||||
if not auth_username_from_request(request):
|
||||
if not auth_session_from_request(request):
|
||||
raise HTTPException(401, "unauthorized")
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/auth/config")
|
||||
def auth_config() -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"auth_configured": WEB_AUTH_CONFIGURED,
|
||||
"password_enabled": PASSWORD_AUTH_CONFIGURED,
|
||||
"feishu_enabled": FEISHU_AUTH_CONFIGURED,
|
||||
"data_isolation_enabled": AUTH_DATA_ISOLATION_ENABLED,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/auth/me")
|
||||
def auth_me(request: Request) -> dict:
|
||||
session = auth_session_from_request(request)
|
||||
if not session:
|
||||
raise HTTPException(401, "unauthorized")
|
||||
return {"ok": True, "user": session}
|
||||
|
||||
|
||||
@app.post("/auth/login")
|
||||
def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
|
||||
ensure_auth_configured()
|
||||
ensure_password_auth_configured()
|
||||
username = payload.username.strip()
|
||||
password = payload.password
|
||||
valid_user = hmac.compare_digest(username, WEB_AUTH_USERNAME)
|
||||
@@ -1515,6 +1872,66 @@ def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
|
||||
return {"ok": True, "username": WEB_AUTH_USERNAME}
|
||||
|
||||
|
||||
@app.get("/auth/feishu/start")
|
||||
def auth_feishu_start(request: Request) -> RedirectResponse:
|
||||
ensure_feishu_auth_configured()
|
||||
next_url = _normalize_next_url(request.query_params.get("next"))
|
||||
state = _feishu_oauth_state(next_url)
|
||||
response = RedirectResponse(_feishu_authorize_url(request, state), status_code=302)
|
||||
response.set_cookie(
|
||||
key=FEISHU_STATE_COOKIE_NAME,
|
||||
value=state,
|
||||
max_age=600,
|
||||
httponly=True,
|
||||
secure=WEB_AUTH_COOKIE_SECURE,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/auth/feishu/callback")
|
||||
def auth_feishu_callback(request: Request) -> RedirectResponse:
|
||||
ensure_feishu_auth_configured()
|
||||
if request.query_params.get("error"):
|
||||
raise HTTPException(401, f"飞书授权取消或失败:{request.query_params.get('error')}")
|
||||
|
||||
code = str(request.query_params.get("code") or "").strip()
|
||||
supplied_state = str(request.query_params.get("state") or "").strip()
|
||||
cookie_state = request.cookies.get(FEISHU_STATE_COOKIE_NAME, "")
|
||||
if not code:
|
||||
raise HTTPException(400, "missing feishu code")
|
||||
if not supplied_state or not cookie_state or not hmac.compare_digest(supplied_state, cookie_state):
|
||||
raise HTTPException(401, "invalid feishu state")
|
||||
|
||||
state_payload = _verify_feishu_oauth_state(supplied_state)
|
||||
if not state_payload:
|
||||
raise HTTPException(401, "expired feishu state")
|
||||
|
||||
access_token = _exchange_feishu_code(code, _feishu_redirect_uri(request))
|
||||
session = _build_feishu_session(_fetch_feishu_user(access_token))
|
||||
_validate_feishu_session(session)
|
||||
|
||||
ttl_seconds = 60 * 60 * 24 * 30
|
||||
response = RedirectResponse(_normalize_next_url(str(state_payload.get("next") or "/")), status_code=302)
|
||||
response.set_cookie(
|
||||
key=WEB_AUTH_COOKIE_NAME,
|
||||
value=make_auth_token(session, ttl_seconds),
|
||||
max_age=ttl_seconds,
|
||||
httponly=True,
|
||||
secure=WEB_AUTH_COOKIE_SECURE,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
response.delete_cookie(
|
||||
key=FEISHU_STATE_COOKIE_NAME,
|
||||
path="/",
|
||||
secure=WEB_AUTH_COOKIE_SECURE,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/auth/logout")
|
||||
def auth_logout(response: Response) -> dict:
|
||||
response.delete_cookie(
|
||||
@@ -4275,6 +4692,32 @@ class TranslateReq(BaseModel):
|
||||
target: Literal["en", "zh"] = "en"
|
||||
|
||||
|
||||
class CreativeCopyReq(BaseModel):
|
||||
goal: str
|
||||
product: str = ""
|
||||
audience: str = ""
|
||||
platform: str = "TikTok / Reels"
|
||||
tone: str = "direct"
|
||||
seconds: int = 20
|
||||
source_text: str = ""
|
||||
|
||||
|
||||
class CreativeCopyVariant(BaseModel):
|
||||
title: str = ""
|
||||
hook_zh: str = ""
|
||||
script_zh: str = ""
|
||||
script_en: str = ""
|
||||
image_prompt_en: str = ""
|
||||
video_prompt_en: str = ""
|
||||
caption_zh: str = ""
|
||||
hashtags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CreativeCopyResp(BaseModel):
|
||||
model: str
|
||||
variants: list[CreativeCopyVariant]
|
||||
|
||||
|
||||
class ScriptRewriteSegmentReq(BaseModel):
|
||||
index: int
|
||||
start: float = 0.0
|
||||
@@ -4339,6 +4782,74 @@ def _ensure_english(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _creative_copy_fallback(req: CreativeCopyReq) -> CreativeCopyResp:
|
||||
goal = req.goal.strip() or "展示 SKG 产品的核心卖点"
|
||||
product = req.product.strip() or "SKG 健康科技产品"
|
||||
seconds = max(6, min(60, int(req.seconds or 20)))
|
||||
script_zh = (
|
||||
f"开场 0-3 秒:直接展示{product}和使用场景,提出一个具体痛点。\n"
|
||||
f"中段 3-{max(4, seconds - 5)} 秒:用三个连续镜头说明{goal},画面保持产品清晰可见。\n"
|
||||
f"结尾 {max(4, seconds - 5)}-{seconds} 秒:给出一句明确行动口播,收在产品近景。"
|
||||
)
|
||||
script_en = _ensure_english(script_zh)
|
||||
image_prompt = _ensure_english(
|
||||
f"{product}, premium health-tech product advertising image, clean lifestyle scene, clear product visibility, natural lighting, vertical composition"
|
||||
)
|
||||
video_prompt = _ensure_english(
|
||||
f"{seconds}-second vertical short video ad for {product}. {goal}. Start with the product in use, show one clear benefit, keep camera motion smooth, realistic lifestyle lighting, no medical treatment claims."
|
||||
)
|
||||
return CreativeCopyResp(
|
||||
model="fallback",
|
||||
variants=[
|
||||
CreativeCopyVariant(
|
||||
title="快速成片版",
|
||||
hook_zh=f"{product},把一个日常痛点变成一个清楚的使用理由。",
|
||||
script_zh=script_zh,
|
||||
script_en=script_en,
|
||||
image_prompt_en=image_prompt,
|
||||
video_prompt_en=video_prompt,
|
||||
caption_zh=f"{product}|{goal}",
|
||||
hashtags=["#SKG", "#健康科技", "#短视频广告"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _parse_creative_copy_response(raw: str, req: CreativeCopyReq) -> CreativeCopyResp:
|
||||
text = (raw or "").strip()
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I).strip()
|
||||
text = re.sub(r"\s*```$", "", text).strip()
|
||||
match = re.search(r"\{[\s\S]*\}", text)
|
||||
json_text = match.group(0) if match else text
|
||||
try:
|
||||
data = json.loads(json_text)
|
||||
except Exception:
|
||||
return _creative_copy_fallback(req)
|
||||
raw_items = data.get("variants") if isinstance(data, dict) else None
|
||||
if not isinstance(raw_items, list):
|
||||
return _creative_copy_fallback(req)
|
||||
variants: list[CreativeCopyVariant] = []
|
||||
for item in raw_items[:3]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
hashtags = item.get("hashtags") or []
|
||||
if not isinstance(hashtags, list):
|
||||
hashtags = []
|
||||
variants.append(CreativeCopyVariant(
|
||||
title=str(item.get("title") or "").strip()[:80],
|
||||
hook_zh=str(item.get("hook_zh") or "").strip()[:180],
|
||||
script_zh=str(item.get("script_zh") or "").strip()[:900],
|
||||
script_en=_ensure_english(str(item.get("script_en") or item.get("script_zh") or "").strip())[:1200],
|
||||
image_prompt_en=_ensure_english(str(item.get("image_prompt_en") or "").strip())[:1200],
|
||||
video_prompt_en=_ensure_english(str(item.get("video_prompt_en") or "").strip())[:1400],
|
||||
caption_zh=str(item.get("caption_zh") or "").strip()[:240],
|
||||
hashtags=[str(tag).strip()[:40] for tag in hashtags if str(tag).strip()][:8],
|
||||
))
|
||||
if not variants:
|
||||
return _creative_copy_fallback(req)
|
||||
return CreativeCopyResp(model=REWRITE_MODEL if LLM_API_KEY else "fallback", variants=variants)
|
||||
|
||||
|
||||
@app.post("/translate")
|
||||
def translate_text(req: TranslateReq) -> dict:
|
||||
"""单条文本翻译(给生图自定义提取元素 zh→en 用)"""
|
||||
@@ -4374,6 +4885,44 @@ def translate_text(req: TranslateReq) -> dict:
|
||||
raise HTTPException(500, f"translate failed: {e}")
|
||||
|
||||
|
||||
@app.post("/creative/copy", response_model=CreativeCopyResp)
|
||||
def generate_creative_copy(req: CreativeCopyReq) -> CreativeCopyResp:
|
||||
goal = req.goal.strip()
|
||||
if not goal:
|
||||
raise HTTPException(400, "goal required")
|
||||
if not LLM_API_KEY:
|
||||
return _creative_copy_fallback(req)
|
||||
seconds = max(6, min(60, int(req.seconds or 20)))
|
||||
prompt = (
|
||||
"You are creating practical short-form ad material for an SKG AI creative tool. "
|
||||
"Return strict JSON only. Create 3 distinct variants that can be pasted directly into image/video generation. "
|
||||
"Avoid medical treatment claims; describe comfort, relaxation, daily use, visual proof, and product clarity instead. "
|
||||
"Every variant must include title, hook_zh, script_zh, script_en, image_prompt_en, video_prompt_en, caption_zh, hashtags.\n\n"
|
||||
f"Goal: {goal}\n"
|
||||
f"Product: {req.product.strip() or 'SKG health-tech product'}\n"
|
||||
f"Audience: {req.audience.strip() or 'short-form shoppers'}\n"
|
||||
f"Platform: {req.platform.strip() or 'TikTok / Reels'}\n"
|
||||
f"Tone: {req.tone.strip() or 'direct'}\n"
|
||||
f"Length: {seconds}s\n"
|
||||
f"Source/reference text:\n{req.source_text.strip()[:1500]}"
|
||||
)
|
||||
try:
|
||||
resp = llm().chat.completions.create(
|
||||
model=REWRITE_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": "Return valid JSON only. No markdown. No explanation."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.72,
|
||||
max_tokens=2200,
|
||||
)
|
||||
return _parse_creative_copy_response(resp.choices[0].message.content or "", req)
|
||||
except Exception as e:
|
||||
print(f"[creative copy fallback] {e}", flush=True)
|
||||
return _creative_copy_fallback(req)
|
||||
|
||||
|
||||
def _fallback_script_rewrite_item(segment: ScriptRewriteSegmentReq, author_intent: str = "") -> dict:
|
||||
source = (segment.source or "").strip()
|
||||
intent = _ensure_english(author_intent or "")
|
||||
@@ -4510,6 +5059,11 @@ def health() -> dict:
|
||||
"ok": True,
|
||||
"llm_configured": bool(LLM_API_KEY),
|
||||
"auth_configured": WEB_AUTH_CONFIGURED,
|
||||
"auth_modes": {
|
||||
"password": PASSWORD_AUTH_CONFIGURED,
|
||||
"feishu": FEISHU_AUTH_CONFIGURED,
|
||||
"data_isolation": AUTH_DATA_ISOLATION_ENABLED,
|
||||
},
|
||||
"base_url": LLM_BASE_URL or "openai-default",
|
||||
"asr_base_url": ASR_BASE_URL or LLM_BASE_URL or "openai-default",
|
||||
"image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default",
|
||||
@@ -4557,6 +5111,9 @@ def health() -> dict:
|
||||
class JobSummary(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
owner_name: str = ""
|
||||
owner_email: str = ""
|
||||
owner_provider: str = ""
|
||||
status: JobStatus
|
||||
progress: int = 0
|
||||
message: str = ""
|
||||
@@ -4572,16 +5129,22 @@ class JobSummary(BaseModel):
|
||||
|
||||
|
||||
@app.get("/jobs", response_model=list[JobSummary])
|
||||
def list_jobs(limit: int | None = None) -> list[JobSummary]:
|
||||
"""所有 job 的精简列表,按磁盘 state.json mtime 倒序(最新优先)。前端无 ?job= 时用它回填历史。"""
|
||||
def list_jobs(request: Request, limit: int | None = None) -> list[JobSummary]:
|
||||
"""当前用户可见 job 的精简列表,按磁盘 state.json mtime 倒序(最新优先)。"""
|
||||
user = data_user_from_request(request)
|
||||
items: list[JobSummary] = []
|
||||
for job_id, job in JOBS.items():
|
||||
if not user_can_access_job(job, user):
|
||||
continue
|
||||
state_path = JOBS_DIR / job_id / "state.json"
|
||||
mtime = state_path.stat().st_mtime if state_path.exists() else 0.0
|
||||
thumb = f"/jobs/{job_id}/frames/{job.frames[0].index}.jpg" if job.frames else ""
|
||||
items.append(JobSummary(
|
||||
id=job.id,
|
||||
url=job.url,
|
||||
owner_name=job.owner_name,
|
||||
owner_email=job.owner_email,
|
||||
owner_provider=job.owner_provider,
|
||||
status=job.status,
|
||||
progress=job.progress,
|
||||
message=job.message,
|
||||
@@ -4602,11 +5165,13 @@ def list_jobs(limit: int | None = None) -> list[JobSummary]:
|
||||
|
||||
|
||||
@app.post("/jobs", response_model=Job)
|
||||
async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
|
||||
async def create_job(req: CreateJobReq, bg: BackgroundTasks, request: Request) -> Job:
|
||||
if not req.url.strip():
|
||||
raise HTTPException(400, "url required")
|
||||
user = data_user_from_request(request)
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
job = Job(id=job_id, url=req.url.strip())
|
||||
assign_owner(job, user)
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
bg.add_task(pipeline_download, job_id)
|
||||
@@ -4640,13 +5205,14 @@ async def retry_job_download(job_id: str, bg: BackgroundTasks) -> Job:
|
||||
|
||||
|
||||
@app.post("/jobs/upload", response_model=Job)
|
||||
async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job:
|
||||
async def create_job_from_upload(bg: BackgroundTasks, request: Request, file: UploadFile = File(...)) -> Job:
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "file required")
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}:
|
||||
raise HTTPException(400, f"unsupported video format: {ext}")
|
||||
|
||||
user = data_user_from_request(request)
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
d = job_dir(job_id)
|
||||
mp4 = d / "source.mp4"
|
||||
@@ -4657,12 +5223,63 @@ async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(..
|
||||
raise HTTPException(500, "upload failed")
|
||||
|
||||
job = Job(id=job_id, url=f"upload://{file.filename}")
|
||||
assign_owner(job, user)
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
bg.add_task(pipeline_download, job_id)
|
||||
return job
|
||||
|
||||
|
||||
def _write_creative_reference_frame(job_id: str, file_bytes: bytes | None = None) -> tuple[int, int]:
|
||||
frames_dir = job_dir(job_id) / "frames"
|
||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = frames_dir / "000.jpg"
|
||||
if file_bytes:
|
||||
try:
|
||||
with Image.open(io.BytesIO(file_bytes)) as raw:
|
||||
im = ImageOps.exif_transpose(raw).convert("RGB")
|
||||
im.thumbnail((1600, 1600), Image.LANCZOS)
|
||||
width, height = im.size
|
||||
im.save(out, "JPEG", quality=92)
|
||||
return width, height
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"invalid image file: {e}")
|
||||
im = Image.new("RGB", (1024, 1024), (246, 248, 246))
|
||||
im.save(out, "JPEG", quality=92)
|
||||
return im.size
|
||||
|
||||
|
||||
@app.post("/creative/jobs/image", response_model=Job)
|
||||
async def create_creative_image_job(request: Request, file: UploadFile | None = File(default=None)) -> Job:
|
||||
user = data_user_from_request(request)
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
file_bytes: bytes | None = None
|
||||
source_label = "blank"
|
||||
if file and file.filename:
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
raise HTTPException(400, f"unsupported image format: {ext}")
|
||||
file_bytes = await file.read()
|
||||
source_label = file.filename
|
||||
width, height = _write_creative_reference_frame(job_id, file_bytes)
|
||||
frame = KeyFrame(index=0, timestamp=0, url=f"/jobs/{job_id}/frames/0.jpg")
|
||||
job = Job(
|
||||
id=job_id,
|
||||
url=f"creative://{source_label}",
|
||||
status="frames_extracted",
|
||||
progress=100,
|
||||
message="创作任务已就绪",
|
||||
width=width,
|
||||
height=height,
|
||||
duration=0,
|
||||
frames=[frame],
|
||||
)
|
||||
assign_owner(job, user)
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/analyze", response_model=Job)
|
||||
async def trigger_analyze(
|
||||
job_id: str,
|
||||
@@ -8044,6 +8661,11 @@ class AgentRunLog(BaseModel):
|
||||
class AgentRun(BaseModel):
|
||||
id: str
|
||||
job_id: str
|
||||
owner_id: str = ""
|
||||
owner_name: str = ""
|
||||
owner_email: str = ""
|
||||
owner_provider: str = ""
|
||||
tenant_key: str = ""
|
||||
status: Literal["draft", "queued", "executing", "reviewing", "completed", "failed"] = "queued"
|
||||
stage: str = "queued"
|
||||
progress: int = 0
|
||||
@@ -8384,14 +9006,17 @@ def agent_run_worker(run_id: str, product_refs: list[dict]) -> None:
|
||||
|
||||
@app.post("/agent-runs", response_model=AgentRun)
|
||||
async def create_agent_run(
|
||||
request: Request,
|
||||
tk_url: str = Form(...),
|
||||
product_files: list[UploadFile] | None = File(None),
|
||||
) -> AgentRun:
|
||||
if not tk_url.strip():
|
||||
raise HTTPException(400, "tk_url required")
|
||||
user = data_user_from_request(request)
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
run_id = uuid.uuid4().hex[:12]
|
||||
job = Job(id=job_id, url=tk_url.strip())
|
||||
assign_owner(job, user)
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
|
||||
@@ -8400,6 +9025,7 @@ async def create_agent_run(
|
||||
refs.append(await save_agent_product_upload(job_id, upload, index))
|
||||
|
||||
run = AgentRun(id=run_id, job_id=job_id, status="queued", stage="queued", progress=1)
|
||||
assign_owner(run, user)
|
||||
save_agent_run(run)
|
||||
agent_log(run, f"任务已入队 · job={job_id} · 产品图 {len(refs)} 张", status="queued", stage="queued", progress=1)
|
||||
threading.Thread(target=agent_run_worker, args=(run_id, refs), daemon=True).start()
|
||||
@@ -8407,14 +9033,15 @@ async def create_agent_run(
|
||||
|
||||
|
||||
@app.get("/agent-runs", response_model=list[AgentRun])
|
||||
def list_agent_runs(limit: int = 20) -> list[AgentRun]:
|
||||
def list_agent_runs(request: Request, limit: int = 20) -> list[AgentRun]:
|
||||
user = data_user_from_request(request)
|
||||
for p in AGENT_RUNS_DIR.iterdir():
|
||||
if p.is_dir() and (p / "state.json").exists() and p.name not in AGENT_RUNS:
|
||||
try:
|
||||
AGENT_RUNS[p.name] = AgentRun.model_validate_json((p / "state.json").read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
items = list(AGENT_RUNS.values())
|
||||
items = [item for item in AGENT_RUNS.values() if user_can_access_agent_run(item.id, user)]
|
||||
items.sort(key=lambda item: item.updated_at, reverse=True)
|
||||
return items[:max(1, min(100, limit))]
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
WEB_AUTH_COOKIE_SECURE=true
|
||||
AUTH_DATA_ISOLATION_ENABLED=true
|
||||
|
||||
# Feishu OAuth login. Register this callback in the Feishu developer console:
|
||||
# https://marketing.skg.com/api/auth/feishu/callback
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
|
||||
FEISHU_OAUTH_SCOPE=
|
||||
FEISHU_ALLOWED_EMAIL_DOMAINS=
|
||||
FEISHU_ALLOWED_EMAILS=
|
||||
FEISHU_ALLOWED_TENANT_KEYS=
|
||||
|
||||
# SKG AI gateway, OpenAI-compatible
|
||||
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
|
||||
@@ -20,6 +20,20 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/auth/ {
|
||||
proxy_pass http://skg-marketing-api:4291/auth/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
|
||||
location = /api/auth/login {
|
||||
proxy_pass http://skg-marketing-api:4291/auth/login;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SKG TK 二创工作台",
|
||||
description: "SKG AI 素材生产管线 · 节点工作流",
|
||||
title: "SKG 营销内容工作台",
|
||||
description: "SKG AI 图片、视频和文案创作台",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FormEvent } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -14,8 +15,14 @@ import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/l
|
||||
import { OasisCanvas } from "@/components/login/oasis-canvas"
|
||||
|
||||
type LoginStatus = "idle" | "loading" | "success"
|
||||
type AuthConfig = {
|
||||
auth_configured?: boolean
|
||||
password_enabled?: boolean
|
||||
feishu_enabled?: boolean
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [remember, setRemember] = useState(true)
|
||||
@@ -25,6 +32,21 @@ export default function LoginPage() {
|
||||
const [status, setStatus] = useState<LoginStatus>("idle")
|
||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data) => {
|
||||
if (!cancelled && data) setAuthConfig(data)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAuthConfig(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const centerX = window.innerWidth / 2
|
||||
@@ -38,6 +60,8 @@ export default function LoginPage() {
|
||||
}, [])
|
||||
|
||||
const disabled = status === "loading" || status === "success"
|
||||
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
|
||||
const passwordEnabled = authConfig?.password_enabled ?? true
|
||||
|
||||
const mood: LoginCharacterMood = useMemo(() => {
|
||||
if (status === "success") return "success"
|
||||
@@ -50,6 +74,7 @@ export default function LoginPage() {
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setHasError(false)
|
||||
if (!passwordEnabled) return
|
||||
if (!username.trim() || !password) {
|
||||
setHasError(true)
|
||||
return
|
||||
@@ -75,6 +100,11 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function onFeishuLogin() {
|
||||
setStatus("loading")
|
||||
window.location.href = "/api/auth/feishu/start?next=/"
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
|
||||
<OasisCanvas />
|
||||
@@ -89,7 +119,29 @@ export default function LoginPage() {
|
||||
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
||||
</div>
|
||||
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
|
||||
<div className="space-y-3">
|
||||
{feishuEnabled ? (
|
||||
<button
|
||||
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onFeishuLogin}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
飞书免登录
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{feishuEnabled && passwordEnabled ? (
|
||||
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
|
||||
<span className="h-px flex-1 bg-white/10" />
|
||||
<span>备用账号</span>
|
||||
<span className="h-px flex-1 bg-white/10" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordEnabled ? (
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
||||
<UserRound className="h-4 w-4 text-white/45" />
|
||||
@@ -135,9 +187,11 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
||||
{passwordEnabled ? (
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
|
||||
type="checkbox"
|
||||
@@ -146,7 +200,8 @@ export default function LoginPage() {
|
||||
onChange={(event) => setRemember(event.target.checked)}
|
||||
/>
|
||||
<span>保持登录</span>
|
||||
</label>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{status === "success" ? (
|
||||
<div className="mt-3">
|
||||
@@ -156,13 +211,15 @@ export default function LoginPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
{passwordEnabled ? (
|
||||
<button
|
||||
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
1736
web/app/page.tsx
1736
web/app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -316,6 +316,54 @@ export async function getRuntimeHealth(): Promise<RuntimeHealth> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface CreativeCopyVariant {
|
||||
title: string
|
||||
hook_zh: string
|
||||
script_zh: string
|
||||
script_en: string
|
||||
image_prompt_en: string
|
||||
video_prompt_en: string
|
||||
caption_zh: string
|
||||
hashtags: string[]
|
||||
}
|
||||
|
||||
export interface CreativeCopyResult {
|
||||
model: string
|
||||
variants: CreativeCopyVariant[]
|
||||
}
|
||||
|
||||
export async function generateCreativeCopy(body: {
|
||||
goal: string
|
||||
product?: string
|
||||
audience?: string
|
||||
platform?: string
|
||||
tone?: string
|
||||
seconds?: number
|
||||
source_text?: string
|
||||
}): Promise<CreativeCopyResult> {
|
||||
const res = await fetch(`${API_BASE}/creative/copy`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("generateCreativeCopy", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createCreativeImageJob(file?: File | null): Promise<Job> {
|
||||
const fd = new FormData()
|
||||
if (file) fd.append("file", file)
|
||||
const res = await fetch(`${API_BASE}/creative/jobs/image`, { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("createCreativeImageJob", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// 把 ImageRef 解析成可显示的 src URL
|
||||
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
|
||||
if (ref.kind === "keyframe") {
|
||||
@@ -953,6 +1001,11 @@ export interface ProductRefStateItem {
|
||||
export interface Job {
|
||||
id: string
|
||||
url: string
|
||||
owner_id?: string
|
||||
owner_name?: string
|
||||
owner_email?: string
|
||||
owner_provider?: string
|
||||
tenant_key?: string
|
||||
status: JobStatus
|
||||
progress: number
|
||||
message?: string
|
||||
@@ -975,6 +1028,11 @@ export interface BackendHealth {
|
||||
ok: boolean
|
||||
llm_configured: boolean
|
||||
auth_configured?: boolean
|
||||
auth_modes?: {
|
||||
password?: boolean
|
||||
feishu?: boolean
|
||||
data_isolation?: boolean
|
||||
}
|
||||
base_url: string
|
||||
models?: {
|
||||
asr?: string
|
||||
@@ -1071,6 +1129,9 @@ export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }
|
||||
export interface JobSummary {
|
||||
id: string
|
||||
url: string
|
||||
owner_name?: string
|
||||
owner_email?: string
|
||||
owner_provider?: string
|
||||
status: JobStatus
|
||||
progress: number
|
||||
message: string
|
||||
|
||||
Reference in New Issue
Block a user