diff --git a/.memory/worklog.json b/.memory/worklog.json index 7c44d82..5cb66df 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,51 +1,5 @@ { "entries": [ - { - "files_changed": 2, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:fix: enlarge filmstrip hover near waveform", - "ts": "2026-05-19T10:14:37Z", - "type": "session-heartbeat" - }, - { - "files_changed": 3, - "hash": "f574ab4", - "message": "fix: refine waveform filmstrip controls", - "ts": "2026-05-19T18:16:57+08:00", - "type": "commit" - }, - { - "files_changed": 2, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:fix: refine waveform filmstrip controls", - "ts": "2026-05-19T10:24:37Z", - "type": "session-heartbeat" - }, - { - "files_changed": 4, - "hash": "b099876", - "message": "auto-save 2026-05-19 18:27 (~4)", - "ts": "2026-05-19T18:28:00+08:00", - "type": "commit" - }, - { - "files_changed": 3, - "hash": "7604ed1", - "message": "fix: lift filmstrip hover preview", - "ts": "2026-05-19T18:29:12+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: lift filmstrip hover preview", - "ts": "2026-05-19T10:34:37Z", - "type": "session-heartbeat" - }, - { - "files_changed": 2, - "hash": "d503ca6", - "message": "auto-save 2026-05-19 18:38 (~2)", - "ts": "2026-05-19T18:38:51+08:00", - "type": "commit" - }, { "files_changed": 3, "hash": "ce5f3b4", @@ -3201,6 +3155,51 @@ "message": "auto-save 2026-05-23 23:50 (~3)", "hash": "e13bb0b", "files_changed": 3 + }, + { + "ts": "2026-05-23T23:55:05+08:00", + "type": "commit", + "message": "feat: redesign creative studio entry", + "hash": "3146266", + "files_changed": 6 + }, + { + "ts": "2026-05-23T15:57:18Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: redesign creative studio entry", + "files_changed": 1 + }, + { + "ts": "2026-05-24T00:00:58+08:00", + "type": "commit", + "message": "chore: ignore local verification artifacts", + "hash": "90dde14", + "files_changed": 1 + }, + { + "ts": "2026-05-23T16:07:18Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:chore: ignore local verification artifacts", + "files_changed": 1 + }, + { + "ts": "2026-05-23T16:17:18Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:chore: ignore local verification artifacts", + "files_changed": 1 + }, + { + "ts": "2026-05-24T00:23:22+08:00", + "type": "commit", + "message": "auto-save 2026-05-24 00:23 (~2)", + "hash": "91a7831", + "files_changed": 2 + }, + { + "ts": "2026-05-23T16:27:19Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 13 项未提交变更 · 最近提交:auto-save 2026-05-24 00:23 (~2)", + "files_changed": 13 } ] } diff --git a/.project.json b/.project.json index 3f7ba59..ebcc291 100644 --- a/.project.json +++ b/.project.json @@ -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" diff --git a/AGENTS.md b/AGENTS.md index e8a4d19..4982011 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# SKG AI 素材管线 - TK 二创验证 Agent Rules +# SKG 营销内容工作台 Agent Rules ## Must Read First diff --git a/RULES.md b/RULES.md index 9026524..bf11ff2 100644 --- a/RULES.md +++ b/RULES.md @@ -1,4 +1,4 @@ -# SKG AI 素材管线 - TK 二创验证 +# SKG 营销内容工作台 ## 启动 - 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`) @@ -66,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` @@ -121,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` diff --git a/api/.env.example b/api/.env.example index eec9ab6..96baa14 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/api/main.py b/api/main.py index 7b89cd8..67a8a89 100644 --- a/api/main.py +++ b/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( @@ -4642,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", @@ -4689,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 = "" @@ -4704,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, @@ -4734,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) @@ -4772,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" @@ -4789,6 +5223,7 @@ 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) @@ -4815,7 +5250,8 @@ def _write_creative_reference_frame(job_id: str, file_bytes: bytes | None = None @app.post("/creative/jobs/image", response_model=Job) -async def create_creative_image_job(file: UploadFile | None = File(default=None)) -> 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" @@ -4838,6 +5274,7 @@ async def create_creative_image_job(file: UploadFile | None = File(default=None) duration=0, frames=[frame], ) + assign_owner(job, user) JOBS[job_id] = job save_state(job) return job @@ -8224,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 @@ -8564,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) @@ -8580,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() @@ -8587,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))] diff --git a/deploy/.env.production.example b/deploy/.env.production.example index f9cd7ab..cf44f29 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -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 diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 201b6cb..beff11b 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -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; diff --git a/docs/source-analysis.html b/docs/source-analysis.html index af63957..5c97d27 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -3,7 +3,7 @@
-