auto-save 2026-05-26 00:13 (~8)

This commit is contained in:
2026-05-26 00:13:17 +08:00
parent 089a30d970
commit 544087cf9d
8 changed files with 407 additions and 67 deletions

View File

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