feat: harden storyforge production baseline

This commit is contained in:
kris
2026-03-26 19:33:36 +08:00
parent 38b02a9799
commit a30ed8decd
9 changed files with 1883 additions and 20 deletions

View File

@@ -199,6 +199,15 @@ class PublishAppUpdateRequest(BaseModel):
isActive: bool = True isActive: bool = True
class RetryFailedJobsRequest(BaseModel):
user_id: str = ""
project_id: str = ""
workflow_key: str = ""
line_type: str = ""
source_type: str = ""
limit: int = Field(default=20, ge=1, le=100)
class ProjectCreateRequest(BaseModel): class ProjectCreateRequest(BaseModel):
name: str name: str
description: str = "" description: str = ""
@@ -1772,6 +1781,182 @@ def job_payload(row: dict[str, Any]) -> dict[str, Any]:
} }
USAGE_COST_DEFAULTS: dict[str, dict[str, Any]] = {
"analysis": {"cost_cents": 6, "quota_field": "analysis_quota"},
"content_source_sync": {"cost_cents": 8, "quota_field": "analysis_quota"},
"copy": {"cost_cents": 3, "quota_field": "copy_quota"},
"review": {"cost_cents": 1, "quota_field": "analysis_quota"},
"ai_video": {"cost_cents": 30, "quota_field": "ai_video_quota"},
"real_cut": {"cost_cents": 20, "quota_field": "real_cut_quota"},
"live_recorder": {"cost_cents": 2, "quota_field": "recorder_quota"},
}
def _current_cycle_start() -> str:
current = datetime.now(timezone.utc)
return current.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat().replace("+00:00", "Z")
def _tenant_usage_payload(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": row["id"],
"user_id": row.get("user_id", ""),
"project_id": row.get("project_id", ""),
"category": row.get("category", ""),
"quantity": int(row.get("quantity") or 0),
"cost_cents": int(row.get("cost_cents") or 0),
"reference_type": row.get("reference_type", ""),
"reference_id": row.get("reference_id", ""),
"details": parse_json_object(row.get("details_json") or "{}"),
"created_at": row.get("created_at", ""),
}
def _tenant_quota_payload(row: dict[str, Any] | None, *, usage: dict[str, Any] | None = None) -> dict[str, Any]:
data = row or {}
return {
"id": data.get("id", ""),
"user_id": data.get("user_id", ""),
"project_id": data.get("project_id", ""),
"monthly_budget_cents": int(data.get("monthly_budget_cents") or 0),
"storage_limit_bytes": int(data.get("storage_limit_bytes") or 0),
"analysis_quota": int(data.get("analysis_quota") or 0),
"copy_quota": int(data.get("copy_quota") or 0),
"ai_video_quota": int(data.get("ai_video_quota") or 0),
"real_cut_quota": int(data.get("real_cut_quota") or 0),
"recorder_quota": int(data.get("recorder_quota") or 0),
"enabled": True if row is None else bool(int(data.get("enabled", 1) or 0)),
"config": parse_json_object(data.get("config_json") or "{}"),
"usage": usage or {},
"created_at": data.get("created_at", ""),
"updated_at": data.get("updated_at", ""),
}
def _project_storage_bytes(account: dict[str, Any], *, project_id: str) -> int:
normalized_project_id = (project_id or "").strip() or None
jobs_root = job_project_root(account["id"], normalized_project_id) if normalized_project_id else job_account_root(account["id"])
downloads_root = tenant_download_root(account["id"], normalized_project_id) if normalized_project_id else download_account_root(account["id"])
jobs_usage = directory_usage_payload(jobs_root)
downloads_usage = directory_usage_payload(downloads_root)
return int(jobs_usage.get("bytes") or 0) + int(downloads_usage.get("bytes") or 0)
def _tenant_usage_summary(account: dict[str, Any], *, project_id: str) -> dict[str, Any]:
cycle_start = _current_cycle_start()
rows = db.fetch_all(
"""
SELECT category, SUM(quantity) AS quantity, SUM(cost_cents) AS cost_cents
FROM tenant_usage_ledger
WHERE user_id = ? AND project_id = ? AND created_at >= ?
GROUP BY category
ORDER BY category ASC
""",
(account["id"], project_id, cycle_start),
)
by_category: dict[str, dict[str, Any]] = {}
for row in rows:
category = row.get("category", "")
by_category[category] = {
"category": category,
"quantity": int(row.get("quantity") or 0),
"cost_cents": int(row.get("cost_cents") or 0),
}
recent_rows = db.fetch_all(
"""
SELECT * FROM tenant_usage_ledger
WHERE user_id = ? AND project_id = ?
ORDER BY created_at DESC
LIMIT 20
""",
(account["id"], project_id),
)
total_cost = sum(item["cost_cents"] for item in by_category.values())
storage_bytes = _project_storage_bytes(account, project_id=project_id)
return {
"cycle_start": cycle_start,
"categories": by_category,
"total_cost_cents": total_cost,
"recent_items": [_tenant_usage_payload(row) for row in recent_rows],
"storage_bytes": storage_bytes,
}
def _get_tenant_quota_row(account: dict[str, Any], *, project_id: str) -> dict[str, Any] | None:
return db.fetch_one(
"SELECT * FROM tenant_quota_profiles WHERE user_id = ? AND project_id = ?",
(account["id"], project_id),
)
def _get_tenant_quota(account: dict[str, Any], *, project_id: str) -> dict[str, Any]:
usage = _tenant_usage_summary(account, project_id=project_id)
row = _get_tenant_quota_row(account, project_id=project_id)
payload = _tenant_quota_payload(row, usage=usage)
storage_limit = int(payload.get("storage_limit_bytes") or 0)
payload["storage_over_limit"] = bool(storage_limit and usage["storage_bytes"] >= storage_limit)
return payload
def _record_tenant_usage(
account: dict[str, Any],
*,
project_id: str,
category: str,
reference_type: str,
reference_id: str,
details: dict[str, Any] | None = None,
quantity: int = 1,
) -> dict[str, Any]:
usage_meta = USAGE_COST_DEFAULTS.get(category, {})
usage_id = make_id("usage")
db.execute(
"""
INSERT INTO tenant_usage_ledger (
id, user_id, project_id, category, quantity, cost_cents, reference_type, reference_id, details_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
usage_id,
account["id"],
project_id,
category,
int(quantity or 1),
int(usage_meta.get("cost_cents") or 0) * int(quantity or 1),
reference_type,
reference_id,
json.dumps(details or {}, ensure_ascii=False),
utc_now(),
),
)
row = db.fetch_one("SELECT * FROM tenant_usage_ledger WHERE id = ?", (usage_id,))
return _tenant_usage_payload(row)
def _enforce_tenant_quota(account: dict[str, Any], *, project_id: str, usage_category: str) -> None:
quota = _get_tenant_quota(account, project_id=project_id)
if not quota.get("enabled", True):
return
usage = quota.get("usage", {})
category_meta = USAGE_COST_DEFAULTS.get(usage_category, {})
quota_field = category_meta.get("quota_field")
if quota_field:
allowed = int(quota.get(quota_field) or 0)
consumed = int(((usage.get("categories") or {}).get(usage_category) or {}).get("quantity") or 0)
if allowed and consumed >= allowed:
raise HTTPException(status_code=403, detail=f"当前租户本周期的 {usage_category} 配额已用完")
budget = int(quota.get("monthly_budget_cents") or 0)
total_cost = int(usage.get("total_cost_cents") or 0)
next_cost = int(category_meta.get("cost_cents") or 0)
if budget and total_cost + next_cost > budget:
raise HTTPException(status_code=403, detail="当前租户本周期预算不足,已阻止本次动作执行")
storage_limit = int(quota.get("storage_limit_bytes") or 0)
if storage_limit and usage_category in {"analysis", "content_source_sync", "ai_video", "real_cut"}:
storage_bytes = int(usage.get("storage_bytes") or 0)
if storage_bytes >= storage_limit:
raise HTTPException(status_code=403, detail="当前租户存储额度已满,已阻止继续产生大文件缓存")
def require_auth(authorization: str | None = Header(default=None)) -> dict[str, Any]: def require_auth(authorization: str | None = Header(default=None)) -> dict[str, Any]:
if not authorization or not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token") raise HTTPException(status_code=401, detail="Missing bearer token")
@@ -3130,6 +3315,7 @@ def create_live_recorder_source(
) -> dict[str, Any]: ) -> dict[str, Any]:
project = resolve_target_project(account["id"], request.project_id or None, username=account["username"]) project = resolve_target_project(account["id"], request.project_id or None, username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"]) assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
_enforce_tenant_quota(account, project_id=project["id"], usage_category="live_recorder")
binding = upsert_live_recorder_binding( binding = upsert_live_recorder_binding(
user_id=account["id"], user_id=account["id"],
project_id=project["id"], project_id=project["id"],
@@ -3140,6 +3326,14 @@ def create_live_recorder_source(
quality=request.quality, quality=request.quality,
enabled=request.enabled, enabled=request.enabled,
) )
_record_tenant_usage(
account,
project_id=project["id"],
category="live_recorder",
reference_type="live_recorder_binding",
reference_id=binding["id"],
details={"source_url": request.source_url, "platform": request.platform or infer_platform_from_url(request.source_url)},
)
sync_result = sync_live_recorder_remote_config() sync_result = sync_live_recorder_remote_config()
return {"item": live_recorder_binding_payload(binding), "sync": sync_result} return {"item": live_recorder_binding_payload(binding), "sync": sync_result}
@@ -3561,6 +3755,7 @@ async def create_content_source_sync_job(
kb = resolve_target_kb(account["id"], request.knowledge_base_id or None, project["id"], username=account["username"]) kb = resolve_target_kb(account["id"], request.knowledge_base_id or None, project["id"], username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"]) assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
profile = model_profile_for_account(account["id"], request.analysis_model_profile_id or None) profile = model_profile_for_account(account["id"], request.analysis_model_profile_id or None)
_enforce_tenant_quota(account, project_id=project["id"], usage_category="content_source_sync")
source_url = (request.source_url or (source_row or {}).get("source_url") or "").strip() source_url = (request.source_url or (source_row or {}).get("source_url") or "").strip()
if not source_url: if not source_url:
@@ -3619,6 +3814,14 @@ async def create_content_source_sync_job(
}, },
analysis_model_profile_id=profile["id"], analysis_model_profile_id=profile["id"],
) )
_record_tenant_usage(
account,
project_id=project["id"],
category="content_source_sync",
reference_type="job",
reference_id=job_row["id"],
details={"workflow_key": "content_source_sync_pipeline", "source_url": source_url, "platform": platform},
)
update_content_source_metadata( update_content_source_metadata(
source_row["id"], source_row["id"],
{ {
@@ -3732,6 +3935,7 @@ def create_review(request: ReviewCreateRequest, account: dict[str, Any] = Depend
requested_project_id = request.project_id.strip() or (source_job.get("project_id", "") if source_job else "") requested_project_id = request.project_id.strip() or (source_job.get("project_id", "") if source_job else "")
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"]) project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"]) assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
_enforce_tenant_quota(account, project_id=project["id"], usage_category="review")
review_id = make_id("review") review_id = make_id("review")
title = request.title.strip() or (source_job.get("title", "") if source_job else "") title = request.title.strip() or (source_job.get("title", "") if source_job else "")
if not title: if not title:
@@ -3767,6 +3971,14 @@ def create_review(request: ReviewCreateRequest, account: dict[str, Any] = Depend
), ),
) )
row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,)) row = db.fetch_one("SELECT * FROM publish_reviews WHERE id = ?", (review_id,))
_record_tenant_usage(
account,
project_id=project["id"],
category="review",
reference_type="review",
reference_id=review_id,
details={"source_job_id": source_job["id"] if source_job else "", "platform": normalized_platform},
)
return review_payload(row) return review_payload(row)
@@ -3852,6 +4064,106 @@ def get_job_events(job_id: str, account: dict[str, Any] = Depends(require_approv
] ]
async def _requeue_job(row: dict[str, Any], *, actor_id: str, action: str, force: bool = False) -> dict[str, Any]:
current_status = str(row.get("status") or "")
if action == "retry" and current_status != "failed":
raise HTTPException(status_code=409, detail="Only failed jobs can be retried")
if action == "requeue" and current_status == "completed" and not force:
raise HTTPException(status_code=409, detail="Completed jobs cannot be requeued")
if current_status == "processing" and not force:
raise HTTPException(status_code=409, detail="Processing jobs cannot be requeued without force")
job_id = row["id"]
append_job_event(
job_id,
f"job.{action}.requested",
{
"actor_id": actor_id,
"status_before": current_status,
"workflow_key": row.get("workflow_key", ""),
"source_type": row.get("source_type", ""),
},
)
updated = update_job_state(
job_id,
status="queued",
error="",
provider_name="",
provider_task_id="",
result={
"retry": {
"action": action,
"actor_id": actor_id,
"status_before": current_status,
"requeued_at": utc_now(),
}
},
)
append_job_event(
job_id,
f"job.{action}.queued",
{
"actor_id": actor_id,
"status_before": current_status,
"status_after": "queued",
},
)
return await trigger_orchestrated_job(updated)
@app.post("/v2/explore/jobs/{job_id}/retry")
async def retry_job(job_id: str, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
row = load_owned_job(job_id, account["id"])
return job_payload(await _requeue_job(row, actor_id=account["id"], action="retry"))
@app.post("/v2/explore/jobs/{job_id}/requeue")
async def requeue_job(job_id: str, account: dict[str, Any] = Depends(require_approved)) -> dict[str, Any]:
row = load_owned_job(job_id, account["id"])
return job_payload(await _requeue_job(row, actor_id=account["id"], action="requeue", force=True))
@app.post("/v2/admin/jobs/retry-failed")
async def admin_retry_failed_jobs(
request: RetryFailedJobsRequest,
admin: dict[str, Any] = Depends(require_super_admin),
) -> dict[str, Any]:
clauses = ["status = 'failed'"]
params: list[Any] = []
if request.user_id.strip():
clauses.append("user_id = ?")
params.append(request.user_id.strip())
if request.project_id.strip():
clauses.append("project_id = ?")
params.append(request.project_id.strip())
if request.workflow_key.strip():
clauses.append("workflow_key = ?")
params.append(request.workflow_key.strip())
if request.line_type.strip():
clauses.append("line_type = ?")
params.append(request.line_type.strip())
if request.source_type.strip():
clauses.append("source_type = ?")
params.append(request.source_type.strip())
rows = db.fetch_all(
f"""
SELECT * FROM jobs
WHERE {' AND '.join(clauses)}
ORDER BY updated_at ASC
LIMIT ?
""",
tuple(params + [request.limit]),
)
retried: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for row in rows:
try:
retried.append(job_payload(await _requeue_job(row, actor_id=admin["id"], action="retry", force=True)))
except HTTPException as exc:
skipped.append({"job_id": row["id"], "detail": str(exc.detail)})
return {"retried": retried, "skipped": skipped, "count": len(retried), "matched": len(rows)}
def resolve_target_kb(account_id: str, requested_kb_id: str | None, project_id: str = "", username: str = "默认用户") -> dict[str, Any]: def resolve_target_kb(account_id: str, requested_kb_id: str | None, project_id: str = "", username: str = "默认用户") -> dict[str, Any]:
if requested_kb_id: if requested_kb_id:
kb = db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ? AND user_id = ?", (requested_kb_id, account_id)) kb = db.fetch_one("SELECT * FROM knowledge_bases WHERE id = ? AND user_id = ?", (requested_kb_id, account_id))
@@ -3869,6 +4181,7 @@ async def create_text_job(request: ExploreTextRequest, account: dict[str, Any] =
kb = resolve_target_kb(account["id"], request.knowledge_base_id, project["id"], username=account["username"]) kb = resolve_target_kb(account["id"], request.knowledge_base_id, project["id"], username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id, project["id"]) assistant = resolve_target_assistant(account["id"], request.assistant_id, project["id"])
profile = model_profile_for_account(account["id"], request.analysis_model_profile_id) profile = model_profile_for_account(account["id"], request.analysis_model_profile_id)
_enforce_tenant_quota(account, project_id=project["id"], usage_category="analysis")
source = create_content_source( source = create_content_source(
account_id=account["id"], account_id=account["id"],
project_id=project["id"], project_id=project["id"],
@@ -3890,6 +4203,14 @@ async def create_text_job(request: ExploreTextRequest, account: dict[str, Any] =
artifacts={"input_text": request.content}, artifacts={"input_text": request.content},
analysis_model_profile_id=profile["id"], analysis_model_profile_id=profile["id"],
) )
_record_tenant_usage(
account,
project_id=project["id"],
category="analysis",
reference_type="job",
reference_id=job_row["id"],
details={"workflow_key": "analysis_pipeline", "source_type": "text"},
)
return job_payload(await trigger_orchestrated_job(job_row)) return job_payload(await trigger_orchestrated_job(job_row))
@@ -3899,6 +4220,7 @@ async def create_video_link_job(request: ExploreVideoLinkRequest, account: dict[
kb = resolve_target_kb(account["id"], request.knowledge_base_id, project["id"], username=account["username"]) kb = resolve_target_kb(account["id"], request.knowledge_base_id, project["id"], username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id, project["id"]) assistant = resolve_target_assistant(account["id"], request.assistant_id, project["id"])
profile = model_profile_for_account(account["id"], request.analysis_model_profile_id) profile = model_profile_for_account(account["id"], request.analysis_model_profile_id)
_enforce_tenant_quota(account, project_id=project["id"], usage_category="analysis")
source = create_content_source( source = create_content_source(
account_id=account["id"], account_id=account["id"],
project_id=project["id"], project_id=project["id"],
@@ -3922,6 +4244,14 @@ async def create_video_link_job(request: ExploreVideoLinkRequest, account: dict[
artifacts={}, artifacts={},
analysis_model_profile_id=profile["id"], analysis_model_profile_id=profile["id"],
) )
_record_tenant_usage(
account,
project_id=project["id"],
category="analysis",
reference_type="job",
reference_id=job_row["id"],
details={"workflow_key": "analysis_pipeline", "source_type": "video_link"},
)
return job_payload(await trigger_orchestrated_job(job_row)) return job_payload(await trigger_orchestrated_job(job_row))
@@ -3939,6 +4269,7 @@ async def upload_video(
kb = resolve_target_kb(account["id"], knowledge_base_id or None, project["id"], username=account["username"]) kb = resolve_target_kb(account["id"], knowledge_base_id or None, project["id"], username=account["username"])
assistant = resolve_target_assistant(account["id"], assistant_id or None, project["id"]) assistant = resolve_target_assistant(account["id"], assistant_id or None, project["id"])
profile = model_profile_for_account(account["id"], analysis_model_profile_id or None) profile = model_profile_for_account(account["id"], analysis_model_profile_id or None)
_enforce_tenant_quota(account, project_id=project["id"], usage_category="analysis")
job_id = make_id("job_upload") job_id = make_id("job_upload")
job_dir = job_storage_dir(account_id=account["id"], project_id=project["id"], job_id=job_id) job_dir = job_storage_dir(account_id=account["id"], project_id=project["id"], job_id=job_id)
job_dir.mkdir(parents=True, exist_ok=True) job_dir.mkdir(parents=True, exist_ok=True)
@@ -3969,6 +4300,14 @@ async def upload_video(
artifacts={"uploaded_path": str(target_path)}, artifacts={"uploaded_path": str(target_path)},
analysis_model_profile_id=profile["id"], analysis_model_profile_id=profile["id"],
) )
_record_tenant_usage(
account,
project_id=project["id"],
category="analysis",
reference_type="job",
reference_id=job_row["id"],
details={"workflow_key": "analysis_pipeline", "source_type": "upload_video"},
)
return job_payload(await trigger_orchestrated_job(job_row)) return job_payload(await trigger_orchestrated_job(job_row))
@@ -3985,6 +4324,7 @@ async def create_real_cut_job(request: RealCutJobRequest, account: dict[str, Any
raise HTTPException(status_code=400, detail="Source job does not belong to target project") raise HTTPException(status_code=400, detail="Source job does not belong to target project")
kb = ensure_user_kb(account["id"], project["id"], username=account["username"]) kb = ensure_user_kb(account["id"], project["id"], username=account["username"])
_enforce_tenant_quota(account, project_id=project["id"], usage_category="real_cut")
resolved_input_dir = request.input_dir.strip() resolved_input_dir = request.input_dir.strip()
staged_payload: dict[str, Any] = {} staged_payload: dict[str, Any] = {}
if not resolved_input_dir: if not resolved_input_dir:
@@ -4042,6 +4382,14 @@ async def create_real_cut_job(request: RealCutJobRequest, account: dict[str, Any
} }
}, },
) )
_record_tenant_usage(
account,
project_id=project["id"],
category="real_cut",
reference_type="job",
reference_id=job_row["id"],
details={"workflow_key": "real_cut_pipeline", "source_job_id": source_job["id"] if source_job else ""},
)
return job_payload(await trigger_orchestrated_job(job_row)) return job_payload(await trigger_orchestrated_job(job_row))
@@ -4063,6 +4411,7 @@ async def create_ai_video_job(request: AiVideoJobRequest, account: dict[str, Any
project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"]) project = resolve_target_project(account["id"], requested_project_id or None, username=account["username"])
kb = resolve_target_kb(account["id"], request.knowledge_base_id or source_kb_id or None, project["id"], username=account["username"]) kb = resolve_target_kb(account["id"], request.knowledge_base_id or source_kb_id or None, project["id"], username=account["username"])
assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"]) assistant = resolve_target_assistant(account["id"], request.assistant_id or None, project["id"])
_enforce_tenant_quota(account, project_id=project["id"], usage_category="ai_video")
source = create_content_source( source = create_content_source(
account_id=account["id"], account_id=account["id"],
project_id=project["id"], project_id=project["id"],
@@ -4093,6 +4442,14 @@ async def create_ai_video_job(request: AiVideoJobRequest, account: dict[str, Any
"source_job_id": request.source_job_id.strip(), "source_job_id": request.source_job_id.strip(),
}, },
) )
_record_tenant_usage(
account,
project_id=project["id"],
category="ai_video",
reference_type="job",
reference_id=job_row["id"],
details={"workflow_key": "ai_video_pipeline", "source_job_id": request.source_job_id.strip()},
)
return job_payload(await trigger_orchestrated_job(job_row)) return job_payload(await trigger_orchestrated_job(job_row))
@@ -4196,6 +4553,9 @@ async def generate_copy(assistant_id: str, request: GenerateCopyRequest, account
assistant = db.fetch_one("SELECT * FROM assistants WHERE id = ? AND user_id = ?", (assistant_id, account["id"])) assistant = db.fetch_one("SELECT * FROM assistants WHERE id = ? AND user_id = ?", (assistant_id, account["id"]))
if not assistant: if not assistant:
raise HTTPException(status_code=404, detail="Assistant not found") raise HTTPException(status_code=404, detail="Assistant not found")
project_id = assistant.get("project_id", "") or ""
if project_id:
_enforce_tenant_quota(account, project_id=project_id, usage_category="copy")
kb_ids = request.knowledge_base_ids or [row["knowledge_base_id"] for row in db.fetch_all("SELECT knowledge_base_id FROM assistant_knowledge_bases WHERE assistant_id = ?", (assistant_id,))] kb_ids = request.knowledge_base_ids or [row["knowledge_base_id"] for row in db.fetch_all("SELECT knowledge_base_id FROM assistant_knowledge_bases WHERE assistant_id = ?", (assistant_id,))]
used_documents: list[dict[str, Any]] = [] used_documents: list[dict[str, Any]] = []
excerpts: list[str] = [] excerpts: list[str] = []
@@ -4221,6 +4581,15 @@ async def generate_copy(assistant_id: str, request: GenerateCopyRequest, account
) )
profile = model_profile_for_account(account["id"], assistant.get("model_profile_id") or None) profile = model_profile_for_account(account["id"], assistant.get("model_profile_id") or None)
content = await call_model(profile, system_prompt, user_prompt, temperature=0.7) content = await call_model(profile, system_prompt, user_prompt, temperature=0.7)
if project_id:
_record_tenant_usage(
account,
project_id=project_id,
category="copy",
reference_type="assistant",
reference_id=assistant_id,
details={"knowledge_base_ids": kb_ids, "used_documents": len(used_documents)},
)
return { return {
"assistant_id": assistant_id, "assistant_id": assistant_id,
"knowledge_base_ids": kb_ids, "knowledge_base_ids": kb_ids,

View File

@@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
import os
import sqlite3 import sqlite3
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
SQLITE_BUSY_TIMEOUT_MS = int(os.getenv("SQLITE_BUSY_TIMEOUT_MS", "5000"))
SQLITE_CONNECT_TIMEOUT_SEC = float(os.getenv("SQLITE_CONNECT_TIMEOUT_SEC", "30"))
def utc_now() -> str: def utc_now() -> str:
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -22,9 +27,14 @@ class Database:
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
def connect(self) -> sqlite3.Connection: def connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.path) conn = sqlite3.connect(self.path, timeout=SQLITE_CONNECT_TIMEOUT_SEC)
conn.row_factory = dict_factory conn.row_factory = dict_factory
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA temp_store = MEMORY")
conn.execute("PRAGMA wal_autocheckpoint = 1000")
return conn return conn
@contextmanager @contextmanager
@@ -265,6 +275,41 @@ class Database:
FOREIGN KEY(source_id) REFERENCES live_recorder_sources(id) ON DELETE CASCADE FOREIGN KEY(source_id) REFERENCES live_recorder_sources(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS tenant_quota_profiles (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
monthly_budget_cents INTEGER NOT NULL DEFAULT 0,
storage_limit_bytes INTEGER NOT NULL DEFAULT 0,
analysis_quota INTEGER NOT NULL DEFAULT 0,
copy_quota INTEGER NOT NULL DEFAULT 0,
ai_video_quota INTEGER NOT NULL DEFAULT 0,
real_cut_quota INTEGER NOT NULL DEFAULT 0,
recorder_quota INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
config_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, project_id),
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS tenant_usage_ledger (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
cost_cents INTEGER NOT NULL DEFAULT 0,
reference_type TEXT NOT NULL DEFAULT '',
reference_id TEXT NOT NULL DEFAULT '',
details_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS job_events ( CREATE TABLE IF NOT EXISTS job_events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
job_id TEXT NOT NULL, job_id TEXT NOT NULL,

View File

@@ -40,6 +40,13 @@
- 复盘 - 复盘
- 额度与运维面板 - 额度与运维面板
## 当前量产基线
- SQLite 已默认启用 `WAL``busy_timeout``synchronous=NORMAL``foreign_keys=ON` 等连接参数,减少并发写入时的锁冲突。
- `tenant_quota_profiles``tenant_usage_ledger` 已接入核心生产链,`explore/*``content-source-sync``reviews``real-cut``ai-video``assistants/{id}/generate``live-recorder create` 都会先做额度硬拦截,再记账。
- `jobs` 已补 `retry / requeue` 单任务入口,以及管理员批量重试失败任务入口,便于失败链路恢复。
- 仓库内已新增 SQLite 备份脚本,可在发布或故障前快速生成一致性快照。
## 当前支持的平台 ## 当前支持的平台
- `douyin` - `douyin`
@@ -73,4 +80,5 @@
1. 继续按当前仓库边界维护,不再把 `AI Glasses` 代码重新叠进来。 1. 继续按当前仓库边界维护,不再把 `AI Glasses` 代码重新叠进来。
2. Web 功能优先围绕多平台工作台、生产中心和租户控制面继续深化。 2. Web 功能优先围绕多平台工作台、生产中心和租户控制面继续深化。
3. 需要真实平台验证的事项,单独作为联调任务推进,不再和仓库边界治理混在一起。 3. 需要真实平台验证的事项,单独作为联调任务推进,不再和仓库边界治理混在一起。
4. 公网环境出现异常时,先检查云服务器上的 `nginx / storyforge-web-v4.service / collector-service`,再检查本机桥接链 4. 生产基线任务优先按“任务恢复、额度硬控、数据库备份、观测补齐”继续深化
5. 公网环境出现异常时,先检查云服务器上的 `nginx / storyforge-web-v4.service / collector-service`,再检查本机桥接链。

View File

@@ -0,0 +1,42 @@
# StoryForge 生产基线
日期2026-03-26
本文档描述当前仓库已经落地的量产底盘,便于后续继续开发和运维。
## 已落地能力
- SQLite 默认连接参数已收紧:
- `journal_mode=WAL`
- `synchronous=NORMAL`
- `busy_timeout`
- `foreign_keys=ON`
- `temp_store=MEMORY`
- 核心生产 API 已接入 tenant quota 硬控制与 usage ledger 记账:
- `POST /v2/explore/text`
- `POST /v2/explore/video-link`
- `POST /v2/explore/upload-video`
- `POST /v2/pipelines/content-source-sync`
- `POST /v2/reviews`
- `POST /v2/pipelines/real-cut`
- `POST /v2/pipelines/ai-video`
- `POST /v2/assistants/{assistant_id}/generate`
- `POST /v2/live-recorder/sources`
- 失败任务恢复入口已补齐:
- `POST /v2/explore/jobs/{job_id}/retry`
- `POST /v2/explore/jobs/{job_id}/requeue`
- `POST /v2/admin/jobs/retry-failed`
- 仓库内已新增 SQLite 备份脚本:
- `scripts/backup_storyforge_sqlite.sh`
## 运行建议
- 发布前先执行一次数据库备份,再执行服务升级。
- quota 配置建议按 project 维度维护,避免不同项目之间互相干扰。
- 批量 retry 建议优先筛选 `workflow_key``source_type`,避免把不同流水线一起打回去。
## 当前外部阻塞
- 真正的额度策略仍取决于业务侧如何配置 `tenant_quota_profiles`
- `real-cut``ai-video``content-source-sync` 的完整链路仍依赖外部服务可用性。
- 抖音等真实平台采集仍可能受到平台风控影响,需要真实联调确认。

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DATABASE_PATH="${DATABASE_PATH:-$ROOT_DIR/data/collector/storyforge.db}"
BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/data/backups}"
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
TARGET_PATH="$BACKUP_DIR/storyforge-${TIMESTAMP}.db"
mkdir -p "$BACKUP_DIR"
python3 - "$DATABASE_PATH" "$TARGET_PATH" <<'PY'
from __future__ import annotations
import pathlib
import sqlite3
import sys
source_path = pathlib.Path(sys.argv[1])
target_path = pathlib.Path(sys.argv[2])
if not source_path.exists():
raise SystemExit(f"source database not found: {source_path}")
target_path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(source_path) as source_conn, sqlite3.connect(target_path) as target_conn:
source_conn.backup(target_conn)
print(target_path)
PY

View File

@@ -0,0 +1,355 @@
from __future__ import annotations
import importlib
import json
import os
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
APP_ROOT = ROOT / "collector-service"
if str(APP_ROOT) not in sys.path:
sys.path.insert(0, str(APP_ROOT))
class ProductionBaselineTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tempdir = tempfile.TemporaryDirectory()
temp_root = Path(cls.tempdir.name)
os.environ["DATA_DIR"] = str(temp_root / "data")
os.environ["DATABASE_PATH"] = str(temp_root / "data" / "storyforge.db")
os.environ["DOWNLOADS_DIR"] = str(temp_root / "downloads")
os.environ["JOBS_DIR"] = str(temp_root / "jobs")
os.environ["MODELS_DIR"] = str(temp_root / "models")
os.environ["ORCHESTRATOR_SHARED_SECRET"] = "test-secret"
os.environ.setdefault("BOOTSTRAP_SUPERADMIN_USERNAME", "")
os.environ.setdefault("BOOTSTRAP_SUPERADMIN_PASSWORD", "")
cls.db_module = importlib.reload(importlib.import_module("app.database"))
cls.core = importlib.reload(importlib.import_module("app.core_main"))
cls.app_main = importlib.reload(importlib.import_module("app.main"))
async def fake_trigger(job_row: dict[str, Any]) -> dict[str, Any]:
cls.core.append_job_event(job_row["id"], "workflow.trigger.requested", {"workflow_key": job_row.get("workflow_key", "")})
cls.core.update_job_state(
job_row["id"],
status="queued",
provider_name="n8n",
provider_task_id="",
result={"n8n_trigger": {"requested": True, "mocked": True}},
)
return cls.core.db.fetch_one("SELECT * FROM jobs WHERE id = ?", (job_row["id"],))
async def fake_call_model(*_args: object, **_kwargs: object) -> str:
return "mock content"
cls.core.trigger_orchestrated_job = fake_trigger
cls.core.call_model = fake_call_model
cls.core.sync_live_recorder_remote_config = lambda: {"ok": True}
cls.core.db.init_schema()
cls.client = TestClient(cls.app_main.app)
@classmethod
def tearDownClass(cls) -> None:
cls.client.close()
cls.tempdir.cleanup()
def setUp(self) -> None:
self._clear_tables()
def _clear_tables(self) -> None:
tables = [
"job_events",
"tenant_usage_ledger",
"tenant_quota_profiles",
"auth_tokens",
"publish_reviews",
"live_recorder_bindings",
"live_recorder_sources",
"jobs",
"content_sources",
"assistant_knowledge_bases",
"assistants",
"knowledge_documents",
"knowledge_bases",
"projects",
"accounts",
"model_profiles",
]
for table in tables:
self.core.db.execute(f"DELETE FROM {table}")
def _seed_context(self, tag: str, *, exhausted: bool = False) -> dict[str, Any]:
now = self.db_module.utc_now()
account_id = f"acct_{tag}"
project_id = f"proj_{tag}"
model_id = f"model_{tag}"
kb_id = f"kb_{tag}"
assistant_id = f"assistant_{tag}"
token = f"token_{tag}"
username = f"user_{tag}"
self.core.db.execute(
"""
INSERT INTO accounts (
id, username, password_hash, password_salt, display_name, role, approval_status,
approved_by, approved_at, preferred_analysis_model_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
account_id,
username,
"hash",
"salt",
f"User {tag}",
"super_admin",
"approved",
account_id,
now,
model_id,
now,
now,
),
)
self.core.db.execute(
"""
INSERT INTO projects (id, user_id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(project_id, account_id, f"Project {tag}", "", now, now),
)
self.core.db.execute(
"""
INSERT INTO model_profiles (
id, owner_account_id, name, provider, base_url, api_key, model_name,
is_system, is_default, created_at, updated_at
) VALUES (?, NULL, ?, ?, ?, ?, ?, 1, 1, ?, ?)
""",
(model_id, "Default Model", "openai_compat", "http://127.0.0.1:8317/v1", "", "GLM-5", now, now),
)
self.core.db.execute(
"""
INSERT INTO knowledge_bases (id, user_id, project_id, name, description, sync_status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'ready', ?, ?)
""",
(kb_id, account_id, project_id, f"KB {tag}", "", now, now),
)
self.core.db.execute(
"""
INSERT INTO assistants (id, user_id, project_id, name, description, system_prompt, generation_goal, config_json, model_profile_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '{}', ?, ?, ?)
""",
(
assistant_id,
account_id,
project_id,
f"Assistant {tag}",
"",
"你是文案助手。",
"生成短视频文案。",
model_id,
now,
now,
),
)
self.core.db.execute("INSERT INTO assistant_knowledge_bases (assistant_id, knowledge_base_id) VALUES (?, ?)", (assistant_id, kb_id))
self.core.db.execute(
"INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)",
(token, account_id, now),
)
if exhausted:
quota_id = f"quota_{tag}"
self.core.db.execute(
"""
INSERT INTO tenant_quota_profiles (
id, user_id, project_id, monthly_budget_cents, storage_limit_bytes, analysis_quota,
copy_quota, ai_video_quota, real_cut_quota, recorder_quota, enabled, config_json,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, '{}', ?, ?)
""",
(quota_id, account_id, project_id, 9999, 0, 1, 1, 1, 1, 1, now, now),
)
for category in ["analysis", "content_source_sync", "review", "copy", "ai_video", "real_cut", "live_recorder"]:
usage_id = f"usage_{tag}_{category}"
cost_map = {
"analysis": 6,
"content_source_sync": 8,
"review": 1,
"copy": 3,
"ai_video": 30,
"real_cut": 20,
"live_recorder": 2,
}
self.core.db.execute(
"""
INSERT INTO tenant_usage_ledger (
id, user_id, project_id, category, quantity, cost_cents, reference_type, reference_id, details_json, created_at
) VALUES (?, ?, ?, ?, 1, ?, 'seed', ?, '{}', ?)
""",
(usage_id, account_id, project_id, category, cost_map[category], usage_id, now),
)
return {
"account_id": account_id,
"project_id": project_id,
"model_id": model_id,
"kb_id": kb_id,
"assistant_id": assistant_id,
"token": token,
}
def test_database_uses_wal_and_busy_timeout(self) -> None:
conn = self.core.db.connect()
try:
journal_mode_row = conn.execute("PRAGMA journal_mode").fetchone()
busy_timeout_row = conn.execute("PRAGMA busy_timeout").fetchone()
journal_mode = journal_mode_row["journal_mode"] if isinstance(journal_mode_row, dict) else journal_mode_row[0]
if isinstance(busy_timeout_row, dict):
busy_timeout = int(busy_timeout_row.get("timeout") or busy_timeout_row.get("busy_timeout") or next(iter(busy_timeout_row.values())))
else:
busy_timeout = int(busy_timeout_row[0])
finally:
conn.close()
self.assertEqual(str(journal_mode).lower(), "wal")
self.assertGreaterEqual(busy_timeout, 1000)
def test_quota_blocks_production_endpoints(self) -> None:
ctx = self._seed_context("quota", exhausted=True)
headers = {"Authorization": f"Bearer {ctx['token']}"}
blocked_requests = [
("POST", "/v2/explore/text", {"title": "T", "content": "C", "project_id": ctx["project_id"], "knowledge_base_id": ctx["kb_id"], "assistant_id": ctx["assistant_id"], "analysis_model_profile_id": ctx["model_id"]}, None),
("POST", "/v2/explore/video-link", {"video_url": "https://example.com/video", "title": "V", "project_id": ctx["project_id"], "knowledge_base_id": ctx["kb_id"], "assistant_id": ctx["assistant_id"], "analysis_model_profile_id": ctx["model_id"]}, None),
("POST", "/v2/pipelines/content-source-sync", {"project_id": ctx["project_id"]}, None),
("POST", "/v2/reviews", {"project_id": ctx["project_id"], "assistant_id": ctx["assistant_id"], "title": "Review"}, None),
("POST", "/v2/pipelines/real-cut", {"project_id": ctx["project_id"], "title": "Cut"}, None),
("POST", "/v2/pipelines/ai-video", {"project_id": ctx["project_id"], "title": "Video", "brief": "Brief"}, None),
("POST", f"/v2/assistants/{ctx['assistant_id']}/generate", {"brief": "Copy", "project_id": ctx["project_id"], "knowledge_base_ids": [ctx["kb_id"]]}, None),
("POST", "/v2/live-recorder/sources", {"project_id": ctx["project_id"], "source_url": "https://example.com/live", "title": "Live"}, None),
]
for method, path, json_body, files in blocked_requests:
with self.subTest(path=path):
response = self.client.request(method, path, headers=headers, json=json_body, files=files)
self.assertEqual(response.status_code, 403, response.text)
upload_response = self.client.post(
"/v2/explore/upload-video",
headers=headers,
data={
"title": "Upload",
"project_id": ctx["project_id"],
"knowledge_base_id": ctx["kb_id"],
"assistant_id": ctx["assistant_id"],
"analysis_model_profile_id": ctx["model_id"],
},
files={"file": ("clip.mp4", b"clip-bytes", "video/mp4")},
)
self.assertEqual(upload_response.status_code, 403, upload_response.text)
def test_successful_analysis_records_usage_and_retry_endpoints_work(self) -> None:
ctx = self._seed_context("happy", exhausted=False)
headers = {"Authorization": f"Bearer {ctx['token']}"}
text_response = self.client.post(
"/v2/explore/text",
headers=headers,
json={
"title": "Hello",
"content": "Hello StoryForge",
"project_id": ctx["project_id"],
"knowledge_base_id": ctx["kb_id"],
"assistant_id": ctx["assistant_id"],
"analysis_model_profile_id": ctx["model_id"],
},
)
self.assertEqual(text_response.status_code, 200, text_response.text)
text_job = text_response.json()
usage_row = self.core.db.fetch_one(
"SELECT * FROM tenant_usage_ledger WHERE user_id = ? AND project_id = ? AND category = ? ORDER BY created_at DESC LIMIT 1",
(ctx["account_id"], ctx["project_id"], "analysis"),
)
self.assertIsNotNone(usage_row)
self.assertEqual(text_job["status"], "queued")
now = self.db_module.utc_now()
failed_jobs = []
for index in range(2):
job_id = f"job_{index}_{ctx['project_id']}"
self.core.db.execute(
"""
INSERT INTO jobs (
id, user_id, project_id, parent_job_id, assistant_id, knowledge_base_id, content_source_id,
source_type, line_type, workflow_key, orchestrator, provider_name, provider_task_id,
source_url, title, language, status, transcript_text, style_summary, upload_status,
error, artifacts_json, result_json, analysis_model_profile_id, created_at, updated_at
) VALUES (?, ?, ?, '', ?, ?, '', ?, ?, ?, 'n8n', 'collector', '', '', ?, 'auto', 'failed', '', '', 'pending', ?, '{}', '{}', ?, ?, ?)
""",
(
job_id,
ctx["account_id"],
ctx["project_id"],
ctx["assistant_id"],
ctx["kb_id"],
"text",
"analysis",
"analysis_pipeline",
f"Failed {index}",
"boom",
ctx["model_id"],
now,
now,
),
)
failed_jobs.append(job_id)
retry_response = self.client.post(f"/v2/explore/jobs/{failed_jobs[0]}/retry", headers=headers)
self.assertEqual(retry_response.status_code, 200, retry_response.text)
retry_payload = retry_response.json()
self.assertEqual(retry_payload["status"], "queued")
bulk_response = self.client.post(
"/v2/admin/jobs/retry-failed",
headers=headers,
json={"project_id": ctx["project_id"], "limit": 10},
)
self.assertEqual(bulk_response.status_code, 200, bulk_response.text)
bulk_payload = bulk_response.json()
self.assertEqual(bulk_payload["count"], 1)
self.assertEqual(len(bulk_payload["retried"]), 1)
event_rows = self.core.db.fetch_all("SELECT event_type FROM job_events WHERE job_id = ? ORDER BY created_at ASC", (failed_jobs[0],))
event_types = [row["event_type"] for row in event_rows]
self.assertIn("job.retry.requested", event_types)
self.assertIn("job.retry.queued", event_types)
bulk_job = self.core.db.fetch_one("SELECT * FROM jobs WHERE id = ?", (failed_jobs[1],))
self.assertEqual(bulk_job["status"], "queued")
def test_backup_script_creates_consistent_snapshot(self) -> None:
ctx = self._seed_context("backup", exhausted=False)
backup_dir = Path(self.tempdir.name) / "backups"
script = ROOT / "scripts" / "backup_storyforge_sqlite.sh"
result = subprocess.run(
["bash", str(script)],
check=True,
text=True,
capture_output=True,
env={
**os.environ,
"DATABASE_PATH": str(self.core.db.path),
"BACKUP_DIR": str(backup_dir),
},
)
backup_path = Path(result.stdout.strip().splitlines()[-1])
self.assertTrue(backup_path.exists(), result.stdout)
with sqlite3.connect(backup_path) as conn:
account_count = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0]
self.assertGreaterEqual(int(account_count), 1)

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,17 @@
const getCapabilities = typeof options.getCapabilities === "function" ? options.getCapabilities : () => null; const getCapabilities = typeof options.getCapabilities === "function" ? options.getCapabilities : () => null;
const defaultBackendUrl = normalizeBackendUrl(options.defaultBackendUrl, detectDefaultBackendUrl()); const defaultBackendUrl = normalizeBackendUrl(options.defaultBackendUrl, detectDefaultBackendUrl());
function createApiError(response, payload) {
const detail = typeof payload === "object" && payload
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
const error = new Error(detail);
error.status = response.status;
error.statusText = response.statusText;
error.payload = payload;
return error;
}
function resolveBackendUrl(requestOptions = {}) { function resolveBackendUrl(requestOptions = {}) {
return normalizeBackendUrl( return normalizeBackendUrl(
requestOptions.backendUrl || getSession()?.backendUrl || defaultBackendUrl, requestOptions.backendUrl || getSession()?.backendUrl || defaultBackendUrl,
@@ -57,10 +68,7 @@
const isJson = (response.headers.get("content-type") || "").includes("application/json"); const isJson = (response.headers.get("content-type") || "").includes("application/json");
const payload = isJson ? await response.json() : await response.text(); const payload = isJson ? await response.json() : await response.text();
if (!response.ok) { if (!response.ok) {
const detail = typeof payload === "object" && payload throw createApiError(response, payload);
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
throw new Error(detail);
} }
return payload; return payload;
} }
@@ -71,10 +79,7 @@
const payload = (response.headers.get("content-type") || "").includes("application/json") const payload = (response.headers.get("content-type") || "").includes("application/json")
? await response.json().catch(() => null) ? await response.json().catch(() => null)
: await response.text().catch(() => ""); : await response.text().catch(() => "");
const detail = typeof payload === "object" && payload throw createApiError(response, payload);
? payload.detail || payload.message || JSON.stringify(payload)
: String(payload || response.statusText);
throw new Error(detail);
} }
return response.blob(); return response.blob();
} }

View File

@@ -716,6 +716,30 @@ select {
cursor: pointer; cursor: pointer;
} }
.quota-notice {
margin-top: 14px;
border-color: rgba(228, 103, 103, 0.18);
background:
linear-gradient(180deg, rgba(255, 245, 245, 0.98) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.quota-notice .task-item {
border-color: rgba(228, 103, 103, 0.14);
background: linear-gradient(180deg, rgba(255, 251, 251, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
}
.quota-notice .mini-card {
background: rgba(255, 255, 255, 0.92);
}
.quota-notice .mini-card strong {
font-size: 15px;
}
.quota-notice .tag.red {
background: rgba(228, 103, 103, 0.14);
}
.integration-panel { .integration-panel {
display: grid; display: grid;
gap: 14px; gap: 14px;