auto-save 2026-05-26 00:13 (~8)
This commit is contained in:
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record subject pack deployment",
|
||||
"ts": "2026-05-20T02:53:56Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record subject pack deployment",
|
||||
@@ -3225,6 +3219,13 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record Feishu OAuth enablement",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-26T00:07:48+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-26 00:07 (+1, ~3)",
|
||||
"hash": "089a30d",
|
||||
"files_changed": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
18
api/db.py
18
api/db.py
@@ -417,14 +417,20 @@ def upsert_canvas_project(user: dict, project: dict) -> dict | None:
|
||||
)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
thumbnail = EXCLUDED.thumbnail,
|
||||
name = CASE
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.name
|
||||
ELSE canvas_projects.name
|
||||
END,
|
||||
thumbnail = CASE
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.thumbnail
|
||||
ELSE canvas_projects.thumbnail
|
||||
END,
|
||||
visibility = CASE
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN EXCLUDED.visibility
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.visibility
|
||||
ELSE canvas_projects.visibility
|
||||
END,
|
||||
canvas_data = CASE
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN EXCLUDED.canvas_data
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN EXCLUDED.canvas_data
|
||||
ELSE canvas_projects.canvas_data
|
||||
END,
|
||||
updated_at = CASE
|
||||
@@ -432,11 +438,11 @@ def upsert_canvas_project(user: dict, project: dict) -> dict | None:
|
||||
ELSE canvas_projects.updated_at
|
||||
END,
|
||||
version = CASE
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN canvas_projects.version + 1
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN canvas_projects.version + 1
|
||||
ELSE canvas_projects.version
|
||||
END,
|
||||
deleted_at = CASE
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id THEN NULL
|
||||
WHEN canvas_projects.owner_id = EXCLUDED.owner_id AND EXCLUDED.updated_at >= canvas_projects.updated_at THEN NULL
|
||||
ELSE canvas_projects.deleted_at
|
||||
END
|
||||
RETURNING id, name, thumbnail, visibility, canvas_data, created_at, updated_at, version, owner_id
|
||||
|
||||
180
api/main.py
180
api/main.py
@@ -1544,6 +1544,7 @@ def _write_prompt_item(item: PromptLibraryItem) -> None:
|
||||
PROMPT_LIBRARY_ITEMS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_prompt_item_file(item).write_text(item.model_dump_json(indent=2), encoding="utf-8")
|
||||
_write_prompt_library_index()
|
||||
db.index_prompt_item(item.model_dump())
|
||||
|
||||
|
||||
def _write_asset_item(item: AssetLibraryItem) -> None:
|
||||
@@ -1551,6 +1552,7 @@ def _write_asset_item(item: AssetLibraryItem) -> None:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(item.model_dump_json(indent=2), encoding="utf-8")
|
||||
_write_asset_library_index()
|
||||
db.index_asset_item(item.model_dump())
|
||||
|
||||
|
||||
def _read_prompt_item(path: Path) -> PromptLibraryItem | None:
|
||||
@@ -1921,6 +1923,7 @@ def cancel_queued_video_task(job_id: str, video_id: str) -> bool:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
db_ready = db.init_schema()
|
||||
try:
|
||||
_rebuild_library_index()
|
||||
except Exception as e:
|
||||
@@ -2000,6 +2003,16 @@ async def lifespan(_: FastAPI):
|
||||
JOBS[p.name] = job
|
||||
except Exception:
|
||||
pass
|
||||
if db_ready:
|
||||
for job in JOBS.values():
|
||||
db.index_job(job.model_dump(), str(job_dir(job.id) / "state.json"))
|
||||
try:
|
||||
for item in load_prompt_library_items():
|
||||
db.index_prompt_item(item.model_dump())
|
||||
for item in load_asset_library_items():
|
||||
db.index_asset_item(item.model_dump())
|
||||
except Exception as e:
|
||||
print(f"[db] initial library sync failed: {e}", flush=True)
|
||||
yield
|
||||
|
||||
|
||||
@@ -2057,11 +2070,12 @@ def auth_me(request: Request) -> dict:
|
||||
session = auth_session_from_request(request)
|
||||
if not session:
|
||||
raise HTTPException(401, "unauthorized")
|
||||
db.upsert_user(session, request)
|
||||
return {"ok": True, "user": session}
|
||||
|
||||
|
||||
@app.post("/auth/login")
|
||||
def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
|
||||
def auth_login(payload: AuthLoginPayload, request: Request, response: Response) -> dict:
|
||||
ensure_password_auth_configured()
|
||||
username = payload.username.strip()
|
||||
password = payload.password
|
||||
@@ -2080,6 +2094,9 @@ def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
session = _public_session({"u": WEB_AUTH_USERNAME, "name": WEB_AUTH_USERNAME, "provider": "password", "uid": f"password:{WEB_AUTH_USERNAME}"})
|
||||
db.upsert_user(session, request)
|
||||
db.audit(session, "login.password", "user", session["uid"], request=request)
|
||||
return {"ok": True, "username": WEB_AUTH_USERNAME}
|
||||
|
||||
|
||||
@@ -2122,6 +2139,8 @@ def auth_feishu_callback(request: Request) -> RedirectResponse:
|
||||
access_token = _exchange_feishu_code(code, _feishu_redirect_uri(request))
|
||||
session = _build_feishu_session(_fetch_feishu_user(access_token))
|
||||
_validate_feishu_session(session)
|
||||
db.upsert_user(session, request)
|
||||
db.audit(session, "login.feishu", "user", session["uid"], request=request)
|
||||
|
||||
ttl_seconds = 60 * 60 * 24 * 30
|
||||
response = RedirectResponse(_normalize_next_url(str(state_payload.get("next") or "/")), status_code=302)
|
||||
@@ -2154,6 +2173,128 @@ def auth_logout(response: Response) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class CanvasProjectWriteReq(BaseModel):
|
||||
id: str = ""
|
||||
name: str = "未命名项目"
|
||||
thumbnail: str = ""
|
||||
visibility: Literal["private", "team", "company"] = "private"
|
||||
canvas_data: dict = Field(default_factory=dict)
|
||||
created_at: float = 0.0
|
||||
updated_at: float = 0.0
|
||||
source: str = "canvas"
|
||||
|
||||
|
||||
class CanvasProjectImportReq(BaseModel):
|
||||
projects: list[CanvasProjectWriteReq] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _ts(value) -> float:
|
||||
if hasattr(value, "timestamp"):
|
||||
return float(value.timestamp())
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _require_db() -> None:
|
||||
if not db.enabled():
|
||||
raise HTTPException(503, "database not configured")
|
||||
|
||||
|
||||
def _canvas_project_public(row: dict) -> dict:
|
||||
return {
|
||||
"id": str(row.get("id") or ""),
|
||||
"name": str(row.get("name") or ""),
|
||||
"thumbnail": str(row.get("thumbnail") or ""),
|
||||
"visibility": str(row.get("visibility") or "private"),
|
||||
"canvas_data": row.get("canvas_data") or {},
|
||||
"created_at": _ts(row.get("created_at")),
|
||||
"updated_at": _ts(row.get("updated_at")),
|
||||
"version": int(row.get("version") or 1),
|
||||
"owner_id": str(row.get("owner_id") or ""),
|
||||
"owner_name": str(row.get("owner_name") or ""),
|
||||
"owner_email": str(row.get("owner_email") or ""),
|
||||
"owner_provider": str(row.get("owner_provider") or ""),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/canvas-projects")
|
||||
def list_canvas_projects(request: Request) -> dict:
|
||||
_require_db()
|
||||
user = data_user_from_request(request)
|
||||
db.upsert_user(user, request)
|
||||
return {
|
||||
"ok": True,
|
||||
"items": [_canvas_project_public(row) for row in db.list_canvas_projects(user)],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/canvas-projects")
|
||||
def create_canvas_project(req: CanvasProjectWriteReq, request: Request) -> dict:
|
||||
_require_db()
|
||||
user = data_user_from_request(request)
|
||||
db.upsert_user(user, request)
|
||||
row = db.upsert_canvas_project(user, req.model_dump())
|
||||
if not row:
|
||||
raise HTTPException(500, "canvas project save failed")
|
||||
db.audit(user, "canvas_project.create", "canvas_project", str(row.get("id") or ""), req.model_dump(exclude={"canvas_data"}), request, str(row.get("visibility") or "private"))
|
||||
return {"ok": True, "item": _canvas_project_public(row)}
|
||||
|
||||
|
||||
@app.put("/canvas-projects/{project_id}")
|
||||
def put_canvas_project(project_id: str, req: CanvasProjectWriteReq, request: Request) -> dict:
|
||||
_require_db()
|
||||
user = data_user_from_request(request)
|
||||
db.upsert_user(user, request)
|
||||
payload = req.model_dump()
|
||||
payload["id"] = project_id
|
||||
row = db.upsert_canvas_project(user, payload)
|
||||
if not row:
|
||||
raise HTTPException(500, "canvas project save failed")
|
||||
if str(row.get("owner_id") or "") != _session_user_id(user):
|
||||
raise HTTPException(403, "canvas project belongs to another user")
|
||||
db.audit(user, "canvas_project.save", "canvas_project", project_id, {"name": req.name, "visibility": req.visibility}, request, str(row.get("visibility") or "private"))
|
||||
return {"ok": True, "item": _canvas_project_public(row)}
|
||||
|
||||
|
||||
@app.get("/canvas-projects/{project_id}")
|
||||
def get_canvas_project(project_id: str, request: Request) -> dict:
|
||||
_require_db()
|
||||
user = data_user_from_request(request)
|
||||
row = db.get_canvas_project(project_id, user)
|
||||
if not row:
|
||||
raise HTTPException(404, "canvas project not found")
|
||||
return {"ok": True, "item": _canvas_project_public(row)}
|
||||
|
||||
|
||||
@app.delete("/canvas-projects/{project_id}")
|
||||
def delete_canvas_project(project_id: str, request: Request) -> dict:
|
||||
_require_db()
|
||||
user = data_user_from_request(request)
|
||||
ok = db.soft_delete_canvas_project(user, project_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "canvas project not found")
|
||||
db.audit(user, "canvas_project.delete", "canvas_project", project_id, request=request)
|
||||
return {"ok": True, "id": project_id}
|
||||
|
||||
|
||||
@app.post("/canvas-projects/import")
|
||||
def import_canvas_projects(req: CanvasProjectImportReq, request: Request) -> dict:
|
||||
_require_db()
|
||||
user = data_user_from_request(request)
|
||||
db.upsert_user(user, request)
|
||||
imported = []
|
||||
for item in req.projects[:200]:
|
||||
payload = item.model_dump()
|
||||
payload["source"] = "localStorage"
|
||||
row = db.upsert_canvas_project(user, payload)
|
||||
if row:
|
||||
imported.append(_canvas_project_public(row))
|
||||
db.audit(user, "canvas_project.import", "canvas_project", "", {"count": len(imported)}, request)
|
||||
return {"ok": True, "items": imported}
|
||||
|
||||
|
||||
def _parse_library_metadata(raw: str) -> dict:
|
||||
if not raw.strip():
|
||||
return {}
|
||||
@@ -2209,7 +2350,8 @@ def get_prompt_library_item(item_id: str) -> PromptLibraryItem:
|
||||
|
||||
|
||||
@app.post("/prompt-library", response_model=PromptLibraryItem)
|
||||
def create_prompt_library_item(req: PromptLibraryWriteReq) -> PromptLibraryItem:
|
||||
def create_prompt_library_item(req: PromptLibraryWriteReq, request: Request) -> PromptLibraryItem:
|
||||
user = data_user_from_request(request)
|
||||
now = _now_ts()
|
||||
name = req.name.strip()
|
||||
prompt_en = _ensure_english(req.prompt_en.strip()) if req.prompt_en.strip() else ""
|
||||
@@ -2229,11 +2371,13 @@ def create_prompt_library_item(req: PromptLibraryWriteReq) -> PromptLibraryItem:
|
||||
updated_at=now,
|
||||
)
|
||||
_write_prompt_item(item)
|
||||
db.audit(user, "prompt_library.create", "prompt", item.id, {"category": item.category, "name": item.name}, request, "company")
|
||||
return item
|
||||
|
||||
|
||||
@app.patch("/prompt-library/{item_id}", response_model=PromptLibraryItem)
|
||||
def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq) -> PromptLibraryItem:
|
||||
def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq, request: Request) -> PromptLibraryItem:
|
||||
user = data_user_from_request(request)
|
||||
item = find_prompt_library_item(item_id)
|
||||
data = item.model_dump()
|
||||
patch = req.model_dump(exclude_unset=True)
|
||||
@@ -2249,26 +2393,31 @@ def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq) -> Promp
|
||||
if not updated.name.strip():
|
||||
raise HTTPException(400, "prompt name required")
|
||||
_write_prompt_item(updated)
|
||||
db.audit(user, "prompt_library.update", "prompt", item_id, {"fields": sorted(patch.keys())}, request, "company")
|
||||
return updated
|
||||
|
||||
|
||||
@app.delete("/prompt-library/{item_id}")
|
||||
def delete_prompt_library_item(item_id: str) -> dict:
|
||||
def delete_prompt_library_item(item_id: str, request: Request) -> dict:
|
||||
user = data_user_from_request(request)
|
||||
item = find_prompt_library_item(item_id)
|
||||
src = _prompt_item_file(item)
|
||||
trash = LIBRARY_TRASH_DIR / "prompt_library" / f"{item.id}_{int(_now_ts())}.json"
|
||||
trash.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src), str(trash))
|
||||
_write_prompt_library_index()
|
||||
db.audit(user, "prompt_library.delete", "prompt", item.id, request=request, visibility="company")
|
||||
return {"ok": True, "id": item.id, "trashed": str(trash)}
|
||||
|
||||
|
||||
@app.post("/prompt-library/{item_id}/use", response_model=PromptLibraryItem)
|
||||
def use_prompt_library_item(item_id: str) -> PromptLibraryItem:
|
||||
def use_prompt_library_item(item_id: str, request: Request) -> PromptLibraryItem:
|
||||
user = data_user_from_request(request)
|
||||
item = find_prompt_library_item(item_id)
|
||||
item.use_count += 1
|
||||
item.updated_at = _now_ts()
|
||||
_write_prompt_item(item)
|
||||
db.audit(user, "prompt_library.use", "prompt", item.id, request=request, visibility="company")
|
||||
return item
|
||||
|
||||
|
||||
@@ -2288,9 +2437,11 @@ def get_asset_library_item(kind: AssetLibraryKind, item_id: str) -> AssetLibrary
|
||||
@app.post("/asset-library/{kind}", response_model=AssetLibraryItem)
|
||||
async def create_asset_library_item(
|
||||
kind: AssetLibraryKind,
|
||||
request: Request,
|
||||
metadata: str = Form("{}"),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
) -> AssetLibraryItem:
|
||||
user = data_user_from_request(request)
|
||||
meta = _parse_library_metadata(metadata)
|
||||
if not files:
|
||||
raise HTTPException(400, "at least one file required")
|
||||
@@ -2363,11 +2514,13 @@ async def create_asset_library_item(
|
||||
raise HTTPException(400, "video file required")
|
||||
_hydrate_asset_library_urls(item)
|
||||
_write_asset_item(item)
|
||||
db.audit(user, "asset_library.create", "asset_library", item.id, {"kind": kind, "name": item.name}, request, "company")
|
||||
return item
|
||||
|
||||
|
||||
@app.patch("/asset-library/{kind}/{item_id}", response_model=AssetLibraryItem)
|
||||
def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLibraryPatchReq) -> AssetLibraryItem:
|
||||
def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLibraryPatchReq, request: Request) -> AssetLibraryItem:
|
||||
user = data_user_from_request(request)
|
||||
item = find_asset_library_item(kind, item_id)
|
||||
data = item.model_dump()
|
||||
patch = req.model_dump(exclude_unset=True)
|
||||
@@ -2378,6 +2531,7 @@ def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLib
|
||||
updated = AssetLibraryItem(**data)
|
||||
_hydrate_asset_library_urls(updated)
|
||||
_write_asset_item(updated)
|
||||
db.audit(user, "asset_library.update", "asset_library", item_id, {"kind": kind, "fields": sorted(patch.keys())}, request, "company")
|
||||
return updated
|
||||
|
||||
|
||||
@@ -2388,7 +2542,8 @@ def asset_library_refs(kind: AssetLibraryKind, item_id: str) -> dict:
|
||||
|
||||
|
||||
@app.delete("/asset-library/{kind}/{item_id}")
|
||||
def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, force: bool = False) -> dict:
|
||||
def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, request: Request, force: bool = False) -> dict:
|
||||
user = data_user_from_request(request)
|
||||
item = find_asset_library_item(kind, item_id)
|
||||
refs = _library_ref_usage(kind, item_id)
|
||||
if refs["count"] and not force:
|
||||
@@ -2398,11 +2553,14 @@ def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, force: bool
|
||||
trash.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src), str(trash))
|
||||
_write_asset_library_index()
|
||||
db.audit(user, "asset_library.delete", "asset_library", item.id, {"kind": kind, "refs": refs}, request, "company")
|
||||
return {"ok": True, "id": item.id, "refs": refs, "trashed": str(trash)}
|
||||
|
||||
|
||||
@app.post("/asset-library/{kind}/{item_id}/copy-to-job/{job_id}")
|
||||
def copy_asset_library_to_job(kind: AssetLibraryKind, item_id: str, job_id: str) -> dict:
|
||||
def copy_asset_library_to_job(kind: AssetLibraryKind, item_id: str, job_id: str, request: Request) -> dict:
|
||||
user = data_user_from_request(request)
|
||||
db.audit(user, "asset_library.copy_to_job", "asset_library", item_id, {"kind": kind, "job_id": job_id}, request, "company")
|
||||
return _copy_library_to_job(kind, item_id, job_id)
|
||||
|
||||
|
||||
@@ -5455,6 +5613,7 @@ def health() -> dict:
|
||||
"feishu": FEISHU_AUTH_CONFIGURED,
|
||||
"data_isolation": AUTH_DATA_ISOLATION_ENABLED,
|
||||
},
|
||||
"database": db.health(),
|
||||
"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",
|
||||
@@ -5571,6 +5730,7 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks, request: Request) -
|
||||
assign_owner(job, user)
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
db.audit(user, "job.create", "job", job_id, {"url": job.url}, request)
|
||||
bg.add_task(pipeline_download, job_id)
|
||||
return job
|
||||
|
||||
@@ -5623,6 +5783,7 @@ async def create_job_from_upload(bg: BackgroundTasks, request: Request, file: Up
|
||||
assign_owner(job, user)
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
db.audit(user, "job.upload", "job", job_id, {"filename": file.filename}, request)
|
||||
bg.add_task(pipeline_download, job_id)
|
||||
return job
|
||||
|
||||
@@ -5688,6 +5849,7 @@ async def create_creative_image_job(request: Request) -> Job:
|
||||
assign_owner(job, user)
|
||||
JOBS[job_id] = job
|
||||
save_state(job)
|
||||
db.audit(user, "creative_job.create", "job", job_id, {"source": source_label}, request)
|
||||
return job
|
||||
|
||||
|
||||
@@ -9139,6 +9301,7 @@ def save_agent_run(run: AgentRun) -> None:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
agent_run_path(run.id).write_text(run.model_dump_json(indent=2), encoding="utf-8")
|
||||
AGENT_RUNS[run.id] = run
|
||||
db.index_agent_run(run.model_dump())
|
||||
|
||||
|
||||
def agent_log(
|
||||
@@ -9462,6 +9625,7 @@ async def create_agent_run(
|
||||
assign_owner(run, user)
|
||||
save_agent_run(run)
|
||||
agent_log(run, f"任务已入队 · job={job_id} · 产品图 {len(refs)} 张", status="queued", stage="queued", progress=1)
|
||||
db.audit(user, "agent_run.create", "agent_run", run_id, {"job_id": job_id, "product_refs": len(refs)}, request)
|
||||
threading.Thread(target=agent_run_worker, args=(run_id, refs), daemon=True).start()
|
||||
return run
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ KEYFRAME_COUNT=12
|
||||
CORS_ORIGINS=https://marketing.skg.com
|
||||
API_PORT=4291
|
||||
|
||||
# Company persistence database. Real password and DATABASE_URL live only on server.
|
||||
POSTGRES_DB=skg_marketing
|
||||
POSTGRES_USER=skg_marketing
|
||||
POSTGRES_PASSWORD=
|
||||
DATABASE_URL=postgresql://skg_marketing:CHANGE_ME@postgres:5432/skg_marketing
|
||||
|
||||
# Web login. Keep real password and session secret only on the server.
|
||||
WEB_AUTH_USERNAME=skg
|
||||
WEB_AUTH_PASSWORD=
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
name: skg-marketing-studio
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: skg-marketing-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- skg-marketing-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -13,6 +31,7 @@ services:
|
||||
AGENT_RUNS_DIR: /data/agent_runs
|
||||
ASSET_LIBRARY_DIR: /data/asset_library
|
||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||
CORS_ORIGINS: https://marketing.skg.com
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
@@ -22,6 +41,9 @@ services:
|
||||
- ./data/_trash:/data/_trash
|
||||
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- skg-marketing-internal
|
||||
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
name: skg-agent-cut
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: skg-agent-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-skg_marketing}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-skg_marketing}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- skg-agent-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skg_marketing} -d ${POSTGRES_DB:-skg_marketing}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -13,6 +31,7 @@ services:
|
||||
AGENT_RUNS_DIR: /data/agent_runs
|
||||
ASSET_LIBRARY_DIR: /data/asset_library
|
||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||
CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
@@ -22,6 +41,9 @@ services:
|
||||
- ./data/_trash:/data/_trash
|
||||
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
skg-agent-internal:
|
||||
aliases:
|
||||
|
||||
@@ -16,11 +16,93 @@ export const projects = ref([])
|
||||
// Current project ID | 当前项目ID
|
||||
export const currentProjectId = ref(null)
|
||||
|
||||
export const projectSyncStatus = ref('idle')
|
||||
export const projectSyncError = ref('')
|
||||
|
||||
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
|
||||
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
const remoteSaveTimers = new Map()
|
||||
let initPromise = null
|
||||
let remoteAvailable = false
|
||||
|
||||
// Current project | 当前项目
|
||||
export const currentProject = computed(() => {
|
||||
return projects.value.find(p => p.id === currentProjectId.value) || null
|
||||
})
|
||||
|
||||
const dateToSeconds = (value) => {
|
||||
if (value instanceof Date) return value.getTime() / 1000
|
||||
const parsed = new Date(value)
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.getTime() / 1000 : Date.now() / 1000
|
||||
}
|
||||
|
||||
const secondsToDate = (value) => {
|
||||
if (value instanceof Date) return value
|
||||
const num = Number(value || 0)
|
||||
return new Date(num > 100000000000 ? num : num * 1000)
|
||||
}
|
||||
|
||||
const projectFromApi = (item) => ({
|
||||
id: item.id,
|
||||
name: item.name || '未命名项目',
|
||||
thumbnail: item.thumbnail || '',
|
||||
visibility: item.visibility || 'private',
|
||||
ownerId: item.owner_id || '',
|
||||
ownerName: item.owner_name || '',
|
||||
ownerEmail: item.owner_email || '',
|
||||
ownerProvider: item.owner_provider || '',
|
||||
version: item.version || 1,
|
||||
createdAt: secondsToDate(item.created_at),
|
||||
updatedAt: secondsToDate(item.updated_at),
|
||||
canvasData: item.canvas_data || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||
}
|
||||
})
|
||||
|
||||
const projectToApi = (project) => ({
|
||||
id: project.id,
|
||||
name: project.name || '未命名项目',
|
||||
thumbnail: project.thumbnail || '',
|
||||
visibility: project.visibility || 'private',
|
||||
canvas_data: cleanProjectForStorage(project).canvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||
},
|
||||
created_at: dateToSeconds(project.createdAt),
|
||||
updated_at: dateToSeconds(project.updatedAt),
|
||||
source: 'canvas'
|
||||
})
|
||||
|
||||
const requestJson = async (path, init = {}) => {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(init.headers || {})
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(text || `${path} ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const mergeProjectLists = (localItems, remoteItems) => {
|
||||
const byId = new Map()
|
||||
for (const item of remoteItems) byId.set(item.id, item)
|
||||
for (const item of localItems) {
|
||||
const existing = byId.get(item.id)
|
||||
if (!existing || dateToSeconds(item.updatedAt) > dateToSeconds(existing.updatedAt)) {
|
||||
byId.set(item.id, item)
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => dateToSeconds(b.updatedAt) - dateToSeconds(a.updatedAt))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load projects from localStorage | 从 localStorage 加载项目
|
||||
*/
|
||||
@@ -42,6 +124,69 @@ export const loadProjects = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const saveRemoteProjectNow = async (project) => {
|
||||
if (!project?.id) return null
|
||||
const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(projectToApi(project))
|
||||
})
|
||||
return response.item ? projectFromApi(response.item) : null
|
||||
}
|
||||
|
||||
const scheduleRemoteSave = (project, delay = 800) => {
|
||||
if (!remoteAvailable || !project?.id) return
|
||||
if (remoteSaveTimers.has(project.id)) {
|
||||
clearTimeout(remoteSaveTimers.get(project.id))
|
||||
}
|
||||
remoteSaveTimers.set(project.id, setTimeout(async () => {
|
||||
remoteSaveTimers.delete(project.id)
|
||||
try {
|
||||
projectSyncStatus.value = 'syncing'
|
||||
await saveRemoteProjectNow(project)
|
||||
projectSyncStatus.value = 'synced'
|
||||
projectSyncError.value = ''
|
||||
} catch (err) {
|
||||
projectSyncStatus.value = 'error'
|
||||
projectSyncError.value = err.message || '项目同步失败'
|
||||
console.warn('Failed to sync project:', err)
|
||||
}
|
||||
}, delay))
|
||||
}
|
||||
|
||||
const importLocalProjectsToServer = async (localItems) => {
|
||||
if (!localItems.length) return []
|
||||
const payload = { projects: localItems.map(projectToApi) }
|
||||
const response = await requestJson('/canvas-projects/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
return (response.items || []).map(projectFromApi)
|
||||
}
|
||||
|
||||
export const loadRemoteProjects = async () => {
|
||||
try {
|
||||
projectSyncStatus.value = 'syncing'
|
||||
const localItems = [...projects.value]
|
||||
const response = await requestJson('/canvas-projects')
|
||||
remoteAvailable = true
|
||||
const remoteItems = (response.items || []).map(projectFromApi)
|
||||
const missingLocal = localItems.filter(local => !remoteItems.some(remote => remote.id === local.id))
|
||||
const importedItems = await importLocalProjectsToServer(missingLocal)
|
||||
const merged = mergeProjectLists(localItems, [...remoteItems, ...importedItems])
|
||||
projects.value = merged
|
||||
saveProjects({ remote: false })
|
||||
projectSyncStatus.value = 'synced'
|
||||
projectSyncError.value = ''
|
||||
return merged
|
||||
} catch (err) {
|
||||
remoteAvailable = false
|
||||
projectSyncStatus.value = 'error'
|
||||
projectSyncError.value = err.message || '项目同步失败'
|
||||
console.warn('Remote project sync unavailable:', err)
|
||||
return projects.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean node data for storage | 清理节点数据用于存储
|
||||
* Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小
|
||||
@@ -89,7 +234,7 @@ const cleanProjectForStorage = (project) => {
|
||||
* Save projects to localStorage | 保存项目到 localStorage
|
||||
* Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误
|
||||
*/
|
||||
export const saveProjects = () => {
|
||||
export const saveProjects = ({ remote = false } = {}) => {
|
||||
// Always clean data before saving | 保存前始终清理数据
|
||||
const cleanedProjects = projects.value.map(cleanProjectForStorage)
|
||||
|
||||
@@ -128,6 +273,10 @@ export const saveProjects = () => {
|
||||
console.error('Failed to save projects:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
for (const project of projects.value) scheduleRemoteSave(project)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +304,7 @@ export const createProject = (name = '未命名项目') => {
|
||||
|
||||
projects.value = [newProject, ...projects.value]
|
||||
saveProjects()
|
||||
scheduleRemoteSave(newProject, 0)
|
||||
|
||||
return id
|
||||
}
|
||||
@@ -179,6 +329,7 @@ export const updateProject = (id, data) => {
|
||||
projects.value = [updated, ...projects.value]
|
||||
|
||||
saveProjects()
|
||||
scheduleRemoteSave(updated)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -239,6 +390,10 @@ export const getProjectCanvas = (id) => {
|
||||
export const deleteProject = (id) => {
|
||||
projects.value = projects.value.filter(p => p.id !== id)
|
||||
saveProjects()
|
||||
if (remoteAvailable) {
|
||||
requestJson(`/canvas-projects/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
.catch(err => console.warn('Failed to delete remote project:', err))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +418,7 @@ export const duplicateProject = (id) => {
|
||||
|
||||
projects.value = [newProject, ...projects.value]
|
||||
saveProjects()
|
||||
scheduleRemoteSave(newProject, 0)
|
||||
|
||||
return newId
|
||||
}
|
||||
@@ -320,51 +476,14 @@ export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => {
|
||||
/**
|
||||
* Initialize projects store | 初始化项目存储
|
||||
*/
|
||||
export const initProjectsStore = () => {
|
||||
export const initProjectsStore = async () => {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
loadProjects()
|
||||
|
||||
// Create sample project if empty | 如果为空则创建示例项目
|
||||
if (projects.value.length === 0) {
|
||||
const id = createProject('示例项目')
|
||||
const project = projects.value.find(p => p.id === id)
|
||||
if (project) {
|
||||
project.canvasData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node_0',
|
||||
type: 'text',
|
||||
position: { x: 150, y: 150 },
|
||||
data: {
|
||||
content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。',
|
||||
label: '文本输入'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'node_1',
|
||||
type: 'imageConfig',
|
||||
position: { x: 500, y: 150 },
|
||||
data: {
|
||||
prompt: '',
|
||||
model: 'auto',
|
||||
size: '1024x1024',
|
||||
label: '文生图'
|
||||
}
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'edge_node_0_node_1',
|
||||
source: 'node_0',
|
||||
target: 'node_1',
|
||||
sourceHandle: 'right',
|
||||
targetHandle: 'left'
|
||||
}
|
||||
],
|
||||
viewport: { x: 100, y: 50, zoom: 0.8 }
|
||||
}
|
||||
saveProjects()
|
||||
}
|
||||
}
|
||||
await loadRemoteProjects()
|
||||
return projects.value
|
||||
})()
|
||||
return initPromise
|
||||
}
|
||||
|
||||
// Export for debugging | 导出用于调试
|
||||
|
||||
@@ -415,7 +415,7 @@ const scrollToProjects = () => {
|
||||
}
|
||||
|
||||
// Initialize projects store on mount | 挂载时初始化项目存储
|
||||
onMounted(() => {
|
||||
initProjectsStore()
|
||||
onMounted(async () => {
|
||||
await initProjectsStore()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user