feat(P0-A): 整合第一版并冻结最终发布 #1
@@ -0,0 +1,13 @@
|
|||||||
|
# Dada 开发约定
|
||||||
|
|
||||||
|
## 适用范围
|
||||||
|
|
||||||
|
本约定自当前“Dada P0-A Windows 单机内测版”第一版开发起立即生效,适用于任务计划中的每个 `TASK-*`,也适用于第一版完成后的问题修改和相关工单。
|
||||||
|
|
||||||
|
## 开发约定
|
||||||
|
|
||||||
|
- 每个 `TASK-*` 或后续工单都是独立交付单元。完成测试与回归后,必须形成范围明确的本地 Git 提交,推送到 `intelligrow` 组织仓库的同名 Gitea 远端分支,并记录任务号、完整提交 SHA、远端分支和远端哈希核对结果。不得等第一版全部开发完成后再统一提交;本地未提交、推送失败、仅推送个人命名空间或远端哈希不一致时,不得宣告当前任务完成,也不得开始下一任务。
|
||||||
|
- 修复任何工单必须在独立的 git worktree 中进行;开工前先用 `git worktree add` 创建专属工作目录,避免污染主工作区、便于多工单并行。修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。
|
||||||
|
- 处理任何工单必须先检查并使用适用的 Superpowers Skill;在分析、提问、制定计划或改代码前,至少先启用 `using-superpowers`,并按任务性质继续使用 `systematic-debugging`、`test-driven-development`、`using-git-worktrees`、`verification-before-completion` 等相关技能。若判断没有适用技能,必须简短说明原因后再继续。
|
||||||
|
- 新功能需求类工单必须先使用 Superpowers 的 `brainstorming` 技能帮助澄清目标、约束和方案,再进入计划或实现;缺陷类工单必须先使用 `systematic-debugging` 技能复现问题并分析 root cause,再开始修复,禁止在根因未明确时直接改代码。
|
||||||
|
- 实现或修复工单完成后,必须继续按 Superpowers 收尾流程执行验证、代码审查、PR/合并准备和工作区清理;通常应依次使用 `verification-before-completion`、`requesting-code-review`、`finishing-a-development-branch` 等适用技能,在完成这些流程前不得声称工单已结束。
|
||||||
+7
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 文档版本 | 1.1 |
|
| 文档版本 | 1.2 |
|
||||||
| 适用产品版本 | PRD v1.10,P0-A 本机测试需求冻结稿 |
|
| 适用产品版本 | PRD v1.10,P0-A 本机测试需求冻结稿 |
|
||||||
| 功能索引版本 | FeatureSummary 1.1 |
|
| 功能索引版本 | FeatureSummary 1.1 |
|
||||||
| 当前交付 | 仅 P0-A Windows 本机测试版 |
|
| 当前交付 | 仅 P0-A Windows 本机测试版 |
|
||||||
@@ -55,6 +55,12 @@ P1 的本地底图上传、空白画布、普通贴纸关键词搜索、公开
|
|||||||
|
|
||||||
Gitea Actions 是已确定的工程交付组件,不是 PRD 产品依赖,也不计入上述三项外部发布阻塞。当前电脑的 Windows 自托管 Runner 在隔离工作目录注册并跑通无真实凭据流水线之前,本地工程初始化和测试脚本可继续,但正式 CI 和 WP-0 工程完成不得宣告。
|
Gitea Actions 是已确定的工程交付组件,不是 PRD 产品依赖,也不计入上述三项外部发布阻塞。当前电脑的 Windows 自托管 Runner 在隔离工作目录注册并跑通无真实凭据流水线之前,本地工程初始化和测试脚本可继续,但正式 CI 和 WP-0 工程完成不得宣告。
|
||||||
|
|
||||||
|
### 1.6 任务级 Git/Gitea 交付协议
|
||||||
|
|
||||||
|
`tasks.md` 中每个 `TASK-*` 是最小工程交付单元。每个任务在开始实现前建立独立的 `codex/<wp-task>` 分支;完成 Red、Green、所属门禁、回归和脱敏扫描后,必须形成只包含该任务及其必要文档同步的本地 Git 提交,并推送到项目指定的 Gitea 组织仓库同名远端分支。当前项目的目标组织命名空间固定为 `intelligrow`,个人命名空间中的仓库或分支不作为任务交付位置。
|
||||||
|
|
||||||
|
任务交付必须记录 `task_id`、完整提交 SHA、Gitea 仓库、远端分支和远端哈希核对结果;记录不得包含凭据。推送后必须通过远端引用确认 Gitea 分支哈希与本地提交完全一致。只有本地测试 Green、必要真实/人工门禁齐全且远端核对通过,任务才可从 `green` 或 `externally_blocked` 进入 `passed`。仅有工作区改动、本地提交、推送失败、推送到个人命名空间或远端哈希不一致时,均不得宣告任务完成、工作包完成,也不得开始下一个 `TASK-*`。
|
||||||
|
|
||||||
## 2. 技术架构与选型
|
## 2. 技术架构与选型
|
||||||
|
|
||||||
### 2.1 固定技术基线
|
### 2.1 固定技术基线
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"appVersion": "0.0.0",
|
||||||
|
"browsers": [
|
||||||
|
{
|
||||||
|
"brand": "Google Chrome",
|
||||||
|
"fullVersion": "150.0.7871.187"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brand": "Microsoft Edge",
|
||||||
|
"fullVersion": "151.0.4129.59"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buildCommit": "08f3cccae4a1e75e2f2292eef14611313523916d",
|
||||||
|
"deferredExternalTasks": [
|
||||||
|
"TASK-WP7-03",
|
||||||
|
"TASK-WP7-04"
|
||||||
|
],
|
||||||
|
"finalRelease": true,
|
||||||
|
"fixedPort": 43121,
|
||||||
|
"frozenFromCommit": "08e9c39e49d68f8642d5acfe22b0fdb40a3a08fa",
|
||||||
|
"recordedAt": "2026-08-04T15:20:54.271Z",
|
||||||
|
"releaseStatus": "first_version_internal",
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"windows": {
|
||||||
|
"arch": "x64",
|
||||||
|
"build": "26200.8875",
|
||||||
|
"displayVersion": "25H2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
| 文档项 | 内容 |
|
| 文档项 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 文档版本 | 1.0 |
|
| 文档版本 | 1.1 |
|
||||||
| 产品范围 | P0-A Windows 单机版 |
|
| 产品范围 | P0-A Windows 单机版 |
|
||||||
| 设计状态 | 低保真交互与界面规范 |
|
| 设计状态 | 低保真交互与界面规范 |
|
||||||
| 主要画布 | Windows 当前稳定版 Chrome / Edge,1920 × 1080 优先验收 |
|
| 主要画布 | Windows 当前稳定版 Chrome / Edge,1920 × 1080 优先验收 |
|
||||||
| 产品需求权威 | PRD.md v1.10 |
|
| 产品需求权威 | PRD.md v1.10 |
|
||||||
| 功能与状态导航 | FeatureSummary.md 1.1 |
|
| 功能与状态导航 | FeatureSummary.md 1.1 |
|
||||||
| 工程与接口约束 | DevelopmentPlan.md 1.1 |
|
| 工程与接口约束 | DevelopmentPlan.md 1.2 |
|
||||||
| 本阶段产出 | 页面、布局、组件、状态、文案、焦点顺序和可访问性规范 |
|
| 本阶段产出 | 页面、布局、组件、状态、文案、焦点顺序和可访问性规范 |
|
||||||
| 本阶段不产出 | 高保真视觉稿、前端代码、移动端、P0-B、部署人员 CLI |
|
| 本阶段不产出 | 高保真视觉稿、前端代码、移动端、P0-B、部署人员 CLI |
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| PRD.md | 31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565 |
|
| PRD.md | 31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565 |
|
||||||
| FeatureSummary.md | 6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844 |
|
| FeatureSummary.md | 6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844 |
|
||||||
| DevelopmentPlan.md | 7C579E9DF8D00DA9333F587D05FC966123437EE68575D87D73C903C1487FA9D6 |
|
| DevelopmentPlan.md | 76CCC786E910F3E503921AEF5B9BD22976E364184BA8C0062CDCF1C5F376AC0A |
|
||||||
|
|
||||||
## 文档使用规则
|
## 文档使用规则
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,16 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dada/asset-release-manifest": "workspace:*",
|
||||||
"@dada/shared-contracts": "workspace:*",
|
"@dada/shared-contracts": "workspace:*",
|
||||||
|
"@dada/static-sticker-catalog": "workspace:*",
|
||||||
|
"@fastify/multipart": "10.1.0",
|
||||||
"@fastify/swagger": "9.8.1",
|
"@fastify/swagger": "9.8.1",
|
||||||
"@sinclair/typebox": "0.34.52",
|
"@sinclair/typebox": "0.34.52",
|
||||||
"better-sqlite3": "13.0.1",
|
"better-sqlite3": "13.0.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"fastify": "5.10.0"
|
"fastify": "5.10.0",
|
||||||
|
"sharp": "0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminAuditQuery,
|
||||||
|
AdminOperationAuditItem,
|
||||||
|
AdminOperationAuditResponse,
|
||||||
|
PrivateContentAccessAuditItem,
|
||||||
|
PrivateContentAccessAuditResponse,
|
||||||
|
} from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
interface AuditCursor {
|
||||||
|
logId: string;
|
||||||
|
occurredAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminOperationRow {
|
||||||
|
actor_ref: string;
|
||||||
|
actor_type: "system" | "super_admin";
|
||||||
|
after_summary: string | null;
|
||||||
|
before_summary: string | null;
|
||||||
|
expires_at: number;
|
||||||
|
log_id: string;
|
||||||
|
occurred_at: number;
|
||||||
|
operation_type: string;
|
||||||
|
result: "failed" | "succeeded";
|
||||||
|
target_ref: string;
|
||||||
|
target_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrivateContentAccessRow {
|
||||||
|
actor_ref: string;
|
||||||
|
content_type: "image" | "prompt";
|
||||||
|
expires_at: number;
|
||||||
|
log_id: string;
|
||||||
|
occurred_at: number;
|
||||||
|
target_ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminAuditQueryError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("admin_audit_query_invalid");
|
||||||
|
this.name = "AdminAuditQueryError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(row: { log_id: string; occurred_at: number }) {
|
||||||
|
return Buffer.from(JSON.stringify([row.occurred_at, row.log_id]), "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(cursor: string | undefined): AuditCursor | undefined {
|
||||||
|
if (!cursor) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||||
|
if (!Array.isArray(parsed) || parsed.length !== 2 || !Number.isSafeInteger(parsed[0])
|
||||||
|
|| typeof parsed[1] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(parsed[1])) {
|
||||||
|
throw new AdminAuditQueryError();
|
||||||
|
}
|
||||||
|
return { occurredAt: parsed[0] as number, logId: parsed[1] };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AdminAuditQueryError) throw error;
|
||||||
|
throw new AdminAuditQueryError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(limit: number | undefined) {
|
||||||
|
if (limit === undefined) return 50;
|
||||||
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new AdminAuditQueryError();
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageRows<Row extends { log_id: string; occurred_at: number }>(rows: Row[], limit: number) {
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
return { items, nextCursor: hasMore ? encodeCursor(items[items.length - 1]!) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(value: number) {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAdminOperationAudit(
|
||||||
|
database: BetterSqlite3.Database,
|
||||||
|
query: AdminAuditQuery,
|
||||||
|
clock: () => number = Date.now,
|
||||||
|
): AdminOperationAuditResponse {
|
||||||
|
const cursor = decodeCursor(query.cursor);
|
||||||
|
const limit = normalizeLimit(query.limit);
|
||||||
|
const rows = (cursor
|
||||||
|
? database.prepare(`
|
||||||
|
SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id,
|
||||||
|
occurred_at, operation_type, result, target_ref, target_type
|
||||||
|
FROM admin_operation_logs
|
||||||
|
WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?)
|
||||||
|
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||||
|
`).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1)
|
||||||
|
: database.prepare(`
|
||||||
|
SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id,
|
||||||
|
occurred_at, operation_type, result, target_ref, target_type
|
||||||
|
FROM admin_operation_logs
|
||||||
|
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||||
|
`).all(limit + 1)) as AdminOperationRow[];
|
||||||
|
const page = pageRows(rows, limit);
|
||||||
|
const items: AdminOperationAuditItem[] = page.items.map((row) => ({
|
||||||
|
actor_ref: row.actor_ref,
|
||||||
|
actor_type: row.actor_type,
|
||||||
|
after_summary: row.after_summary,
|
||||||
|
before_summary: row.before_summary,
|
||||||
|
expires_at: iso(row.expires_at),
|
||||||
|
log_id: row.log_id,
|
||||||
|
occurred_at: iso(row.occurred_at),
|
||||||
|
operation_type: row.operation_type,
|
||||||
|
result: row.result,
|
||||||
|
target_ref: row.target_ref,
|
||||||
|
target_type: row.target_type,
|
||||||
|
}));
|
||||||
|
return { generated_at: iso(clock()), items, next_cursor: page.nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPrivateContentAccessAudit(
|
||||||
|
database: BetterSqlite3.Database,
|
||||||
|
query: AdminAuditQuery,
|
||||||
|
clock: () => number = Date.now,
|
||||||
|
): PrivateContentAccessAuditResponse {
|
||||||
|
const cursor = decodeCursor(query.cursor);
|
||||||
|
const limit = normalizeLimit(query.limit);
|
||||||
|
const rows = (cursor
|
||||||
|
? database.prepare(`
|
||||||
|
SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref
|
||||||
|
FROM private_content_access_logs
|
||||||
|
WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?)
|
||||||
|
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||||
|
`).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1)
|
||||||
|
: database.prepare(`
|
||||||
|
SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref
|
||||||
|
FROM private_content_access_logs
|
||||||
|
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||||
|
`).all(limit + 1)) as PrivateContentAccessRow[];
|
||||||
|
const page = pageRows(rows, limit);
|
||||||
|
const items: PrivateContentAccessAuditItem[] = page.items.map((row) => ({
|
||||||
|
actor_ref: row.actor_ref,
|
||||||
|
content_type: row.content_type,
|
||||||
|
expires_at: iso(row.expires_at),
|
||||||
|
log_id: row.log_id,
|
||||||
|
occurred_at: iso(row.occurred_at),
|
||||||
|
target_ref: row.target_ref,
|
||||||
|
}));
|
||||||
|
return { generated_at: iso(clock()), items, next_cursor: page.nextCursor };
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import type { BrowserSupportRelease } from "./browser-support.js";
|
||||||
|
import type { ManagedStorage } from "./managed-storage.js";
|
||||||
|
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||||
|
|
||||||
|
type AdminService = AdminServicesStorageResponse["services"][number];
|
||||||
|
const adminServiceIds = ["resend", "amap", "ai_gateway", "worker", "api", "asset_root"] as const;
|
||||||
|
const forbiddenDiagnosticPatterns = [
|
||||||
|
/\b(?:api[_ -]?key|secret|password|credential|authorization|bearer|session[_ -]?token|cookie|prompt|email|token)\b/i,
|
||||||
|
/[A-Z]:[\\/](?:Users|Documents|ProgramData|Windows)[\\/]/i,
|
||||||
|
/\\\\[^\\\s]+\\[^\s]+/,
|
||||||
|
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
|
||||||
|
/https?:\/\//i,
|
||||||
|
];
|
||||||
|
const safePauseReasons = new Set([
|
||||||
|
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
||||||
|
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
||||||
|
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
||||||
|
"worker_degraded", "worker_state_missing", "worker_stopped",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function iso(value: number | string | null | undefined) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return typeof value === "number" ? new Date(value).toISOString() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableExists(database: BetterSqlite3.Database, table: string) {
|
||||||
|
return Boolean(database.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?").get(table));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeService(
|
||||||
|
service_id: AdminService["service_id"],
|
||||||
|
status: AdminService["status"],
|
||||||
|
impact_scope: AdminService["impact_scope"],
|
||||||
|
configured: boolean,
|
||||||
|
checked_at: string | null,
|
||||||
|
pause_reason: string | null = null,
|
||||||
|
): AdminService {
|
||||||
|
return { checked_at, configured, impact_scope, pause_reason, service_id, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReason(value: unknown) {
|
||||||
|
return typeof value === "string" && safePauseReasons.has(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceUsage(database: BetterSqlite3.Database, serviceName: string) {
|
||||||
|
if (!tableExists(database, "external_service_usage")) return undefined;
|
||||||
|
const columns = new Set((database.prepare("PRAGMA table_info(external_service_usage)").all() as Array<{ name: string }>).map((column) => column.name));
|
||||||
|
const serviceColumn = columns.has("service_name") ? "service_name" : columns.has("service_id") ? "service_id" : undefined;
|
||||||
|
if (!serviceColumn || !columns.has("service_status")) return undefined;
|
||||||
|
const row = database.prepare(`SELECT service_status, ${columns.has("checked_at") ? "checked_at" : "NULL AS checked_at"}, ${columns.has("pause_reason") ? "pause_reason" : "NULL AS pause_reason"} FROM external_service_usage WHERE ${serviceColumn} = ? ORDER BY rowid DESC LIMIT 1`).get(serviceName) as { service_status: string; checked_at: number | string | null; pause_reason: string | null } | undefined;
|
||||||
|
if (!row) return undefined;
|
||||||
|
const status = new Set<AdminService["status"]>(["active", "paused_quota", "paused_provider", "disabled"]).has(row.service_status as AdminService["status"])
|
||||||
|
? row.service_status as AdminService["status"]
|
||||||
|
: "degraded";
|
||||||
|
return { status, checked_at: iso(row.checked_at), pause_reason: safeReason(row.pause_reason) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerStatus(database: BetterSqlite3.Database) {
|
||||||
|
if (!tableExists(database, "worker_runtime_state")) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" };
|
||||||
|
const row = database.prepare("SELECT status, reason, updated_at FROM worker_runtime_state WHERE singleton = 1").get() as { status: string; reason: string | null; updated_at: number | string | null } | undefined;
|
||||||
|
if (!row) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" };
|
||||||
|
return {
|
||||||
|
status: row.status === "ready" ? "active" as const : row.status === "degraded" ? "degraded" as const : "unavailable" as const,
|
||||||
|
checked_at: iso(row.updated_at),
|
||||||
|
pause_reason: safeReason(row.reason),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminServicesStorageProvider(input: {
|
||||||
|
database: BetterSqlite3.Database;
|
||||||
|
models?: ModelConfigurationService;
|
||||||
|
storage?: ManagedStorage;
|
||||||
|
assetRoot?: Pick<AdminService, "configured" | "status" | "checked_at" | "pause_reason">;
|
||||||
|
clock?: () => number;
|
||||||
|
}): () => AdminServicesStorageResponse {
|
||||||
|
const clock = input.clock ?? Date.now;
|
||||||
|
return () => {
|
||||||
|
const generatedAt = new Date(clock()).toISOString();
|
||||||
|
const resend = serviceUsage(input.database, "resend");
|
||||||
|
const amap = serviceUsage(input.database, "amap");
|
||||||
|
const worker = workerStatus(input.database);
|
||||||
|
const modelRuntime = input.models?.read().models ?? [];
|
||||||
|
const unavailableModels = modelRuntime.filter((model) => !model.runtime_availability.available_for_new_jobs);
|
||||||
|
const gatewayReason = unavailableModels[0]?.runtime_availability.reason ?? null;
|
||||||
|
const storageState = input.storage?.getState();
|
||||||
|
const cleanupPendingCount = tableExists(input.database, "file_cleanup_queue")
|
||||||
|
? (input.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status IN ('pending', 'failed')").get() as { count: number }).count
|
||||||
|
: 0;
|
||||||
|
const services: AdminService[] = [
|
||||||
|
safeService("resend", resend?.status ?? "unavailable", "authentication", Boolean(resend), resend?.checked_at ?? null, resend?.pause_reason ?? "service_state_missing"),
|
||||||
|
safeService("amap", amap?.status ?? "unavailable", "location", Boolean(amap), amap?.checked_at ?? null, amap?.pause_reason ?? "service_state_missing"),
|
||||||
|
safeService("ai_gateway", unavailableModels.length > 0 ? "degraded" : modelRuntime.length > 0 ? "active" : "unavailable", "generation", modelRuntime.length > 0, generatedAt, safeReason(gatewayReason)),
|
||||||
|
safeService("worker", worker.status, "generation", worker.status !== "unavailable", worker.checked_at, worker.pause_reason),
|
||||||
|
safeService("api", "active", "api", true, generatedAt),
|
||||||
|
safeService(
|
||||||
|
"asset_root",
|
||||||
|
input.assetRoot?.status ?? "unavailable",
|
||||||
|
"storage",
|
||||||
|
input.assetRoot?.configured ?? false,
|
||||||
|
input.assetRoot?.checked_at ?? null,
|
||||||
|
input.assetRoot?.pause_reason ?? "asset_root_state_missing",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return assertSafeAdminServicesStorage({
|
||||||
|
generated_at: generatedAt,
|
||||||
|
services,
|
||||||
|
storage: {
|
||||||
|
capacity_notice_level: storageState?.capacity_notice_level ?? "normal",
|
||||||
|
cleanup_pending_count: cleanupPendingCount,
|
||||||
|
data_root_ref: "configured_local_data_root",
|
||||||
|
hard_limit_bytes: storageState?.hard_limit_bytes ?? 5_368_709_120,
|
||||||
|
last_measured_at: storageState?.measured_at ?? null,
|
||||||
|
managed_content_bytes: storageState?.managed_content_bytes ?? 0,
|
||||||
|
remeasurement_required: storageState?.storage_status === "unavailable",
|
||||||
|
status: storageState?.storage_status ?? "unavailable",
|
||||||
|
storage_backend: "local_filesystem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticText(input: AdminServicesStorageResponse, system: AdminDiagnosticsResponse["system"]) {
|
||||||
|
const lines = [
|
||||||
|
"Dada P0-A diagnostics",
|
||||||
|
`app_version=${system.app_version}`,
|
||||||
|
`api_status=${system.api_status}`,
|
||||||
|
`worker_status=${system.worker_status}`,
|
||||||
|
`storage_status=${input.storage.status}`,
|
||||||
|
`capacity_notice_level=${input.storage.capacity_notice_level}`,
|
||||||
|
`managed_content_bytes=${input.storage.managed_content_bytes}`,
|
||||||
|
`hard_limit_bytes=${input.storage.hard_limit_bytes}`,
|
||||||
|
`cleanup_pending_count=${input.storage.cleanup_pending_count}`,
|
||||||
|
];
|
||||||
|
for (const service of input.services) lines.push(`service.${service.service_id}=${service.status}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminDiagnosticsProvider(input: {
|
||||||
|
servicesStorage: () => AdminServicesStorageResponse;
|
||||||
|
browserSupportRelease?: BrowserSupportRelease;
|
||||||
|
appVersion?: string;
|
||||||
|
clock?: () => number;
|
||||||
|
}): () => AdminDiagnosticsResponse {
|
||||||
|
const clock = input.clock ?? Date.now;
|
||||||
|
return () => {
|
||||||
|
const services = input.servicesStorage();
|
||||||
|
const system: AdminDiagnosticsResponse["system"] = {
|
||||||
|
api_status: "ready",
|
||||||
|
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
|
||||||
|
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
|
||||||
|
brand: browser.brand,
|
||||||
|
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
|
||||||
|
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
|
||||||
|
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
|
||||||
|
? "ready"
|
||||||
|
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
|
||||||
|
? "unavailable"
|
||||||
|
: "degraded",
|
||||||
|
};
|
||||||
|
return assertSafeAdminDiagnostics({
|
||||||
|
generated_at: new Date(clock()).toISOString(),
|
||||||
|
diagnostic_text: diagnosticText(services, system),
|
||||||
|
services,
|
||||||
|
system,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeAdminServicesStorage(input: AdminServicesStorageResponse) {
|
||||||
|
const ids = input.services.map((service) => service.service_id);
|
||||||
|
if (ids.length !== adminServiceIds.length || new Set(ids).size !== adminServiceIds.length
|
||||||
|
|| adminServiceIds.some((serviceId) => !ids.includes(serviceId))) {
|
||||||
|
throw new Error("admin_service_state_incomplete");
|
||||||
|
}
|
||||||
|
if (input.services.some((service) => service.pause_reason !== null && !safePauseReasons.has(service.pause_reason))) {
|
||||||
|
throw new Error("admin_services_redaction_failed");
|
||||||
|
}
|
||||||
|
if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(JSON.stringify(input)))) {
|
||||||
|
throw new Error("admin_services_redaction_failed");
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeAdminDiagnostics(input: AdminDiagnosticsResponse) {
|
||||||
|
assertSafeAdminServicesStorage(input.services);
|
||||||
|
if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(input.diagnostic_text))) {
|
||||||
|
throw new Error("admin_diagnostics_redaction_failed");
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { request as httpsRequest } from "node:https";
|
||||||
|
|
||||||
|
const amapHostname = "restapi.amap.com" as const;
|
||||||
|
const amapMaxResponseBytes = 65_536;
|
||||||
|
const amapTimeoutMs = 15_000;
|
||||||
|
|
||||||
|
export interface AmapHttpRequest {
|
||||||
|
allowRedirects: false;
|
||||||
|
hostname: typeof amapHostname;
|
||||||
|
maxResponseBytes: number;
|
||||||
|
method: "GET";
|
||||||
|
path: string;
|
||||||
|
protocol: "https:";
|
||||||
|
rejectUnauthorized: true;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmapRequester = (request: AmapHttpRequest) => Promise<unknown>;
|
||||||
|
|
||||||
|
export class AmapAdapterError extends Error {
|
||||||
|
constructor(readonly code: "amap_adapter_disposed" | "amap_invalid_request" | "amap_invalid_response" | "amap_provider_rejected" | "amap_provider_unavailable" | "amap_redirect_rejected" | "amap_request_timeout" | "amap_response_too_large") {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmapAdapter {
|
||||||
|
dispose?(): void;
|
||||||
|
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" | "real" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockAmapAdapter implements AmapAdapter {
|
||||||
|
readonly calls: Array<{ latitude: number; longitude: number }> = [];
|
||||||
|
|
||||||
|
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
|
||||||
|
this.calls.push({ ...coordinates });
|
||||||
|
return {
|
||||||
|
formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`,
|
||||||
|
serviceMode: "mock" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestAmapJson(input: AmapHttpRequest) {
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
if (input.protocol !== "https:" || input.hostname !== amapHostname || input.allowRedirects || !input.rejectUnauthorized) {
|
||||||
|
reject(new AmapAdapterError("amap_invalid_request"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settled = false;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
const request = httpsRequest({
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
hostname: input.hostname,
|
||||||
|
method: input.method,
|
||||||
|
path: input.path,
|
||||||
|
port: 443,
|
||||||
|
protocol: input.protocol,
|
||||||
|
rejectUnauthorized: input.rejectUnauthorized,
|
||||||
|
servername: input.hostname,
|
||||||
|
}, (response) => {
|
||||||
|
const statusCode = response.statusCode ?? 0;
|
||||||
|
if (statusCode >= 300 && statusCode < 400) {
|
||||||
|
response.resume();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_redirect_rejected")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
response.resume();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_provider_unavailable")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const declaredLength = Number(response.headers["content-length"] ?? 0);
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > input.maxResponseBytes) {
|
||||||
|
response.destroy();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_response_too_large")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let receivedBytes = 0;
|
||||||
|
response.on("data", (chunk: Buffer | string) => {
|
||||||
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
receivedBytes += bytes.length;
|
||||||
|
if (receivedBytes > input.maxResponseBytes) {
|
||||||
|
response.destroy();
|
||||||
|
finish(() => reject(new AmapAdapterError("amap_response_too_large")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(bytes);
|
||||||
|
});
|
||||||
|
response.on("end", () => {
|
||||||
|
finish(() => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
} catch {
|
||||||
|
reject(new AmapAdapterError("amap_invalid_response"));
|
||||||
|
} finally {
|
||||||
|
for (const chunk of chunks) chunk.fill(0);
|
||||||
|
chunks.length = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.setTimeout(input.timeoutMs, () => request.destroy(new AmapAdapterError("amap_request_timeout")));
|
||||||
|
request.on("error", (error) => finish(() => reject(error instanceof AmapAdapterError ? error : new AmapAdapterError("amap_provider_unavailable"))));
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealAmapAdapter implements AmapAdapter {
|
||||||
|
private readonly credential: Buffer;
|
||||||
|
private readonly requester: AmapRequester;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(value: string, options: { request?: AmapRequester } = {}) {
|
||||||
|
if (!value.trim()) throw new AmapAdapterError("amap_invalid_request");
|
||||||
|
this.credential = Buffer.from(value, "utf8");
|
||||||
|
this.requester = options.request ?? requestAmapJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
|
||||||
|
if (this.disposed) throw new AmapAdapterError("amap_adapter_disposed");
|
||||||
|
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|
||||||
|
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
|
||||||
|
throw new AmapAdapterError("amap_invalid_request");
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
extensions: "base",
|
||||||
|
key: this.credential.toString("utf8"),
|
||||||
|
location: `${coordinates.longitude},${coordinates.latitude}`,
|
||||||
|
});
|
||||||
|
const response = await this.requester({
|
||||||
|
allowRedirects: false,
|
||||||
|
hostname: amapHostname,
|
||||||
|
maxResponseBytes: amapMaxResponseBytes,
|
||||||
|
method: "GET",
|
||||||
|
path: `/v3/geocode/regeo?${query.toString()}`,
|
||||||
|
protocol: "https:",
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
timeoutMs: amapTimeoutMs,
|
||||||
|
});
|
||||||
|
if (!isRecord(response) || response.status !== "1" || !isRecord(response.regeocode)) {
|
||||||
|
throw new AmapAdapterError("amap_provider_rejected");
|
||||||
|
}
|
||||||
|
const formattedValue = typeof response.regeocode.formatted_address === "string"
|
||||||
|
? response.regeocode.formatted_address.trim()
|
||||||
|
: "";
|
||||||
|
if (!formattedValue || formattedValue.length > 200) throw new AmapAdapterError("amap_invalid_response");
|
||||||
|
return { formattedValue, serviceMode: "real" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
this.credential.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3074
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
|||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
export const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||||
|
const forbiddenSummaryKeys = new Set([
|
||||||
|
"absolute_path", "api_key", "body", "code_hmac", "content", "credential",
|
||||||
|
"email", "image", "image_content", "password", "path", "prompt", "secret",
|
||||||
|
"session_token", "verification_code", "whitelist",
|
||||||
|
]);
|
||||||
|
const forbiddenSummaryKeyFragments = [
|
||||||
|
"content", "credential", "email", "image", "password", "path", "prompt", "secret", "token",
|
||||||
|
];
|
||||||
|
const safeStringPattern = /^[A-Za-z0-9_.:@-]{1,160}$/;
|
||||||
|
|
||||||
|
function isSafeSummaryValue(value: unknown, depth: number): boolean {
|
||||||
|
if (depth > 3) return false;
|
||||||
|
if (value === null || typeof value === "boolean") return true;
|
||||||
|
if (typeof value === "number") return Number.isSafeInteger(value);
|
||||||
|
if (typeof value === "string") return safeStringPattern.test(value) && !value.includes("@");
|
||||||
|
if (Array.isArray(value)) return value.length <= 20 && value.every((entry) => isSafeSummaryValue(entry, depth + 1));
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
return entries.length <= 32 && entries.every(([key, entry]) => (
|
||||||
|
auditRefPattern.test(key)
|
||||||
|
&& !forbiddenSummaryKeys.has(key.toLowerCase())
|
||||||
|
&& !forbiddenSummaryKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||||
|
&& isSafeSummaryValue(entry, depth + 1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeAuditRef(value: unknown) {
|
||||||
|
return typeof value === "string" && auditRefPattern.test(value) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeAuditSummaryJson(value: unknown) {
|
||||||
|
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
|
||||||
|
try {
|
||||||
|
return isSafeSummaryValue(JSON.parse(value), 0) ? 1 : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAuditSummary(value: Record<string, unknown> | null) {
|
||||||
|
if (value === null) return null;
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
if (isSafeAuditSummaryJson(serialized) !== 1) throw new Error("audit_summary_sensitive_or_invalid");
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMilliseconds(value: string | number | null | undefined, fallback: number) {
|
||||||
|
if (typeof value === "number" && Number.isSafeInteger(value)) return value;
|
||||||
|
const parsed = Date.parse(String(value));
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdminOperationTable(database: BetterSqlite3.Database) {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE admin_operation_logs (
|
||||||
|
log_id TEXT PRIMARY KEY,
|
||||||
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
|
actor_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(actor_ref) = 1),
|
||||||
|
operation_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(operation_type) = 1),
|
||||||
|
target_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_type) = 1),
|
||||||
|
target_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_ref) = 1),
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
|
before_summary TEXT CHECK (before_summary IS NULL OR dada_audit_summary_is_safe(before_summary) = 1),
|
||||||
|
after_summary TEXT CHECK (after_summary IS NULL OR dada_audit_summary_is_safe(after_summary) = 1),
|
||||||
|
occurred_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installAdminOperationTriggers(database: BetterSqlite3.Database) {
|
||||||
|
database.exec(`
|
||||||
|
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
|
||||||
|
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
|
||||||
|
CREATE TRIGGER admin_operation_logs_no_update
|
||||||
|
BEFORE UPDATE ON admin_operation_logs
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER admin_operation_logs_no_delete
|
||||||
|
BEFORE DELETE ON admin_operation_logs
|
||||||
|
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAdminOperationAuditSchema(database: BetterSqlite3.Database, fallbackNow: number) {
|
||||||
|
const table = database.prepare(`
|
||||||
|
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'admin_operation_logs'
|
||||||
|
`).get() as { sql: string } | undefined;
|
||||||
|
if (!table) {
|
||||||
|
createAdminOperationTable(database);
|
||||||
|
installAdminOperationTriggers(database);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (table.sql.includes("dada_audit_summary_is_safe") && table.sql.includes(String(auditRetentionMilliseconds))) {
|
||||||
|
installAdminOperationTriggers(database);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>;
|
||||||
|
const current = columns.some((column) => column.name === "actor_type");
|
||||||
|
const rows = current
|
||||||
|
? database.prepare("SELECT * FROM admin_operation_logs").all() as Array<Record<string, string | number | null>>
|
||||||
|
: database.prepare("SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs").all() as Array<Record<string, string | number | null>>;
|
||||||
|
const migrate = database.transaction(() => {
|
||||||
|
database.exec(`
|
||||||
|
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
|
||||||
|
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
|
||||||
|
ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_before_wp1_06;
|
||||||
|
`);
|
||||||
|
createAdminOperationTable(database);
|
||||||
|
const insert = database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const occurredAt = toMilliseconds(current ? row.occurred_at! : row.created_at!, fallbackNow);
|
||||||
|
const expiresAt = current ? toMilliseconds(row.expires_at!, fallbackNow) : occurredAt + auditRetentionMilliseconds;
|
||||||
|
if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid");
|
||||||
|
const beforeSummary = current && typeof row.before_summary === "string"
|
||||||
|
? serializeAuditSummary(JSON.parse(row.before_summary) as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const afterSummary = current
|
||||||
|
? typeof row.after_summary === "string" ? serializeAuditSummary(JSON.parse(row.after_summary) as Record<string, unknown>) : null
|
||||||
|
: serializeAuditSummary({ legacy_outcome: String(row.outcome) });
|
||||||
|
insert.run(
|
||||||
|
row.log_id,
|
||||||
|
current ? row.actor_type : "system",
|
||||||
|
current ? row.actor_ref : "managed_storage_migration",
|
||||||
|
current ? row.operation_type : row.operation,
|
||||||
|
current ? row.target_type : "legacy_operation",
|
||||||
|
row.target_ref,
|
||||||
|
current ? row.result : String(row.outcome).startsWith("denied") ? "failed" : "succeeded",
|
||||||
|
beforeSummary,
|
||||||
|
afterSummary,
|
||||||
|
occurredAt,
|
||||||
|
expiresAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
database.exec("DROP TABLE admin_operation_logs_before_wp1_06");
|
||||||
|
installAdminOperationTriggers(database);
|
||||||
|
});
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPrivateAccessTable(database: BetterSqlite3.Database) {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE private_content_access_logs (
|
||||||
|
log_id TEXT PRIMARY KEY,
|
||||||
|
actor_ref TEXT NOT NULL CHECK (length(actor_ref) = 36 AND dada_audit_ref_is_safe(actor_ref) = 1),
|
||||||
|
subject_ref TEXT NOT NULL CHECK (length(subject_ref) = 36 AND dada_audit_ref_is_safe(subject_ref) = 1),
|
||||||
|
target_ref TEXT NOT NULL CHECK (length(target_ref) = 36 AND dada_audit_ref_is_safe(target_ref) = 1),
|
||||||
|
content_type TEXT NOT NULL CHECK (content_type IN ('image', 'prompt')),
|
||||||
|
occurred_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds})
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPrivateAccessTriggers(database: BetterSqlite3.Database) {
|
||||||
|
database.exec(`
|
||||||
|
DROP TRIGGER IF EXISTS private_content_access_logs_no_update;
|
||||||
|
DROP TRIGGER IF EXISTS private_content_access_logs_no_delete;
|
||||||
|
CREATE TRIGGER private_content_access_logs_no_update
|
||||||
|
BEFORE UPDATE ON private_content_access_logs
|
||||||
|
WHEN dada_allow_privacy_purge() <> 1
|
||||||
|
OR OLD.subject_ref <> dada_privacy_purge_subject()
|
||||||
|
OR NEW.actor_ref <> OLD.actor_ref
|
||||||
|
OR NEW.content_type <> OLD.content_type
|
||||||
|
OR NEW.occurred_at <> OLD.occurred_at
|
||||||
|
OR NEW.expires_at <> OLD.expires_at
|
||||||
|
OR NEW.subject_ref = OLD.subject_ref
|
||||||
|
OR NEW.target_ref = OLD.target_ref
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER private_content_access_logs_no_delete
|
||||||
|
BEFORE DELETE ON private_content_access_logs
|
||||||
|
WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now()
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensurePrivateAccessAuditSchema(database: BetterSqlite3.Database) {
|
||||||
|
const table = database.prepare(`
|
||||||
|
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'private_content_access_logs'
|
||||||
|
`).get() as { sql: string } | undefined;
|
||||||
|
if (!table) {
|
||||||
|
createPrivateAccessTable(database);
|
||||||
|
installPrivateAccessTriggers(database);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (table.sql.includes(String(auditRetentionMilliseconds))) {
|
||||||
|
installPrivateAccessTriggers(database);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = database.prepare("SELECT * FROM private_content_access_logs").all() as Array<Record<string, string | number>>;
|
||||||
|
const migrate = database.transaction(() => {
|
||||||
|
database.exec(`
|
||||||
|
DROP TRIGGER IF EXISTS private_content_access_logs_no_update;
|
||||||
|
DROP TRIGGER IF EXISTS private_content_access_logs_no_delete;
|
||||||
|
ALTER TABLE private_content_access_logs RENAME TO private_content_access_logs_before_wp1_06;
|
||||||
|
`);
|
||||||
|
createPrivateAccessTable(database);
|
||||||
|
const insert = database.prepare(`
|
||||||
|
INSERT INTO private_content_access_logs (
|
||||||
|
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const occurredAt = toMilliseconds(row.occurred_at, 0);
|
||||||
|
const expiresAt = toMilliseconds(row.expires_at, 0);
|
||||||
|
if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid");
|
||||||
|
insert.run(row.log_id, row.actor_ref, row.subject_ref, row.target_ref, row.content_type, occurredAt, expiresAt);
|
||||||
|
}
|
||||||
|
database.exec("DROP TABLE private_content_access_logs_before_wp1_06");
|
||||||
|
installPrivateAccessTriggers(database);
|
||||||
|
});
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type CreditErrorCode =
|
||||||
|
| "credit_account_not_found"
|
||||||
|
| "credit_generation_not_found"
|
||||||
|
| "credit_insufficient"
|
||||||
|
| "credit_invariant_failed"
|
||||||
|
| "credit_operation_conflict"
|
||||||
|
| "credit_request_invalid";
|
||||||
|
|
||||||
|
export class CreditError extends Error {
|
||||||
|
readonly code: CreditErrorCode;
|
||||||
|
|
||||||
|
constructor(code: CreditErrorCode) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import {
|
||||||
|
auditRetentionMilliseconds,
|
||||||
|
ensureAdminOperationAuditSchema,
|
||||||
|
isSafeAuditRef,
|
||||||
|
isSafeAuditSummaryJson,
|
||||||
|
serializeAuditSummary,
|
||||||
|
} from "./audit-policy.js";
|
||||||
|
import { CreditError } from "./credit-errors.js";
|
||||||
|
|
||||||
|
export { CreditError } from "./credit-errors.js";
|
||||||
|
export type { CreditErrorCode } from "./credit-errors.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||||
|
|
||||||
|
interface CreditAccountRow {
|
||||||
|
available_balance: number;
|
||||||
|
reserved_balance: number;
|
||||||
|
updated_at: number;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LedgerRow {
|
||||||
|
amount: number;
|
||||||
|
available_after: number;
|
||||||
|
available_before: number;
|
||||||
|
created_at: number;
|
||||||
|
entry_status: "succeeded" | "frozen" | "committed" | "released";
|
||||||
|
entry_type: "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||||
|
ledger_id: string;
|
||||||
|
model_id: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
reference_id: string | null;
|
||||||
|
reference_type: "registration" | "generation" | "admin_adjustment" | null;
|
||||||
|
reserved_after: number;
|
||||||
|
reserved_before: number;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReservationRow {
|
||||||
|
amount: number;
|
||||||
|
generation_id: string;
|
||||||
|
model_id: string;
|
||||||
|
status: "reserved" | "committed" | "released";
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(timestamp: number) {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(row: Pick<LedgerRow, "created_at" | "ledger_id">) {
|
||||||
|
return Buffer.from(JSON.stringify([row.created_at, row.ledger_id]), "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(cursor: string | undefined) {
|
||||||
|
if (!cursor) return undefined;
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||||
|
if (!Array.isArray(value) || value.length !== 2 || !Number.isSafeInteger(value[0]) || typeof value[1] !== "string") {
|
||||||
|
throw new Error("cursor_invalid");
|
||||||
|
}
|
||||||
|
return { createdAt: value[0] as number, ledgerId: value[1] };
|
||||||
|
} catch {
|
||||||
|
throw new CreditError("credit_request_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreditService {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; databasePath: string }) {
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||||
|
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||||
|
this.database.pragma("journal_mode = WAL");
|
||||||
|
this.database.pragma("foreign_keys = ON");
|
||||||
|
this.database.pragma("synchronous = FULL");
|
||||||
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
|
||||||
|
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
|
||||||
|
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
readAccount(userId: string) {
|
||||||
|
const row = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(userId) as CreditAccountRow | undefined;
|
||||||
|
if (!row) throw new CreditError("credit_account_not_found");
|
||||||
|
return {
|
||||||
|
availableBalance: row.available_balance,
|
||||||
|
reservedBalance: row.reserved_balance,
|
||||||
|
updatedAt: iso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
listLedger(input: {
|
||||||
|
cursor?: string;
|
||||||
|
eventType?: LedgerRow["entry_type"];
|
||||||
|
from?: string;
|
||||||
|
limit?: number;
|
||||||
|
to?: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const account = this.readAccount(input.userId);
|
||||||
|
const limit = input.limit ?? 20;
|
||||||
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new CreditError("credit_request_invalid");
|
||||||
|
const cursor = decodeCursor(input.cursor);
|
||||||
|
const conditions = ["user_id = ?"];
|
||||||
|
const values: Array<number | string> = [input.userId];
|
||||||
|
if (input.eventType) {
|
||||||
|
conditions.push("entry_type = ?");
|
||||||
|
values.push(input.eventType);
|
||||||
|
}
|
||||||
|
if (input.from) {
|
||||||
|
const from = Date.parse(input.from);
|
||||||
|
if (!Number.isFinite(from)) throw new CreditError("credit_request_invalid");
|
||||||
|
conditions.push("created_at >= ?");
|
||||||
|
values.push(from);
|
||||||
|
}
|
||||||
|
if (input.to) {
|
||||||
|
const to = Date.parse(input.to);
|
||||||
|
if (!Number.isFinite(to)) throw new CreditError("credit_request_invalid");
|
||||||
|
conditions.push("created_at <= ?");
|
||||||
|
values.push(to);
|
||||||
|
}
|
||||||
|
if (cursor) {
|
||||||
|
conditions.push("(created_at < ? OR (created_at = ? AND ledger_id < ?))");
|
||||||
|
values.push(cursor.createdAt, cursor.createdAt, cursor.ledgerId);
|
||||||
|
}
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT * FROM credit_ledger WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY created_at DESC, ledger_id DESC LIMIT ?
|
||||||
|
`).all(...values, limit + 1) as LedgerRow[];
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = rows.slice(0, limit);
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
entries: page.map((row) => ({
|
||||||
|
amount: row.amount,
|
||||||
|
availableAfter: row.available_after,
|
||||||
|
availableBefore: row.available_before,
|
||||||
|
createdAt: iso(row.created_at),
|
||||||
|
entryId: row.ledger_id,
|
||||||
|
entryType: row.entry_type,
|
||||||
|
modelId: row.model_id,
|
||||||
|
reason: row.reason,
|
||||||
|
referenceId: row.reference_id,
|
||||||
|
referenceType: row.reference_type,
|
||||||
|
reservedAfter: row.reserved_after,
|
||||||
|
reservedBefore: row.reserved_before,
|
||||||
|
status: row.entry_status,
|
||||||
|
})),
|
||||||
|
nextCursor: hasMore && page.length > 0 ? encodeCursor(page.at(-1)!) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
reserveGeneration(input: {
|
||||||
|
creditCost: number;
|
||||||
|
generationId: string;
|
||||||
|
modelId: string;
|
||||||
|
operationKey: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
if (!Number.isSafeInteger(input.creditCost) || input.creditCost <= 0 || !input.modelId || !input.operationKey) {
|
||||||
|
throw new CreditError("credit_request_invalid");
|
||||||
|
}
|
||||||
|
return this.immediate(() => {
|
||||||
|
const replay = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?")
|
||||||
|
.get(input.operationKey) as LedgerRow | undefined;
|
||||||
|
if (replay) {
|
||||||
|
if (replay.user_id !== input.userId || replay.reference_id !== input.generationId
|
||||||
|
|| replay.entry_type !== "generation_reserve" || replay.amount !== -input.creditCost
|
||||||
|
|| replay.model_id !== input.modelId) {
|
||||||
|
throw new CreditError("credit_operation_conflict");
|
||||||
|
}
|
||||||
|
return { availableBalance: replay.available_after, reservedBalance: replay.reserved_after, status: "reserved" as const };
|
||||||
|
}
|
||||||
|
const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(input.userId) as CreditAccountRow | undefined;
|
||||||
|
if (!account) throw new CreditError("credit_account_not_found");
|
||||||
|
if (account.available_balance < input.creditCost) throw new CreditError("credit_insufficient");
|
||||||
|
if (!this.tableExists("generation_jobs")) throw new CreditError("credit_generation_not_found");
|
||||||
|
const generation = this.database.prepare(`
|
||||||
|
SELECT generation_id FROM generation_jobs
|
||||||
|
WHERE generation_id = ? AND owner_id = ? AND status IN ('queued', 'running')
|
||||||
|
`).get(input.generationId, input.userId);
|
||||||
|
if (!generation) throw new CreditError("credit_generation_not_found");
|
||||||
|
const existing = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
|
||||||
|
.get(input.generationId) as ReservationRow | undefined;
|
||||||
|
if (existing) throw new CreditError("credit_operation_conflict");
|
||||||
|
const availableAfter = account.available_balance - input.creditCost;
|
||||||
|
const reservedAfter = account.reserved_balance + input.creditCost;
|
||||||
|
const now = this.clock();
|
||||||
|
const changed = this.database.prepare(`
|
||||||
|
UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ?
|
||||||
|
WHERE user_id = ? AND available_balance = ? AND reserved_balance = ?
|
||||||
|
`).run(availableAfter, reservedAfter, now, input.userId, account.available_balance, account.reserved_balance);
|
||||||
|
if (changed.changes !== 1) throw new CreditError("credit_invariant_failed");
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO credit_reservations (generation_id, user_id, model_id, amount, status, created_at, finalized_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'reserved', ?, NULL)
|
||||||
|
`).run(input.generationId, input.userId, input.modelId, input.creditCost, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE generation_jobs SET model_id = ?, confirmed_credit_cost = ?, reserved_credits = ?, final_credit_state = NULL
|
||||||
|
WHERE generation_id = ?
|
||||||
|
`).run(input.modelId, input.creditCost, input.creditCost, input.generationId);
|
||||||
|
this.insertLedger({
|
||||||
|
amount: -input.creditCost,
|
||||||
|
availableAfter,
|
||||||
|
availableBefore: account.available_balance,
|
||||||
|
createdAt: now,
|
||||||
|
entryStatus: "frozen",
|
||||||
|
entryType: "generation_reserve",
|
||||||
|
modelId: input.modelId,
|
||||||
|
operationKey: input.operationKey,
|
||||||
|
reason: null,
|
||||||
|
referenceId: input.generationId,
|
||||||
|
referenceType: "generation",
|
||||||
|
reservedAfter,
|
||||||
|
reservedBefore: account.reserved_balance,
|
||||||
|
userId: input.userId,
|
||||||
|
});
|
||||||
|
this.insertOutbox("generation_credit_reserved", input.generationId, input.operationKey, { amount: input.creditCost, user_id: input.userId }, now);
|
||||||
|
return { availableBalance: availableAfter, reservedBalance: reservedAfter, status: "reserved" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeGeneration(input: {
|
||||||
|
generationId: string;
|
||||||
|
operationKey: string;
|
||||||
|
outcome: "succeeded" | "failed" | "rejected";
|
||||||
|
}) {
|
||||||
|
if (!input.operationKey) throw new CreditError("credit_request_invalid");
|
||||||
|
return this.immediate(() => {
|
||||||
|
const reservation = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
|
||||||
|
.get(input.generationId) as ReservationRow | undefined;
|
||||||
|
if (!reservation) throw new CreditError("credit_generation_not_found");
|
||||||
|
if (reservation.status !== "reserved") {
|
||||||
|
const replay = this.database.prepare(`
|
||||||
|
SELECT * FROM credit_ledger WHERE reference_id = ? AND entry_type IN ('generation_commit', 'generation_release')
|
||||||
|
ORDER BY created_at DESC, ledger_id DESC LIMIT 1
|
||||||
|
`).get(input.generationId) as LedgerRow | undefined;
|
||||||
|
if (!replay) throw new CreditError("credit_invariant_failed");
|
||||||
|
return {
|
||||||
|
availableBalance: replay.available_after,
|
||||||
|
creditState: reservation.status,
|
||||||
|
reservedBalance: replay.reserved_after,
|
||||||
|
status: "finalized" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const operation = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?").get(input.operationKey) as LedgerRow | undefined;
|
||||||
|
if (operation) throw new CreditError("credit_operation_conflict");
|
||||||
|
const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(reservation.user_id) as CreditAccountRow | undefined;
|
||||||
|
if (!account || account.reserved_balance < reservation.amount) throw new CreditError("credit_invariant_failed");
|
||||||
|
const committed = input.outcome === "succeeded";
|
||||||
|
const creditState = committed ? "committed" as const : "released" as const;
|
||||||
|
const availableAfter = committed ? account.available_balance : account.available_balance + reservation.amount;
|
||||||
|
const reservedAfter = account.reserved_balance - reservation.amount;
|
||||||
|
if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_invariant_failed");
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? WHERE user_id = ?
|
||||||
|
`).run(availableAfter, reservedAfter, now, reservation.user_id);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE credit_reservations SET status = ?, finalized_at = ? WHERE generation_id = ? AND status = 'reserved'
|
||||||
|
`).run(creditState, now, input.generationId);
|
||||||
|
this.database.prepare("UPDATE generation_jobs SET final_credit_state = ? WHERE generation_id = ?")
|
||||||
|
.run(creditState, input.generationId);
|
||||||
|
this.insertLedger({
|
||||||
|
amount: committed ? -reservation.amount : reservation.amount,
|
||||||
|
availableAfter,
|
||||||
|
availableBefore: account.available_balance,
|
||||||
|
createdAt: now,
|
||||||
|
entryStatus: committed ? "committed" : "released",
|
||||||
|
entryType: committed ? "generation_commit" : "generation_release",
|
||||||
|
modelId: reservation.model_id,
|
||||||
|
operationKey: input.operationKey,
|
||||||
|
reason: null,
|
||||||
|
referenceId: input.generationId,
|
||||||
|
referenceType: "generation",
|
||||||
|
reservedAfter,
|
||||||
|
reservedBefore: account.reserved_balance,
|
||||||
|
userId: reservation.user_id,
|
||||||
|
});
|
||||||
|
this.insertOutbox(committed ? "generation_credit_committed" : "generation_credit_released", input.generationId, input.operationKey, { outcome: input.outcome }, now);
|
||||||
|
return { availableBalance: availableAfter, creditState, reservedBalance: reservedAfter, status: "finalized" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustAvailable(input: {
|
||||||
|
adjustmentId: string;
|
||||||
|
adminId: string;
|
||||||
|
amount: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
reason: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const reason = input.reason.trim();
|
||||||
|
if (!Number.isSafeInteger(input.amount) || input.amount === 0 || !reason || reason.length > 500
|
||||||
|
|| input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200
|
||||||
|
|| !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) {
|
||||||
|
throw new CreditError("credit_request_invalid");
|
||||||
|
}
|
||||||
|
const idempotencyKeyDigest = createHash("sha256").update(input.idempotencyKey, "utf8").digest("hex");
|
||||||
|
const requestHash = createHash("sha256")
|
||||||
|
.update(JSON.stringify([input.adjustmentId, input.adminId, input.userId, input.amount, reason]), "utf8")
|
||||||
|
.digest("hex");
|
||||||
|
return this.immediate(() => {
|
||||||
|
type Receipt = {
|
||||||
|
adjustment_id: string;
|
||||||
|
admin_id: string | null;
|
||||||
|
available_after: number;
|
||||||
|
idempotency_key_digest: string | null;
|
||||||
|
request_hash: string;
|
||||||
|
reserved_after: number;
|
||||||
|
user_id: string | null;
|
||||||
|
};
|
||||||
|
const receiptByKey = this.database.prepare(`
|
||||||
|
SELECT * FROM credit_adjustment_receipts
|
||||||
|
WHERE admin_id = ? AND user_id = ? AND idempotency_key_digest = ?
|
||||||
|
`).get(input.adminId, input.userId, idempotencyKeyDigest) as Receipt | undefined;
|
||||||
|
if (receiptByKey) {
|
||||||
|
if (receiptByKey.request_hash !== requestHash) throw new CreditError("credit_operation_conflict");
|
||||||
|
return {
|
||||||
|
adjustmentId: receiptByKey.adjustment_id,
|
||||||
|
availableBalance: receiptByKey.available_after,
|
||||||
|
reservedBalance: receiptByKey.reserved_after,
|
||||||
|
status: "adjusted" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const receipt = this.database.prepare("SELECT * FROM credit_adjustment_receipts WHERE adjustment_id = ?")
|
||||||
|
.get(input.adjustmentId) as Receipt | undefined;
|
||||||
|
if (receipt) {
|
||||||
|
if (receipt.request_hash !== requestHash || receipt.admin_id !== input.adminId || receipt.user_id !== input.userId
|
||||||
|
|| receipt.idempotency_key_digest !== idempotencyKeyDigest) {
|
||||||
|
throw new CreditError("credit_operation_conflict");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
adjustmentId: input.adjustmentId,
|
||||||
|
availableBalance: receipt.available_after,
|
||||||
|
reservedBalance: receipt.reserved_after,
|
||||||
|
status: "adjusted" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const admin = this.database.prepare(`
|
||||||
|
SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id
|
||||||
|
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
|
||||||
|
`).get(input.adminId);
|
||||||
|
if (!admin) throw new CreditError("credit_account_not_found");
|
||||||
|
const target = this.database.prepare(`
|
||||||
|
SELECT c.* FROM credit_accounts c JOIN users u ON u.user_id = c.user_id
|
||||||
|
WHERE c.user_id = ? AND u.role = 'user' AND u.status <> 'deleted'
|
||||||
|
`).get(input.userId) as CreditAccountRow | undefined;
|
||||||
|
if (!target) throw new CreditError("credit_account_not_found");
|
||||||
|
const availableAfter = target.available_balance + input.amount;
|
||||||
|
if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_request_invalid");
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.prepare("UPDATE credit_accounts SET available_balance = ?, updated_at = ? WHERE user_id = ?")
|
||||||
|
.run(availableAfter, now, input.userId);
|
||||||
|
const ledgerId = this.insertLedger({
|
||||||
|
amount: input.amount,
|
||||||
|
availableAfter,
|
||||||
|
availableBefore: target.available_balance,
|
||||||
|
createdAt: now,
|
||||||
|
entryStatus: "succeeded",
|
||||||
|
entryType: "admin_adjustment",
|
||||||
|
modelId: null,
|
||||||
|
operationKey: `admin_adjustment:${createHash("sha256").update(`${input.adminId}\0${input.userId}\0${idempotencyKeyDigest}`, "utf8").digest("hex")}`,
|
||||||
|
reason,
|
||||||
|
referenceId: input.adjustmentId,
|
||||||
|
referenceType: "admin_adjustment",
|
||||||
|
reservedAfter: target.reserved_balance,
|
||||||
|
reservedBefore: target.reserved_balance,
|
||||||
|
userId: input.userId,
|
||||||
|
});
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'super_admin', ?, 'credit_adjustment', 'user_credit_account', ?, 'succeeded', ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), input.adminId, input.userId,
|
||||||
|
serializeAuditSummary({ available_balance: target.available_balance, reserved_balance: target.reserved_balance }),
|
||||||
|
serializeAuditSummary({ adjustment_amount: input.amount, available_balance: availableAfter, reserved_balance: target.reserved_balance }),
|
||||||
|
now, now + auditRetentionMilliseconds,
|
||||||
|
);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO credit_adjustment_receipts (
|
||||||
|
adjustment_id, admin_id, user_id, idempotency_key_digest, request_hash,
|
||||||
|
ledger_id, available_after, reserved_after, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
input.adjustmentId, input.adminId, input.userId, idempotencyKeyDigest, requestHash,
|
||||||
|
ledgerId, availableAfter, target.reserved_balance, now,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
adjustmentId: input.adjustmentId,
|
||||||
|
availableBalance: availableAfter,
|
||||||
|
reservedBalance: target.reserved_balance,
|
||||||
|
status: "adjusted" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private immediate<T>(action: () => T): T {
|
||||||
|
if (this.database.inTransaction) return action();
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const result = action();
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertLedger(input: {
|
||||||
|
amount: number;
|
||||||
|
availableAfter: number;
|
||||||
|
availableBefore: number;
|
||||||
|
createdAt: number;
|
||||||
|
entryStatus: LedgerRow["entry_status"];
|
||||||
|
entryType: LedgerRow["entry_type"];
|
||||||
|
modelId: string | null;
|
||||||
|
operationKey: string;
|
||||||
|
reason: string | null;
|
||||||
|
referenceId: string | null;
|
||||||
|
referenceType: LedgerRow["reference_type"];
|
||||||
|
reservedAfter: number;
|
||||||
|
reservedBefore: number;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const ledgerId = randomUUID();
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO credit_ledger (
|
||||||
|
ledger_id, user_id, operation_key, entry_type, amount,
|
||||||
|
available_before, available_after, reserved_before, reserved_after, created_at,
|
||||||
|
reference_type, reference_id, model_id, reason, entry_status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
ledgerId, input.userId, input.operationKey, input.entryType, input.amount,
|
||||||
|
input.availableBefore, input.availableAfter, input.reservedBefore, input.reservedAfter, input.createdAt,
|
||||||
|
input.referenceType, input.referenceId, input.modelId, input.reason, input.entryStatus,
|
||||||
|
);
|
||||||
|
return ledgerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertOutbox(topic: string, aggregateId: string, operationKey: string, payload: Record<string, unknown>, now: number) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO outbox_events (
|
||||||
|
event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at
|
||||||
|
) VALUES (?, ?, ?, 'generation', ?, ?, 'pending', ?, NULL)
|
||||||
|
`).run(randomUUID(), operationKey, topic, aggregateId, JSON.stringify(payload), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tableExists(name: string) {
|
||||||
|
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureColumn(table: string, column: string, definition: string) {
|
||||||
|
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||||
|
if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
if (!this.tableExists("credit_accounts") || !this.tableExists("credit_ledger")) {
|
||||||
|
throw new Error("credit_schema_unavailable");
|
||||||
|
}
|
||||||
|
this.ensureColumn("credit_ledger", "reference_type", "TEXT");
|
||||||
|
this.ensureColumn("credit_ledger", "reference_id", "TEXT");
|
||||||
|
this.ensureColumn("credit_ledger", "model_id", "TEXT");
|
||||||
|
this.ensureColumn("credit_ledger", "reason", "TEXT");
|
||||||
|
this.ensureColumn("credit_ledger", "entry_status", "TEXT NOT NULL DEFAULT 'succeeded'");
|
||||||
|
if (this.tableExists("generation_jobs")) {
|
||||||
|
this.ensureColumn("generation_jobs", "model_id", "TEXT");
|
||||||
|
this.ensureColumn("generation_jobs", "model_config_version", "INTEGER");
|
||||||
|
this.ensureColumn("generation_jobs", "confirmed_credit_cost", "INTEGER");
|
||||||
|
this.ensureColumn("generation_jobs", "reserved_credits", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
this.ensureColumn("generation_jobs", "final_credit_state", "TEXT");
|
||||||
|
this.ensureColumn("generation_jobs", "finished_at", "INTEGER");
|
||||||
|
}
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS credit_reservations (
|
||||||
|
generation_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL CHECK (amount > 0),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('reserved', 'committed', 'released')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
finalized_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS credit_reservations_user_status ON credit_reservations(user_id, status, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS credit_adjustment_receipts (
|
||||||
|
adjustment_id TEXT PRIMARY KEY,
|
||||||
|
admin_id TEXT REFERENCES users(user_id),
|
||||||
|
user_id TEXT REFERENCES users(user_id),
|
||||||
|
idempotency_key_digest TEXT CHECK (idempotency_key_digest IS NULL OR length(idempotency_key_digest) = 64),
|
||||||
|
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||||
|
ledger_id TEXT NOT NULL UNIQUE REFERENCES credit_ledger(ledger_id),
|
||||||
|
available_after INTEGER NOT NULL,
|
||||||
|
reserved_after INTEGER NOT NULL CHECK (reserved_after >= 0),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS outbox_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
operation_key TEXT NOT NULL UNIQUE,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
aggregate_type TEXT NOT NULL,
|
||||||
|
aggregate_id TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'published')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
published_at INTEGER
|
||||||
|
);
|
||||||
|
DROP TRIGGER IF EXISTS credit_ledger_shape_guard;
|
||||||
|
CREATE TRIGGER credit_ledger_shape_guard BEFORE INSERT ON credit_ledger
|
||||||
|
WHEN
|
||||||
|
NEW.entry_status NOT IN ('succeeded', 'frozen', 'committed', 'released')
|
||||||
|
OR (NEW.entry_type = 'admin_adjustment' AND (
|
||||||
|
NEW.reference_type <> 'admin_adjustment' OR NEW.reference_id IS NULL OR trim(COALESCE(NEW.reason, '')) = ''
|
||||||
|
))
|
||||||
|
OR (NEW.entry_type IN ('generation_reserve', 'generation_commit', 'generation_release') AND (
|
||||||
|
NEW.reference_type <> 'generation' OR NEW.reference_id IS NULL OR NEW.model_id IS NULL
|
||||||
|
))
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'credit_ledger_shape_invalid'); END;
|
||||||
|
`);
|
||||||
|
this.ensureColumn("credit_adjustment_receipts", "admin_id", "TEXT REFERENCES users(user_id)");
|
||||||
|
this.ensureColumn("credit_adjustment_receipts", "user_id", "TEXT REFERENCES users(user_id)");
|
||||||
|
this.ensureColumn("credit_adjustment_receipts", "idempotency_key_digest", "TEXT");
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS credit_adjustment_idempotency
|
||||||
|
ON credit_adjustment_receipts(admin_id, user_id, idempotency_key_digest)
|
||||||
|
WHERE admin_id IS NOT NULL AND user_id IS NOT NULL AND idempotency_key_digest IS NOT NULL;
|
||||||
|
`);
|
||||||
|
ensureAdminOperationAuditSchema(this.database, this.clock());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import { serializeAuditSummary } from "./audit-policy.js";
|
||||||
|
|
||||||
|
export type ExternalServiceId = "resend_email" | "amap_web_service";
|
||||||
|
export type ExternalServicePeriodType = "daily" | "monthly";
|
||||||
|
export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled";
|
||||||
|
|
||||||
|
const retentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||||
|
const recoveryCheckLifetimeMilliseconds = 15 * 60 * 1_000;
|
||||||
|
const maximumHardLimits: Record<ExternalServiceId, Partial<Record<ExternalServicePeriodType, number>>> = {
|
||||||
|
resend_email: { daily: 80, monthly: 2_400 },
|
||||||
|
amap_web_service: { monthly: 1_000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ExternalServiceUsageRow {
|
||||||
|
serviceId: ExternalServiceId;
|
||||||
|
periodType: ExternalServicePeriodType;
|
||||||
|
periodStart: number;
|
||||||
|
hardLimit: number;
|
||||||
|
usedCount: number;
|
||||||
|
status: ExternalServiceStatus;
|
||||||
|
pauseReason: string | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExternalServiceUsageError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code:
|
||||||
|
| "service_paused_quota"
|
||||||
|
| "service_paused_provider"
|
||||||
|
| "service_disabled"
|
||||||
|
| "hard_limit_increase_forbidden"
|
||||||
|
| "hard_limit_invalid"
|
||||||
|
| "health_check_required"
|
||||||
|
| "quota_exhausted"
|
||||||
|
| "service_not_found",
|
||||||
|
message = code,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ExternalServiceUsageError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExternalServiceUsageOptions {
|
||||||
|
clock?: () => number;
|
||||||
|
database: BetterSqlite3.Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawUsageRow {
|
||||||
|
service_id: ExternalServiceId;
|
||||||
|
period_type: ExternalServicePeriodType;
|
||||||
|
period_start: number;
|
||||||
|
hard_limit: number;
|
||||||
|
used_count: number;
|
||||||
|
service_status: ExternalServiceStatus;
|
||||||
|
pause_reason: string | null;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodStart(periodType: ExternalServicePeriodType, now: number) {
|
||||||
|
const date = new Date(now);
|
||||||
|
if (periodType === "daily") return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||||
|
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredPeriods(serviceId: ExternalServiceId): ExternalServicePeriodType[] {
|
||||||
|
return serviceId === "resend_email" ? ["daily", "monthly"] : ["monthly"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublic(row: RawUsageRow): ExternalServiceUsageRow {
|
||||||
|
return {
|
||||||
|
hardLimit: row.hard_limit,
|
||||||
|
pauseReason: row.pause_reason,
|
||||||
|
periodStart: row.period_start,
|
||||||
|
periodType: row.period_type,
|
||||||
|
serviceId: row.service_id,
|
||||||
|
status: row.service_status,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
usedCount: row.used_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExternalServiceUsage {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
readonly clock: () => number;
|
||||||
|
|
||||||
|
constructor(options: ExternalServiceUsageOptions) {
|
||||||
|
this.database = options.database;
|
||||||
|
this.clock = options.clock ?? Date.now;
|
||||||
|
this.ensureSchema();
|
||||||
|
this.runImmediate(() => {
|
||||||
|
this.ensureCurrentRows(this.clock(), "resend_email");
|
||||||
|
this.ensureCurrentRows(this.clock(), "amap_web_service");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
claimResend(now = this.clock()) {
|
||||||
|
return this.runImmediate(() => this.claimWithinTransaction("resend_email", now));
|
||||||
|
}
|
||||||
|
|
||||||
|
claimResendWithinTransaction(now = this.clock()) {
|
||||||
|
return this.claimWithinTransaction("resend_email", now);
|
||||||
|
}
|
||||||
|
|
||||||
|
claimAmap(now = this.clock()) {
|
||||||
|
return this.runImmediate(() => this.claimWithinTransaction("amap_web_service", now));
|
||||||
|
}
|
||||||
|
|
||||||
|
claimAmapWithinTransaction(now = this.clock()) {
|
||||||
|
return this.claimWithinTransaction("amap_web_service", now);
|
||||||
|
}
|
||||||
|
|
||||||
|
markProviderFailure(input: { serviceId: ExternalServiceId; reason: string; now?: number }) {
|
||||||
|
return this.runImmediate(() => this.markProviderFailureWithinTransaction(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
markProviderFailureWithinTransaction(input: { serviceId: ExternalServiceId; reason: string; now?: number }) {
|
||||||
|
const now = input.now ?? this.clock();
|
||||||
|
const rows = this.ensureCurrentRows(now, input.serviceId);
|
||||||
|
const reason = normalizeReason(input.reason);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.service_status === "disabled") continue;
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE external_service_usage
|
||||||
|
SET service_status = 'paused_provider', pause_reason = ?, updated_at = ?
|
||||||
|
WHERE service_id = ? AND period_type = ? AND period_start = ?
|
||||||
|
`).run(reason, now, row.service_id, row.period_type, row.period_start);
|
||||||
|
}
|
||||||
|
this.recordAudit({
|
||||||
|
actorRef: "external_service_runtime",
|
||||||
|
actorType: "system",
|
||||||
|
afterSummary: { pause_reason: reason, status: "paused_provider" },
|
||||||
|
beforeSummary: { status: rows[0]?.service_status ?? "active" },
|
||||||
|
operationType: "service_provider_pause",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: input.serviceId,
|
||||||
|
targetType: "external_service",
|
||||||
|
}, now);
|
||||||
|
return this.read(input.serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordHealthCheck(input: { serviceId: ExternalServiceId; available: boolean; reason?: string; now?: number }) {
|
||||||
|
const now = input.now ?? this.clock();
|
||||||
|
const checkId = randomUUID();
|
||||||
|
const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now);
|
||||||
|
const result = this.runImmediate(() => {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO service_recovery_checks (
|
||||||
|
check_id, service_name, target_ref, status, checked_at, expires_at, details_json,
|
||||||
|
service_id, period_start, available, check_reason
|
||||||
|
) VALUES (?, 'external_service', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
checkId,
|
||||||
|
input.serviceId,
|
||||||
|
input.available ? "passed" : "failed",
|
||||||
|
now,
|
||||||
|
now + recoveryCheckLifetimeMilliseconds,
|
||||||
|
JSON.stringify({ non_sensitive: true }),
|
||||||
|
input.serviceId,
|
||||||
|
currentPeriod,
|
||||||
|
input.available ? 1 : 0,
|
||||||
|
input.reason ? normalizeReason(input.reason) : null,
|
||||||
|
);
|
||||||
|
return { checkId, available: input.available, checkedAt: now };
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
recover(input: { serviceId: ExternalServiceId; actorId: string; checkId: string; now?: number }) {
|
||||||
|
const now = input.now ?? this.clock();
|
||||||
|
const result = this.runImmediate(() => {
|
||||||
|
const check = this.database.prepare(`
|
||||||
|
SELECT check_id, period_start, available, expires_at
|
||||||
|
FROM service_recovery_checks
|
||||||
|
WHERE check_id = ? AND service_id = ? AND service_name = 'external_service'
|
||||||
|
`).get(input.checkId, input.serviceId) as { available: number; check_id: string; expires_at: number; period_start: number } | undefined;
|
||||||
|
const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now);
|
||||||
|
if (!check?.available || check.period_start !== currentPeriod || check.expires_at <= now) {
|
||||||
|
this.recordAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { reason: "health_check_required" },
|
||||||
|
beforeSummary: null,
|
||||||
|
operationType: "service_recovery",
|
||||||
|
result: "failed",
|
||||||
|
targetRef: input.serviceId,
|
||||||
|
targetType: "external_service",
|
||||||
|
}, now);
|
||||||
|
return { error: new ExternalServiceUsageError("health_check_required") };
|
||||||
|
}
|
||||||
|
const rows = this.ensureCurrentRows(now, input.serviceId);
|
||||||
|
if (rows.some((row) => row.used_count >= row.hard_limit)) {
|
||||||
|
this.recordAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { reason: "quota_exhausted" },
|
||||||
|
beforeSummary: { status: rows[0]?.service_status ?? "paused_quota" },
|
||||||
|
operationType: "service_recovery",
|
||||||
|
result: "failed",
|
||||||
|
targetRef: input.serviceId,
|
||||||
|
targetType: "external_service",
|
||||||
|
}, now);
|
||||||
|
return { error: new ExternalServiceUsageError("quota_exhausted") };
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE external_service_usage
|
||||||
|
SET service_status = 'active', pause_reason = NULL, updated_at = ?
|
||||||
|
WHERE service_id = ? AND period_type = ? AND period_start = ?
|
||||||
|
`).run(now, row.service_id, row.period_type, row.period_start);
|
||||||
|
}
|
||||||
|
this.recordAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { check_id: input.checkId, status: "active" },
|
||||||
|
beforeSummary: { status: rows[0]?.service_status ?? "paused_provider" },
|
||||||
|
operationType: "service_recovery",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: input.serviceId,
|
||||||
|
targetType: "external_service",
|
||||||
|
}, now);
|
||||||
|
return { status: "active" as const };
|
||||||
|
});
|
||||||
|
if ("error" in result && result.error) throw result.error;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHardLimit(input: {
|
||||||
|
serviceId: ExternalServiceId;
|
||||||
|
periodType: ExternalServicePeriodType;
|
||||||
|
hardLimit: number;
|
||||||
|
actorId: string;
|
||||||
|
now?: number;
|
||||||
|
}) {
|
||||||
|
const now = input.now ?? this.clock();
|
||||||
|
const result = this.runImmediate(() => {
|
||||||
|
const maximum = maximumHardLimits[input.serviceId][input.periodType];
|
||||||
|
const current = this.ensureCurrentRows(now, input.serviceId).find((row) => row.period_type === input.periodType);
|
||||||
|
if (current && input.hardLimit > current.hard_limit) {
|
||||||
|
this.recordAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { reason: "hard_limit_increase_forbidden" },
|
||||||
|
beforeSummary: { hard_limit: current.hard_limit },
|
||||||
|
operationType: "service_hard_limit_update",
|
||||||
|
result: "failed",
|
||||||
|
targetRef: `${input.serviceId}:${input.periodType}`,
|
||||||
|
targetType: "external_service_limit",
|
||||||
|
}, now);
|
||||||
|
return { error: new ExternalServiceUsageError("hard_limit_increase_forbidden") };
|
||||||
|
}
|
||||||
|
const valid = maximum !== undefined && Number.isSafeInteger(input.hardLimit) && input.hardLimit >= 1 && input.hardLimit <= maximum;
|
||||||
|
if (!valid || !current) {
|
||||||
|
this.recordAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { reason: "hard_limit_invalid" },
|
||||||
|
beforeSummary: current ? { hard_limit: current.hard_limit } : null,
|
||||||
|
operationType: "service_hard_limit_update",
|
||||||
|
result: "failed",
|
||||||
|
targetRef: `${input.serviceId}:${input.periodType}`,
|
||||||
|
targetType: "external_service_limit",
|
||||||
|
}, now);
|
||||||
|
return { error: new ExternalServiceUsageError("hard_limit_invalid") };
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE external_service_usage
|
||||||
|
SET hard_limit = ?, service_status = CASE
|
||||||
|
WHEN used_count >= ? THEN 'paused_quota'
|
||||||
|
ELSE service_status
|
||||||
|
END, pause_reason = CASE
|
||||||
|
WHEN used_count >= ? THEN 'hard_limit_reached'
|
||||||
|
ELSE pause_reason
|
||||||
|
END, updated_at = ?
|
||||||
|
WHERE service_id = ? AND period_type = ? AND period_start = ?
|
||||||
|
`).run(input.hardLimit, input.hardLimit, input.hardLimit, now, current.service_id, current.period_type, current.period_start);
|
||||||
|
this.recordAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { hard_limit: input.hardLimit },
|
||||||
|
beforeSummary: { hard_limit: current.hard_limit },
|
||||||
|
operationType: "service_hard_limit_update",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: `${input.serviceId}:${input.periodType}`,
|
||||||
|
targetType: "external_service_limit",
|
||||||
|
}, now);
|
||||||
|
return this.read(input.serviceId);
|
||||||
|
});
|
||||||
|
if ("error" in result && result.error) throw result.error;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
read(serviceId?: ExternalServiceId) {
|
||||||
|
const rows = (serviceId
|
||||||
|
? this.database.prepare("SELECT * FROM external_service_usage WHERE service_id = ? ORDER BY period_type").all(serviceId)
|
||||||
|
: this.database.prepare("SELECT * FROM external_service_usage ORDER BY service_id, period_type").all()) as RawUsageRow[];
|
||||||
|
return rows.map(toPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
readCurrent() {
|
||||||
|
const now = this.clock();
|
||||||
|
return this.read().filter((row) => row.periodStart === periodStart(row.periodType, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
readStatus(serviceId: ExternalServiceId) {
|
||||||
|
const rows = this.read(serviceId).filter((row) => row.periodStart >= periodStart(row.periodType, this.clock()));
|
||||||
|
const status = rows.some((row) => row.status === "disabled")
|
||||||
|
? "disabled"
|
||||||
|
: rows.some((row) => row.status === "paused_provider")
|
||||||
|
? "paused_provider"
|
||||||
|
: rows.some((row) => row.status === "paused_quota")
|
||||||
|
? "paused_quota"
|
||||||
|
: "active";
|
||||||
|
return { serviceId, status, rows } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
private claimWithinTransaction(serviceId: ExternalServiceId, now: number) {
|
||||||
|
const rows = this.ensureCurrentRows(now, serviceId);
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.service_status === "paused_quota") throw new ExternalServiceUsageError("service_paused_quota");
|
||||||
|
if (row.service_status === "paused_provider") throw new ExternalServiceUsageError("service_paused_provider");
|
||||||
|
if (row.service_status === "disabled") throw new ExternalServiceUsageError("service_disabled");
|
||||||
|
if (row.used_count >= row.hard_limit) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE external_service_usage
|
||||||
|
SET service_status = 'paused_quota', pause_reason = 'hard_limit_reached', updated_at = ?
|
||||||
|
WHERE service_id = ? AND period_type = ? AND period_start = ?
|
||||||
|
`).run(now, row.service_id, row.period_type, row.period_start);
|
||||||
|
throw new ExternalServiceUsageError("service_paused_quota");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = rows.map((row) => {
|
||||||
|
const usedCount = row.used_count + 1;
|
||||||
|
const status: ExternalServiceStatus = usedCount >= row.hard_limit ? "paused_quota" : "active";
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE external_service_usage
|
||||||
|
SET used_count = ?, service_status = ?, pause_reason = CASE WHEN ? = 'active' THEN NULL ELSE 'hard_limit_reached' END, updated_at = ?
|
||||||
|
WHERE service_id = ? AND period_type = ? AND period_start = ?
|
||||||
|
`).run(usedCount, status, status, now, row.service_id, row.period_type, row.period_start);
|
||||||
|
return { periodType: row.period_type, remaining: Math.max(0, row.hard_limit - usedCount), usedCount };
|
||||||
|
});
|
||||||
|
return { allowed: true as const, serviceId, allocations: updated, remaining: Math.min(...updated.map((item) => item.remaining)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCurrentRows(now: number, serviceId: ExternalServiceId) {
|
||||||
|
const periods = requiredPeriods(serviceId);
|
||||||
|
for (const periodType of periods) {
|
||||||
|
const start = periodStart(periodType, now);
|
||||||
|
const current = this.database.prepare(`
|
||||||
|
SELECT * FROM external_service_usage WHERE service_id = ? AND period_type = ? AND period_start = ?
|
||||||
|
`).get(serviceId, periodType, start) as RawUsageRow | undefined;
|
||||||
|
if (current) continue;
|
||||||
|
const previous = this.database.prepare(`
|
||||||
|
SELECT hard_limit FROM external_service_usage
|
||||||
|
WHERE service_id = ? AND period_type = ? ORDER BY period_start DESC LIMIT 1
|
||||||
|
`).get(serviceId, periodType) as { hard_limit: number } | undefined;
|
||||||
|
const maximum = maximumHardLimits[serviceId][periodType];
|
||||||
|
if (maximum === undefined) throw new ExternalServiceUsageError("service_not_found");
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO external_service_usage (
|
||||||
|
service_id, period_type, period_start, hard_limit, used_count,
|
||||||
|
service_status, pause_reason, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, 0, ?, ?, ?)
|
||||||
|
`).run(serviceId, periodType, start, previous?.hard_limit ?? maximum, previous ? "paused_quota" : "active", previous ? "period_confirmation_required" : null, now);
|
||||||
|
}
|
||||||
|
return this.database.prepare(`
|
||||||
|
SELECT * FROM external_service_usage
|
||||||
|
WHERE service_id = ? AND period_start IN (${periods.map(() => "?").join(",")})
|
||||||
|
ORDER BY period_type
|
||||||
|
`).all(serviceId, ...periods.map((period) => periodStart(period, now))) as RawUsageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSchema() {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS external_service_usage (
|
||||||
|
service_id TEXT NOT NULL CHECK (service_id IN ('resend_email', 'amap_web_service')),
|
||||||
|
period_type TEXT NOT NULL CHECK (period_type IN ('daily', 'monthly')),
|
||||||
|
period_start INTEGER NOT NULL,
|
||||||
|
hard_limit INTEGER NOT NULL CHECK (hard_limit >= 1),
|
||||||
|
used_count INTEGER NOT NULL CHECK (used_count >= 0 AND used_count <= hard_limit),
|
||||||
|
service_status TEXT NOT NULL CHECK (service_status IN ('active', 'paused_quota', 'paused_provider', 'disabled')),
|
||||||
|
pause_reason TEXT,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (service_id, period_type, period_start)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS service_recovery_checks (
|
||||||
|
check_id TEXT PRIMARY KEY,
|
||||||
|
service_name TEXT NOT NULL DEFAULT 'external_service',
|
||||||
|
target_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'passed' CHECK (status IN ('passed', 'failed')),
|
||||||
|
checked_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
details_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
service_id TEXT CHECK (service_id IS NULL OR service_id IN ('resend_email', 'amap_web_service')),
|
||||||
|
period_start INTEGER,
|
||||||
|
available INTEGER CHECK (available IS NULL OR available IN (0, 1)),
|
||||||
|
check_reason TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const columns = new Set((this.database.prepare("PRAGMA table_info(service_recovery_checks)").all() as Array<{ name: string }>).map((column) => column.name));
|
||||||
|
const additions: Array<[string, string]> = [
|
||||||
|
["service_name", "TEXT NOT NULL DEFAULT 'external_service'"],
|
||||||
|
["target_ref", "TEXT NOT NULL DEFAULT ''"],
|
||||||
|
["status", "TEXT NOT NULL DEFAULT 'passed'"],
|
||||||
|
["expires_at", "INTEGER NOT NULL DEFAULT 0"],
|
||||||
|
["details_json", "TEXT NOT NULL DEFAULT '{}'"],
|
||||||
|
["service_id", "TEXT"],
|
||||||
|
["period_start", "INTEGER"],
|
||||||
|
["available", "INTEGER"],
|
||||||
|
["check_reason", "TEXT"],
|
||||||
|
];
|
||||||
|
for (const [name, definition] of additions) {
|
||||||
|
if (!columns.has(name)) this.database.exec(`ALTER TABLE service_recovery_checks ADD COLUMN ${name} ${definition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runImmediate<T>(action: () => T): T {
|
||||||
|
const nested = this.database.inTransaction;
|
||||||
|
if (!nested) this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const result = action();
|
||||||
|
if (!nested) this.database.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (!nested && this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordAudit(input: {
|
||||||
|
actorRef: string;
|
||||||
|
actorType: "system" | "super_admin";
|
||||||
|
afterSummary: Record<string, unknown> | null;
|
||||||
|
beforeSummary: Record<string, unknown> | null;
|
||||||
|
operationType: string;
|
||||||
|
result: "succeeded" | "failed";
|
||||||
|
targetRef: string;
|
||||||
|
targetType: string;
|
||||||
|
}, now: number) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), input.actorType, input.actorRef, input.operationType, input.targetType, input.targetRef,
|
||||||
|
input.result, serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), now,
|
||||||
|
now + retentionMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReason(reason: string) {
|
||||||
|
const normalized = reason.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "_").slice(0, 120);
|
||||||
|
return normalized || "provider_unavailable";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type GenerationSubmissionErrorCode =
|
||||||
|
| "generation_blocked"
|
||||||
|
| "generation_idempotency_conflict"
|
||||||
|
| "generation_not_found"
|
||||||
|
| "generation_request_invalid"
|
||||||
|
| "generation_storage_unavailable"
|
||||||
|
| "model_config_stale"
|
||||||
|
| "reference_invalid";
|
||||||
|
|
||||||
|
export class GenerationSubmissionError extends Error {
|
||||||
|
readonly code: GenerationSubmissionErrorCode;
|
||||||
|
readonly errorCategory: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | undefined;
|
||||||
|
readonly latest: { configVersion: number; creditCost: number; modelId: string } | undefined;
|
||||||
|
readonly storage: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number } | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: GenerationSubmissionErrorCode,
|
||||||
|
options: {
|
||||||
|
errorCategory?: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid";
|
||||||
|
latest?: { configVersion: number; creditCost: number; modelId: string };
|
||||||
|
storage?: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number };
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
this.errorCategory = options.errorCategory;
|
||||||
|
this.latest = options.latest;
|
||||||
|
this.storage = options.storage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import type { CreditService } from "./credits.js";
|
||||||
|
import { GenerationSubmissionError } from "./generation-submission-errors.js";
|
||||||
|
import type { ManagedStorage, StagedManagedFile } from "./managed-storage.js";
|
||||||
|
import {
|
||||||
|
defaultCanvasState,
|
||||||
|
defaultProjectName,
|
||||||
|
historyLimit,
|
||||||
|
normalizePrompt,
|
||||||
|
projectLimit,
|
||||||
|
projectRatios,
|
||||||
|
ratioPixels,
|
||||||
|
stableJson,
|
||||||
|
type ProjectRatio,
|
||||||
|
} from "./projects.js";
|
||||||
|
import { classifyCapacity } from "./storage-policy.js";
|
||||||
|
|
||||||
|
export { GenerationSubmissionError } from "./generation-submission-errors.js";
|
||||||
|
|
||||||
|
const idempotencyPattern = /^[A-Za-z0-9_-]{32,200}$/;
|
||||||
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export interface GenerationModelSnapshot {
|
||||||
|
configSetVersion: number;
|
||||||
|
configVersion: number;
|
||||||
|
contractValidationStatus: "verified" | "unverified";
|
||||||
|
creditCost: number;
|
||||||
|
enabled: boolean;
|
||||||
|
modelId: string;
|
||||||
|
promptMaxLength: number;
|
||||||
|
referenceLimits: { maxFileBytes: number; maxFiles: number; maxTotalBytes: number };
|
||||||
|
runtimeAvailability: {
|
||||||
|
availableForNewJobs: boolean;
|
||||||
|
reason: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | null;
|
||||||
|
};
|
||||||
|
supportedRatios: readonly ProjectRatio[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationModelCatalog {
|
||||||
|
readModel(modelId: string): GenerationModelSnapshot | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StaticGenerationModelCatalog implements GenerationModelCatalog {
|
||||||
|
private readonly models = new Map<string, GenerationModelSnapshot>();
|
||||||
|
|
||||||
|
constructor(models: GenerationModelSnapshot[]) {
|
||||||
|
for (const model of models) this.models.set(model.modelId, structuredClone(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
readModel(modelId: string) {
|
||||||
|
const model = this.models.get(modelId);
|
||||||
|
return model ? structuredClone(model) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(model: GenerationModelSnapshot) {
|
||||||
|
this.models.set(model.modelId, structuredClone(model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewGenerationReference {
|
||||||
|
content: Readable;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||||
|
projectedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationSubmissionInput {
|
||||||
|
clientSubmissionId: string;
|
||||||
|
confirmedCreditCost: number;
|
||||||
|
existingReferenceAssetIds: string[];
|
||||||
|
idempotencyKey: string;
|
||||||
|
mode: "new_project" | "existing_project";
|
||||||
|
modelConfigVersion: number;
|
||||||
|
modelId: string;
|
||||||
|
newReferences: NewGenerationReference[];
|
||||||
|
projectId?: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerationSubmissionFields = Omit<GenerationSubmissionInput, "newReferences">;
|
||||||
|
|
||||||
|
export interface GenerationTaskView {
|
||||||
|
confirmedCreditCost: number;
|
||||||
|
createdAt: string;
|
||||||
|
errorCategory: "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled"
|
||||||
|
| "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid"
|
||||||
|
| "unknown_retryable" | "unknown_non_retryable" | null;
|
||||||
|
generationId: string;
|
||||||
|
modelConfigVersion: number;
|
||||||
|
modelId: string;
|
||||||
|
projectId: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
referenceAssetIds: string[];
|
||||||
|
referenceCount: number;
|
||||||
|
reservedCredits: number;
|
||||||
|
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerationSubmissionResult = { created: boolean; task: GenerationTaskView };
|
||||||
|
|
||||||
|
export interface GenerationUploadSession {
|
||||||
|
abort(): void;
|
||||||
|
commit(): Promise<GenerationSubmissionResult>;
|
||||||
|
stageReference(reference: NewGenerationReference): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationRow {
|
||||||
|
confirmed_credit_cost: number;
|
||||||
|
created_at: number;
|
||||||
|
error_category: GenerationTaskView["errorCategory"];
|
||||||
|
generation_id: string;
|
||||||
|
model_config_version: number;
|
||||||
|
model_id: string;
|
||||||
|
owner_id: string;
|
||||||
|
project_id: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
reserved_credits: number;
|
||||||
|
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(timestamp: number) {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value: string) {
|
||||||
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: number) {
|
||||||
|
return Number.isSafeInteger(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationSubmissionService {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly beforeTransaction: (() => Promise<void>) | undefined;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly credits: CreditService;
|
||||||
|
private readonly models: GenerationModelCatalog;
|
||||||
|
private readonly storage: ManagedStorage;
|
||||||
|
|
||||||
|
constructor(input: {
|
||||||
|
beforeTransaction?: () => Promise<void>;
|
||||||
|
clock?: () => number;
|
||||||
|
credits: CreditService;
|
||||||
|
models: GenerationModelCatalog;
|
||||||
|
storage: ManagedStorage;
|
||||||
|
}) {
|
||||||
|
this.beforeTransaction = input.beforeTransaction;
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
this.credits = input.credits;
|
||||||
|
this.database = input.credits.database;
|
||||||
|
this.models = input.models;
|
||||||
|
this.storage = input.storage;
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// The database connection is owned by CreditService.
|
||||||
|
}
|
||||||
|
|
||||||
|
readCurrentTask(userId: string) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT * FROM generation_jobs
|
||||||
|
WHERE owner_id = ? AND status IN ('queued', 'running') AND submission_ready = 1
|
||||||
|
ORDER BY created_at DESC, generation_id DESC LIMIT 1
|
||||||
|
`).get(userId) as GenerationRow | undefined;
|
||||||
|
return row ? this.taskView(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
readTask(userId: string, generationId: string) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT * FROM generation_jobs WHERE generation_id = ? AND owner_id = ? AND submission_ready = 1
|
||||||
|
`).get(generationId, userId) as GenerationRow | undefined;
|
||||||
|
if (!row) throw new GenerationSubmissionError("generation_not_found");
|
||||||
|
return this.taskView(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(input: GenerationSubmissionInput): Promise<GenerationSubmissionResult> {
|
||||||
|
const current = this.readCurrentTask(input.userId);
|
||||||
|
if (current) return { created: false as const, task: current };
|
||||||
|
const { newReferences, ...fields } = input;
|
||||||
|
const upload = this.beginUpload(fields);
|
||||||
|
try {
|
||||||
|
for (const reference of newReferences) await upload.stageReference(reference);
|
||||||
|
return await upload.commit();
|
||||||
|
} catch (error) {
|
||||||
|
upload.abort();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beginUpload(input: GenerationSubmissionFields): GenerationUploadSession {
|
||||||
|
const preflightModel = this.validate({ ...input, newReferences: [] });
|
||||||
|
const staged: StagedManagedFile[] = [];
|
||||||
|
let finished = false;
|
||||||
|
const abort = () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
for (const file of staged) this.storage.abandonStagedFile(file);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
abort,
|
||||||
|
commit: async () => {
|
||||||
|
if (finished) throw new GenerationSubmissionError("generation_request_invalid");
|
||||||
|
if (this.beforeTransaction) await this.beforeTransaction();
|
||||||
|
try {
|
||||||
|
const result = this.immediate(() => this.commitSubmission({ ...input, newReferences: [] }, staged));
|
||||||
|
if (result.created) finished = true;
|
||||||
|
else abort();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
abort();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stageReference: async (reference) => {
|
||||||
|
if (finished) throw new GenerationSubmissionError("generation_request_invalid");
|
||||||
|
const nextCount = input.existingReferenceAssetIds.length + staged.length + 1;
|
||||||
|
const nextBytes = staged.reduce((sum, file) => sum + file.bytes, 0) + reference.projectedBytes;
|
||||||
|
if (nextCount > preflightModel.referenceLimits.maxFiles || nextBytes > preflightModel.referenceLimits.maxTotalBytes
|
||||||
|
|| !safeInteger(reference.projectedBytes) || reference.projectedBytes > preflightModel.referenceLimits.maxFileBytes) {
|
||||||
|
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
staged.push(await this.storage.stagePrivateImage({
|
||||||
|
content: reference.content,
|
||||||
|
expectedMimeType: reference.mimeType,
|
||||||
|
fileName: reference.fileName,
|
||||||
|
maximumBytes: preflightModel.referenceLimits.maxFileBytes,
|
||||||
|
operationId: randomUUID(),
|
||||||
|
ownerRef: input.userId,
|
||||||
|
projectedWriteBytes: reference.projectedBytes,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GenerationSubmissionError || (error && typeof error === "object" && "code" in error)) throw error;
|
||||||
|
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private validate(input: GenerationSubmissionInput) {
|
||||||
|
if (!uuidPattern.test(input.userId) || !uuidPattern.test(input.clientSubmissionId)
|
||||||
|
|| !idempotencyPattern.test(input.idempotencyKey) || !projectRatios.includes(input.ratio)
|
||||||
|
|| !safeInteger(input.modelConfigVersion) || !safeInteger(input.confirmedCreditCost)
|
||||||
|
|| (input.mode === "existing_project" && (!input.projectId || !uuidPattern.test(input.projectId)))
|
||||||
|
|| (input.mode === "new_project" && input.projectId !== undefined)) {
|
||||||
|
throw new GenerationSubmissionError("generation_request_invalid");
|
||||||
|
}
|
||||||
|
const prompt = normalizePrompt(input.prompt);
|
||||||
|
const model = this.models.readModel(input.modelId);
|
||||||
|
if (!model) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" });
|
||||||
|
const workerState = this.database.prepare("SELECT status FROM worker_runtime_state WHERE singleton = 1").get() as { status: "ready" | "degraded" } | undefined;
|
||||||
|
if (workerState?.status === "degraded") throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" });
|
||||||
|
if (model.configVersion !== input.modelConfigVersion || model.creditCost !== input.confirmedCreditCost) {
|
||||||
|
throw new GenerationSubmissionError("model_config_stale", {
|
||||||
|
latest: { configVersion: model.configVersion, creditCost: model.creditCost, modelId: model.modelId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!model.enabled) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" });
|
||||||
|
if (model.contractValidationStatus !== "verified") {
|
||||||
|
throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" });
|
||||||
|
}
|
||||||
|
if (!model.runtimeAvailability.availableForNewJobs) {
|
||||||
|
throw new GenerationSubmissionError("generation_blocked", { errorCategory: model.runtimeAvailability.reason ?? "model_disabled" });
|
||||||
|
}
|
||||||
|
if (prompt.length > model.promptMaxLength || !model.supportedRatios.includes(input.ratio)) {
|
||||||
|
throw new GenerationSubmissionError("generation_request_invalid");
|
||||||
|
}
|
||||||
|
const referenceCount = input.newReferences.length + input.existingReferenceAssetIds.length;
|
||||||
|
const projectedBytes = input.newReferences.reduce((sum, reference) => sum + reference.projectedBytes, 0);
|
||||||
|
if (referenceCount > model.referenceLimits.maxFiles || projectedBytes > model.referenceLimits.maxTotalBytes
|
||||||
|
|| input.newReferences.some((reference) => !safeInteger(reference.projectedBytes) || reference.projectedBytes > model.referenceLimits.maxFileBytes)
|
||||||
|
|| new Set(input.existingReferenceAssetIds).size !== input.existingReferenceAssetIds.length
|
||||||
|
|| input.existingReferenceAssetIds.some((id) => !uuidPattern.test(id))
|
||||||
|
|| (input.mode === "new_project" && input.existingReferenceAssetIds.length > 0)) {
|
||||||
|
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||||
|
}
|
||||||
|
const storageState = this.storage.getState();
|
||||||
|
if (storageState.storage_status !== "active") {
|
||||||
|
throw new GenerationSubmissionError("generation_storage_unavailable", {
|
||||||
|
storage: {
|
||||||
|
capacityStatus: storageState.storage_status,
|
||||||
|
remainingBytes: Math.max(
|
||||||
|
0,
|
||||||
|
storageState.hard_limit_bytes - storageState.managed_content_bytes - storageState.active_storage_reservations_bytes,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commitSubmission(input: GenerationSubmissionInput, staged: StagedManagedFile[]) {
|
||||||
|
const model = this.validate(input);
|
||||||
|
const requestHash = digest(stableJson({
|
||||||
|
client_submission_id: input.clientSubmissionId,
|
||||||
|
confirmed_credit_cost: input.confirmedCreditCost,
|
||||||
|
existing_reference_asset_ids: input.existingReferenceAssetIds,
|
||||||
|
mode: input.mode,
|
||||||
|
model_config_version: input.modelConfigVersion,
|
||||||
|
model_id: input.modelId,
|
||||||
|
new_references: staged.map((file) => ({ bytes: file.bytes, mime_type: file.mimeType, sha256: file.sha256 })),
|
||||||
|
project_id: input.projectId ?? null,
|
||||||
|
prompt: normalizePrompt(input.prompt),
|
||||||
|
ratio: input.ratio,
|
||||||
|
}));
|
||||||
|
const keyDigest = digest(input.idempotencyKey);
|
||||||
|
const receipt = this.database.prepare(`
|
||||||
|
SELECT r.request_hash, g.* FROM generation_submission_receipts r
|
||||||
|
JOIN generation_jobs g ON g.generation_id = r.generation_id
|
||||||
|
WHERE r.owner_id = ? AND r.idempotency_key_digest = ?
|
||||||
|
`).get(input.userId, keyDigest) as (GenerationRow & { request_hash: string }) | undefined;
|
||||||
|
if (receipt) {
|
||||||
|
if (receipt.request_hash !== requestHash) throw new GenerationSubmissionError("generation_idempotency_conflict");
|
||||||
|
return { created: false as const, task: this.taskView(receipt) };
|
||||||
|
}
|
||||||
|
const bySubmission = this.database.prepare("SELECT * FROM generation_jobs WHERE client_submission_id = ?")
|
||||||
|
.get(input.clientSubmissionId) as (GenerationRow & { submission_request_hash: string | null }) | undefined;
|
||||||
|
if (bySubmission) {
|
||||||
|
if (bySubmission.submission_request_hash !== requestHash || bySubmission.owner_id !== input.userId) {
|
||||||
|
throw new GenerationSubmissionError("generation_idempotency_conflict");
|
||||||
|
}
|
||||||
|
return { created: false as const, task: this.taskView(bySubmission) };
|
||||||
|
}
|
||||||
|
const current = this.readCurrentTask(input.userId);
|
||||||
|
if (current) return { created: false as const, task: current };
|
||||||
|
|
||||||
|
const now = this.clock();
|
||||||
|
const prompt = normalizePrompt(input.prompt);
|
||||||
|
const generationId = randomUUID();
|
||||||
|
const projectId = input.mode === "new_project"
|
||||||
|
? this.insertProject(input.userId, prompt, input.ratio, now)
|
||||||
|
: this.validateExistingProject(input.userId, input.projectId!, input.ratio, prompt, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO generation_jobs (
|
||||||
|
generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version,
|
||||||
|
confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at,
|
||||||
|
client_submission_id, submission_request_hash, submission_ready, config_snapshot_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, 0, NULL, NULL, ?, ?, ?, ?, 0, ?)
|
||||||
|
`).run(
|
||||||
|
generationId, input.userId, projectId, prompt, input.ratio, input.modelId, input.modelConfigVersion,
|
||||||
|
input.confirmedCreditCost, now, now, input.clientSubmissionId, requestHash,
|
||||||
|
stableJson({
|
||||||
|
config_set_version: model.configSetVersion,
|
||||||
|
config_version: model.configVersion,
|
||||||
|
credit_cost: model.creditCost,
|
||||||
|
model_id: model.modelId,
|
||||||
|
prompt_max_length: model.promptMaxLength,
|
||||||
|
reference_limits: model.referenceLimits,
|
||||||
|
supported_ratios: model.supportedRatios,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const referenceIds: string[] = [];
|
||||||
|
for (const file of staged) {
|
||||||
|
this.storage.moveStagedFile(file);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||||
|
VALUES (?, 'reference', ?, ?, ?, ?, ?, 'committed', ?)
|
||||||
|
`).run(file.fileId, input.userId, file.relativePath, file.bytes, file.mimeType, file.sha256, iso(now));
|
||||||
|
this.database.prepare(`INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'reference', ?)`)
|
||||||
|
.run(projectId, file.fileId, now);
|
||||||
|
this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)`)
|
||||||
|
.run(`project:${projectId}:${file.fileId}`, file.fileId, iso(now));
|
||||||
|
referenceIds.push(file.fileId);
|
||||||
|
}
|
||||||
|
for (const referenceId of input.existingReferenceAssetIds) {
|
||||||
|
const allowed = this.database.prepare(`
|
||||||
|
SELECT mf.file_id FROM managed_files mf
|
||||||
|
JOIN project_resource_files prf ON prf.managed_file_id = mf.file_id
|
||||||
|
JOIN projects p ON p.project_id = prf.project_id
|
||||||
|
WHERE mf.file_id = ? AND mf.file_kind = 'reference' AND mf.status = 'committed'
|
||||||
|
AND mf.owner_ref = ? AND prf.project_id = ? AND p.owner_id = ? AND p.status = 'active'
|
||||||
|
`).get(referenceId, input.userId, projectId, input.userId);
|
||||||
|
if (!allowed) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||||
|
referenceIds.push(referenceId);
|
||||||
|
}
|
||||||
|
referenceIds.forEach((referenceId, index) => this.database.prepare(`
|
||||||
|
INSERT INTO generation_reference_snapshots (generation_id, position, managed_file_id, source_kind, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(generationId, index, referenceId, index < staged.length ? "uploaded" : "existing", now));
|
||||||
|
|
||||||
|
this.credits.reserveGeneration({
|
||||||
|
creditCost: input.confirmedCreditCost,
|
||||||
|
generationId,
|
||||||
|
modelId: input.modelId,
|
||||||
|
operationKey: `generation:${generationId}:reserve`,
|
||||||
|
userId: input.userId,
|
||||||
|
});
|
||||||
|
this.database.prepare("UPDATE generation_jobs SET submission_ready = 1 WHERE generation_id = ?").run(generationId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO generation_submission_receipts (owner_id, idempotency_key_digest, request_hash, generation_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(input.userId, keyDigest, requestHash, generationId, now);
|
||||||
|
this.consumeStagedStorage(staged, now);
|
||||||
|
return { created: true as const, task: this.readTask(input.userId, generationId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertProject(ownerId: string, prompt: string, ratio: ProjectRatio, now: number) {
|
||||||
|
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||||
|
.get(ownerId) as { count: number };
|
||||||
|
if (active.count >= projectLimit) throw new GenerationSubmissionError("generation_request_invalid");
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const pixels = ratioPixels[ratio];
|
||||||
|
const name = defaultProjectName(prompt, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO projects (
|
||||||
|
project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height,
|
||||||
|
status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL)
|
||||||
|
`).run(projectId, ownerId, name, prompt, ratio, pixels.width, pixels.height, now, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, 1, ?, ?, ?)
|
||||||
|
`).run(projectId, name, stableJson(defaultCanvasState(ratio, pixels, null)), now);
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateExistingProject(ownerId: string, projectId: string, ratio: ProjectRatio, prompt: string, now: number) {
|
||||||
|
const project = this.database.prepare("SELECT ratio FROM projects WHERE project_id = ? AND owner_id = ? AND status = 'active'")
|
||||||
|
.get(projectId, ownerId) as { ratio: ProjectRatio } | undefined;
|
||||||
|
if (!project || project.ratio !== ratio) throw new GenerationSubmissionError("generation_request_invalid");
|
||||||
|
const history = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?").get(projectId) as { count: number };
|
||||||
|
if (history.count >= historyLimit) throw new GenerationSubmissionError("generation_request_invalid");
|
||||||
|
this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?").run(prompt, now, projectId);
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private consumeStagedStorage(staged: StagedManagedFile[], now: number) {
|
||||||
|
if (staged.length === 0) return;
|
||||||
|
const total = staged.reduce((sum, file) => sum + file.bytes, 0);
|
||||||
|
for (const file of staged) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE storage_reservations SET status = 'consumed', resolved_at = ?
|
||||||
|
WHERE operation_id = ? AND status = 'active'
|
||||||
|
`).run(iso(now), file.operationId);
|
||||||
|
}
|
||||||
|
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1")
|
||||||
|
.get() as { managed_content_bytes: number };
|
||||||
|
const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'")
|
||||||
|
.get() as { bytes: number };
|
||||||
|
const nextBytes = state.managed_content_bytes + total;
|
||||||
|
const classification = classifyCapacity(nextBytes, active.bytes);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE local_backend_storage_state
|
||||||
|
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||||
|
WHERE singleton = 1
|
||||||
|
`).run(nextBytes, classification.capacity_notice_level, classification.storage_status, iso(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
private taskView(row: GenerationRow): GenerationTaskView {
|
||||||
|
const referenceAssetIds = (this.database.prepare(`
|
||||||
|
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||||
|
`).all(row.generation_id) as Array<{ managed_file_id: string }>).map((entry) => entry.managed_file_id);
|
||||||
|
return {
|
||||||
|
confirmedCreditCost: row.confirmed_credit_cost,
|
||||||
|
createdAt: iso(row.created_at),
|
||||||
|
errorCategory: row.error_category,
|
||||||
|
generationId: row.generation_id,
|
||||||
|
modelConfigVersion: row.model_config_version,
|
||||||
|
modelId: row.model_id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
prompt: row.prompt,
|
||||||
|
ratio: row.ratio,
|
||||||
|
referenceAssetIds,
|
||||||
|
referenceCount: referenceAssetIds.length,
|
||||||
|
reservedCredits: row.reserved_credits,
|
||||||
|
status: row.status,
|
||||||
|
updatedAt: iso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private immediate<T>(action: () => T): T {
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const result = action();
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureColumn(table: string, column: string, definition: string) {
|
||||||
|
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||||
|
if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
this.ensureColumn("generation_jobs", "client_submission_id", "TEXT");
|
||||||
|
this.ensureColumn("generation_jobs", "submission_request_hash", "TEXT");
|
||||||
|
this.ensureColumn("generation_jobs", "submission_ready", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
this.ensureColumn("generation_jobs", "config_snapshot_json", "TEXT");
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_runtime_state (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('ready', 'degraded')),
|
||||||
|
reason TEXT,
|
||||||
|
owner_id TEXT,
|
||||||
|
lock_expires_at INTEGER,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_active_owner
|
||||||
|
ON generation_jobs(owner_id) WHERE status IN ('queued', 'running');
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_client_submission
|
||||||
|
ON generation_jobs(client_submission_id) WHERE client_submission_id IS NOT NULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_submission_receipts (
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
idempotency_key_digest TEXT NOT NULL CHECK (length(idempotency_key_digest) = 64),
|
||||||
|
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||||
|
generation_id TEXT NOT NULL UNIQUE REFERENCES generation_jobs(generation_id),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (owner_id, idempotency_key_digest)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_reference_snapshots (
|
||||||
|
generation_id TEXT NOT NULL REFERENCES generation_jobs(generation_id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
managed_file_id TEXT NOT NULL REFERENCES managed_files(file_id),
|
||||||
|
source_kind TEXT NOT NULL CHECK (source_kind IN ('uploaded', 'existing')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (generation_id, position),
|
||||||
|
UNIQUE (generation_id, managed_file_id)
|
||||||
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS generation_reference_snapshots_no_update
|
||||||
|
BEFORE UPDATE ON generation_reference_snapshots BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END;
|
||||||
|
DROP TRIGGER IF EXISTS generation_reference_snapshots_no_delete;
|
||||||
|
CREATE TRIGGER generation_reference_snapshots_no_delete
|
||||||
|
BEFORE DELETE ON generation_reference_snapshots
|
||||||
|
WHEN dada_allow_privacy_purge() <> 1
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END;
|
||||||
|
`);
|
||||||
|
this.ensureColumn("worker_runtime_state", "owner_id", "TEXT");
|
||||||
|
this.ensureColumn("worker_runtime_state", "lock_expires_at", "INTEGER");
|
||||||
|
this.database.prepare("INSERT OR IGNORE INTO worker_runtime_state (singleton, status, reason, owner_id, lock_expires_at, updated_at) VALUES (1, 'ready', NULL, NULL, NULL, ?)").run(this.clock());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class LatestExportError extends Error {
|
||||||
|
constructor(readonly code: "invalid" | "not_found" | "conflict") {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
|
import { stableJson } from "./projects.js";
|
||||||
|
import { LatestExportError } from "./latest-export-errors.js";
|
||||||
|
export { LatestExportError } from "./latest-export-errors.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||||
|
|
||||||
|
export type ExportFormat = "jpg" | "png";
|
||||||
|
|
||||||
|
interface ExportRow {
|
||||||
|
byte_size: number;
|
||||||
|
created_at: number;
|
||||||
|
export_id: string;
|
||||||
|
format: ExportFormat;
|
||||||
|
managed_file_id: string;
|
||||||
|
pixel_height: number;
|
||||||
|
pixel_width: number;
|
||||||
|
project_id: string;
|
||||||
|
sha256: string;
|
||||||
|
state_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagedFileRow {
|
||||||
|
byte_size: number;
|
||||||
|
file_id: string;
|
||||||
|
mime_type: string;
|
||||||
|
relative_path: string;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LatestExportView = ReturnType<LatestExportService["view"]>;
|
||||||
|
|
||||||
|
function iso(timestamp: number) {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSha256(value: string) {
|
||||||
|
return /^[0-9a-f]{64}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LatestExportService {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly storage: ManagedStorage;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) {
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
this.storage = input.storage;
|
||||||
|
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||||
|
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||||
|
this.database.pragma("journal_mode = WAL");
|
||||||
|
this.database.pragma("foreign_keys = ON");
|
||||||
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveLatest(input: {
|
||||||
|
byteSize: number;
|
||||||
|
content: Readable;
|
||||||
|
exportId: string;
|
||||||
|
format: ExportFormat;
|
||||||
|
ownerId: string;
|
||||||
|
pixelHeight: number;
|
||||||
|
pixelWidth: number;
|
||||||
|
projectId: string;
|
||||||
|
sha256: string;
|
||||||
|
stateVersion: number;
|
||||||
|
}) {
|
||||||
|
this.validateInput(input);
|
||||||
|
const requestHash = createHash("sha256").update(stableJson({
|
||||||
|
byte_size: input.byteSize, export_id: input.exportId, format: input.format,
|
||||||
|
owner_id: input.ownerId,
|
||||||
|
pixel_height: input.pixelHeight, pixel_width: input.pixelWidth, project_id: input.projectId,
|
||||||
|
sha256: input.sha256, state_version: input.stateVersion,
|
||||||
|
})).digest("hex");
|
||||||
|
const replay = this.database.prepare("SELECT owner_id, request_hash, response_json FROM latest_export_receipts WHERE export_id = ?")
|
||||||
|
.get(input.exportId) as { owner_id: string; request_hash: string; response_json: string | null } | undefined;
|
||||||
|
if (replay) {
|
||||||
|
if (replay.owner_id !== input.ownerId || replay.request_hash !== requestHash) throw new LatestExportError("conflict");
|
||||||
|
const replayHash = createHash("sha256");
|
||||||
|
let replayBytes = 0;
|
||||||
|
for await (const chunk of input.content) {
|
||||||
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
replayBytes += bytes.byteLength;
|
||||||
|
replayHash.update(bytes);
|
||||||
|
}
|
||||||
|
if (replayBytes !== input.byteSize || replayHash.digest("hex") !== input.sha256) throw new LatestExportError("conflict");
|
||||||
|
return replay.response_json ? JSON.parse(replay.response_json) as LatestExportView : this.readByExportId(input.ownerId, input.exportId);
|
||||||
|
}
|
||||||
|
this.assertWritableProject(input);
|
||||||
|
const stored = await this.storage.commitStream({
|
||||||
|
content: input.content,
|
||||||
|
expectedMimeType: input.format === "png" ? "image/png" : "image/jpeg",
|
||||||
|
expectedSha256: input.sha256,
|
||||||
|
fileKind: "export",
|
||||||
|
fileName: `latest.${input.format === "jpg" ? "jpg" : "png"}`,
|
||||||
|
operationId: input.exportId,
|
||||||
|
ownerRef: input.ownerId,
|
||||||
|
projectedWriteBytes: input.byteSize,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (stored.bytes !== input.byteSize) throw new LatestExportError("invalid");
|
||||||
|
const createdAt = this.clock();
|
||||||
|
const response = this.view({
|
||||||
|
byte_size: stored.bytes,
|
||||||
|
created_at: createdAt,
|
||||||
|
export_id: input.exportId,
|
||||||
|
format: input.format,
|
||||||
|
managed_file_id: stored.file_id,
|
||||||
|
pixel_height: input.pixelHeight,
|
||||||
|
pixel_width: input.pixelWidth,
|
||||||
|
project_id: input.projectId,
|
||||||
|
sha256: stored.sha256,
|
||||||
|
state_version: input.stateVersion,
|
||||||
|
});
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
this.assertWritableProject(input);
|
||||||
|
const previous = this.database.prepare("SELECT managed_file_id FROM latest_exports WHERE project_id = ? AND format = ?")
|
||||||
|
.get(input.projectId, input.format) as { managed_file_id: string } | undefined;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at)
|
||||||
|
VALUES (?, ?, 'export', ?)
|
||||||
|
`).run(input.projectId, stored.file_id, createdAt);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
|
||||||
|
VALUES (?, ?, 'project', ?)
|
||||||
|
`).run(`project:${input.projectId}:${stored.file_id}`, stored.file_id, iso(createdAt));
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO latest_exports (
|
||||||
|
project_id, format, export_id, managed_file_id, state_version, sha256,
|
||||||
|
byte_size, pixel_width, pixel_height, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(project_id, format) DO UPDATE SET
|
||||||
|
export_id = excluded.export_id, managed_file_id = excluded.managed_file_id,
|
||||||
|
state_version = excluded.state_version, sha256 = excluded.sha256,
|
||||||
|
byte_size = excluded.byte_size, pixel_width = excluded.pixel_width,
|
||||||
|
pixel_height = excluded.pixel_height, created_at = excluded.created_at
|
||||||
|
`).run(
|
||||||
|
input.projectId, input.format, input.exportId, stored.file_id, input.stateVersion,
|
||||||
|
stored.sha256, stored.bytes, input.pixelWidth, input.pixelHeight, createdAt,
|
||||||
|
);
|
||||||
|
this.database.prepare("INSERT INTO latest_export_receipts (export_id, owner_id, request_hash, response_json, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.run(input.exportId, input.ownerId, requestHash, stableJson(response), createdAt);
|
||||||
|
if (previous && previous.managed_file_id !== stored.file_id) this.retireReplacedFile(previous.managed_file_id, createdAt);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
this.storage.retireManagedFile(stored.file_id, "compensation");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatest(ownerId: string, projectId: string, format: ExportFormat) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT le.*, mf.mime_type, mf.relative_path
|
||||||
|
FROM latest_exports le
|
||||||
|
JOIN projects p ON p.project_id = le.project_id
|
||||||
|
JOIN managed_files mf ON mf.file_id = le.managed_file_id AND mf.status = 'committed'
|
||||||
|
WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND le.format = ?
|
||||||
|
`).get(ownerId, projectId, format) as (ExportRow & ManagedFileRow) | undefined;
|
||||||
|
if (!row) throw new LatestExportError("not_found");
|
||||||
|
const path = this.storage.resolveManagedFile(row.managed_file_id);
|
||||||
|
if (!path) throw new LatestExportError("not_found");
|
||||||
|
return { ...this.view(row), mimeType: row.mime_type, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
getOriginal(ownerId: string, projectId: string, imageId: string) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT mf.file_id, mf.relative_path, mf.byte_size, mf.mime_type, mf.sha256
|
||||||
|
FROM project_images pi
|
||||||
|
JOIN projects p ON p.project_id = pi.project_id
|
||||||
|
JOIN managed_files mf ON mf.file_id = pi.image_id AND mf.status = 'committed'
|
||||||
|
WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND pi.image_id = ?
|
||||||
|
`).get(ownerId, projectId, imageId) as ManagedFileRow | undefined;
|
||||||
|
if (!row) throw new LatestExportError("not_found");
|
||||||
|
const path = this.storage.resolveManagedFile(row.file_id);
|
||||||
|
if (!path) throw new LatestExportError("not_found");
|
||||||
|
return { ...row, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertWritableProject(input: Pick<Parameters<LatestExportService["saveLatest"]>[0], "ownerId" | "pixelHeight" | "pixelWidth" | "projectId" | "stateVersion">) {
|
||||||
|
const project = this.database.prepare(`
|
||||||
|
SELECT state_version, pixel_width, pixel_height FROM projects
|
||||||
|
WHERE owner_id = ? AND project_id = ? AND status = 'active'
|
||||||
|
`).get(input.ownerId, input.projectId) as { pixel_height: number; pixel_width: number; state_version: number } | undefined;
|
||||||
|
if (!project) throw new LatestExportError("not_found");
|
||||||
|
if (project.state_version !== input.stateVersion) throw new LatestExportError("conflict");
|
||||||
|
if (project.pixel_width !== input.pixelWidth || project.pixel_height !== input.pixelHeight) throw new LatestExportError("invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
private readByExportId(ownerId: string, exportId: string) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT le.* FROM latest_exports le JOIN projects p ON p.project_id = le.project_id
|
||||||
|
WHERE p.owner_id = ? AND le.export_id = ?
|
||||||
|
`).get(ownerId, exportId) as ExportRow | undefined;
|
||||||
|
if (!row) throw new LatestExportError("not_found");
|
||||||
|
return this.view(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private retireReplacedFile(fileId: string, timestamp: number) {
|
||||||
|
const file = this.database.prepare("SELECT relative_path, byte_size FROM managed_files WHERE file_id = ? AND status = 'committed'")
|
||||||
|
.get(fileId) as { byte_size: number; relative_path: string } | undefined;
|
||||||
|
if (!file) return;
|
||||||
|
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
||||||
|
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(iso(timestamp), fileId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||||
|
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||||
|
reason, status, created_at, completed_at, last_error
|
||||||
|
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
|
||||||
|
`).run(randomUUID(), fileId, file.relative_path, file.byte_size, iso(timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateInput(input: Parameters<LatestExportService["saveLatest"]>[0]) {
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.exportId) || !["jpg", "png"].includes(input.format)
|
||||||
|
|| !validSha256(input.sha256) || ![input.byteSize, input.pixelHeight, input.pixelWidth, input.stateVersion].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
||||||
|
throw new LatestExportError("invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private view(row: ExportRow) {
|
||||||
|
return {
|
||||||
|
byteSize: row.byte_size,
|
||||||
|
createdAt: iso(row.created_at),
|
||||||
|
downloadUrl: `/api/v1/projects/${row.project_id}/latest-exports/${row.format}`,
|
||||||
|
exportId: row.export_id,
|
||||||
|
format: row.format,
|
||||||
|
pixelHeight: row.pixel_height,
|
||||||
|
pixelWidth: row.pixel_width,
|
||||||
|
projectId: row.project_id,
|
||||||
|
sha256: row.sha256,
|
||||||
|
stateVersion: row.state_version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS latest_export_receipts (
|
||||||
|
export_id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||||
|
response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const columns = this.database.prepare("PRAGMA table_info(latest_export_receipts)").all() as Array<{ name: string }>;
|
||||||
|
if (!columns.some((column) => column.name === "response_json")) {
|
||||||
|
this.database.exec("ALTER TABLE latest_export_receipts ADD COLUMN response_json TEXT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-6
@@ -1,19 +1,112 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { registrationNotice } from "@dada/shared-contracts";
|
||||||
|
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||||
import { readConfiguredLocalDataRoot } from "./local-data-root.js";
|
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
||||||
import { ManagedStorage } from "./managed-storage.js";
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
|
import { LatestExportService } from "./latest-exports.js";
|
||||||
|
import { CreditService } from "./credits.js";
|
||||||
|
import { ProjectService } from "./projects.js";
|
||||||
|
import { RegistrationService } from "./registration.js";
|
||||||
|
import { RecentAssetService } from "./recent-assets.js";
|
||||||
|
import { MockResendAdapter } from "./resend-adapter.js";
|
||||||
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
|
import { ModelConfigurationService } from "./model-configuration.js";
|
||||||
|
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
||||||
|
import { StickerReleaseService } from "./sticker-releases.js";
|
||||||
|
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
|
let registration: RegistrationService | undefined;
|
||||||
|
let projects: ProjectService | undefined;
|
||||||
|
let credits: CreditService | undefined;
|
||||||
|
let storage: ManagedStorage | undefined;
|
||||||
|
let latestExports: LatestExportService | undefined;
|
||||||
|
let models: ModelConfigurationService | undefined;
|
||||||
|
let recentAssets: RecentAssetService | undefined;
|
||||||
|
let stickers: StickerReleaseService | undefined;
|
||||||
|
let amap: AmapAdapter = new MockAmapAdapter();
|
||||||
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
|
try {
|
||||||
|
amap = clients.amap;
|
||||||
|
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
||||||
|
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||||
|
.digest();
|
||||||
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||||
|
registration = new RegistrationService({
|
||||||
|
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||||
|
challengePepper: derivePepper("challenge-pepper"),
|
||||||
|
currentPrivacyNoticeVersion: registrationNotice.version,
|
||||||
|
databasePath,
|
||||||
|
invitePepper: derivePepper("invite-pepper"),
|
||||||
|
resend: new MockResendAdapter(),
|
||||||
|
sessionPepper: derivePepper("session-pepper"),
|
||||||
|
});
|
||||||
|
projects = new ProjectService({ databasePath });
|
||||||
|
credits = new CreditService({ databasePath });
|
||||||
|
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||||
|
stickers = new StickerReleaseService({ databasePath, storage });
|
||||||
|
latestExports = new LatestExportService({ databasePath, storage });
|
||||||
|
models = new ModelConfigurationService({ database: registration.database });
|
||||||
|
recentAssets = new RecentAssetService({ database: registration.database });
|
||||||
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
|
} catch (error) {
|
||||||
|
amap.dispose?.();
|
||||||
|
amap = new MockAmapAdapter();
|
||||||
|
stickers?.close();
|
||||||
|
stickers = undefined;
|
||||||
|
latestExports?.close();
|
||||||
|
latestExports = undefined;
|
||||||
|
storage?.close();
|
||||||
|
storage = undefined;
|
||||||
|
credits?.close();
|
||||||
|
credits = undefined;
|
||||||
|
projects?.close();
|
||||||
|
projects = undefined;
|
||||||
|
registration?.close();
|
||||||
|
registration = undefined;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clients.adminAllowlistPepper.fill(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
||||||
const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {});
|
const adminServicesStorage = registration
|
||||||
|
? createAdminServicesStorageProvider({
|
||||||
|
database: registration.database,
|
||||||
|
...(models ? { models } : {}),
|
||||||
|
...(storage ? { storage } : {}),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const adminDiagnostics = adminServicesStorage
|
||||||
|
? createAdminDiagnosticsProvider({
|
||||||
|
...(browserSupportRelease ? { browserSupportRelease, appVersion: browserSupportRelease.appVersion } : {}),
|
||||||
|
servicesStorage: adminServicesStorage,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const app = await createApp({
|
||||||
|
...(adminServicesStorage ? { adminServicesStorage } : {}),
|
||||||
|
...(adminDiagnostics ? { adminDiagnostics } : {}),
|
||||||
|
amap,
|
||||||
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
|
...(credits ? { credits } : {}),
|
||||||
|
...(latestExports ? { latestExports } : {}),
|
||||||
|
...(models ? { models } : {}),
|
||||||
|
...(projects ? { projects } : {}),
|
||||||
|
...(registration ? { registration } : {}),
|
||||||
|
...(recentAssets ? { recentAssets } : {}),
|
||||||
|
...(stickers ? { stickers } : {}),
|
||||||
|
...(storage ? { storage } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
await app.listen({
|
await app.listen({
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -24,14 +117,19 @@ const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
|||||||
if (controlPipeIndex >= 0) {
|
if (controlPipeIndex >= 0) {
|
||||||
const controlPipe = process.argv[controlPipeIndex + 1];
|
const controlPipe = process.argv[controlPipeIndex + 1];
|
||||||
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||||
let storage: ManagedStorage | undefined;
|
|
||||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
amap.dispose?.();
|
||||||
|
latestExports?.close();
|
||||||
|
credits?.close();
|
||||||
|
projects?.close();
|
||||||
|
registration?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
|
stickers?.close();
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const dataRoot = readConfiguredLocalDataRoot();
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
if (!storage) storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
||||||
const logger = new StructuredJsonlLogger({
|
const logger = new StructuredJsonlLogger({
|
||||||
component: "api",
|
component: "api",
|
||||||
directory: join(dataRoot, "logs", "api"),
|
directory: join(dataRoot, "logs", "api"),
|
||||||
|
|||||||
+591
-15
@@ -15,6 +15,13 @@ import { pipeline } from "node:stream/promises";
|
|||||||
|
|
||||||
import type BetterSqlite3 from "better-sqlite3";
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import {
|
||||||
|
auditRetentionMilliseconds,
|
||||||
|
ensureAdminOperationAuditSchema,
|
||||||
|
isSafeAuditRef,
|
||||||
|
isSafeAuditSummaryJson,
|
||||||
|
serializeAuditSummary,
|
||||||
|
} from "./audit-policy.js";
|
||||||
import { resolvePathWithinRoot } from "./local-data-root.js";
|
import { resolvePathWithinRoot } from "./local-data-root.js";
|
||||||
import {
|
import {
|
||||||
HARD_LIMIT_BYTES,
|
HARD_LIMIT_BYTES,
|
||||||
@@ -68,6 +75,31 @@ interface CleanupQueueRow {
|
|||||||
relative_path: string;
|
relative_path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetCleanupCandidateView {
|
||||||
|
byte_size: number;
|
||||||
|
file_id: string;
|
||||||
|
file_kind: "original" | "thumbnail";
|
||||||
|
hash_prefix: string;
|
||||||
|
reference_count: 0;
|
||||||
|
resource_version: string;
|
||||||
|
stable_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetCleanupCandidatesView {
|
||||||
|
candidate_snapshot_version: string;
|
||||||
|
expires_at: string;
|
||||||
|
items: AssetCleanupCandidateView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetCleanupIntentView {
|
||||||
|
confirmation_token: string;
|
||||||
|
expires_at: string;
|
||||||
|
file_count: number;
|
||||||
|
request_id: string;
|
||||||
|
status: "pending_confirmation" | "denied" | "queued" | "completed";
|
||||||
|
total_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class StorageCapacityError extends Error {
|
export class StorageCapacityError extends Error {
|
||||||
readonly code = "STORAGE_CAPACITY_EXCEEDED";
|
readonly code = "STORAGE_CAPACITY_EXCEEDED";
|
||||||
readonly httpStatus = 507;
|
readonly httpStatus = 507;
|
||||||
@@ -93,6 +125,14 @@ function now() {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auditExpiry(occurredAt: number) {
|
||||||
|
return occurredAt + auditRetentionMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value: string) {
|
||||||
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
function validatePositiveBytes(value: number, name: string) {
|
function validatePositiveBytes(value: number, name: string) {
|
||||||
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`);
|
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`);
|
||||||
}
|
}
|
||||||
@@ -102,6 +142,9 @@ function sniffMime(prefix: Buffer) {
|
|||||||
return "image/png";
|
return "image/png";
|
||||||
}
|
}
|
||||||
if (prefix.length >= 3 && prefix[0] === 0xff && prefix[1] === 0xd8 && prefix[2] === 0xff) return "image/jpeg";
|
if (prefix.length >= 3 && prefix[0] === 0xff && prefix[1] === 0xd8 && prefix[2] === 0xff) return "image/jpeg";
|
||||||
|
if (prefix.length >= 12 && prefix.subarray(0, 4).toString("ascii") === "RIFF" && prefix.subarray(8, 12).toString("ascii") === "WEBP") {
|
||||||
|
return "image/webp";
|
||||||
|
}
|
||||||
return "application/octet-stream";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +161,7 @@ function listFiles(root: string): string[] {
|
|||||||
|
|
||||||
export interface CommitStreamInput {
|
export interface CommitStreamInput {
|
||||||
content: Readable;
|
content: Readable;
|
||||||
expectedMimeType: "image/png" | "image/jpeg" | "application/octet-stream";
|
expectedMimeType: "image/png" | "image/jpeg" | "image/webp" | "application/octet-stream";
|
||||||
expectedSha256?: string;
|
expectedSha256?: string;
|
||||||
failurePoint?: CommitFailurePoint;
|
failurePoint?: CommitFailurePoint;
|
||||||
fileKind: ManagedFileKind;
|
fileKind: ManagedFileKind;
|
||||||
@@ -128,6 +171,20 @@ export interface CommitStreamInput {
|
|||||||
projectedWriteBytes: number;
|
projectedWriteBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StagedManagedFile {
|
||||||
|
bytes: number;
|
||||||
|
destinationPath: string;
|
||||||
|
fileId: string;
|
||||||
|
fileKind: ManagedFileKind;
|
||||||
|
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
||||||
|
operationId: string;
|
||||||
|
ownerRef: string;
|
||||||
|
relativePath: string;
|
||||||
|
sha256: string;
|
||||||
|
stagingDirectory: string;
|
||||||
|
stagingPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ManagedStorage {
|
export class ManagedStorage {
|
||||||
readonly dataRoot: string;
|
readonly dataRoot: string;
|
||||||
readonly databasePath: string;
|
readonly databasePath: string;
|
||||||
@@ -151,6 +208,12 @@ export class ManagedStorage {
|
|||||||
this.database.pragma("journal_mode = WAL");
|
this.database.pragma("journal_mode = WAL");
|
||||||
this.database.pragma("foreign_keys = ON");
|
this.database.pragma("foreign_keys = ON");
|
||||||
this.database.pragma("busy_timeout = 5000");
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
|
||||||
|
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
|
||||||
|
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||||
|
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||||
this.migrate();
|
this.migrate();
|
||||||
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1").get() as { managed_content_bytes: number };
|
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1").get() as { managed_content_bytes: number };
|
||||||
this.measurementBaselineBytes = Math.max(0, state.managed_content_bytes - this.physicalManagedBytes());
|
this.measurementBaselineBytes = Math.max(0, state.managed_content_bytes - this.physicalManagedBytes());
|
||||||
@@ -180,6 +243,7 @@ export class ManagedStorage {
|
|||||||
CREATE TABLE IF NOT EXISTS managed_files (
|
CREATE TABLE IF NOT EXISTS managed_files (
|
||||||
file_id TEXT PRIMARY KEY,
|
file_id TEXT PRIMARY KEY,
|
||||||
file_kind TEXT NOT NULL CHECK (file_kind IN ('reference', 'generated', 'export', 'derived', 'sticker_original', 'sticker_thumbnail')),
|
file_kind TEXT NOT NULL CHECK (file_kind IN ('reference', 'generated', 'export', 'derived', 'sticker_original', 'sticker_thumbnail')),
|
||||||
|
owner_ref TEXT,
|
||||||
relative_path TEXT NOT NULL UNIQUE,
|
relative_path TEXT NOT NULL UNIQUE,
|
||||||
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
||||||
mime_type TEXT NOT NULL,
|
mime_type TEXT NOT NULL,
|
||||||
@@ -220,14 +284,94 @@ export class ManagedStorage {
|
|||||||
FOREIGN KEY (request_id) REFERENCES asset_cleanup_requests(request_id),
|
FOREIGN KEY (request_id) REFERENCES asset_cleanup_requests(request_id),
|
||||||
FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_id)
|
FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_id)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_cleanup_candidate_snapshots (
|
||||||
|
snapshot_version TEXT PRIMARY KEY,
|
||||||
|
items_json TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sticker_managed_file_history (
|
||||||
|
managed_file_id TEXT NOT NULL,
|
||||||
|
stable_id TEXT NOT NULL,
|
||||||
|
resource_version TEXT NOT NULL,
|
||||||
|
file_kind TEXT NOT NULL CHECK (file_kind IN ('original', 'thumbnail')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (managed_file_id, file_kind),
|
||||||
|
FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_sticker_asset_refs (
|
||||||
|
reference_id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
stable_id TEXT NOT NULL,
|
||||||
|
resource_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||||
log_id TEXT PRIMARY KEY,
|
log_id TEXT PRIMARY KEY,
|
||||||
operation TEXT NOT NULL,
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
outcome TEXT NOT NULL,
|
actor_ref TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
target_ref TEXT NOT NULL,
|
target_ref TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
|
before_summary TEXT,
|
||||||
|
after_summary TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||||
|
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||||
|
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
`);
|
`);
|
||||||
|
const managedFileColumns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>;
|
||||||
|
if (!managedFileColumns.some((column) => column.name === "owner_ref")) {
|
||||||
|
this.database.exec("ALTER TABLE managed_files ADD COLUMN owner_ref TEXT");
|
||||||
|
}
|
||||||
|
if (!managedFileColumns.some((column) => column.name === "cleanup_status")) {
|
||||||
|
this.database.exec("ALTER TABLE managed_files ADD COLUMN cleanup_status TEXT");
|
||||||
|
}
|
||||||
|
const cleanupRequestColumns = this.database.prepare("PRAGMA table_info(asset_cleanup_requests)").all() as Array<{ name: string }>;
|
||||||
|
const cleanupRequestAdditions: Array<[string, string]> = [
|
||||||
|
["created_by", "TEXT"],
|
||||||
|
["confirmed_by", "TEXT"],
|
||||||
|
["snapshot_version", "TEXT"],
|
||||||
|
["expires_at", "INTEGER"],
|
||||||
|
["confirmation_token_digest", "TEXT"],
|
||||||
|
["idempotency_key_digest", "TEXT"],
|
||||||
|
["request_hash", "TEXT"],
|
||||||
|
["file_count", "INTEGER"],
|
||||||
|
["total_bytes", "INTEGER"],
|
||||||
|
["denied_reason", "TEXT"],
|
||||||
|
];
|
||||||
|
for (const [column, type] of cleanupRequestAdditions) {
|
||||||
|
if (!cleanupRequestColumns.some((item) => item.name === column)) {
|
||||||
|
this.database.exec(`ALTER TABLE asset_cleanup_requests ADD COLUMN ${column} ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cleanupItemColumns = this.database.prepare("PRAGMA table_info(asset_cleanup_request_items)").all() as Array<{ name: string }>;
|
||||||
|
const cleanupItemAdditions: Array<[string, string]> = [
|
||||||
|
["stable_id", "TEXT"],
|
||||||
|
["resource_version", "TEXT"],
|
||||||
|
["file_kind", "TEXT"],
|
||||||
|
["byte_size", "INTEGER"],
|
||||||
|
["sha256_prefix", "TEXT"],
|
||||||
|
];
|
||||||
|
for (const [column, type] of cleanupItemAdditions) {
|
||||||
|
if (!cleanupItemColumns.some((item) => item.name === column)) {
|
||||||
|
this.database.exec(`ALTER TABLE asset_cleanup_request_items ADD COLUMN ${column} ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS asset_cleanup_requests_actor_idempotency
|
||||||
|
ON asset_cleanup_requests (created_by, idempotency_key_digest)
|
||||||
|
WHERE created_by IS NOT NULL AND idempotency_key_digest IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS sticker_managed_file_history_lookup
|
||||||
|
ON sticker_managed_file_history (stable_id, resource_version, file_kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS asset_cleanup_candidate_snapshots_expiry
|
||||||
|
ON asset_cleanup_candidate_snapshots (expires_at);
|
||||||
|
`);
|
||||||
|
ensureAdminOperationAuditSchema(this.database, Date.now());
|
||||||
const initial = classifyCapacity(0, 0);
|
const initial = classifyCapacity(0, 0);
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT OR IGNORE INTO local_backend_storage_state
|
INSERT OR IGNORE INTO local_backend_storage_state
|
||||||
@@ -249,6 +393,106 @@ export class ManagedStorage {
|
|||||||
return withReservations;
|
return withReservations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readAssetCleanupCandidates(): AssetCleanupCandidateView[] {
|
||||||
|
const releaseReferenceClause = this.tableExists("sticker_release_items") ? `
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sticker_release_items release_items
|
||||||
|
WHERE release_items.original_file_id = mf.file_id OR release_items.thumbnail_file_id = mf.file_id
|
||||||
|
)` : "";
|
||||||
|
return this.database.prepare(`
|
||||||
|
SELECT
|
||||||
|
mf.file_id,
|
||||||
|
mf.byte_size,
|
||||||
|
history.stable_id,
|
||||||
|
history.resource_version,
|
||||||
|
history.file_kind,
|
||||||
|
substr(mf.sha256, 1, 12) AS hash_prefix,
|
||||||
|
0 AS reference_count
|
||||||
|
FROM sticker_managed_file_history history
|
||||||
|
JOIN managed_files mf ON mf.file_id = history.managed_file_id
|
||||||
|
WHERE mf.status = 'committed'
|
||||||
|
AND mf.cleanup_status IS NULL
|
||||||
|
AND mf.file_kind IN ('sticker_original', 'sticker_thumbnail')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project_asset_refs refs WHERE refs.managed_file_id = mf.file_id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project_sticker_asset_refs project_refs
|
||||||
|
WHERE project_refs.stable_id = history.stable_id
|
||||||
|
AND project_refs.resource_version = history.resource_version
|
||||||
|
)
|
||||||
|
${releaseReferenceClause}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM asset_cleanup_request_items request_items
|
||||||
|
JOIN asset_cleanup_requests requests ON requests.request_id = request_items.request_id
|
||||||
|
WHERE request_items.managed_file_id = mf.file_id
|
||||||
|
AND requests.status IN ('pending_confirmation', 'queued')
|
||||||
|
)
|
||||||
|
ORDER BY history.stable_id, history.resource_version, history.file_kind, mf.file_id
|
||||||
|
`).all() as AssetCleanupCandidateView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertActiveAdmin(actorId: string) {
|
||||||
|
const admin = this.database.prepare(`
|
||||||
|
SELECT 1 AS allowed FROM users u
|
||||||
|
JOIN admin_access access ON access.user_id = u.user_id
|
||||||
|
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND access.allowed = 1
|
||||||
|
`).get(actorId);
|
||||||
|
if (!admin) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE");
|
||||||
|
}
|
||||||
|
|
||||||
|
private assetReferenceCount(fileId: string, requestId: string) {
|
||||||
|
const projectOrRelease = (this.database.prepare(`
|
||||||
|
SELECT COUNT(*) AS count FROM project_asset_refs WHERE managed_file_id = ?
|
||||||
|
`).get(fileId) as { count: number }).count;
|
||||||
|
const releaseItems = this.tableExists("sticker_release_items")
|
||||||
|
? (this.database.prepare(`
|
||||||
|
SELECT COUNT(*) AS count FROM sticker_release_items
|
||||||
|
WHERE original_file_id = ? OR thumbnail_file_id = ?
|
||||||
|
`).get(fileId, fileId) as { count: number }).count
|
||||||
|
: 0;
|
||||||
|
const projectStickerRefs = (this.database.prepare(`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM sticker_managed_file_history history
|
||||||
|
JOIN project_sticker_asset_refs refs
|
||||||
|
ON refs.stable_id = history.stable_id AND refs.resource_version = history.resource_version
|
||||||
|
WHERE history.managed_file_id = ?
|
||||||
|
`).get(fileId) as { count: number }).count;
|
||||||
|
const otherCleanup = (this.database.prepare(`
|
||||||
|
SELECT COUNT(*) AS count FROM asset_cleanup_request_items items
|
||||||
|
JOIN asset_cleanup_requests requests ON requests.request_id = items.request_id
|
||||||
|
WHERE items.managed_file_id = ? AND items.request_id <> ?
|
||||||
|
AND requests.status IN ('pending_confirmation', 'queued')
|
||||||
|
`).get(fileId, requestId) as { count: number }).count;
|
||||||
|
return projectOrRelease + releaseItems + projectStickerRefs + otherCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupConfirmationToken(requestId: string, actorId: string, keyDigest: string) {
|
||||||
|
return digest(`Dada/P0A/asset-cleanup-confirm/v1:${requestId}:${actorId}:${keyDigest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tableExists(name: string) {
|
||||||
|
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertCleanupAudit(input: {
|
||||||
|
actorRef: string;
|
||||||
|
afterSummary: Record<string, unknown>;
|
||||||
|
operationType: string;
|
||||||
|
requestId: string;
|
||||||
|
result: "failed" | "succeeded";
|
||||||
|
}, occurredAt: number) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'super_admin', ?, ?, 'asset_cleanup_request', ?, ?, NULL, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), input.actorRef, input.operationType, input.requestId, input.result,
|
||||||
|
serializeAuditSummary(input.afterSummary), occurredAt, auditExpiry(occurredAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private activeReservationBytes(excludingOperationId?: string) {
|
private activeReservationBytes(excludingOperationId?: string) {
|
||||||
const row = this.database.prepare(`
|
const row = this.database.prepare(`
|
||||||
SELECT COALESCE(SUM(projected_bytes), 0) AS bytes
|
SELECT COALESCE(SUM(projected_bytes), 0) AS bytes
|
||||||
@@ -416,9 +660,9 @@ export class ManagedStorage {
|
|||||||
|
|
||||||
const commit = this.database.transaction(() => {
|
const commit = this.database.transaction(() => {
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO managed_files (file_id, file_kind, relative_path, byte_size, mime_type, sha256, status, created_at)
|
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'committed', ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'committed', ?)
|
||||||
`).run(fileId, input.fileKind, destination.relativePath, byteSize, input.expectedMimeType, sha256, now());
|
`).run(fileId, input.fileKind, input.ownerRef, destination.relativePath, byteSize, input.expectedMimeType, sha256, now());
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
UPDATE local_backend_storage_state SET managed_content_bytes = managed_content_bytes + ? WHERE singleton = 1
|
UPDATE local_backend_storage_state SET managed_content_bytes = managed_content_bytes + ? WHERE singleton = 1
|
||||||
`).run(byteSize);
|
`).run(byteSize);
|
||||||
@@ -446,6 +690,103 @@ export class ManagedStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stageManagedImage(input: {
|
||||||
|
content: Readable;
|
||||||
|
expectedMimeType: "image/png" | "image/jpeg" | "image/webp";
|
||||||
|
expectedSha256?: string;
|
||||||
|
fileKind: ManagedFileKind;
|
||||||
|
fileName: string;
|
||||||
|
maximumBytes: number;
|
||||||
|
operationId: string;
|
||||||
|
ownerRef: string;
|
||||||
|
projectedWriteBytes: number;
|
||||||
|
}): Promise<StagedManagedFile> {
|
||||||
|
const fileId = randomUUID();
|
||||||
|
const destination = this.destination({
|
||||||
|
content: input.content,
|
||||||
|
expectedMimeType: input.expectedMimeType,
|
||||||
|
fileKind: input.fileKind,
|
||||||
|
fileName: input.fileName,
|
||||||
|
operationId: input.operationId,
|
||||||
|
ownerRef: input.ownerRef,
|
||||||
|
projectedWriteBytes: input.projectedWriteBytes,
|
||||||
|
}, fileId);
|
||||||
|
if (!Number.isSafeInteger(input.maximumBytes) || input.maximumBytes <= 0) throw new Error("maximum_bytes_invalid");
|
||||||
|
this.reserve(input.operationId, input.projectedWriteBytes);
|
||||||
|
const stagingDirectory = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}`);
|
||||||
|
const stagingPath = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}/payload.tmp`);
|
||||||
|
try {
|
||||||
|
mkdirSync(stagingDirectory, { recursive: true });
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
let byteSize = 0;
|
||||||
|
let prefix = Buffer.alloc(0);
|
||||||
|
const inspect = new Transform({
|
||||||
|
transform(chunk: Buffer | string, encoding, callback) {
|
||||||
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
||||||
|
byteSize += bytes.byteLength;
|
||||||
|
if (byteSize > input.maximumBytes) return callback(new Error("content_size_invalid"));
|
||||||
|
hash.update(bytes);
|
||||||
|
if (prefix.byteLength < 16) prefix = Buffer.concat([prefix, bytes.subarray(0, 16 - prefix.byteLength)]);
|
||||||
|
callback(null, bytes);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pipeline(input.content, inspect, createWriteStream(stagingPath, { flags: "wx" }));
|
||||||
|
validatePositiveBytes(byteSize, "actual_write_bytes");
|
||||||
|
if (sniffMime(prefix) !== input.expectedMimeType) throw new Error("content_mime_invalid");
|
||||||
|
const sha256 = hash.digest("hex");
|
||||||
|
if (input.expectedSha256 && sha256.toLowerCase() !== input.expectedSha256.toLowerCase()) throw new Error("content_hash_invalid");
|
||||||
|
const state = this.getState();
|
||||||
|
const otherReservations = this.activeReservationBytes(input.operationId);
|
||||||
|
if (state.managed_content_bytes + otherReservations + byteSize > HARD_LIMIT_BYTES) {
|
||||||
|
throw new StorageCapacityError({ activeReservationBytes: otherReservations, managedContentBytes: state.managed_content_bytes, projectedWriteBytes: byteSize });
|
||||||
|
}
|
||||||
|
this.database.prepare("UPDATE storage_reservations SET projected_bytes = ? WHERE operation_id = ? AND status = 'active'")
|
||||||
|
.run(byteSize, input.operationId);
|
||||||
|
this.refreshState();
|
||||||
|
return {
|
||||||
|
bytes: byteSize,
|
||||||
|
destinationPath: destination.absolutePath,
|
||||||
|
fileId,
|
||||||
|
fileKind: input.fileKind,
|
||||||
|
mimeType: input.expectedMimeType,
|
||||||
|
operationId: input.operationId,
|
||||||
|
ownerRef: input.ownerRef,
|
||||||
|
relativePath: destination.relativePath,
|
||||||
|
sha256,
|
||||||
|
stagingDirectory,
|
||||||
|
stagingPath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
rmSync(stagingDirectory, { force: true, recursive: true });
|
||||||
|
this.releaseReservation(input.operationId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stagePrivateImage(input: {
|
||||||
|
content: Readable;
|
||||||
|
expectedMimeType: "image/png" | "image/jpeg" | "image/webp";
|
||||||
|
fileName: string;
|
||||||
|
maximumBytes: number;
|
||||||
|
operationId: string;
|
||||||
|
ownerRef: string;
|
||||||
|
projectedWriteBytes: number;
|
||||||
|
}): Promise<StagedManagedFile> {
|
||||||
|
return this.stageManagedImage({ ...input, fileKind: "reference" });
|
||||||
|
}
|
||||||
|
|
||||||
|
moveStagedFile(file: StagedManagedFile) {
|
||||||
|
mkdirSync(dirname(file.destinationPath), { recursive: true });
|
||||||
|
renameSync(file.stagingPath, file.destinationPath);
|
||||||
|
rmSync(file.stagingDirectory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
abandonStagedFile(file: StagedManagedFile) {
|
||||||
|
if (existsSync(file.destinationPath)) this.queueCompensation(file.relativePath, statSync(file.destinationPath).size);
|
||||||
|
else rmSync(file.stagingDirectory, { force: true, recursive: true });
|
||||||
|
this.releaseReservation(file.operationId);
|
||||||
|
}
|
||||||
|
|
||||||
async commitBufferFixture(fileKind: ManagedFileKind, fileName: string, bytes: Buffer) {
|
async commitBufferFixture(fileKind: ManagedFileKind, fileName: string, bytes: Buffer) {
|
||||||
return this.commitStream({
|
return this.commitStream({
|
||||||
content: Readable.from(bytes),
|
content: Readable.from(bytes),
|
||||||
@@ -514,6 +855,26 @@ export class ManagedStorage {
|
|||||||
return row ? resolvePathWithinRoot(this.dataRoot, row.relative_path) : undefined;
|
return row ? resolvePathWithinRoot(this.dataRoot, row.relative_path) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retireManagedFile(fileId: string, reason: "compensation" | "purge" = "compensation") {
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const file = this.database.prepare(`
|
||||||
|
SELECT file_id, relative_path, byte_size FROM managed_files
|
||||||
|
WHERE file_id = ? AND status = 'committed'
|
||||||
|
`).get(fileId) as { byte_size: number; file_id: string; relative_path: string } | undefined;
|
||||||
|
if (!file) return;
|
||||||
|
const retiredAt = now();
|
||||||
|
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
||||||
|
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(retiredAt, fileId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||||
|
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||||
|
reason, status, created_at, completed_at, last_error
|
||||||
|
) VALUES (?, ?, ?, ?, 1, ?, 'pending', ?, NULL, NULL)
|
||||||
|
`).run(randomUUID(), fileId, file.relative_path, file.byte_size, reason, retiredAt);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
}
|
||||||
|
|
||||||
addAssetReference(fileId: string, referenceType: "project" | "release") {
|
addAssetReference(fileId: string, referenceType: "project" | "release") {
|
||||||
if (this.inspectAction("project_json_write") !== "allow") throw new StorageUnavailableError();
|
if (this.inspectAction("project_json_write") !== "allow") throw new StorageUnavailableError();
|
||||||
this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, ?, ?)`)
|
this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, ?, ?)`)
|
||||||
@@ -525,6 +886,203 @@ export class ManagedStorage {
|
|||||||
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listAssetCleanupCandidates(): AssetCleanupCandidatesView {
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const expiresAt = createdAt + 5 * 60 * 1_000;
|
||||||
|
const items = this.readAssetCleanupCandidates();
|
||||||
|
const snapshotVersion = digest(JSON.stringify({
|
||||||
|
created_at: createdAt,
|
||||||
|
nonce: randomUUID(),
|
||||||
|
items: items.map((item) => ({ byte_size: item.byte_size, file_id: item.file_id, hash_prefix: item.hash_prefix })),
|
||||||
|
}));
|
||||||
|
this.database.prepare("DELETE FROM asset_cleanup_candidate_snapshots WHERE expires_at <= ?").run(createdAt);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO asset_cleanup_candidate_snapshots (
|
||||||
|
snapshot_version, items_json, created_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
`).run(snapshotVersion, JSON.stringify(items), createdAt, expiresAt);
|
||||||
|
return {
|
||||||
|
candidate_snapshot_version: snapshotVersion,
|
||||||
|
expires_at: new Date(expiresAt).toISOString(),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createAssetCleanupIntent(input: {
|
||||||
|
actorId: string;
|
||||||
|
fileIds: string[];
|
||||||
|
idempotencyKey: string;
|
||||||
|
snapshotVersion: string;
|
||||||
|
}): AssetCleanupIntentView {
|
||||||
|
if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted");
|
||||||
|
const fileIds = [...new Set(input.fileIds)].sort();
|
||||||
|
if (!uuidPattern.test(input.actorId) || fileIds.length === 0 || fileIds.length !== input.fileIds.length
|
||||||
|
|| fileIds.length > 100 || fileIds.some((fileId) => !uuidPattern.test(fileId))
|
||||||
|
|| !/^[A-Za-z0-9_-]{32,200}$/.test(input.idempotencyKey)
|
||||||
|
|| !/^[0-9a-f]{64}$/.test(input.snapshotVersion)) {
|
||||||
|
throw new Error("cleanup_candidates_invalid");
|
||||||
|
}
|
||||||
|
const keyDigest = digest(input.idempotencyKey);
|
||||||
|
const requestHash = digest(JSON.stringify({ file_ids: fileIds, snapshot_version: input.snapshotVersion }));
|
||||||
|
const existing = this.database.prepare(`
|
||||||
|
SELECT request_id, request_hash, expires_at, file_count, total_bytes, status
|
||||||
|
FROM asset_cleanup_requests
|
||||||
|
WHERE created_by = ? AND idempotency_key_digest = ?
|
||||||
|
`).get(input.actorId, keyDigest) as {
|
||||||
|
expires_at: number; file_count: number; request_hash: string; request_id: string; status: AssetCleanupIntentView["status"]; total_bytes: number;
|
||||||
|
} | undefined;
|
||||||
|
if (existing) {
|
||||||
|
if (existing.request_hash !== requestHash) throw new Error("IDEMPOTENCY_KEY_CONFLICT");
|
||||||
|
return {
|
||||||
|
confirmation_token: this.cleanupConfirmationToken(existing.request_id, input.actorId, keyDigest),
|
||||||
|
expires_at: new Date(existing.expires_at).toISOString(),
|
||||||
|
file_count: existing.file_count,
|
||||||
|
request_id: existing.request_id,
|
||||||
|
status: existing.status,
|
||||||
|
total_bytes: existing.total_bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const confirmationToken = this.cleanupConfirmationToken(requestId, input.actorId, keyDigest);
|
||||||
|
const createdAt = Date.now();
|
||||||
|
let view!: AssetCleanupIntentView;
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
this.assertActiveAdmin(input.actorId);
|
||||||
|
const snapshot = this.database.prepare(`
|
||||||
|
SELECT items_json, expires_at FROM asset_cleanup_candidate_snapshots
|
||||||
|
WHERE snapshot_version = ?
|
||||||
|
`).get(input.snapshotVersion) as { expires_at: number; items_json: string } | undefined;
|
||||||
|
if (!snapshot || snapshot.expires_at <= createdAt) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE");
|
||||||
|
const snapshotItems = JSON.parse(snapshot.items_json) as AssetCleanupCandidateView[];
|
||||||
|
const byId = new Map(snapshotItems.map((item) => [item.file_id, item]));
|
||||||
|
const selected = fileIds.map((fileId) => byId.get(fileId));
|
||||||
|
if (selected.some((item) => !item)) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE");
|
||||||
|
const current = new Map(this.readAssetCleanupCandidates().map((item) => [item.file_id, item]));
|
||||||
|
if (fileIds.some((fileId) => !current.has(fileId))) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE");
|
||||||
|
const safeItems = selected as AssetCleanupCandidateView[];
|
||||||
|
const totalBytes = safeItems.reduce((sum, item) => sum + item.byte_size, 0);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO asset_cleanup_requests (
|
||||||
|
request_id, status, created_at, confirmed_at, created_by, confirmed_by,
|
||||||
|
snapshot_version, expires_at, confirmation_token_digest,
|
||||||
|
idempotency_key_digest, request_hash, file_count, total_bytes, denied_reason
|
||||||
|
) VALUES (?, 'pending_confirmation', ?, NULL, ?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
|
`).run(
|
||||||
|
requestId, new Date(createdAt).toISOString(), input.actorId, input.snapshotVersion,
|
||||||
|
snapshot.expires_at, digest(confirmationToken), keyDigest, requestHash, safeItems.length, totalBytes,
|
||||||
|
);
|
||||||
|
const insert = this.database.prepare(`
|
||||||
|
INSERT INTO asset_cleanup_request_items (
|
||||||
|
request_id, managed_file_id, stable_id, resource_version, file_kind, byte_size, sha256_prefix
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const item of safeItems) {
|
||||||
|
insert.run(requestId, item.file_id, item.stable_id, item.resource_version, item.file_kind, item.byte_size, item.hash_prefix);
|
||||||
|
}
|
||||||
|
this.insertCleanupAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
afterSummary: { file_count: safeItems.length, snapshot_version: input.snapshotVersion, total_bytes: totalBytes },
|
||||||
|
operationType: "asset_cleanup_requested",
|
||||||
|
requestId,
|
||||||
|
result: "succeeded",
|
||||||
|
}, createdAt);
|
||||||
|
view = {
|
||||||
|
confirmation_token: confirmationToken,
|
||||||
|
expires_at: new Date(snapshot.expires_at).toISOString(),
|
||||||
|
file_count: safeItems.length,
|
||||||
|
request_id: requestId,
|
||||||
|
status: "pending_confirmation",
|
||||||
|
total_bytes: totalBytes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmAssetCleanupIntent(input: { actorId: string; confirmationToken: string; requestId: string }) {
|
||||||
|
if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted");
|
||||||
|
if (!uuidPattern.test(input.actorId) || !uuidPattern.test(input.requestId) || !/^[0-9a-f]{64}$/.test(input.confirmationToken)) {
|
||||||
|
throw new Error("ASSET_CLEANUP_CANDIDATE_STALE");
|
||||||
|
}
|
||||||
|
const confirmedAt = Date.now();
|
||||||
|
const outcome = this.database.transaction(() => {
|
||||||
|
this.assertActiveAdmin(input.actorId);
|
||||||
|
const request = this.database.prepare(`
|
||||||
|
SELECT status, created_by, expires_at, confirmation_token_digest, file_count, total_bytes
|
||||||
|
FROM asset_cleanup_requests WHERE request_id = ?
|
||||||
|
`).get(input.requestId) as {
|
||||||
|
confirmation_token_digest: string | null; created_by: string | null; expires_at: number | null;
|
||||||
|
file_count: number | null; status: string; total_bytes: number | null;
|
||||||
|
} | undefined;
|
||||||
|
if (!request || request.status !== "pending_confirmation" || request.created_by !== input.actorId
|
||||||
|
|| !request.expires_at || request.expires_at <= confirmedAt
|
||||||
|
|| request.confirmation_token_digest !== digest(input.confirmationToken)) {
|
||||||
|
throw new Error("ASSET_CLEANUP_CANDIDATE_STALE");
|
||||||
|
}
|
||||||
|
const files = this.database.prepare(`
|
||||||
|
SELECT mf.file_id, mf.file_kind, mf.relative_path, mf.byte_size, mf.status
|
||||||
|
FROM asset_cleanup_request_items items
|
||||||
|
JOIN managed_files mf ON mf.file_id = items.managed_file_id
|
||||||
|
WHERE items.request_id = ? ORDER BY mf.file_id
|
||||||
|
`).all(input.requestId) as ManagedFileRow[];
|
||||||
|
if (files.length !== request.file_count) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE");
|
||||||
|
const conflicted = files.some((file) => file.status !== "committed"
|
||||||
|
|| !new Set(["sticker_original", "sticker_thumbnail"]).has(file.file_kind)
|
||||||
|
|| this.assetReferenceCount(file.file_id, input.requestId) > 0);
|
||||||
|
if (conflicted) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE asset_cleanup_requests
|
||||||
|
SET status = 'denied', confirmed_at = ?, confirmed_by = ?, denied_reason = 'reference_conflict'
|
||||||
|
WHERE request_id = ?
|
||||||
|
`).run(new Date(confirmedAt).toISOString(), input.actorId, input.requestId);
|
||||||
|
this.insertCleanupAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
afterSummary: { file_count: files.length, reason: "reference_conflict", status: "denied" },
|
||||||
|
operationType: "asset_cleanup_reference_denied",
|
||||||
|
requestId: input.requestId,
|
||||||
|
result: "failed",
|
||||||
|
}, confirmedAt);
|
||||||
|
return { conflict: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.insertCleanupAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
afterSummary: { file_count: files.length, status: "validated" },
|
||||||
|
operationType: "asset_cleanup_validated",
|
||||||
|
requestId: input.requestId,
|
||||||
|
result: "succeeded",
|
||||||
|
}, confirmedAt);
|
||||||
|
for (const file of files) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE managed_files SET status = 'purged', purged_at = ?, cleanup_status = 'pending_delete'
|
||||||
|
WHERE file_id = ? AND status = 'committed'
|
||||||
|
`).run(new Date(confirmedAt).toISOString(), file.file_id);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO file_cleanup_queue (
|
||||||
|
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||||
|
reason, status, created_at, completed_at, last_error
|
||||||
|
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
|
||||||
|
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, new Date(confirmedAt).toISOString());
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE asset_cleanup_requests
|
||||||
|
SET status = 'queued', confirmed_at = ?, confirmed_by = ?
|
||||||
|
WHERE request_id = ?
|
||||||
|
`).run(new Date(confirmedAt).toISOString(), input.actorId, input.requestId);
|
||||||
|
this.insertCleanupAudit({
|
||||||
|
actorRef: input.actorId,
|
||||||
|
afterSummary: { file_count: files.length, status: "queued", total_bytes: request.total_bytes ?? 0 },
|
||||||
|
operationType: "asset_cleanup_scheduled",
|
||||||
|
requestId: input.requestId,
|
||||||
|
result: "succeeded",
|
||||||
|
}, confirmedAt + 1);
|
||||||
|
return { conflict: false as const, file_count: files.length, request_id: input.requestId, status: "queued" as const };
|
||||||
|
}).immediate();
|
||||||
|
if (outcome.conflict) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
createCleanupIntent(fileIds: string[]) {
|
createCleanupIntent(fileIds: string[]) {
|
||||||
if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted");
|
if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted");
|
||||||
if (fileIds.length === 0 || new Set(fileIds).size !== fileIds.length) throw new Error("cleanup_candidates_invalid");
|
if (fileIds.length === 0 || new Set(fileIds).size !== fileIds.length) throw new Error("cleanup_candidates_invalid");
|
||||||
@@ -561,9 +1119,15 @@ export class ManagedStorage {
|
|||||||
return row.count > 0;
|
return row.count > 0;
|
||||||
});
|
});
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
|
const confirmedAt = now();
|
||||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'denied_reference_conflict', ?, ?)")
|
const occurredAt = Date.now();
|
||||||
.run(randomUUID(), requestId, now());
|
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'failed', NULL, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), requestId, serializeAuditSummary({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -574,9 +1138,15 @@ export class ManagedStorage {
|
|||||||
VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
|
VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
|
||||||
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now());
|
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now());
|
||||||
}
|
}
|
||||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
|
const confirmedAt = now();
|
||||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'queued', ?, ?)")
|
const occurredAt = Date.now();
|
||||||
.run(randomUUID(), requestId, now());
|
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'succeeded', NULL, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), requestId, serializeAuditSummary({ status: "queued" }), occurredAt, auditExpiry(occurredAt));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
|
if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
|
||||||
@@ -598,6 +1168,7 @@ export class ManagedStorage {
|
|||||||
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id);
|
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||||
const requests = this.database.prepare("SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ?").all(row.managed_file_id) as Array<{ request_id: string }>;
|
const requests = this.database.prepare("SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ?").all(row.managed_file_id) as Array<{ request_id: string }>;
|
||||||
this.database.prepare("DELETE FROM asset_cleanup_request_items WHERE managed_file_id = ?").run(row.managed_file_id);
|
this.database.prepare("DELETE FROM asset_cleanup_request_items WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||||
|
this.database.prepare("DELETE FROM sticker_managed_file_history WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||||
this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id);
|
this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id);
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
const pendingItems = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?").get(request.request_id) as { count: number };
|
const pendingItems = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?").get(request.request_id) as { count: number };
|
||||||
@@ -605,8 +1176,13 @@ export class ManagedStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id);
|
this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id);
|
||||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'physical_file_cleanup', 'completed', ?, ?)")
|
const occurredAt = Date.now();
|
||||||
.run(randomUUID(), row.cleanup_id, now());
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'system', 'managed_storage', 'physical_file_cleanup', 'cleanup_queue_item', ?, 'succeeded', NULL, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), row.cleanup_id, serializeAuditSummary({ status: "completed" }), occurredAt, auditExpiry(occurredAt));
|
||||||
this.recordPhysicalMeasurement();
|
this.recordPhysicalMeasurement();
|
||||||
});
|
});
|
||||||
finish();
|
finish();
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import { randomUUID, createHash } from "node:crypto";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
||||||
|
|
||||||
|
export const modelIds = [
|
||||||
|
"gemini-3.1-flash-image-preview",
|
||||||
|
"gemini-3-pro-image-preview",
|
||||||
|
"gpt-image-2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModelId = typeof modelIds[number];
|
||||||
|
export type ContractValidationStatus = "blocked" | "unverified" | "verified";
|
||||||
|
export type ModelRuntimeReason =
|
||||||
|
| "available"
|
||||||
|
| "configured_disabled"
|
||||||
|
| "contract_unverified"
|
||||||
|
| "contract_blocked"
|
||||||
|
| "gateway_balance_insufficient"
|
||||||
|
| "gateway_paused"
|
||||||
|
| "worker_degraded";
|
||||||
|
|
||||||
|
export interface ModelConfigCandidate {
|
||||||
|
model_id: string;
|
||||||
|
display_name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
recommendation_priority: number;
|
||||||
|
route_profile: Record<string, unknown>;
|
||||||
|
gateway_account_ref: string;
|
||||||
|
error_mapping_profile: Record<string, string>;
|
||||||
|
credit_cost: number;
|
||||||
|
supported_ratios: string[];
|
||||||
|
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
|
||||||
|
prompt_max_length: number;
|
||||||
|
safety_source: string;
|
||||||
|
contract_validation_status?: ContractValidationStatus;
|
||||||
|
contract_evidence_ref?: string | null;
|
||||||
|
config_version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelRuntimeAvailability {
|
||||||
|
available_for_new_jobs: boolean;
|
||||||
|
reason: ModelRuntimeReason;
|
||||||
|
checked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelConfigView extends ModelConfigCandidate {
|
||||||
|
config_version: number;
|
||||||
|
contract_validation_status: ContractValidationStatus;
|
||||||
|
contract_evidence_ref: string | null;
|
||||||
|
runtime_availability: ModelRuntimeAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelConfigurationView {
|
||||||
|
config_set_version: number;
|
||||||
|
configured_default_model_id: ModelId;
|
||||||
|
recommended_model_id: ModelId | null;
|
||||||
|
models: ModelConfigView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModelConfigurationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code:
|
||||||
|
| "MODEL_CONFIG_VERSION_CONFLICT"
|
||||||
|
| "MODEL_DEFAULT_REPLACEMENT_REQUIRED"
|
||||||
|
| "MODEL_DEFAULT_REPLACEMENT_INVALID"
|
||||||
|
| "MODEL_RECOMMENDATION_PRIORITY_INVALID"
|
||||||
|
| "MODEL_RECOMMENDATION_PRIORITY_CONFLICT"
|
||||||
|
| "IDEMPOTENCY_KEY_CONFLICT",
|
||||||
|
readonly details: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
this.name = "ModelConfigurationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredVersion {
|
||||||
|
config_version: number;
|
||||||
|
contract_evidence_ref: string | null;
|
||||||
|
contract_validation_status: ContractValidationStatus;
|
||||||
|
credit_cost: number;
|
||||||
|
display_name: string;
|
||||||
|
error_mapping_profile_json: string;
|
||||||
|
gateway_account_ref: string;
|
||||||
|
model_id: ModelId;
|
||||||
|
prompt_max_length: number;
|
||||||
|
reference_limits_json: string;
|
||||||
|
route_profile_json: string;
|
||||||
|
safety_source: string;
|
||||||
|
supported_ratios_json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredMember {
|
||||||
|
config_set_id: string;
|
||||||
|
config_version: number;
|
||||||
|
enabled: 0 | 1;
|
||||||
|
is_default: 0 | 1;
|
||||||
|
model_id: ModelId;
|
||||||
|
recommendation_priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultErrorMapping: Record<string, string> = {
|
||||||
|
gateway_contract_invalid: "gateway_contract_invalid",
|
||||||
|
gateway_balance_insufficient: "gateway_balance_insufficient",
|
||||||
|
reference_invalid: "reference_invalid",
|
||||||
|
safety_rejected: "safety_rejected",
|
||||||
|
unknown_non_retryable: "unknown_non_retryable",
|
||||||
|
unknown_retryable: "unknown_retryable",
|
||||||
|
upstream_failed: "upstream_failed",
|
||||||
|
upstream_timeout: "upstream_timeout",
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedCandidates: ModelConfigCandidate[] = [
|
||||||
|
{
|
||||||
|
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
|
||||||
|
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||||
|
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
|
||||||
|
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||||
|
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
|
||||||
|
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model_id: modelIds[1], display_name: "Gemini 3 Pro Image Preview", enabled: true, is_default: false,
|
||||||
|
recommendation_priority: 2, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||||
|
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
|
||||||
|
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||||
|
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
|
||||||
|
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model_id: modelIds[2], display_name: "GPT Image 2", enabled: true, is_default: false,
|
||||||
|
recommendation_priority: 3, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
|
||||||
|
gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1,
|
||||||
|
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
||||||
|
reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 },
|
||||||
|
prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function stableJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(candidate: ModelConfigCandidate) {
|
||||||
|
return createHash("sha256").update(stableJson({
|
||||||
|
error_mapping_profile: candidate.error_mapping_profile,
|
||||||
|
gateway_account_ref: candidate.gateway_account_ref,
|
||||||
|
prompt_max_length: candidate.prompt_max_length,
|
||||||
|
reference_limits: candidate.reference_limits,
|
||||||
|
route_profile: candidate.route_profile,
|
||||||
|
safety_source: candidate.safety_source,
|
||||||
|
supported_ratios: candidate.supported_ratios,
|
||||||
|
})).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileRef(prefix: "error" | "route", value: Record<string, unknown>) {
|
||||||
|
return `${prefix}:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateModelConfigurationCandidateSet(input: ModelConfigCandidate[]) {
|
||||||
|
if (input.length !== modelIds.length || new Set(input.map((item) => item.model_id)).size !== modelIds.length
|
||||||
|
|| input.some((item) => !modelIds.includes(item.model_id as ModelId))) {
|
||||||
|
throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "fixed_model_set" });
|
||||||
|
}
|
||||||
|
for (const item of input) {
|
||||||
|
if (!Number.isSafeInteger(item.recommendation_priority) || item.recommendation_priority <= 0) {
|
||||||
|
throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_INVALID", {
|
||||||
|
field_errors: [{ field: `models.${item.model_id}.recommendation_priority`, message_key: "model.priority.invalid" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const byPriority = new Map<number, ModelConfigCandidate[]>();
|
||||||
|
for (const item of input) byPriority.set(item.recommendation_priority, [...(byPriority.get(item.recommendation_priority) ?? []), item]);
|
||||||
|
const duplicate = [...byPriority.values()].find((items) => items.length > 1);
|
||||||
|
if (duplicate) {
|
||||||
|
throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_CONFLICT", {
|
||||||
|
conflict_model_ids: duplicate.map((item) => item.model_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.some((item) => !isPlainRecord(item.route_profile) || !isPlainRecord(item.error_mapping_profile))) {
|
||||||
|
throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "contract_fields_invalid" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeFor(candidate: ModelConfigCandidate, previous: ModelRuntimeAvailability | undefined, contractChanged: boolean, now: number): ModelRuntimeAvailability {
|
||||||
|
if (!candidate.enabled) return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "configured_disabled" };
|
||||||
|
if (candidate.contract_validation_status === "blocked" && !contractChanged) {
|
||||||
|
return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_blocked" };
|
||||||
|
}
|
||||||
|
if (candidate.contract_validation_status !== "verified" || contractChanged) {
|
||||||
|
return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" };
|
||||||
|
}
|
||||||
|
return previous ?? { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelConfigurationServiceOptions {
|
||||||
|
clock?: () => number;
|
||||||
|
database: BetterSqlite3.Database;
|
||||||
|
onChanged?: (configSetVersion: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModelConfigurationService {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
readonly #clock: () => number;
|
||||||
|
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
|
||||||
|
|
||||||
|
constructor(options: ModelConfigurationServiceOptions) {
|
||||||
|
this.database = options.database;
|
||||||
|
this.#clock = options.clock ?? Date.now;
|
||||||
|
this.#onChanged = options.onChanged;
|
||||||
|
this.ensureSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
read(): ModelConfigurationView {
|
||||||
|
const current = this.database.prepare(`
|
||||||
|
SELECT c.config_set_id, c.config_set_version FROM model_config_current mc
|
||||||
|
JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1
|
||||||
|
`).get() as { config_set_id: string; config_set_version: number } | undefined;
|
||||||
|
if (!current) throw new Error("model_config_current_missing");
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT m.model_id, m.enabled, m.is_default, m.recommendation_priority, v.*,
|
||||||
|
r.available_for_new_jobs, r.reason, r.checked_at
|
||||||
|
FROM model_config_set_members m
|
||||||
|
JOIN model_config_versions v ON v.model_id = m.model_id AND v.config_version = m.config_version
|
||||||
|
JOIN model_runtime_availability r ON r.model_id = m.model_id
|
||||||
|
WHERE m.config_set_id = ? ORDER BY m.recommendation_priority
|
||||||
|
`).all(current.config_set_id) as Array<StoredMember & StoredVersion & { available_for_new_jobs: 0 | 1; reason: ModelRuntimeReason; checked_at: number }>;
|
||||||
|
const models = rows.map((row) => ({
|
||||||
|
model_id: row.model_id,
|
||||||
|
display_name: row.display_name,
|
||||||
|
config_version: row.config_version,
|
||||||
|
enabled: row.enabled === 1,
|
||||||
|
is_default: row.is_default === 1,
|
||||||
|
recommendation_priority: row.recommendation_priority,
|
||||||
|
route_profile: JSON.parse(row.route_profile_json) as Record<string, unknown>,
|
||||||
|
gateway_account_ref: row.gateway_account_ref,
|
||||||
|
error_mapping_profile: JSON.parse(row.error_mapping_profile_json) as Record<string, string>,
|
||||||
|
credit_cost: row.credit_cost,
|
||||||
|
supported_ratios: JSON.parse(row.supported_ratios_json) as string[],
|
||||||
|
reference_limits: JSON.parse(row.reference_limits_json) as ModelConfigCandidate["reference_limits"],
|
||||||
|
prompt_max_length: row.prompt_max_length,
|
||||||
|
safety_source: row.safety_source,
|
||||||
|
contract_validation_status: row.contract_validation_status,
|
||||||
|
contract_evidence_ref: row.contract_evidence_ref,
|
||||||
|
runtime_availability: {
|
||||||
|
available_for_new_jobs: row.available_for_new_jobs === 1,
|
||||||
|
checked_at: new Date(row.checked_at).toISOString(),
|
||||||
|
reason: row.reason,
|
||||||
|
},
|
||||||
|
} satisfies ModelConfigView));
|
||||||
|
const recommended = models.find((model) => model.enabled
|
||||||
|
&& model.contract_validation_status === "verified"
|
||||||
|
&& model.runtime_availability.available_for_new_jobs);
|
||||||
|
return {
|
||||||
|
config_set_version: current.config_set_version,
|
||||||
|
configured_default_model_id: models.find((model) => model.enabled && model.is_default)!.model_id as ModelId,
|
||||||
|
recommended_model_id: recommended?.model_id ?? null,
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
readModel(modelId: string) {
|
||||||
|
return this.read().models.find((model) => model.model_id === modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(input: { actorId: string; expectedConfigSetVersion: number; idempotencyKey: string; models: ModelConfigCandidate[] }) {
|
||||||
|
const requestHash = createHash("sha256").update(JSON.stringify({ expected: input.expectedConfigSetVersion, models: input.models })).digest("hex");
|
||||||
|
const now = this.#clock();
|
||||||
|
const result = this.database.transaction(() => {
|
||||||
|
const existing = this.database.prepare("SELECT request_hash, response_json FROM model_config_idempotency WHERE idempotency_key = ?")
|
||||||
|
.get(input.idempotencyKey) as { request_hash: string; response_json: string } | undefined;
|
||||||
|
if (existing) {
|
||||||
|
if (existing.request_hash !== requestHash) throw new ModelConfigurationError("IDEMPOTENCY_KEY_CONFLICT");
|
||||||
|
return { replayed: true, value: JSON.parse(existing.response_json) as ModelConfigurationView };
|
||||||
|
}
|
||||||
|
validateModelConfigurationCandidateSet(input.models);
|
||||||
|
const current = this.database.prepare(`
|
||||||
|
SELECT c.config_set_id, c.config_set_version FROM model_config_current mc
|
||||||
|
JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1
|
||||||
|
`).get() as { config_set_id: string; config_set_version: number };
|
||||||
|
if (input.expectedConfigSetVersion !== current.config_set_version) {
|
||||||
|
throw new ModelConfigurationError("MODEL_CONFIG_VERSION_CONFLICT", { latest_version: current.config_set_version });
|
||||||
|
}
|
||||||
|
const previous = this.read();
|
||||||
|
const oldDefault = previous.models.find((model) => model.is_default && model.enabled);
|
||||||
|
const declaredDefaults = input.models.filter((model) => model.is_default);
|
||||||
|
const nextDefaults = declaredDefaults.filter((model) => model.enabled);
|
||||||
|
const nextDefault = nextDefaults[0];
|
||||||
|
if (oldDefault && !input.models.find((model) => model.model_id === oldDefault.model_id)?.enabled) {
|
||||||
|
if (declaredDefaults.length === 0) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_REQUIRED");
|
||||||
|
if (!nextDefault) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID");
|
||||||
|
if (nextDefault.model_id === oldDefault.model_id) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID");
|
||||||
|
}
|
||||||
|
if (nextDefaults.length !== 1) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "enabled_default_count" });
|
||||||
|
const setId = randomUUID();
|
||||||
|
const nextSetVersion = current.config_set_version + 1;
|
||||||
|
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(setId, nextSetVersion, now, input.actorId);
|
||||||
|
const insertVersion = this.database.prepare(`
|
||||||
|
INSERT INTO model_config_versions (
|
||||||
|
model_id, config_version, display_name, enabled, is_default, recommendation_priority,
|
||||||
|
route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json,
|
||||||
|
credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source,
|
||||||
|
contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const insertMember = this.database.prepare(`
|
||||||
|
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const candidate of input.models) {
|
||||||
|
const old = previous.models.find((model) => model.model_id === candidate.model_id);
|
||||||
|
const changed = !old || fingerprint(candidate) !== fingerprint(old) || candidate.display_name !== old.display_name
|
||||||
|
|| candidate.credit_cost !== old.credit_cost || candidate.enabled !== old.enabled
|
||||||
|
|| candidate.is_default !== old.is_default || candidate.recommendation_priority !== old.recommendation_priority
|
||||||
|
|| (candidate.contract_validation_status !== undefined && candidate.contract_validation_status !== old.contract_validation_status);
|
||||||
|
const contractChanged = Boolean(old && fingerprint(candidate) !== fingerprint(old));
|
||||||
|
const configVersion = changed ? (old?.config_version ?? 0) + 1 : old!.config_version;
|
||||||
|
const status: ContractValidationStatus = contractChanged
|
||||||
|
? "unverified"
|
||||||
|
: candidate.contract_validation_status ?? old?.contract_validation_status ?? "unverified";
|
||||||
|
const evidence = status === "unverified" ? null : candidate.contract_evidence_ref ?? old?.contract_evidence_ref ?? null;
|
||||||
|
if (changed) {
|
||||||
|
const routeProfileId = profileRef("route", candidate.route_profile);
|
||||||
|
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
||||||
|
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||||
|
.run(routeProfileId, stableJson(candidate.route_profile), now);
|
||||||
|
this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||||
|
.run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now);
|
||||||
|
insertVersion.run(
|
||||||
|
candidate.model_id, configVersion, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
||||||
|
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
||||||
|
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios),
|
||||||
|
stableJson(candidate.reference_limits), candidate.prompt_max_length, candidate.safety_source, status, evidence, fingerprint(candidate), now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
insertMember.run(setId, candidate.model_id, configVersion, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
||||||
|
const runtime = runtimeFor({ ...candidate, contract_validation_status: status }, old?.runtime_availability, contractChanged, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
|
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(runtime_availability_version), 0) + 1 FROM model_runtime_availability))
|
||||||
|
ON CONFLICT(model_id) DO UPDATE SET available_for_new_jobs=excluded.available_for_new_jobs, reason=excluded.reason,
|
||||||
|
checked_at=excluded.checked_at, runtime_availability_version=excluded.runtime_availability_version
|
||||||
|
`).run(candidate.model_id, runtime.available_for_new_jobs ? 1 : 0, runtime.reason, now);
|
||||||
|
}
|
||||||
|
this.database.prepare("UPDATE model_config_current SET config_set_id = ? WHERE singleton = 1").run(setId);
|
||||||
|
const after = this.read();
|
||||||
|
const occurredAt = now;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref, result,
|
||||||
|
before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'super_admin', ?, 'model_configuration_replace', 'model_config_set', ?, 'succeeded', ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), input.actorId, setId,
|
||||||
|
serializeAuditSummary({ config_set_version: current.config_set_version }),
|
||||||
|
serializeAuditSummary({ config_set_version: nextSetVersion, model_count: after.models.length }), occurredAt, occurredAt + auditRetentionMilliseconds,
|
||||||
|
);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO outbox_events (event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at)
|
||||||
|
VALUES (?, ?, 'model_config_changed', 'model_config_set', ?, ?, 'pending', ?, NULL)
|
||||||
|
`).run(randomUUID(), `model-config:${input.idempotencyKey}`, setId, JSON.stringify({ config_set_version: nextSetVersion }), now);
|
||||||
|
this.database.prepare("INSERT INTO model_config_idempotency (idempotency_key, request_hash, response_json, created_at) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(input.idempotencyKey, requestHash, JSON.stringify(after), now);
|
||||||
|
return { replayed: false, value: after };
|
||||||
|
}).immediate();
|
||||||
|
if (!result.replayed) this.#onChanged?.(result.value.config_set_version);
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSchema() {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS gateway_route_profiles (
|
||||||
|
route_profile_id TEXT PRIMARY KEY,
|
||||||
|
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS error_mapping_profiles (
|
||||||
|
error_mapping_profile_id TEXT PRIMARY KEY,
|
||||||
|
profile_json TEXT NOT NULL CHECK (json_valid(profile_json)),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS model_config_sets (
|
||||||
|
config_set_id TEXT PRIMARY KEY,
|
||||||
|
config_set_version INTEGER NOT NULL UNIQUE CHECK (config_set_version > 0),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
created_by TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS model_config_versions (
|
||||||
|
model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')),
|
||||||
|
config_version INTEGER NOT NULL CHECK (config_version > 0),
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||||
|
is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)),
|
||||||
|
recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0),
|
||||||
|
route_profile_id TEXT NOT NULL REFERENCES gateway_route_profiles(route_profile_id),
|
||||||
|
route_profile_json TEXT NOT NULL CHECK (json_valid(route_profile_json)),
|
||||||
|
gateway_account_ref TEXT NOT NULL,
|
||||||
|
error_mapping_profile_id TEXT NOT NULL REFERENCES error_mapping_profiles(error_mapping_profile_id),
|
||||||
|
error_mapping_profile_json TEXT NOT NULL CHECK (json_valid(error_mapping_profile_json)),
|
||||||
|
credit_cost INTEGER NOT NULL CHECK (credit_cost > 0),
|
||||||
|
supported_ratios_json TEXT NOT NULL CHECK (json_valid(supported_ratios_json)),
|
||||||
|
reference_limits_json TEXT NOT NULL CHECK (json_valid(reference_limits_json)),
|
||||||
|
prompt_max_length INTEGER NOT NULL CHECK (prompt_max_length > 0),
|
||||||
|
safety_source TEXT NOT NULL,
|
||||||
|
contract_validation_status TEXT NOT NULL CHECK (contract_validation_status IN ('blocked', 'unverified', 'verified')),
|
||||||
|
contract_evidence_ref TEXT,
|
||||||
|
contract_fingerprint TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (model_id, config_version)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS model_config_set_members (
|
||||||
|
config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id),
|
||||||
|
model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')),
|
||||||
|
config_version INTEGER NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||||
|
is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)),
|
||||||
|
recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0),
|
||||||
|
PRIMARY KEY (config_set_id, model_id),
|
||||||
|
FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version)
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_priority_unique
|
||||||
|
ON model_config_set_members(config_set_id, recommendation_priority);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_enabled_default_unique
|
||||||
|
ON model_config_set_members(config_set_id) WHERE enabled = 1 AND is_default = 1;
|
||||||
|
CREATE TABLE IF NOT EXISTS model_config_current (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS model_runtime_availability (
|
||||||
|
model_id TEXT PRIMARY KEY,
|
||||||
|
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||||
|
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS model_config_idempotency (
|
||||||
|
idempotency_key TEXT PRIMARY KEY,
|
||||||
|
request_hash TEXT NOT NULL,
|
||||||
|
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS outbox_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
operation_key TEXT NOT NULL UNIQUE,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
aggregate_type TEXT NOT NULL,
|
||||||
|
aggregate_id TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
published_at INTEGER
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
this.database.exec(`
|
||||||
|
DROP TRIGGER IF EXISTS model_config_current_default_guard_insert;
|
||||||
|
DROP TRIGGER IF EXISTS model_config_current_default_guard_update;
|
||||||
|
CREATE TRIGGER model_config_current_default_guard_insert
|
||||||
|
BEFORE INSERT ON model_config_current
|
||||||
|
WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1
|
||||||
|
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3
|
||||||
|
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id
|
||||||
|
AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END;
|
||||||
|
CREATE TRIGGER model_config_current_default_guard_update
|
||||||
|
BEFORE UPDATE OF config_set_id ON model_config_current
|
||||||
|
WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1
|
||||||
|
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3
|
||||||
|
OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id
|
||||||
|
AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_config_sets_no_update BEFORE UPDATE ON model_config_sets
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_config_sets_no_delete BEFORE DELETE ON model_config_sets
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_config_versions_no_update BEFORE UPDATE ON model_config_versions
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_config_versions_no_delete BEFORE DELETE ON model_config_versions
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_config_members_no_update BEFORE UPDATE ON model_config_set_members
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_config_members_no_delete BEFORE DELETE ON model_config_set_members
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_update BEFORE UPDATE ON gateway_route_profiles
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_delete BEFORE DELETE ON gateway_route_profiles
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_update BEFORE UPDATE ON error_mapping_profiles
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_delete BEFORE DELETE ON error_mapping_profiles
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END;
|
||||||
|
`);
|
||||||
|
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
|
||||||
|
if (current) return;
|
||||||
|
const seed = this.database.transaction(() => {
|
||||||
|
validateModelConfigurationCandidateSet(seedCandidates);
|
||||||
|
const now = this.#clock();
|
||||||
|
const setId = randomUUID();
|
||||||
|
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
|
||||||
|
.run(setId, now);
|
||||||
|
const insertVersion = this.database.prepare(`
|
||||||
|
INSERT INTO model_config_versions (
|
||||||
|
model_id, config_version, display_name, enabled, is_default, recommendation_priority,
|
||||||
|
route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json,
|
||||||
|
credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source,
|
||||||
|
contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at
|
||||||
|
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const insertMember = this.database.prepare(`
|
||||||
|
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const candidate of seedCandidates) {
|
||||||
|
const routeProfileId = profileRef("route", candidate.route_profile);
|
||||||
|
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
|
||||||
|
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||||
|
.run(routeProfileId, stableJson(candidate.route_profile), now);
|
||||||
|
this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
|
||||||
|
.run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now);
|
||||||
|
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
|
||||||
|
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
|
||||||
|
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
|
||||||
|
candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now);
|
||||||
|
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
|
VALUES (?, 0, 'contract_unverified', ?, 0)
|
||||||
|
`).run(candidate.model_id, now);
|
||||||
|
}
|
||||||
|
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
|
||||||
|
});
|
||||||
|
seed.immediate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import { ModelConfigurationService, modelIds, type ModelConfigCandidate } from "./model-configuration.js";
|
||||||
|
|
||||||
|
const safeRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||||
|
const requiredRatios = ["3:4", "1:1", "4:3", "9:16"];
|
||||||
|
const requiredAdapterErrors = [
|
||||||
|
"upstream_timeout", "upstream_failed", "safety_rejected", "gateway_balance_insufficient",
|
||||||
|
"gateway_contract_invalid", "reference_invalid", "unknown_retryable", "unknown_non_retryable",
|
||||||
|
];
|
||||||
|
const forbiddenKeyFragments = ["credential", "password", "prompt_text", "raw", "secret", "token"];
|
||||||
|
|
||||||
|
export interface ModelContractEvidenceInput {
|
||||||
|
evidence_hash: string;
|
||||||
|
evidence_ref: string;
|
||||||
|
matrix: unknown;
|
||||||
|
model_id: string;
|
||||||
|
verified_at: string;
|
||||||
|
verifier_ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasForbiddenEvidenceKey(value: unknown, depth = 0): boolean {
|
||||||
|
if (depth > 8 || !value || typeof value !== "object") return false;
|
||||||
|
if (Array.isArray(value)) return value.some((entry) => hasForbiddenEvidenceKey(entry, depth + 1));
|
||||||
|
return Object.entries(value).some(([key, entry]) => (
|
||||||
|
forbiddenKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||||
|
|| hasForbiddenEvidenceKey(entry, depth + 1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEvidence(input: ModelContractEvidenceInput) {
|
||||||
|
if (!modelIds.includes(input.model_id as typeof modelIds[number])) throw new Error("contract_evidence_model_invalid");
|
||||||
|
if (!safeRefPattern.test(input.evidence_hash) || !safeRefPattern.test(input.evidence_ref) || !safeRefPattern.test(input.verifier_ref)) {
|
||||||
|
throw new Error("contract_evidence_reference_invalid");
|
||||||
|
}
|
||||||
|
const verifiedAt = Date.parse(input.verified_at);
|
||||||
|
if (!Number.isFinite(verifiedAt) || new Date(verifiedAt).toISOString() !== input.verified_at) throw new Error("contract_evidence_time_invalid");
|
||||||
|
if (hasForbiddenEvidenceKey(input.matrix)) throw new Error("contract_evidence_sensitive_field");
|
||||||
|
if (!input.matrix || typeof input.matrix !== "object") throw new Error("contract_evidence_matrix_incomplete");
|
||||||
|
const matrix = input.matrix as Record<string, unknown>;
|
||||||
|
const passedSingleOutput = (value: unknown) => Boolean(value && typeof value === "object"
|
||||||
|
&& "status" in value && value.status === "passed" && "outputs" in value && value.outputs === 1);
|
||||||
|
const ratios = Array.isArray(matrix.ratios) ? matrix.ratios as Array<Record<string, unknown>> : [];
|
||||||
|
const ratioNames = ratios.filter(passedSingleOutput).map((entry) => entry.ratio).toSorted();
|
||||||
|
const executionModes = Array.isArray(matrix.execution_modes) ? matrix.execution_modes : [];
|
||||||
|
const errorMapping = Array.isArray(matrix.error_mapping) ? matrix.error_mapping : [];
|
||||||
|
const executionComplete = executionModes.includes("sync") || (executionModes.includes("async") && executionModes.includes("poll"));
|
||||||
|
if (matrix.model_id !== input.model_id || !passedSingleOutput(matrix.pure_text) || !passedSingleOutput(matrix.reference_image)
|
||||||
|
|| JSON.stringify(ratioNames) !== JSON.stringify([...requiredRatios].toSorted()) || !executionComplete
|
||||||
|
|| !requiredAdapterErrors.every((category) => errorMapping.includes(category))) {
|
||||||
|
throw new Error("contract_evidence_matrix_incomplete");
|
||||||
|
}
|
||||||
|
return verifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableCandidates(models: ReturnType<ModelConfigurationService["read"]>["models"]): ModelConfigCandidate[] {
|
||||||
|
return models.map(({ config_version: _configVersion, runtime_availability: _runtime, ...candidate }) => structuredClone(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModelContractEvidenceService {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly models: ModelConfigurationService;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; database: BetterSqlite3.Database; models: ModelConfigurationService }) {
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
this.database = input.database;
|
||||||
|
this.models = input.models;
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
recordVerified(input: {
|
||||||
|
actorId: string;
|
||||||
|
evidence: ModelContractEvidenceInput;
|
||||||
|
expectedConfigSetVersion: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}) {
|
||||||
|
const verifiedAt = validateEvidence(input.evidence);
|
||||||
|
return this.database.transaction(() => {
|
||||||
|
const current = this.models.read();
|
||||||
|
if (current.config_set_version !== input.expectedConfigSetVersion) throw new Error("contract_evidence_config_set_conflict");
|
||||||
|
const existingHash = this.database.prepare("SELECT model_id FROM model_contract_evidence WHERE evidence_hash = ?").get(input.evidence.evidence_hash) as { model_id: string } | undefined;
|
||||||
|
if (existingHash && existingHash.model_id !== input.evidence.model_id) throw new Error("contract_evidence_shared_between_models");
|
||||||
|
const candidates = editableCandidates(current.models);
|
||||||
|
const target = candidates.find((model) => model.model_id === input.evidence.model_id)!;
|
||||||
|
target.contract_validation_status = "verified";
|
||||||
|
target.contract_evidence_ref = input.evidence.evidence_ref;
|
||||||
|
const configuration = this.models.replace({
|
||||||
|
actorId: input.actorId,
|
||||||
|
expectedConfigSetVersion: input.expectedConfigSetVersion,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
models: candidates,
|
||||||
|
});
|
||||||
|
const model = configuration.models.find((candidate) => candidate.model_id === input.evidence.model_id)!;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO model_contract_evidence (
|
||||||
|
model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
input.evidence.model_id, model.config_version, input.evidence.evidence_hash, input.evidence.evidence_ref,
|
||||||
|
input.evidence.verifier_ref, verifiedAt, JSON.stringify(input.evidence.matrix), this.clock(),
|
||||||
|
);
|
||||||
|
return { configuration, evidence: structuredClone(input.evidence), model };
|
||||||
|
}).immediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
read(modelId: string, configVersion: number) {
|
||||||
|
return this.database.prepare(`
|
||||||
|
SELECT model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json
|
||||||
|
FROM model_contract_evidence WHERE model_id = ? AND config_version = ?
|
||||||
|
`).get(modelId, configVersion) as {
|
||||||
|
config_version: number; evidence_hash: string; evidence_json: string; evidence_ref: string;
|
||||||
|
model_id: string; verified_at: number; verifier_ref: string;
|
||||||
|
} | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS model_contract_evidence (
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
config_version INTEGER NOT NULL,
|
||||||
|
evidence_hash TEXT NOT NULL UNIQUE,
|
||||||
|
evidence_ref TEXT NOT NULL,
|
||||||
|
verifier_ref TEXT NOT NULL,
|
||||||
|
verified_at INTEGER NOT NULL,
|
||||||
|
evidence_json TEXT NOT NULL CHECK (json_valid(evidence_json)),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (model_id, config_version),
|
||||||
|
FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version)
|
||||||
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_update BEFORE UPDATE ON model_contract_evidence
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_delete BEFORE DELETE ON model_contract_evidence
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AssetReleaseManifestItem,
|
||||||
|
AssetReleaseManifestProjection,
|
||||||
|
AssetReleaseReader,
|
||||||
|
} from "@dada/asset-release-manifest";
|
||||||
|
|
||||||
|
import { auditRetentionMilliseconds, serializeAuditSummary } from "./audit-policy.js";
|
||||||
|
import type { RegistrationService } from "./registration.js";
|
||||||
|
|
||||||
|
export type PreviewBatchStatus = "active" | "closed";
|
||||||
|
export type PreviewGrantStatus = "active" | "revoked" | "expired";
|
||||||
|
|
||||||
|
export interface PreviewBatchView {
|
||||||
|
batchId: string;
|
||||||
|
createdAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
name: string;
|
||||||
|
status: PreviewBatchStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewGrantView {
|
||||||
|
batchId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
grantId: string;
|
||||||
|
grantedAt: number;
|
||||||
|
grantedBy: string;
|
||||||
|
status: PreviewGrantStatus;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PreviewGrantError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly reason:
|
||||||
|
| "admin_invalid"
|
||||||
|
| "batch_closed"
|
||||||
|
| "batch_not_found"
|
||||||
|
| "grant_not_found"
|
||||||
|
| "invalid_expiry"
|
||||||
|
| "invalid_request"
|
||||||
|
| "resource_not_found"
|
||||||
|
| "user_not_eligible",
|
||||||
|
) {
|
||||||
|
super(reason);
|
||||||
|
this.name = "PreviewGrantError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewGrantServiceOptions {
|
||||||
|
assetReleases: AssetReleaseReader;
|
||||||
|
clock?: () => number;
|
||||||
|
registration: RegistrationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewManifestItemMapping {
|
||||||
|
releaseVersion: string;
|
||||||
|
resourceId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUuid(value: string) {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertText(value: string, name: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized || normalized.length > 160) throw new PreviewGrantError("invalid_request");
|
||||||
|
if (name === "batchId" && !isUuid(normalized)) throw new PreviewGrantError("invalid_request");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestHash(items: readonly AssetReleaseManifestItem[], releaseVersion: string) {
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(JSON.stringify({
|
||||||
|
items,
|
||||||
|
release_version: releaseVersion,
|
||||||
|
schema_version: "AssetReleaseManifest/v1",
|
||||||
|
}))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the P0-A preview grant state. Preview URLs are deliberately ephemeral:
|
||||||
|
* the random item id is kept only in this process and every read rechecks the
|
||||||
|
* persisted grant, so revocation and expiry take effect without cache busting.
|
||||||
|
*/
|
||||||
|
export class AssetPreviewGrantService {
|
||||||
|
readonly database: RegistrationService["database"];
|
||||||
|
readonly options: Required<Pick<PreviewGrantServiceOptions, "clock">> & PreviewGrantServiceOptions;
|
||||||
|
private readonly itemMappings = new Map<string, PreviewManifestItemMapping>();
|
||||||
|
|
||||||
|
constructor(options: PreviewGrantServiceOptions) {
|
||||||
|
this.database = options.registration.database;
|
||||||
|
this.options = { ...options, clock: options.clock ?? Date.now };
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
createBatch(input: { adminUserId: string; batchId?: string; name: string }): PreviewBatchView {
|
||||||
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
||||||
|
const name = assertText(input.name, "name");
|
||||||
|
const batchId = input.batchId ? assertText(input.batchId, "batchId") : randomUUID();
|
||||||
|
const now = this.options.clock();
|
||||||
|
this.assertAdmin(adminUserId, now);
|
||||||
|
this.immediate(() => {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO test_batches (batch_id, name, status, created_by, created_at, closed_at)
|
||||||
|
VALUES (?, ?, 'active', ?, ?, NULL)
|
||||||
|
`).run(batchId, name, adminUserId, now);
|
||||||
|
this.audit({
|
||||||
|
actorRef: adminUserId,
|
||||||
|
afterSummary: { batch_id: batchId, status: "active" },
|
||||||
|
beforeSummary: null,
|
||||||
|
operationType: "preview_batch_create",
|
||||||
|
targetRef: batchId,
|
||||||
|
targetType: "preview_batch",
|
||||||
|
}, now);
|
||||||
|
});
|
||||||
|
return { batchId, createdAt: now, createdBy: adminUserId, name, status: "active" };
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBatch(input: { adminUserId: string; batchId: string }): PreviewBatchView {
|
||||||
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
||||||
|
const batchId = assertText(input.batchId, "batchId");
|
||||||
|
const now = this.options.clock();
|
||||||
|
this.assertAdmin(adminUserId, now);
|
||||||
|
return this.immediate(() => {
|
||||||
|
const batch = this.readBatch(batchId);
|
||||||
|
if (!batch) throw new PreviewGrantError("batch_not_found");
|
||||||
|
if (batch.status === "active") {
|
||||||
|
this.database.prepare("UPDATE test_batches SET status = 'closed', closed_at = ? WHERE batch_id = ?").run(now, batchId);
|
||||||
|
this.audit({
|
||||||
|
actorRef: adminUserId,
|
||||||
|
afterSummary: { batch_id: batchId, status: "closed" },
|
||||||
|
beforeSummary: { batch_id: batchId, status: batch.status },
|
||||||
|
operationType: "preview_batch_close",
|
||||||
|
targetRef: batchId,
|
||||||
|
targetType: "preview_batch",
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
return { ...batch, status: "closed" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addBatchItems(input: {
|
||||||
|
adminUserId: string;
|
||||||
|
batchId: string;
|
||||||
|
releaseVersion: string;
|
||||||
|
resourceIds: readonly string[];
|
||||||
|
}) {
|
||||||
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
||||||
|
const batchId = assertText(input.batchId, "batchId");
|
||||||
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
||||||
|
const resourceIds = [...new Set(input.resourceIds.map((resourceId) => assertText(resourceId, "resourceId")))];
|
||||||
|
if (resourceIds.length === 0) throw new PreviewGrantError("invalid_request");
|
||||||
|
const now = this.options.clock();
|
||||||
|
this.assertAdmin(adminUserId, now);
|
||||||
|
for (const resourceId of resourceIds) {
|
||||||
|
if (!this.options.assetReleases.read("internal_preview_asset", releaseVersion, resourceId)) {
|
||||||
|
throw new PreviewGrantError("resource_not_found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.immediate(() => {
|
||||||
|
const batch = this.readBatch(batchId);
|
||||||
|
if (!batch) throw new PreviewGrantError("batch_not_found");
|
||||||
|
if (batch.status !== "active") throw new PreviewGrantError("batch_closed");
|
||||||
|
const insert = this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO test_batch_items (test_batch_id, release_version, resource_id)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const resourceId of resourceIds) insert.run(batchId, releaseVersion, resourceId);
|
||||||
|
this.audit({
|
||||||
|
actorRef: adminUserId,
|
||||||
|
afterSummary: { batch_id: batchId, item_count: resourceIds.length, release_version: releaseVersion },
|
||||||
|
beforeSummary: null,
|
||||||
|
operationType: "preview_batch_items_add",
|
||||||
|
targetRef: batchId,
|
||||||
|
targetType: "preview_batch",
|
||||||
|
}, now);
|
||||||
|
});
|
||||||
|
return { batchId, releaseVersion, resourceIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
grant(input: {
|
||||||
|
adminUserId: string;
|
||||||
|
batchId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
userId: string;
|
||||||
|
}): PreviewGrantView {
|
||||||
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
||||||
|
const batchId = assertText(input.batchId, "batchId");
|
||||||
|
const userId = assertText(input.userId, "userId");
|
||||||
|
if (!isUuid(userId)) throw new PreviewGrantError("invalid_request");
|
||||||
|
const now = this.options.clock();
|
||||||
|
if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt <= now) throw new PreviewGrantError("invalid_expiry");
|
||||||
|
this.assertAdmin(adminUserId, now);
|
||||||
|
return this.immediate(() => {
|
||||||
|
const batch = this.readBatch(batchId);
|
||||||
|
if (!batch) throw new PreviewGrantError("batch_not_found");
|
||||||
|
if (batch.status !== "active") throw new PreviewGrantError("batch_closed");
|
||||||
|
const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined;
|
||||||
|
if (!user || user.role !== "user" || user.status !== "active") throw new PreviewGrantError("user_not_eligible");
|
||||||
|
const grantId = randomUUID();
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO asset_preview_grants (
|
||||||
|
grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'active')
|
||||||
|
`).run(grantId, userId, batchId, adminUserId, now, input.expiresAt);
|
||||||
|
this.audit({
|
||||||
|
actorRef: adminUserId,
|
||||||
|
afterSummary: { batch_id: batchId, expires_at: input.expiresAt, grant_id: grantId, status: "active", user_id: userId },
|
||||||
|
beforeSummary: null,
|
||||||
|
operationType: "preview_grant_create",
|
||||||
|
targetRef: grantId,
|
||||||
|
targetType: "preview_grant",
|
||||||
|
}, now);
|
||||||
|
return {
|
||||||
|
batchId,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
grantId,
|
||||||
|
grantedAt: now,
|
||||||
|
grantedBy: adminUserId,
|
||||||
|
status: "active" as const,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke(input: { adminUserId: string; grantId: string }): PreviewGrantView {
|
||||||
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
||||||
|
const grantId = assertText(input.grantId, "grantId");
|
||||||
|
const now = this.options.clock();
|
||||||
|
this.assertAdmin(adminUserId, now);
|
||||||
|
return this.immediate(() => {
|
||||||
|
this.expireDue(now);
|
||||||
|
const grant = this.readGrant(grantId);
|
||||||
|
if (!grant) throw new PreviewGrantError("grant_not_found");
|
||||||
|
if (grant.status === "active") {
|
||||||
|
this.database.prepare("UPDATE asset_preview_grants SET status = 'revoked' WHERE grant_id = ? AND status = 'active'").run(grantId);
|
||||||
|
this.audit({
|
||||||
|
actorRef: adminUserId,
|
||||||
|
afterSummary: { grant_id: grantId, status: "revoked" },
|
||||||
|
beforeSummary: { grant_id: grantId, status: grant.status },
|
||||||
|
operationType: "preview_grant_revoke",
|
||||||
|
targetRef: grantId,
|
||||||
|
targetType: "preview_grant",
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
return { ...grant, status: "revoked" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listBatches(input: { adminUserId: string }): PreviewBatchView[] {
|
||||||
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
||||||
|
this.assertAdmin(adminUserId, this.options.clock());
|
||||||
|
return (this.database.prepare(`
|
||||||
|
SELECT batch_id, name, status, created_by, created_at
|
||||||
|
FROM test_batches ORDER BY created_at DESC, batch_id DESC
|
||||||
|
`).all() as Array<{ batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus }>).map((row) => ({
|
||||||
|
batchId: row.batch_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
name: row.name,
|
||||||
|
status: row.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
listGrants(input: { adminUserId: string; batchId?: string; userId?: string }): PreviewGrantView[] {
|
||||||
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
||||||
|
this.assertAdmin(adminUserId, this.options.clock());
|
||||||
|
const batchId = input.batchId ? assertText(input.batchId, "batchId") : undefined;
|
||||||
|
const userId = input.userId ? assertText(input.userId, "userId") : undefined;
|
||||||
|
const now = this.options.clock();
|
||||||
|
return this.immediate(() => {
|
||||||
|
this.expireDue(now);
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status
|
||||||
|
FROM asset_preview_grants
|
||||||
|
WHERE (? IS NULL OR test_batch_id = ?) AND (? IS NULL OR user_id = ?)
|
||||||
|
ORDER BY granted_at DESC, grant_id DESC
|
||||||
|
`).all(batchId ?? null, batchId ?? null, userId ?? null, userId ?? null) as Array<{
|
||||||
|
expires_at: number; grant_id: string; granted_at: number; granted_by: string;
|
||||||
|
status: PreviewGrantStatus; test_batch_id: string; user_id: string;
|
||||||
|
}>;
|
||||||
|
return rows.map((row) => ({
|
||||||
|
batchId: row.test_batch_id,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
grantId: row.grant_id,
|
||||||
|
grantedAt: row.granted_at,
|
||||||
|
grantedBy: row.granted_by,
|
||||||
|
status: row.status,
|
||||||
|
userId: row.user_id,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
projectManifest(input: { releaseVersion: string; userId: string }): AssetReleaseManifestProjection | undefined {
|
||||||
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
||||||
|
const userId = assertText(input.userId, "userId");
|
||||||
|
const base = this.options.assetReleases.project("internal_preview_asset", releaseVersion);
|
||||||
|
if (!base) return undefined;
|
||||||
|
const authorized = base.items.filter((item) => this.authorizeAsset({ releaseVersion, resourceId: item.resource_id, userId }));
|
||||||
|
if (authorized.length === 0) return undefined;
|
||||||
|
const items = authorized.map((item) => {
|
||||||
|
const manifestItemId = randomUUID();
|
||||||
|
const mapped: AssetReleaseManifestItem = {
|
||||||
|
...item,
|
||||||
|
resource_id: manifestItemId,
|
||||||
|
url: `/api/v1/assets/preview/${releaseVersion}/${manifestItemId}`,
|
||||||
|
};
|
||||||
|
this.itemMappings.set(manifestItemId, {
|
||||||
|
releaseVersion,
|
||||||
|
resourceId: item.resource_id,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return mapped;
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
items: Object.freeze(items.map((item) => Object.freeze(item))),
|
||||||
|
manifest_sha256: manifestHash(items, releaseVersion),
|
||||||
|
release_version: releaseVersion,
|
||||||
|
schema_version: "AssetReleaseManifest/v1" as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizeAsset(input: { releaseVersion: string; resourceId: string; userId: string }) {
|
||||||
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
||||||
|
const resourceId = assertText(input.resourceId, "resourceId");
|
||||||
|
const userId = assertText(input.userId, "userId");
|
||||||
|
const now = this.options.clock();
|
||||||
|
return this.immediate(() => {
|
||||||
|
this.expireDue(now);
|
||||||
|
const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined;
|
||||||
|
if (!user || user.role !== "user" || user.status !== "active") return false;
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT 1 AS authorized
|
||||||
|
FROM asset_preview_grants g
|
||||||
|
JOIN test_batch_items i ON i.test_batch_id = g.test_batch_id
|
||||||
|
WHERE g.user_id = ? AND g.status = 'active' AND g.expires_at > ?
|
||||||
|
AND i.release_version = ? AND i.resource_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(userId, now, releaseVersion, resourceId) as { authorized: 1 } | undefined;
|
||||||
|
return Boolean(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readManifestItem(input: { manifestItemId: string; releaseVersion: string; userId: string }) {
|
||||||
|
const manifestItemId = assertText(input.manifestItemId, "manifestItemId");
|
||||||
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
||||||
|
const userId = assertText(input.userId, "userId");
|
||||||
|
const mapping = this.itemMappings.get(manifestItemId);
|
||||||
|
if (!mapping || mapping.releaseVersion !== releaseVersion || mapping.userId !== userId) return undefined;
|
||||||
|
if (!this.authorizeAsset({ releaseVersion, resourceId: mapping.resourceId, userId })) {
|
||||||
|
this.itemMappings.delete(manifestItemId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const resource = this.options.assetReleases.read("internal_preview_asset", releaseVersion, mapping.resourceId);
|
||||||
|
return resource ? { ...resource, resourceId: manifestItemId } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS test_batches (
|
||||||
|
batch_id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('active', 'closed')),
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(user_id),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
closed_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS test_batch_items (
|
||||||
|
test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id),
|
||||||
|
release_version TEXT NOT NULL,
|
||||||
|
resource_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (test_batch_id, release_version, resource_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_preview_grants (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||||
|
test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id),
|
||||||
|
granted_by TEXT NOT NULL REFERENCES users(user_id),
|
||||||
|
granted_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL CHECK (expires_at > granted_at),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('active', 'revoked', 'expired'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS asset_preview_grants_user_status
|
||||||
|
ON asset_preview_grants(user_id, status, expires_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private immediate<T>(action: () => T): T {
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const value = action();
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAdmin(adminUserId: string, now: number) {
|
||||||
|
const admin = this.database.prepare(`
|
||||||
|
SELECT 1 AS allowed FROM users u JOIN admin_access a ON a.user_id = u.user_id
|
||||||
|
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
|
||||||
|
`).get(adminUserId) as { allowed: 1 } | undefined;
|
||||||
|
if (!admin) throw new PreviewGrantError("admin_invalid");
|
||||||
|
void now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBatch(batchId: string): PreviewBatchView | undefined {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT batch_id, name, status, created_by, created_at
|
||||||
|
FROM test_batches WHERE batch_id = ?
|
||||||
|
`).get(batchId) as { batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus } | undefined;
|
||||||
|
return row ? {
|
||||||
|
batchId: row.batch_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
name: row.name,
|
||||||
|
status: row.status,
|
||||||
|
} : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readGrant(grantId: string): PreviewGrantView | undefined {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status
|
||||||
|
FROM asset_preview_grants WHERE grant_id = ?
|
||||||
|
`).get(grantId) as {
|
||||||
|
expires_at: number; grant_id: string; granted_at: number; granted_by: string;
|
||||||
|
status: PreviewGrantStatus; test_batch_id: string; user_id: string;
|
||||||
|
} | undefined;
|
||||||
|
return row ? {
|
||||||
|
batchId: row.test_batch_id,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
grantId: row.grant_id,
|
||||||
|
grantedAt: row.granted_at,
|
||||||
|
grantedBy: row.granted_by,
|
||||||
|
status: row.status,
|
||||||
|
userId: row.user_id,
|
||||||
|
} : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private expireDue(now: number) {
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT grant_id, user_id, test_batch_id FROM asset_preview_grants
|
||||||
|
WHERE status = 'active' AND expires_at <= ?
|
||||||
|
`).all(now) as Array<{ grant_id: string; test_batch_id: string; user_id: string }>;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
this.database.prepare("UPDATE asset_preview_grants SET status = 'expired' WHERE status = 'active' AND expires_at <= ?").run(now);
|
||||||
|
for (const row of rows) {
|
||||||
|
this.audit({
|
||||||
|
actorRef: "preview_grant_expiry",
|
||||||
|
afterSummary: { grant_id: row.grant_id, status: "expired" },
|
||||||
|
beforeSummary: { grant_id: row.grant_id, status: "active" },
|
||||||
|
operationType: "preview_grant_expire",
|
||||||
|
targetRef: row.grant_id,
|
||||||
|
targetType: "preview_grant",
|
||||||
|
}, now, "system");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private audit(input: {
|
||||||
|
actorRef: string;
|
||||||
|
afterSummary: Record<string, unknown> | null;
|
||||||
|
beforeSummary: Record<string, unknown> | null;
|
||||||
|
operationType: string;
|
||||||
|
targetRef: string;
|
||||||
|
targetType: string;
|
||||||
|
}, now: number, actorType: "super_admin" | "system" = "super_admin") {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), actorType, input.actorRef, input.operationType, input.targetType, input.targetRef,
|
||||||
|
serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary),
|
||||||
|
now, now + auditRetentionMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import { auditRetentionMilliseconds } from "./audit-policy.js";
|
||||||
|
|
||||||
|
type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
|
||||||
|
export class PrivateContentError extends Error {
|
||||||
|
constructor(readonly code: "notice_required" | "notice_version_conflict" | "not_found") {
|
||||||
|
super(code);
|
||||||
|
this.name = "PrivateContentError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(value: number) {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenerationTablePresent(database: BetterSqlite3.Database) {
|
||||||
|
return Boolean(database.prepare(
|
||||||
|
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'generation_jobs'",
|
||||||
|
).get());
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivateContentService {
|
||||||
|
constructor(
|
||||||
|
readonly database: BetterSqlite3.Database,
|
||||||
|
readonly currentNoticeVersion: string,
|
||||||
|
private readonly clock: () => number = Date.now,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
currentNotice() {
|
||||||
|
return {
|
||||||
|
version: this.currentNoticeVersion,
|
||||||
|
messageKey: "admin.private_content.notice",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
readAcknowledgement(adminUserId: string) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT private_content_notice_version, private_content_notice_acknowledged_at
|
||||||
|
FROM user_profiles WHERE user_id = ?
|
||||||
|
`).get(adminUserId) as { private_content_notice_version: string | null; private_content_notice_acknowledged_at: number | null } | undefined;
|
||||||
|
return {
|
||||||
|
version: row?.private_content_notice_version ?? null,
|
||||||
|
acknowledgedAt: row?.private_content_notice_acknowledged_at === null || row?.private_content_notice_acknowledged_at === undefined
|
||||||
|
? null : iso(row.private_content_notice_acknowledged_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isAcknowledged(adminUserId: string) {
|
||||||
|
return this.readAcknowledgement(adminUserId).version === this.currentNoticeVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
requireAcknowledgement(adminUserId: string) {
|
||||||
|
if (!this.isAcknowledged(adminUserId)) throw new PrivateContentError("notice_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledge(adminUserId: string, expectedNoticeVersion: string) {
|
||||||
|
const now = this.clock();
|
||||||
|
return this.database.transaction(() => {
|
||||||
|
if (expectedNoticeVersion !== this.currentNoticeVersion) {
|
||||||
|
throw new PrivateContentError("notice_version_conflict");
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO user_profiles (
|
||||||
|
user_id, creator_name, social_id, private_content_notice_version,
|
||||||
|
private_content_notice_acknowledged_at
|
||||||
|
) VALUES (?, '', '', ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
private_content_notice_version = excluded.private_content_notice_version,
|
||||||
|
private_content_notice_acknowledged_at =
|
||||||
|
CASE WHEN user_profiles.private_content_notice_version = excluded.private_content_notice_version
|
||||||
|
THEN user_profiles.private_content_notice_acknowledged_at ELSE excluded.private_content_notice_acknowledged_at END
|
||||||
|
`).run(adminUserId, this.currentNoticeVersion, now);
|
||||||
|
const acknowledged = this.readAcknowledgement(adminUserId);
|
||||||
|
return {
|
||||||
|
noticeVersion: this.currentNoticeVersion,
|
||||||
|
acknowledgedAt: acknowledged.acknowledgedAt ?? iso(now),
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
listGenerations() {
|
||||||
|
const generatedAt = iso(this.clock());
|
||||||
|
if (!isGenerationTablePresent(this.database)) return { generated_at: generatedAt, items: [] };
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT generation_id, owner_id, project_id, model_id, ratio, status,
|
||||||
|
confirmed_credit_cost, reserved_credits, final_credit_state,
|
||||||
|
error_category, created_at, updated_at
|
||||||
|
FROM generation_jobs
|
||||||
|
WHERE submission_ready = 1
|
||||||
|
ORDER BY created_at DESC, generation_id DESC
|
||||||
|
LIMIT 100
|
||||||
|
`).all() as Array<{
|
||||||
|
generation_id: string;
|
||||||
|
owner_id: string;
|
||||||
|
project_id: string;
|
||||||
|
model_id: string;
|
||||||
|
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
|
status: GenerationStatus;
|
||||||
|
confirmed_credit_cost: number;
|
||||||
|
reserved_credits: number;
|
||||||
|
final_credit_state: "committed" | "released" | null;
|
||||||
|
error_category: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}>;
|
||||||
|
return {
|
||||||
|
generated_at: generatedAt,
|
||||||
|
items: rows.map((row) => {
|
||||||
|
const terminal = row.status === "succeeded" || row.status === "failed" || row.status === "rejected";
|
||||||
|
return {
|
||||||
|
generation_id: row.generation_id,
|
||||||
|
owner_ref: row.owner_id,
|
||||||
|
project_id: row.project_id,
|
||||||
|
model_id: row.model_id,
|
||||||
|
ratio: row.ratio,
|
||||||
|
status: row.status,
|
||||||
|
created_at: iso(row.created_at),
|
||||||
|
completed_at: terminal ? iso(row.updated_at) : null,
|
||||||
|
duration_ms: terminal ? Math.max(0, row.updated_at - row.created_at) : null,
|
||||||
|
confirmed_credit_cost: row.confirmed_credit_cost,
|
||||||
|
reserved_credits: row.reserved_credits,
|
||||||
|
final_credit_state: row.final_credit_state,
|
||||||
|
error_category: row.error_category,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generation(generationId: string) {
|
||||||
|
if (!isGenerationTablePresent(this.database)) throw new PrivateContentError("not_found");
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT generation_id, owner_id, project_id
|
||||||
|
FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1
|
||||||
|
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string } | undefined;
|
||||||
|
if (!row) throw new PrivateContentError("not_found");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordAccess(input: { adminUserId: string; ownerId: string; generationId: string; contentType: "image" | "prompt" }) {
|
||||||
|
const now = this.clock();
|
||||||
|
// The insert is committed before the caller reads the private value. A failed
|
||||||
|
// constraint therefore cannot accidentally release a private response.
|
||||||
|
this.database.transaction(() => {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO private_content_access_logs (
|
||||||
|
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), input.adminUserId, input.ownerId, input.generationId,
|
||||||
|
input.contentType, now, now + auditRetentionMilliseconds,
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPrivateAssetAccess(adminUserId: string, ownerId: string, resourceId: string) {
|
||||||
|
this.recordAccess({ adminUserId, ownerId, generationId: resourceId, contentType: "image" });
|
||||||
|
}
|
||||||
|
|
||||||
|
readPrompt(adminUserId: string, generationId: string) {
|
||||||
|
this.requireAcknowledgement(adminUserId);
|
||||||
|
const row = this.generation(generationId);
|
||||||
|
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "prompt" });
|
||||||
|
const content = this.database.prepare(
|
||||||
|
"SELECT prompt FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1",
|
||||||
|
).get(row.generation_id) as { prompt: string } | undefined;
|
||||||
|
if (!content) throw new PrivateContentError("not_found");
|
||||||
|
return { generationId: row.generation_id, prompt: content.prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
readImageTarget(adminUserId: string, generationId: string) {
|
||||||
|
this.requireAcknowledgement(adminUserId);
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT g.generation_id, g.owner_id, g.project_id, pi.image_id
|
||||||
|
FROM generation_jobs g
|
||||||
|
JOIN project_images pi ON pi.project_id = g.project_id AND pi.generation_id = g.generation_id
|
||||||
|
WHERE g.generation_id = ? AND g.status = 'succeeded'
|
||||||
|
ORDER BY pi.created_at DESC LIMIT 1
|
||||||
|
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string; image_id: string } | undefined;
|
||||||
|
if (!row) throw new PrivateContentError("not_found");
|
||||||
|
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "image" });
|
||||||
|
return { projectId: row.project_id, imageId: row.image_id, ownerId: row.owner_id };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type ProjectErrorCode =
|
||||||
|
| "generation_state_invalid"
|
||||||
|
| "project_active_limit"
|
||||||
|
| "project_history_limit"
|
||||||
|
| "project_name_invalid"
|
||||||
|
| "project_not_found"
|
||||||
|
| "project_ratio_fixed"
|
||||||
|
| "project_retry_not_allowed"
|
||||||
|
| "project_state_conflict"
|
||||||
|
| "project_state_invalid"
|
||||||
|
| "project_state_idempotency_conflict";
|
||||||
|
|
||||||
|
export class ProjectError extends Error {
|
||||||
|
readonly code: ProjectErrorCode;
|
||||||
|
readonly latestStateVersion: number | undefined;
|
||||||
|
|
||||||
|
constructor(code: ProjectErrorCode, latestStateVersion?: number) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
this.latestStateVersion = latestStateVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
import { isProjectEditableState, type CanvasState, type ProjectEditableState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { ProjectError } from "./project-errors.js";
|
||||||
|
export { ProjectError } from "./project-errors.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||||
|
|
||||||
|
export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
|
||||||
|
export type ProjectRatio = typeof projectRatios[number];
|
||||||
|
export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
|
||||||
|
export type ProjectManagedResourceKind = "derived" | "export" | "generated" | "reference";
|
||||||
|
export const ratioPixels: Record<ProjectRatio, { height: number; width: number }> = {
|
||||||
|
"3:4": { height: 1440, width: 1080 },
|
||||||
|
"1:1": { height: 1080, width: 1080 },
|
||||||
|
"4:3": { height: 1080, width: 1440 },
|
||||||
|
"9:16": { height: 1920, width: 1080 },
|
||||||
|
};
|
||||||
|
export const projectLimit = 20;
|
||||||
|
export const historyLimit = 10;
|
||||||
|
const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000;
|
||||||
|
const generationErrorCategories = new Set([
|
||||||
|
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
|
||||||
|
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
|
||||||
|
"unknown_retryable", "unknown_non_retryable",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface ProjectRow {
|
||||||
|
created_at: number;
|
||||||
|
current_image_id: string | null;
|
||||||
|
deleted_at: number | null;
|
||||||
|
draft_prompt: string;
|
||||||
|
name: string;
|
||||||
|
owner_id: string;
|
||||||
|
pixel_height: number;
|
||||||
|
pixel_width: number;
|
||||||
|
project_id: string;
|
||||||
|
purge_at: number | null;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
state_version: number;
|
||||||
|
status: "active" | "trashed" | "purged";
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationRow {
|
||||||
|
created_at: number;
|
||||||
|
error_category: string | null;
|
||||||
|
generation_id: string;
|
||||||
|
prompt: string;
|
||||||
|
project_id: string;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
status: GenerationStatus;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageRow {
|
||||||
|
created_at: number;
|
||||||
|
generation_id: string;
|
||||||
|
image_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectStateRow {
|
||||||
|
canvas_json: string;
|
||||||
|
created_at: number;
|
||||||
|
name: string;
|
||||||
|
project_id: string;
|
||||||
|
state_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LatestExportRow {
|
||||||
|
byte_size: number;
|
||||||
|
created_at: number;
|
||||||
|
export_id: string;
|
||||||
|
format: "jpg" | "png";
|
||||||
|
pixel_height: number;
|
||||||
|
pixel_width: number;
|
||||||
|
sha256: string;
|
||||||
|
state_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectRatio(value: string): value is ProjectRatio {
|
||||||
|
return projectRatios.includes(value as ProjectRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePrompt(value: string) {
|
||||||
|
const normalized = value.trim().replace(/\s+/gu, " ");
|
||||||
|
if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeGraphemes(value: string, count: number) {
|
||||||
|
const Segmenter = Intl.Segmenter;
|
||||||
|
if (Segmenter) {
|
||||||
|
return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)]
|
||||||
|
.slice(0, count)
|
||||||
|
.map((entry) => entry.segment)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return Array.from(value).slice(0, count).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDate(timestamp: number) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return [date.getFullYear(), date.getMonth() + 1, date.getDate()]
|
||||||
|
.map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0"))
|
||||||
|
.join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultProjectName(prompt: string, timestamp: number) {
|
||||||
|
const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作";
|
||||||
|
return `${summary} ${localDate(timestamp)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectName(value: string) {
|
||||||
|
const normalized = value.trim().replace(/\s+/gu, " ");
|
||||||
|
if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(timestamp: number) {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState {
|
||||||
|
return {
|
||||||
|
background: {
|
||||||
|
adjustments: {
|
||||||
|
brightness: 0,
|
||||||
|
contrast: 0,
|
||||||
|
crop: null,
|
||||||
|
filter: "none",
|
||||||
|
fit: "fill",
|
||||||
|
saturation: 0,
|
||||||
|
sharpness: 0,
|
||||||
|
temperature: 0,
|
||||||
|
},
|
||||||
|
asset_id: assetId,
|
||||||
|
},
|
||||||
|
elements: [],
|
||||||
|
pixel_height: pixels.height,
|
||||||
|
pixel_width: pixels.width,
|
||||||
|
ratio,
|
||||||
|
schema_version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stableJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
||||||
|
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectService {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; databasePath: string }) {
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||||
|
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||||
|
this.database.pragma("journal_mode = WAL");
|
||||||
|
this.database.pragma("foreign_keys = ON");
|
||||||
|
this.database.pragma("synchronous = FULL");
|
||||||
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
createProjectForGeneration(input: {
|
||||||
|
ownerId: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
status: Exclude<GenerationStatus, "succeeded">;
|
||||||
|
}) {
|
||||||
|
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
|
||||||
|
const prompt = normalizePrompt(input.prompt);
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const generationId = randomUUID();
|
||||||
|
const now = this.clock();
|
||||||
|
const pixels = ratioPixels[input.ratio];
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||||
|
.get(input.ownerId) as { count: number };
|
||||||
|
if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO projects (
|
||||||
|
project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height,
|
||||||
|
status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL)
|
||||||
|
`).run(
|
||||||
|
projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio,
|
||||||
|
pixels.width, pixels.height, now, now,
|
||||||
|
);
|
||||||
|
this.insertProjectState({
|
||||||
|
canvasState: defaultCanvasState(input.ratio, pixels, null),
|
||||||
|
name: defaultProjectName(prompt, now),
|
||||||
|
projectId,
|
||||||
|
stateVersion: 1,
|
||||||
|
}, now);
|
||||||
|
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
continueProjectGeneration(input: {
|
||||||
|
ownerId: string;
|
||||||
|
projectId: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
status: Exclude<GenerationStatus, "succeeded">;
|
||||||
|
}) {
|
||||||
|
if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid");
|
||||||
|
const generationId = randomUUID();
|
||||||
|
const prompt = normalizePrompt(input.prompt);
|
||||||
|
const now = this.clock();
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const project = this.readOwnedProject(input.ownerId, input.projectId);
|
||||||
|
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||||
|
if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed");
|
||||||
|
if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit");
|
||||||
|
this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now);
|
||||||
|
this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?")
|
||||||
|
.run(prompt, now, input.projectId);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return this.readGeneration(generationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) {
|
||||||
|
const project = this.getProject(input.ownerId, input.projectId);
|
||||||
|
const latest = project.generations.at(-1);
|
||||||
|
if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) {
|
||||||
|
throw new ProjectError("project_retry_not_allowed");
|
||||||
|
}
|
||||||
|
return this.continueProjectGeneration({
|
||||||
|
ownerId: input.ownerId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
prompt: input.prompt,
|
||||||
|
ratio: project.ratio,
|
||||||
|
status: "queued",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSuccessfulImage(input: { generationId: string; imageId: string }) {
|
||||||
|
const now = this.clock();
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?")
|
||||||
|
.get(input.generationId) as GenerationRow | undefined;
|
||||||
|
if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid");
|
||||||
|
if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit");
|
||||||
|
const project = this.database.prepare("SELECT * FROM projects WHERE project_id = ?").get(generation.project_id) as ProjectRow;
|
||||||
|
const currentState = this.readProjectState(generation.project_id);
|
||||||
|
const nextCanvas = structuredClone(currentState.canvasState);
|
||||||
|
if (project.current_image_id === null) nextCanvas.background.asset_id = input.imageId;
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ?
|
||||||
|
`).run(now, input.generationId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)
|
||||||
|
`).run(input.imageId, generation.project_id, input.generationId, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).run(input.imageId, now, generation.project_id);
|
||||||
|
this.insertProjectState({
|
||||||
|
canvasState: nextCanvas,
|
||||||
|
name: project.name,
|
||||||
|
projectId: generation.project_id,
|
||||||
|
stateVersion: project.state_version + 1,
|
||||||
|
}, now);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
markGenerationFailed(generationId: string, errorCategory: string) {
|
||||||
|
if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid");
|
||||||
|
const status = errorCategory === "safety_rejected" ? "rejected" : "failed";
|
||||||
|
const changed = this.database.prepare(`
|
||||||
|
UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ?
|
||||||
|
WHERE generation_id = ? AND status IN ('queued', 'running')
|
||||||
|
`).run(status, errorCategory, this.clock(), generationId);
|
||||||
|
if (changed.changes !== 1) throw new ProjectError("generation_state_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
renameProject(ownerId: string, projectId: string, name: string) {
|
||||||
|
const normalized = normalizeProjectName(name);
|
||||||
|
const now = this.clock();
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const project = this.readOwnedProject(ownerId, projectId);
|
||||||
|
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||||
|
const currentState = this.readProjectState(projectId);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE owner_id = ? AND project_id = ? AND status = 'active'
|
||||||
|
`).run(normalized, now, ownerId, projectId);
|
||||||
|
this.insertProjectState({
|
||||||
|
canvasState: currentState.canvasState,
|
||||||
|
name: normalized,
|
||||||
|
projectId,
|
||||||
|
stateVersion: project.state_version + 1,
|
||||||
|
}, now);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version };
|
||||||
|
}
|
||||||
|
|
||||||
|
saveProjectState(input: {
|
||||||
|
expectedStateVersion: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
ownerId: string;
|
||||||
|
projectId: string;
|
||||||
|
state: ProjectEditableState;
|
||||||
|
}) {
|
||||||
|
if (!Number.isSafeInteger(input.expectedStateVersion) || input.expectedStateVersion < 1 || !isProjectEditableState(input.state)) {
|
||||||
|
throw new ProjectError("project_state_invalid");
|
||||||
|
}
|
||||||
|
const name = normalizeProjectName(input.state.name);
|
||||||
|
const state = { canvas_state: structuredClone(input.state.canvas_state), name } satisfies ProjectEditableState;
|
||||||
|
const requestHash = createHash("sha256").update(stableJson({ expected: input.expectedStateVersion, state })).digest("hex");
|
||||||
|
const now = this.clock();
|
||||||
|
let result!: { stateVersion: number };
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const replay = this.database.prepare(`
|
||||||
|
SELECT request_hash, response_state_version FROM project_state_idempotency
|
||||||
|
WHERE owner_id = ? AND project_id = ? AND idempotency_key = ?
|
||||||
|
`).get(input.ownerId, input.projectId, input.idempotencyKey) as { request_hash: string; response_state_version: number } | undefined;
|
||||||
|
if (replay) {
|
||||||
|
if (replay.request_hash !== requestHash) throw new ProjectError("project_state_idempotency_conflict");
|
||||||
|
result = { stateVersion: replay.response_state_version };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project = this.readOwnedProject(input.ownerId, input.projectId);
|
||||||
|
if (project.status !== "active") throw new ProjectError("project_not_found");
|
||||||
|
if (project.state_version !== input.expectedStateVersion) {
|
||||||
|
throw new ProjectError("project_state_conflict", project.state_version);
|
||||||
|
}
|
||||||
|
const canvas = state.canvas_state;
|
||||||
|
if (canvas.ratio !== project.ratio || canvas.pixel_width !== project.pixel_width || canvas.pixel_height !== project.pixel_height) {
|
||||||
|
throw new ProjectError("project_state_invalid");
|
||||||
|
}
|
||||||
|
if (canvas.background.asset_id) {
|
||||||
|
const owned = this.database.prepare("SELECT 1 FROM project_images WHERE project_id = ? AND image_id = ?")
|
||||||
|
.get(input.projectId, canvas.background.asset_id);
|
||||||
|
if (!owned) throw new ProjectError("project_state_invalid");
|
||||||
|
}
|
||||||
|
const nextVersion = project.state_version + 1;
|
||||||
|
const changed = this.database.prepare(`
|
||||||
|
UPDATE projects SET name = ?, state_version = ?, updated_at = ?
|
||||||
|
WHERE project_id = ? AND owner_id = ? AND state_version = ? AND status = 'active'
|
||||||
|
`).run(name, nextVersion, now, input.projectId, input.ownerId, input.expectedStateVersion);
|
||||||
|
if (changed.changes !== 1) {
|
||||||
|
const latest = this.readOwnedProject(input.ownerId, input.projectId).state_version;
|
||||||
|
throw new ProjectError("project_state_conflict", latest);
|
||||||
|
}
|
||||||
|
this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now);
|
||||||
|
this.rebuildProjectStickerReferences(input.projectId, canvas, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO project_state_idempotency (
|
||||||
|
owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(input.ownerId, input.projectId, input.idempotencyKey, requestHash, nextVersion, now);
|
||||||
|
result = { stateVersion: nextVersion };
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildProjectStickerReferences(projectId: string, canvas: CanvasState, now: number) {
|
||||||
|
if (!this.tableExists("project_sticker_asset_refs")) return;
|
||||||
|
this.database.prepare("DELETE FROM project_sticker_asset_refs WHERE project_id = ?").run(projectId);
|
||||||
|
const insert = this.database.prepare(`
|
||||||
|
INSERT INTO project_sticker_asset_refs (reference_id, project_id, stable_id, resource_version, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const element of canvas.elements) {
|
||||||
|
if (element.type !== "static_sticker") continue;
|
||||||
|
insert.run(
|
||||||
|
`project:${projectId}:sticker:${element.element_id}`,
|
||||||
|
projectId,
|
||||||
|
element.template_or_asset_id,
|
||||||
|
element.resource_version,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trashFailedEmpty(ownerId: string, projectIds: string[]) {
|
||||||
|
const uniqueIds = [...new Set(projectIds)];
|
||||||
|
if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
|
||||||
|
const trashedProjectIds: string[] = [];
|
||||||
|
const ignoredProjectIds: string[] = [];
|
||||||
|
const now = this.clock();
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
for (const projectId of uniqueIds) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT p.project_id,
|
||||||
|
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
|
||||||
|
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
|
||||||
|
FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active'
|
||||||
|
`).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined;
|
||||||
|
if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) {
|
||||||
|
ignoredProjectIds.push(projectId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).run(now, now + trashRetentionMilliseconds, now, projectId);
|
||||||
|
trashedProjectIds.push(projectId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return { ignoredProjectIds, trashedProjectIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
trashProject(ownerId: string, projectId: string) {
|
||||||
|
const now = this.clock();
|
||||||
|
let purged = false;
|
||||||
|
let result!: ReturnType<ProjectService["projectSummary"]>;
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const project = this.readOwnedProject(ownerId, projectId);
|
||||||
|
if (project.status === "purged") throw new ProjectError("project_not_found");
|
||||||
|
if (project.status === "trashed") {
|
||||||
|
if (project.purge_at !== null && project.purge_at <= now) {
|
||||||
|
this.transitionToPurged(project, now);
|
||||||
|
purged = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = this.projectSummary(project, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE project_id = ? AND owner_id = ? AND status = 'active'
|
||||||
|
`).run(now, now + trashRetentionMilliseconds, now, projectId, ownerId);
|
||||||
|
const updated = this.readOwnedProject(ownerId, projectId);
|
||||||
|
result = this.projectSummary(updated, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
if (purged) throw new ProjectError("project_not_found");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreProject(ownerId: string, projectId: string) {
|
||||||
|
const now = this.clock();
|
||||||
|
let purged = false;
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const project = this.readOwnedProject(ownerId, projectId);
|
||||||
|
if (project.status === "purged") throw new ProjectError("project_not_found");
|
||||||
|
if (project.status === "active") return;
|
||||||
|
if (project.purge_at !== null && project.purge_at <= now) {
|
||||||
|
this.transitionToPurged(project, now);
|
||||||
|
purged = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||||
|
.get(ownerId) as { count: number };
|
||||||
|
if (active.count >= projectLimit) throw new ProjectError("project_active_limit");
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE projects
|
||||||
|
SET status = 'active', deleted_at = NULL, purge_at = NULL, updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE project_id = ? AND owner_id = ? AND status = 'trashed'
|
||||||
|
`).run(now, projectId, ownerId);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
if (purged) throw new ProjectError("project_not_found");
|
||||||
|
return this.getProject(ownerId, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeProject(ownerId: string, projectId: string) {
|
||||||
|
const now = this.clock();
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const project = this.readOwnedProject(ownerId, projectId);
|
||||||
|
if (project.status !== "trashed") throw new ProjectError("project_not_found");
|
||||||
|
this.transitionToPurged(project, now);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
return { projectId, status: "purged" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
linkManagedResource(ownerId: string, projectId: string, managedFileId: string, resourceKind: ProjectManagedResourceKind) {
|
||||||
|
const project = this.readOwnedProject(ownerId, projectId);
|
||||||
|
if (project.status === "purged" || !["derived", "export", "generated", "reference"].includes(resourceKind)) {
|
||||||
|
throw new ProjectError("project_not_found");
|
||||||
|
}
|
||||||
|
if (!this.tableExists("managed_files") || !this.tableExists("project_asset_refs")) {
|
||||||
|
throw new ProjectError("project_state_invalid");
|
||||||
|
}
|
||||||
|
const managed = this.database.prepare(`
|
||||||
|
SELECT file_id FROM managed_files WHERE file_id = ? AND owner_ref = ? AND status = 'committed'
|
||||||
|
`).get(managedFileId, ownerId);
|
||||||
|
if (!managed) throw new ProjectError("project_state_invalid");
|
||||||
|
const now = this.clock();
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(projectId, managedFileId, resourceKind, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
|
||||||
|
VALUES (?, ?, 'project', ?)
|
||||||
|
`).run(`project:${projectId}:${managedFileId}`, managedFileId, iso(now));
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
listProjects(ownerId: string, status: "active" | "trashed") {
|
||||||
|
this.purgeExpiredProjects(ownerId);
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT p.*,
|
||||||
|
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
|
||||||
|
(SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status
|
||||||
|
FROM projects p
|
||||||
|
WHERE p.owner_id = ? AND p.status = ?
|
||||||
|
ORDER BY p.updated_at DESC, p.project_id DESC
|
||||||
|
`).all(ownerId, status) as Array<ProjectRow & { image_count: number; latest_status: string | null }>;
|
||||||
|
return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status));
|
||||||
|
}
|
||||||
|
|
||||||
|
getProject(ownerId: string, projectId: string) {
|
||||||
|
this.purgeExpiredProjects(ownerId, projectId);
|
||||||
|
const row = this.readOwnedProject(ownerId, projectId);
|
||||||
|
if (row.status === "purged") throw new ProjectError("project_not_found");
|
||||||
|
const generations = this.database.prepare(`
|
||||||
|
SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid
|
||||||
|
`).all(projectId) as GenerationRow[];
|
||||||
|
const images = this.database.prepare(`
|
||||||
|
SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid
|
||||||
|
`).all(projectId) as ImageRow[];
|
||||||
|
const latestExports = this.database.prepare(`
|
||||||
|
SELECT export_id, format, sha256, byte_size, pixel_width, pixel_height, state_version, created_at
|
||||||
|
FROM latest_exports WHERE project_id = ? ORDER BY format
|
||||||
|
`).all(projectId) as LatestExportRow[];
|
||||||
|
const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null);
|
||||||
|
const projectState = this.readProjectState(projectId);
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
canvasState: projectState.canvasState,
|
||||||
|
createdAt: iso(row.created_at),
|
||||||
|
draftPrompt: row.draft_prompt,
|
||||||
|
generations: generations.map((generation) => this.generationView(generation)),
|
||||||
|
images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })),
|
||||||
|
latestExports: latestExports.map((item) => ({
|
||||||
|
byteSize: item.byte_size,
|
||||||
|
createdAt: iso(item.created_at),
|
||||||
|
downloadUrl: `/api/v1/projects/${projectId}/latest-exports/${item.format}`,
|
||||||
|
exportId: item.export_id,
|
||||||
|
format: item.format,
|
||||||
|
pixelHeight: item.pixel_height,
|
||||||
|
pixelWidth: item.pixel_width,
|
||||||
|
sha256: item.sha256,
|
||||||
|
stateVersion: item.state_version,
|
||||||
|
})),
|
||||||
|
pixelHeight: row.pixel_height,
|
||||||
|
pixelWidth: row.pixel_width,
|
||||||
|
saveStatus: "saved" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
activeProjectCount(ownerId: string) {
|
||||||
|
const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'")
|
||||||
|
.get(ownerId) as { count: number };
|
||||||
|
return row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertGeneration(input: {
|
||||||
|
generationId: string;
|
||||||
|
ownerId: string;
|
||||||
|
projectId: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: ProjectRatio;
|
||||||
|
status: Exclude<GenerationStatus, "succeeded">;
|
||||||
|
}, now: number) {
|
||||||
|
const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO generation_jobs (
|
||||||
|
generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readGeneration(generationId: string) {
|
||||||
|
const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined;
|
||||||
|
if (!row) throw new ProjectError("generation_state_invalid");
|
||||||
|
return this.generationView(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generationView(row: GenerationRow) {
|
||||||
|
return {
|
||||||
|
createdAt: iso(row.created_at),
|
||||||
|
errorCategory: row.error_category,
|
||||||
|
generationId: row.generation_id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
prompt: row.prompt,
|
||||||
|
ratio: row.ratio,
|
||||||
|
status: row.status,
|
||||||
|
updatedAt: iso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readOwnedProject(ownerId: string, projectId: string) {
|
||||||
|
const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?")
|
||||||
|
.get(ownerId, projectId) as ProjectRow | undefined;
|
||||||
|
if (!row) throw new ProjectError("project_not_found");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertProjectState(input: { canvasState: CanvasState; name: string; projectId: string; stateVersion: number }, now: number) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(input.projectId, input.stateVersion, input.name, stableJson(input.canvasState), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readProjectState(projectId: string) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT project_id, state_version, name, canvas_json, created_at
|
||||||
|
FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1
|
||||||
|
`).get(projectId) as ProjectStateRow | undefined;
|
||||||
|
if (!row) throw new ProjectError("project_state_invalid");
|
||||||
|
const canvasState: unknown = JSON.parse(row.canvas_json);
|
||||||
|
if (!isProjectEditableState({ canvas_state: canvasState, name: row.name })) throw new ProjectError("project_state_invalid");
|
||||||
|
return { canvasState, name: row.name, stateVersion: row.state_version } as {
|
||||||
|
canvasState: CanvasState; name: string; stateVersion: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private successfulImageCount(projectId: string) {
|
||||||
|
const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?")
|
||||||
|
.get(projectId) as { count: number };
|
||||||
|
return row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private latestGenerationStatus(projectId: string) {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT status FROM generation_jobs WHERE project_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1
|
||||||
|
`).get(projectId) as { status: string } | undefined;
|
||||||
|
return row?.status ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tableExists(name: string) {
|
||||||
|
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private purgeExpiredProjects(ownerId: string, projectId?: string) {
|
||||||
|
const now = this.clock();
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT * FROM projects
|
||||||
|
WHERE owner_id = ? AND status = 'trashed' AND purge_at <= ? AND (? IS NULL OR project_id = ?)
|
||||||
|
ORDER BY purge_at, project_id
|
||||||
|
`).all(ownerId, now, projectId ?? null, projectId ?? null) as ProjectRow[];
|
||||||
|
for (const row of rows) this.transitionToPurged(row, now);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private transitionToPurged(project: ProjectRow, now: number) {
|
||||||
|
const changed = this.database.prepare(`
|
||||||
|
UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE project_id = ? AND status = 'trashed'
|
||||||
|
`).run(now, project.project_id);
|
||||||
|
if (changed.changes !== 1) return;
|
||||||
|
this.queueManagedProjectFiles(project.project_id, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO project_cleanup_queue (
|
||||||
|
cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error
|
||||||
|
) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), project.project_id, project.owner_id,
|
||||||
|
stableJson(["project_state", "generation", "generated_image", "reference", "location", "latest_export"]), now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueManagedProjectFiles(projectId: string, now: number) {
|
||||||
|
if (!["managed_files", "file_cleanup_queue", "project_asset_refs"].every((table) => this.tableExists(table))) return;
|
||||||
|
const files = this.database.prepare(`
|
||||||
|
SELECT mf.file_id, mf.relative_path, mf.byte_size
|
||||||
|
FROM project_resource_files prf
|
||||||
|
JOIN managed_files mf ON mf.file_id = prf.managed_file_id
|
||||||
|
WHERE prf.project_id = ? AND mf.status = 'committed'
|
||||||
|
ORDER BY mf.file_id
|
||||||
|
`).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
|
||||||
|
for (const file of files) {
|
||||||
|
this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?")
|
||||||
|
.run(`project:${projectId}:${file.file_id}`);
|
||||||
|
const otherProject = this.database.prepare(`
|
||||||
|
SELECT 1 FROM project_resource_files prf
|
||||||
|
JOIN projects p ON p.project_id = prf.project_id
|
||||||
|
WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged'
|
||||||
|
LIMIT 1
|
||||||
|
`).get(file.file_id, projectId);
|
||||||
|
const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id);
|
||||||
|
if (otherProject || otherReference) continue;
|
||||||
|
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'")
|
||||||
|
.run(iso(now), file.file_id);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||||
|
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||||
|
reason, status, created_at, completed_at, last_error
|
||||||
|
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
|
||||||
|
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) {
|
||||||
|
const status: ProjectViewStatus = row.status === "trashed"
|
||||||
|
? "trashed"
|
||||||
|
: imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus)
|
||||||
|
? "failed_empty"
|
||||||
|
: "active";
|
||||||
|
return {
|
||||||
|
currentImageId: row.current_image_id,
|
||||||
|
deletedAt: row.deleted_at === null ? null : iso(row.deleted_at),
|
||||||
|
name: row.name,
|
||||||
|
projectId: row.project_id,
|
||||||
|
purgeAt: row.purge_at === null ? null : iso(row.purge_at),
|
||||||
|
ratio: row.ratio,
|
||||||
|
stateVersion: row.state_version,
|
||||||
|
status,
|
||||||
|
successfulImageCount: imageCount,
|
||||||
|
updatedAt: iso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
project_id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
||||||
|
draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000),
|
||||||
|
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
|
||||||
|
pixel_width INTEGER NOT NULL,
|
||||||
|
pixel_height INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')),
|
||||||
|
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||||
|
current_image_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER,
|
||||||
|
purge_at INTEGER,
|
||||||
|
UNIQUE(project_id, ratio),
|
||||||
|
CHECK (
|
||||||
|
(ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR
|
||||||
|
(ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR
|
||||||
|
(ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR
|
||||||
|
(ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR
|
||||||
|
(status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR
|
||||||
|
(status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC);
|
||||||
|
DROP TRIGGER IF EXISTS projects_active_insert_limit;
|
||||||
|
CREATE TRIGGER projects_active_insert_limit
|
||||||
|
BEFORE INSERT ON projects
|
||||||
|
WHEN NEW.status = 'active' AND (
|
||||||
|
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
|
||||||
|
) >= ${projectLimit}
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
|
||||||
|
CREATE TABLE IF NOT EXISTS project_states (
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||||
|
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
|
||||||
|
canvas_json TEXT NOT NULL CHECK (json_valid(canvas_json)),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, state_version),
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_resource_files (
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
managed_file_id TEXT NOT NULL,
|
||||||
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('derived', 'export', 'generated', 'reference')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, managed_file_id),
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_resource_files_managed ON project_resource_files(managed_file_id, project_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_sticker_asset_refs (
|
||||||
|
reference_id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
stable_id TEXT NOT NULL,
|
||||||
|
resource_version TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_sticker_asset_refs_lookup
|
||||||
|
ON project_sticker_asset_refs (stable_id, resource_version);
|
||||||
|
CREATE TABLE IF NOT EXISTS latest_exports (
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL CHECK (format IN ('jpg', 'png')),
|
||||||
|
export_id TEXT NOT NULL UNIQUE,
|
||||||
|
managed_file_id TEXT NOT NULL,
|
||||||
|
state_version INTEGER NOT NULL CHECK (state_version >= 1),
|
||||||
|
sha256 TEXT NOT NULL CHECK (length(sha256) = 64),
|
||||||
|
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
|
||||||
|
pixel_width INTEGER NOT NULL CHECK (pixel_width > 0),
|
||||||
|
pixel_height INTEGER NOT NULL CHECK (pixel_height > 0),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (project_id, format),
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_cleanup_queue (
|
||||||
|
cleanup_id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL UNIQUE,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
resource_scope_json TEXT NOT NULL CHECK (json_valid(resource_scope_json)),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
completed_at INTEGER,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_cleanup_queue_status_created ON project_cleanup_queue(status, created_at, project_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_state_idempotency (
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
idempotency_key TEXT NOT NULL,
|
||||||
|
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||||
|
response_state_version INTEGER NOT NULL CHECK (response_state_version >= 2),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (owner_id, project_id, idempotency_key),
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
DROP TRIGGER IF EXISTS projects_active_restore_limit;
|
||||||
|
CREATE TRIGGER projects_active_restore_limit
|
||||||
|
BEFORE UPDATE OF status ON projects
|
||||||
|
WHEN OLD.status <> 'active' AND NEW.status = 'active' AND (
|
||||||
|
SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active'
|
||||||
|
) >= ${projectLimit}
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END;
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_jobs (
|
||||||
|
generation_id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000),
|
||||||
|
ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')),
|
||||||
|
model_id TEXT,
|
||||||
|
model_config_version INTEGER,
|
||||||
|
confirmed_credit_cost INTEGER CHECK (confirmed_credit_cost IS NULL OR confirmed_credit_cost > 0),
|
||||||
|
reserved_credits INTEGER NOT NULL DEFAULT 0 CHECK (reserved_credits >= 0),
|
||||||
|
final_credit_state TEXT CHECK (final_credit_state IS NULL OR final_credit_state IN ('committed', 'released')),
|
||||||
|
finished_at INTEGER,
|
||||||
|
error_category TEXT CHECK (error_category IS NULL OR error_category IN (
|
||||||
|
'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled',
|
||||||
|
'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid',
|
||||||
|
'unknown_retryable', 'unknown_non_retryable'
|
||||||
|
)),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(generation_id, project_id),
|
||||||
|
FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
(status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR
|
||||||
|
(status IN ('failed', 'rejected') AND error_category IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS project_images (
|
||||||
|
image_id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
generation_id TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id);
|
||||||
|
DROP TRIGGER IF EXISTS projects_ratio_immutable;
|
||||||
|
CREATE TRIGGER projects_ratio_immutable
|
||||||
|
BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects
|
||||||
|
WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END;
|
||||||
|
DROP TRIGGER IF EXISTS project_images_history_limit;
|
||||||
|
CREATE TRIGGER project_images_history_limit
|
||||||
|
BEFORE INSERT ON project_images
|
||||||
|
WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit}
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END;
|
||||||
|
`);
|
||||||
|
const missingStates = this.database.prepare(`
|
||||||
|
SELECT p.* FROM projects p
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM project_states s WHERE s.project_id = p.project_id)
|
||||||
|
`).all() as ProjectRow[];
|
||||||
|
const insertBackfill = this.database.transaction(() => {
|
||||||
|
for (const project of missingStates) {
|
||||||
|
this.insertProjectState({
|
||||||
|
canvasState: defaultCanvasState(project.ratio, { height: project.pixel_height, width: project.pixel_width }, project.current_image_id),
|
||||||
|
name: project.name,
|
||||||
|
projectId: project.project_id,
|
||||||
|
stateVersion: project.state_version,
|
||||||
|
}, project.updated_at);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertBackfill.immediate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||||
|
|
||||||
|
export type RecentAssetKind = "static_sticker" | "text_template";
|
||||||
|
|
||||||
|
export interface RecentAssetItem {
|
||||||
|
asset_id: string;
|
||||||
|
asset_kind: RecentAssetKind;
|
||||||
|
resource_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecentAssetService {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly ownsDatabase: boolean;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
|
||||||
|
if (!input.database && !input.databasePath) throw new Error("recent_asset_database_required");
|
||||||
|
this.database = input.database ?? new Database(input.databasePath!);
|
||||||
|
this.ownsDatabase = !input.database;
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recent_assets (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
asset_kind TEXT NOT NULL CHECK (asset_kind IN ('text_template', 'static_sticker')),
|
||||||
|
asset_id TEXT NOT NULL,
|
||||||
|
resource_version TEXT NOT NULL,
|
||||||
|
used_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, asset_kind, asset_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS recent_assets_user_kind_used
|
||||||
|
ON recent_assets (user_id, asset_kind, used_at DESC, asset_id ASC);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.ownsDatabase) this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
list(userId: string, assetKind: RecentAssetKind, limit = 12): RecentAssetItem[] {
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("recent_asset_limit_invalid");
|
||||||
|
return this.database.prepare(`
|
||||||
|
SELECT asset_id, asset_kind, resource_version
|
||||||
|
FROM recent_assets
|
||||||
|
WHERE user_id = ? AND asset_kind = ?
|
||||||
|
ORDER BY used_at DESC, asset_id ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, assetKind, limit) as RecentAssetItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSuccessfulUse(input: { assetId: string; assetKind: RecentAssetKind; resourceVersion: string; userId: string }) {
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.assetId)
|
||||||
|
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.resourceVersion)) {
|
||||||
|
throw new Error("recent_asset_reference_invalid");
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO recent_assets (user_id, asset_kind, asset_id, resource_version, used_at)
|
||||||
|
VALUES (@userId, @assetKind, @assetId, @resourceVersion, @usedAt)
|
||||||
|
ON CONFLICT (user_id, asset_kind, asset_id) DO UPDATE SET
|
||||||
|
resource_version = excluded.resource_version,
|
||||||
|
used_at = excluded.used_at
|
||||||
|
`).run({ ...input, usedAt: this.clock() });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export type RegistrationErrorReason =
|
||||||
|
| "invite_not_found"
|
||||||
|
| "invite_expired"
|
||||||
|
| "invite_disabled"
|
||||||
|
| "invite_exhausted"
|
||||||
|
| "stage_limit_reached"
|
||||||
|
| "email_already_registered"
|
||||||
|
| "challenge_invalid"
|
||||||
|
| "challenge_expired"
|
||||||
|
| "privacy_consent_required"
|
||||||
|
| "privacy_notice_version_invalid"
|
||||||
|
| "profile_invalid"
|
||||||
|
| "idempotency_conflict"
|
||||||
|
| "registration_login_required"
|
||||||
|
| "login_registration_required"
|
||||||
|
| "account_suspended"
|
||||||
|
| "login_admin_required"
|
||||||
|
| "admin_not_allowed"
|
||||||
|
| "resend_too_soon"
|
||||||
|
| "too_many_attempts"
|
||||||
|
| "csrf_invalid"
|
||||||
|
| "deletion_confirmation_invalid"
|
||||||
|
| "session_invalid";
|
||||||
|
|
||||||
|
export type RegistrationErrorCode =
|
||||||
|
| "REGISTRATION_REJECTED"
|
||||||
|
| "REGISTRATION_REQUEST_INVALID"
|
||||||
|
| "IDEMPOTENCY_KEY_CONFLICT"
|
||||||
|
| "AUTH_ENTRY_REJECTED"
|
||||||
|
| "AUTH_RATE_LIMITED"
|
||||||
|
| "AUTH_CSRF_INVALID"
|
||||||
|
| "AUTH_SESSION_INVALID";
|
||||||
|
|
||||||
|
export class RegistrationError extends Error {
|
||||||
|
readonly code: RegistrationErrorCode;
|
||||||
|
readonly httpStatus: 400 | 401 | 403 | 409 | 429;
|
||||||
|
readonly reason: RegistrationErrorReason;
|
||||||
|
|
||||||
|
constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
this.httpStatus = code === "REGISTRATION_REQUEST_INVALID"
|
||||||
|
? 400
|
||||||
|
: code === "AUTH_SESSION_INVALID"
|
||||||
|
? 401
|
||||||
|
: code === "AUTH_CSRF_INVALID"
|
||||||
|
? 403
|
||||||
|
: code === "AUTH_RATE_LIMITED"
|
||||||
|
? 429
|
||||||
|
: 409;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registrationFieldError(reason: RegistrationErrorReason) {
|
||||||
|
const entries: Record<RegistrationErrorReason, { field: string; message_key: string }> = {
|
||||||
|
challenge_expired: { field: "verification_code", message_key: "auth.challenge.expired" },
|
||||||
|
challenge_invalid: { field: "verification_code", message_key: "auth.challenge.invalid" },
|
||||||
|
email_already_registered: { field: "email", message_key: "auth.email.already_registered" },
|
||||||
|
idempotency_conflict: { field: "idempotency_key", message_key: "request.idempotency_conflict" },
|
||||||
|
invite_disabled: { field: "invite_code", message_key: "auth.invite.disabled" },
|
||||||
|
invite_exhausted: { field: "invite_code", message_key: "auth.invite.exhausted" },
|
||||||
|
invite_expired: { field: "invite_code", message_key: "auth.invite.expired" },
|
||||||
|
invite_not_found: { field: "invite_code", message_key: "auth.invite.not_found" },
|
||||||
|
privacy_consent_required: { field: "privacy_consent_accepted", message_key: "auth.privacy.consent_required" },
|
||||||
|
privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" },
|
||||||
|
profile_invalid: { field: "profile", message_key: "auth.profile.invalid" },
|
||||||
|
stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" },
|
||||||
|
registration_login_required: { field: "email", message_key: "auth.registration.login_required" },
|
||||||
|
login_registration_required: { field: "email", message_key: "auth.login.registration_required" },
|
||||||
|
account_suspended: { field: "email", message_key: "auth.account.suspended" },
|
||||||
|
login_admin_required: { field: "email", message_key: "auth.login.admin_required" },
|
||||||
|
admin_not_allowed: { field: "email", message_key: "admin.auth.not_allowed" },
|
||||||
|
resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" },
|
||||||
|
too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" },
|
||||||
|
csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" },
|
||||||
|
deletion_confirmation_invalid: { field: "confirmation", message_key: "account.deletion.confirmation_invalid" },
|
||||||
|
session_invalid: { field: "session", message_key: "auth.session.invalid" },
|
||||||
|
};
|
||||||
|
return entries[reason];
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
export interface RegistrationCodeMessage {
|
||||||
|
challengeId: string;
|
||||||
|
code: string;
|
||||||
|
email: string;
|
||||||
|
purpose: "register" | "login" | "admin_login" | "account_delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResendAdapter {
|
||||||
|
sendVerificationCode(message: RegistrationCodeMessage): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockResendAdapter implements ResendAdapter {
|
||||||
|
readonly calls: RegistrationCodeMessage[] = [];
|
||||||
|
|
||||||
|
async sendVerificationCode(message: RegistrationCodeMessage) {
|
||||||
|
this.calls.push({ ...message });
|
||||||
|
}
|
||||||
|
|
||||||
|
readLatestCode(email: string) {
|
||||||
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
|
const call = this.calls.findLast((candidate) => candidate.email === normalizedEmail);
|
||||||
|
if (!call) throw new Error("No mock registration message exists for that email.");
|
||||||
|
return call.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import type { SecureConfigCandidate } from "./registration.js";
|
||||||
|
|
||||||
|
export function readSecureConfigCandidate(path: string): SecureConfigCandidate {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||||
|
if (parsed.schema_version !== 1 || !Number.isSafeInteger(parsed.secure_config_revision)) {
|
||||||
|
throw new Error("secure_config_integrity_invalid");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.admin_allowlist_hashes) || !Array.isArray(parsed.admin_recovery_hashes)) {
|
||||||
|
throw new Error("secure_config_integrity_invalid");
|
||||||
|
}
|
||||||
|
if (parsed.admin_allowlist_hashes.some((value) => typeof value !== "string")
|
||||||
|
|| parsed.admin_recovery_hashes.some((value) => typeof value !== "string")) {
|
||||||
|
throw new Error("secure_config_integrity_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
adminAllowlistHashes: parsed.admin_allowlist_hashes as string[],
|
||||||
|
adminRecoveryHashes: parsed.admin_recovery_hashes as string[],
|
||||||
|
secureConfigRevision: parsed.secure_config_revision as number,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class StickerReleaseError extends Error {
|
||||||
|
readonly httpStatus: number;
|
||||||
|
|
||||||
|
constructor(readonly reason: string, httpStatus = 400) {
|
||||||
|
super(reason);
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { basename, extname } from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
import sharp, { type Metadata } from "sharp";
|
||||||
|
|
||||||
|
import type { StaticStickerCatalogItem } from "@dada/static-sticker-catalog";
|
||||||
|
|
||||||
|
import {
|
||||||
|
auditRetentionMilliseconds,
|
||||||
|
isSafeAuditRef,
|
||||||
|
isSafeAuditSummaryJson,
|
||||||
|
serializeAuditSummary,
|
||||||
|
} from "./audit-policy.js";
|
||||||
|
import { ManagedStorage, type StagedManagedFile } from "./managed-storage.js";
|
||||||
|
import { StickerReleaseError } from "./sticker-release-errors.js";
|
||||||
|
import { classifyCapacity } from "./storage-policy.js";
|
||||||
|
|
||||||
|
export { StickerReleaseError } from "./sticker-release-errors.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||||
|
const stableIdPattern = /^STK([0-9]{4,})$/;
|
||||||
|
const idempotencyPattern = /^[A-Za-z0-9_-]{32,200}$/;
|
||||||
|
const sha256Pattern = /^[0-9a-f]{64}$/i;
|
||||||
|
const maximumOriginalBytes = 20 * 1024 * 1024;
|
||||||
|
const maximumDimension = 8_192;
|
||||||
|
const bundledPartCounts = [203, 36, 27, 48, 38, 75, 37, 67, 48, 24, 40, 30, 27, 51, 62, 19, 36, 45, 92, 53, 69, 31, 36, 30, 183] as const;
|
||||||
|
|
||||||
|
type StickerMime = "image/png" | "image/webp";
|
||||||
|
type StickerVariant = "original" | "thumbnail";
|
||||||
|
|
||||||
|
interface StickerItemRow {
|
||||||
|
enabled: 0 | 1;
|
||||||
|
height: number;
|
||||||
|
mime_type: StickerMime;
|
||||||
|
order_index: number;
|
||||||
|
original_byte_size: number;
|
||||||
|
original_file_id: string;
|
||||||
|
original_filename: string;
|
||||||
|
original_relative_path: string;
|
||||||
|
original_sha256: string;
|
||||||
|
part: number;
|
||||||
|
release_version: string;
|
||||||
|
stable_id: string;
|
||||||
|
thumbnail_byte_size: number;
|
||||||
|
thumbnail_file_id: string;
|
||||||
|
thumbnail_relative_path: string;
|
||||||
|
thumbnail_sha256: string;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StickerUploadInput {
|
||||||
|
actorId: string;
|
||||||
|
content: Readable;
|
||||||
|
enabled: boolean;
|
||||||
|
expectedByteSize: number;
|
||||||
|
expectedMimeType: StickerMime;
|
||||||
|
expectedSha256: string;
|
||||||
|
fileName: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
order: number;
|
||||||
|
part: number;
|
||||||
|
stableId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value: string) {
|
||||||
|
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(timestamp: number) {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemView(row: StickerItemRow): StaticStickerCatalogItem {
|
||||||
|
const originalReference = `/api/v1/assets/public/${encodeURIComponent(row.release_version)}/${encodeURIComponent(row.stable_id)}`;
|
||||||
|
return {
|
||||||
|
enabled: row.enabled === 1,
|
||||||
|
height: row.height,
|
||||||
|
mime: row.mime_type,
|
||||||
|
mime_type: row.mime_type,
|
||||||
|
order: row.order_index,
|
||||||
|
original_filename: row.original_filename,
|
||||||
|
original_reference: originalReference,
|
||||||
|
origin: "admin_uploaded",
|
||||||
|
part: row.part,
|
||||||
|
relative_path: `static-stickers/${row.stable_id}${row.mime_type === "image/png" ? ".png" : ".webp"}`,
|
||||||
|
resource_version: row.release_version,
|
||||||
|
sha256: row.original_sha256,
|
||||||
|
stable_id: row.stable_id,
|
||||||
|
thumbnail_reference: {
|
||||||
|
media: "thumbnail",
|
||||||
|
resource_id: row.stable_id,
|
||||||
|
resource_version: row.release_version,
|
||||||
|
url: `${originalReference}?variant=thumbnail`,
|
||||||
|
},
|
||||||
|
width: row.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StickerReleaseService {
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly storage: ManagedStorage;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) {
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||||
|
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||||
|
this.database.pragma("journal_mode = WAL");
|
||||||
|
this.database.pragma("foreign_keys = ON");
|
||||||
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
|
||||||
|
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
|
||||||
|
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||||
|
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||||
|
this.storage = input.storage;
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(input: StickerUploadInput) {
|
||||||
|
this.validateUpload(input);
|
||||||
|
const requestHash = digest(stableJson({
|
||||||
|
enabled: input.enabled,
|
||||||
|
expected_byte_size: input.expectedByteSize,
|
||||||
|
expected_mime_type: input.expectedMimeType,
|
||||||
|
expected_sha256: input.expectedSha256.toLowerCase(),
|
||||||
|
order: input.order,
|
||||||
|
part: input.part,
|
||||||
|
stable_id: input.stableId,
|
||||||
|
}));
|
||||||
|
const keyDigest = digest(input.idempotencyKey);
|
||||||
|
const receipt = this.database.prepare(`
|
||||||
|
SELECT request_hash, release_version FROM sticker_upload_receipts
|
||||||
|
WHERE actor_id = ? AND idempotency_key_digest = ?
|
||||||
|
`).get(input.actorId, keyDigest) as { release_version: string; request_hash: string } | undefined;
|
||||||
|
if (receipt) {
|
||||||
|
input.content.destroy();
|
||||||
|
if (receipt.request_hash !== requestHash) throw new StickerReleaseError("sticker_idempotency_conflict", 409);
|
||||||
|
return this.uploadResult(receipt.release_version, input.stableId, false);
|
||||||
|
}
|
||||||
|
this.assertNewPosition(input.stableId, input.part, input.order);
|
||||||
|
|
||||||
|
const staged: StagedManagedFile[] = [];
|
||||||
|
try {
|
||||||
|
const original = await this.storage.stageManagedImage({
|
||||||
|
content: input.content,
|
||||||
|
expectedMimeType: input.expectedMimeType,
|
||||||
|
expectedSha256: input.expectedSha256,
|
||||||
|
fileKind: "sticker_original",
|
||||||
|
fileName: `${input.stableId}${input.expectedMimeType === "image/png" ? ".png" : ".webp"}`,
|
||||||
|
maximumBytes: maximumOriginalBytes,
|
||||||
|
operationId: randomUUID(),
|
||||||
|
ownerRef: input.actorId,
|
||||||
|
projectedWriteBytes: input.expectedByteSize,
|
||||||
|
});
|
||||||
|
staged.push(original);
|
||||||
|
if (original.bytes !== input.expectedByteSize) throw new StickerReleaseError("content_size_invalid");
|
||||||
|
|
||||||
|
let metadata: Metadata;
|
||||||
|
let thumbnail: Buffer;
|
||||||
|
const decoder = sharp(readFileSync(original.stagingPath), { failOn: "warning", limitInputPixels: maximumDimension * maximumDimension });
|
||||||
|
try {
|
||||||
|
metadata = await decoder.metadata();
|
||||||
|
if (metadata.format !== (input.expectedMimeType === "image/png" ? "png" : "webp")
|
||||||
|
|| !metadata.width || !metadata.height || metadata.width > maximumDimension || metadata.height > maximumDimension) {
|
||||||
|
throw new Error("content_decode_invalid");
|
||||||
|
}
|
||||||
|
thumbnail = await decoder
|
||||||
|
.rotate()
|
||||||
|
.resize({ fit: "inside", height: 256, width: 256, withoutEnlargement: true })
|
||||||
|
.png({ adaptiveFiltering: true, compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch {
|
||||||
|
throw new StickerReleaseError("content_decode_invalid");
|
||||||
|
} finally {
|
||||||
|
decoder.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailStaged = await this.storage.stageManagedImage({
|
||||||
|
content: Readable.from(thumbnail),
|
||||||
|
expectedMimeType: "image/png",
|
||||||
|
fileKind: "sticker_thumbnail",
|
||||||
|
fileName: `${input.stableId}-thumbnail.png`,
|
||||||
|
maximumBytes: maximumOriginalBytes,
|
||||||
|
operationId: randomUUID(),
|
||||||
|
ownerRef: input.actorId,
|
||||||
|
projectedWriteBytes: thumbnail.byteLength,
|
||||||
|
});
|
||||||
|
staged.push(thumbnailStaged);
|
||||||
|
const releaseVersion = this.immediate(() => this.commitUpload({
|
||||||
|
...input,
|
||||||
|
height: metadata.height!,
|
||||||
|
keyDigest,
|
||||||
|
original,
|
||||||
|
requestHash,
|
||||||
|
thumbnail: thumbnailStaged,
|
||||||
|
width: metadata.width!,
|
||||||
|
}));
|
||||||
|
return this.uploadResult(releaseVersion, input.stableId, true);
|
||||||
|
} catch (error) {
|
||||||
|
for (const file of staged) this.storage.abandonStagedFile(file);
|
||||||
|
if (!(error instanceof StickerReleaseError) && error instanceof Error
|
||||||
|
&& new Set(["content_hash_invalid", "content_mime_invalid", "content_size_invalid", "file_name_invalid"]).has(error.message)) {
|
||||||
|
throw new StickerReleaseError("sticker_upload_invalid");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(input: { actorId: string; enabled?: boolean; order?: number; part?: number; stableId: string }) {
|
||||||
|
const current = this.currentVersion();
|
||||||
|
if (!current) throw new StickerReleaseError("sticker_not_found", 404);
|
||||||
|
const existing = this.readItem(current, input.stableId);
|
||||||
|
if (!existing) throw new StickerReleaseError("sticker_not_found", 404);
|
||||||
|
const part = input.part ?? existing.part;
|
||||||
|
const order = input.order ?? existing.order_index;
|
||||||
|
this.validatePosition(input.stableId, part, order);
|
||||||
|
const releaseVersion = this.immediate(() => {
|
||||||
|
const version = this.nextReleaseVersion();
|
||||||
|
this.copyRelease(current, version);
|
||||||
|
const conflict = this.database.prepare(`
|
||||||
|
SELECT stable_id FROM sticker_release_items
|
||||||
|
WHERE release_version = ? AND part = ? AND order_index = ? AND stable_id <> ?
|
||||||
|
`).get(version, part, order, input.stableId);
|
||||||
|
if (conflict) throw new StickerReleaseError("sticker_order_conflict", 409);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE sticker_release_items SET enabled = ?, part = ?, order_index = ?
|
||||||
|
WHERE release_version = ? AND stable_id = ?
|
||||||
|
`).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId);
|
||||||
|
this.finalizeRelease(version, current, input.actorId);
|
||||||
|
this.insertReleaseAudit({
|
||||||
|
actorId: input.actorId,
|
||||||
|
afterSummary: { enabled: input.enabled ?? existing.enabled === 1, order, part, stable_id: input.stableId },
|
||||||
|
beforeSummary: { enabled: existing.enabled === 1, order: existing.order_index, part: existing.part, stable_id: input.stableId },
|
||||||
|
operationType: "sticker_release_update",
|
||||||
|
releaseVersion: version,
|
||||||
|
});
|
||||||
|
return version;
|
||||||
|
});
|
||||||
|
return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
listPublic(releaseVersion = this.currentVersion()) {
|
||||||
|
if (!releaseVersion) return { count: 0, items: [], release_version: null };
|
||||||
|
const exists = this.database.prepare("SELECT 1 FROM sticker_releases WHERE release_version = ?").get(releaseVersion);
|
||||||
|
if (!exists) return { count: 0, items: [], release_version: null };
|
||||||
|
const items = (this.database.prepare(`
|
||||||
|
SELECT * FROM sticker_release_items WHERE release_version = ? AND enabled = 1
|
||||||
|
ORDER BY part, order_index, stable_id
|
||||||
|
`).all(releaseVersion) as StickerItemRow[]).map(itemView);
|
||||||
|
return { count: items.length, items, release_version: releaseVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
adminView() {
|
||||||
|
const releaseVersion = this.currentVersion();
|
||||||
|
const items = releaseVersion
|
||||||
|
? (this.database.prepare("SELECT * FROM sticker_release_items WHERE release_version = ? ORDER BY part, order_index, stable_id").all(releaseVersion) as StickerItemRow[])
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
count: items.length,
|
||||||
|
items: items.map((row) => ({
|
||||||
|
...itemView(row),
|
||||||
|
file_state: "committed" as const,
|
||||||
|
original_byte_size: row.original_byte_size,
|
||||||
|
thumbnail_byte_size: row.thumbnail_byte_size,
|
||||||
|
})),
|
||||||
|
release_version: releaseVersion,
|
||||||
|
storage: this.storage.getState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
readPublicAsset(releaseVersion: string, stableId: string, variant: StickerVariant) {
|
||||||
|
const row = this.readItem(releaseVersion, stableId);
|
||||||
|
if (!row || row.enabled !== 1) return undefined;
|
||||||
|
const fileId = variant === "thumbnail" ? row.thumbnail_file_id : row.original_file_id;
|
||||||
|
const path = this.storage.resolveManagedFile(fileId);
|
||||||
|
if (!path) return undefined;
|
||||||
|
return {
|
||||||
|
bytes: readFileSync(path),
|
||||||
|
mimeType: variant === "thumbnail" ? "image/png" as const : row.mime_type,
|
||||||
|
sha256: variant === "thumbnail" ? row.thumbnail_sha256 : row.original_sha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectCounts() {
|
||||||
|
const count = (table: string) => (this.database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count;
|
||||||
|
return { items: count("sticker_release_items"), releases: count("sticker_releases"), upload_receipts: count("sticker_upload_receipts") };
|
||||||
|
}
|
||||||
|
|
||||||
|
private uploadResult(releaseVersion: string, stableId: string, created: boolean) {
|
||||||
|
const row = this.readItem(releaseVersion, stableId);
|
||||||
|
if (!row) throw new StickerReleaseError("sticker_not_found", 404);
|
||||||
|
return {
|
||||||
|
created,
|
||||||
|
item: itemView(row),
|
||||||
|
original: { byte_size: row.original_byte_size, file_id: row.original_file_id, sha256: row.original_sha256 },
|
||||||
|
release_version: releaseVersion,
|
||||||
|
thumbnail: { byte_size: row.thumbnail_byte_size, file_id: row.thumbnail_file_id, sha256: row.thumbnail_sha256 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private commitUpload(input: StickerUploadInput & {
|
||||||
|
height: number;
|
||||||
|
keyDigest: string;
|
||||||
|
original: StagedManagedFile;
|
||||||
|
requestHash: string;
|
||||||
|
thumbnail: StagedManagedFile;
|
||||||
|
width: number;
|
||||||
|
}) {
|
||||||
|
this.assertNewPosition(input.stableId, input.part, input.order);
|
||||||
|
const previous = this.currentVersion();
|
||||||
|
const releaseVersion = this.nextReleaseVersion();
|
||||||
|
if (previous) this.copyRelease(previous, releaseVersion);
|
||||||
|
for (const file of [input.original, input.thumbnail]) {
|
||||||
|
this.storage.moveStagedFile(file);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'committed', ?)
|
||||||
|
`).run(file.fileId, file.fileKind, file.ownerRef, file.relativePath, file.bytes, file.mimeType, file.sha256, iso(this.clock()));
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO sticker_release_items (
|
||||||
|
release_version, stable_id, part, order_index, original_filename, original_relative_path,
|
||||||
|
width, height, mime_type, original_sha256, original_file_id, original_byte_size,
|
||||||
|
thumbnail_file_id, thumbnail_relative_path, thumbnail_sha256, thumbnail_byte_size, enabled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
releaseVersion, input.stableId, input.part, input.order, input.fileName, input.original.relativePath,
|
||||||
|
input.width, input.height, input.expectedMimeType, input.original.sha256, input.original.fileId, input.original.bytes,
|
||||||
|
input.thumbnail.fileId, input.thumbnail.relativePath, input.thumbnail.sha256, input.thumbnail.bytes, input.enabled ? 1 : 0,
|
||||||
|
);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO sticker_managed_file_history (
|
||||||
|
managed_file_id, stable_id, resource_version, file_kind, created_at
|
||||||
|
) VALUES (?, ?, ?, 'original', ?), (?, ?, ?, 'thumbnail', ?)
|
||||||
|
`).run(
|
||||||
|
input.original.fileId, input.stableId, releaseVersion, this.clock(),
|
||||||
|
input.thumbnail.fileId, input.stableId, releaseVersion, this.clock(),
|
||||||
|
);
|
||||||
|
this.consumeStagedStorage([input.original, input.thumbnail]);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO sticker_upload_receipts (actor_id, idempotency_key_digest, request_hash, release_version, stable_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock()));
|
||||||
|
this.finalizeRelease(releaseVersion, previous, input.actorId);
|
||||||
|
this.insertReleaseAudit({
|
||||||
|
actorId: input.actorId,
|
||||||
|
afterSummary: { enabled: input.enabled, order: input.order, part: input.part, stable_id: input.stableId },
|
||||||
|
beforeSummary: previous ? { release_version: previous } : null,
|
||||||
|
operationType: "sticker_release_publish",
|
||||||
|
releaseVersion,
|
||||||
|
});
|
||||||
|
return releaseVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertReleaseAudit(input: {
|
||||||
|
actorId: string;
|
||||||
|
afterSummary: Record<string, unknown>;
|
||||||
|
beforeSummary: Record<string, unknown> | null;
|
||||||
|
operationType: "sticker_release_publish" | "sticker_release_update";
|
||||||
|
releaseVersion: string;
|
||||||
|
}) {
|
||||||
|
const occurredAt = this.clock();
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'super_admin', ?, ?, 'sticker_release', ?, 'succeeded', ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), input.actorId, input.operationType, input.releaseVersion,
|
||||||
|
serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary),
|
||||||
|
occurredAt, occurredAt + auditRetentionMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finalizeRelease(releaseVersion: string, previous: string | null, actorId: string) {
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled
|
||||||
|
FROM sticker_release_items WHERE release_version = ? ORDER BY stable_id
|
||||||
|
`).all(releaseVersion);
|
||||||
|
const manifestSha256 = digest(stableJson(rows));
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO sticker_releases (release_version, previous_release_version, manifest_sha256, published_at, published_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(releaseVersion, previous, manifestSha256, iso(this.clock()), actorId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO current_sticker_release (singleton, release_version) VALUES (1, ?)
|
||||||
|
ON CONFLICT(singleton) DO UPDATE SET release_version = excluded.release_version
|
||||||
|
`).run(releaseVersion);
|
||||||
|
const files = this.database.prepare(`
|
||||||
|
SELECT original_file_id AS file_id FROM sticker_release_items WHERE release_version = ?
|
||||||
|
UNION SELECT thumbnail_file_id AS file_id FROM sticker_release_items WHERE release_version = ?
|
||||||
|
`).all(releaseVersion, releaseVersion) as Array<{ file_id: string }>;
|
||||||
|
for (const file of files) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
|
||||||
|
VALUES (?, ?, 'release', ?)
|
||||||
|
`).run(`release:${releaseVersion}:${file.file_id}`, file.file_id, iso(this.clock()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private copyRelease(from: string, to: string) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO sticker_release_items (
|
||||||
|
release_version, stable_id, part, order_index, original_filename, original_relative_path,
|
||||||
|
width, height, mime_type, original_sha256, original_file_id, original_byte_size,
|
||||||
|
thumbnail_file_id, thumbnail_relative_path, thumbnail_sha256, thumbnail_byte_size, enabled
|
||||||
|
)
|
||||||
|
SELECT ?, stable_id, part, order_index, original_filename, original_relative_path,
|
||||||
|
width, height, mime_type, original_sha256, original_file_id, original_byte_size,
|
||||||
|
thumbnail_file_id, thumbnail_relative_path, thumbnail_sha256, thumbnail_byte_size, enabled
|
||||||
|
FROM sticker_release_items WHERE release_version = ?
|
||||||
|
`).run(to, from);
|
||||||
|
}
|
||||||
|
|
||||||
|
private consumeStagedStorage(files: StagedManagedFile[]) {
|
||||||
|
const timestamp = iso(this.clock());
|
||||||
|
for (const file of files) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE storage_reservations SET status = 'consumed', resolved_at = ?
|
||||||
|
WHERE operation_id = ? AND status = 'active'
|
||||||
|
`).run(timestamp, file.operationId);
|
||||||
|
}
|
||||||
|
const total = files.reduce((sum, file) => sum + file.bytes, 0);
|
||||||
|
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1").get() as { managed_content_bytes: number };
|
||||||
|
const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number };
|
||||||
|
const nextBytes = state.managed_content_bytes + total;
|
||||||
|
const classification = classifyCapacity(nextBytes, active.bytes);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE local_backend_storage_state
|
||||||
|
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||||
|
WHERE singleton = 1
|
||||||
|
`).run(nextBytes, classification.capacity_notice_level, classification.storage_status, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentVersion() {
|
||||||
|
return (this.database.prepare("SELECT release_version FROM current_sticker_release WHERE singleton = 1").get() as { release_version: string } | undefined)?.release_version ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextReleaseVersion() {
|
||||||
|
const date = new Date(this.clock()).toISOString().slice(0, 10).replaceAll("-", "");
|
||||||
|
const row = this.database.prepare("SELECT next_sequence FROM sticker_release_sequences WHERE release_date = ?").get(date) as { next_sequence: number } | undefined;
|
||||||
|
const sequence = row?.next_sequence ?? 1;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO sticker_release_sequences (release_date, next_sequence) VALUES (?, ?)
|
||||||
|
ON CONFLICT(release_date) DO UPDATE SET next_sequence = excluded.next_sequence
|
||||||
|
`).run(date, sequence + 1);
|
||||||
|
return `asset-${date}.${sequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readItem(releaseVersion: string, stableId: string) {
|
||||||
|
return this.database.prepare("SELECT * FROM sticker_release_items WHERE release_version = ? AND stable_id = ?")
|
||||||
|
.get(releaseVersion, stableId) as StickerItemRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNewPosition(stableId: string, part: number, order: number) {
|
||||||
|
this.validatePosition(stableId, part, order);
|
||||||
|
const current = this.currentVersion();
|
||||||
|
if (!current) return;
|
||||||
|
if (this.readItem(current, stableId)) throw new StickerReleaseError("sticker_stable_id_conflict", 409);
|
||||||
|
const conflict = this.database.prepare(`
|
||||||
|
SELECT stable_id FROM sticker_release_items WHERE release_version = ? AND part = ? AND order_index = ?
|
||||||
|
`).get(current, part, order);
|
||||||
|
if (conflict) throw new StickerReleaseError("sticker_order_conflict", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePosition(stableId: string, part: number, order: number) {
|
||||||
|
const matched = stableId.match(stableIdPattern);
|
||||||
|
const numericId = matched ? Number(matched[1]) : Number.NaN;
|
||||||
|
if (!matched || !Number.isSafeInteger(numericId) || numericId <= 1_407) throw new StickerReleaseError("sticker_stable_id_invalid");
|
||||||
|
if (!Number.isSafeInteger(part) || part < 1 || part > bundledPartCounts.length
|
||||||
|
|| !Number.isSafeInteger(order) || order <= bundledPartCounts[part - 1]!) {
|
||||||
|
throw new StickerReleaseError("sticker_part_order_invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateUpload(input: StickerUploadInput) {
|
||||||
|
this.validatePosition(input.stableId, input.part, input.order);
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(input.actorId) || !idempotencyPattern.test(input.idempotencyKey)
|
||||||
|
|| !sha256Pattern.test(input.expectedSha256) || !Number.isSafeInteger(input.expectedByteSize)
|
||||||
|
|| input.expectedByteSize <= 0 || input.expectedByteSize > maximumOriginalBytes
|
||||||
|
|| !new Set(["image/png", "image/webp"]).has(input.expectedMimeType)) {
|
||||||
|
throw new StickerReleaseError("sticker_upload_invalid");
|
||||||
|
}
|
||||||
|
const expectedExtension = input.expectedMimeType === "image/png" ? ".png" : ".webp";
|
||||||
|
if (input.fileName.length > 255 || basename(input.fileName) !== input.fileName || /[\u0000-\u001f]/.test(input.fileName)
|
||||||
|
|| extname(input.fileName).toLowerCase() !== expectedExtension) throw new StickerReleaseError("sticker_upload_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
private immediate<T>(action: () => T) {
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const result = action();
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sticker_release_sequences (
|
||||||
|
release_date TEXT PRIMARY KEY,
|
||||||
|
next_sequence INTEGER NOT NULL CHECK (next_sequence >= 1)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sticker_releases (
|
||||||
|
release_version TEXT PRIMARY KEY,
|
||||||
|
previous_release_version TEXT,
|
||||||
|
manifest_sha256 TEXT NOT NULL CHECK (length(manifest_sha256) = 64),
|
||||||
|
published_at TEXT NOT NULL,
|
||||||
|
published_by TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sticker_release_items (
|
||||||
|
release_version TEXT NOT NULL,
|
||||||
|
stable_id TEXT NOT NULL,
|
||||||
|
part INTEGER NOT NULL CHECK (part BETWEEN 1 AND 25),
|
||||||
|
order_index INTEGER NOT NULL CHECK (order_index > 0),
|
||||||
|
original_filename TEXT NOT NULL,
|
||||||
|
original_relative_path TEXT NOT NULL,
|
||||||
|
width INTEGER NOT NULL CHECK (width > 0),
|
||||||
|
height INTEGER NOT NULL CHECK (height > 0),
|
||||||
|
mime_type TEXT NOT NULL CHECK (mime_type IN ('image/png', 'image/webp')),
|
||||||
|
original_sha256 TEXT NOT NULL CHECK (length(original_sha256) = 64),
|
||||||
|
original_file_id TEXT NOT NULL REFERENCES managed_files(file_id),
|
||||||
|
original_byte_size INTEGER NOT NULL CHECK (original_byte_size > 0),
|
||||||
|
thumbnail_file_id TEXT NOT NULL REFERENCES managed_files(file_id),
|
||||||
|
thumbnail_relative_path TEXT NOT NULL,
|
||||||
|
thumbnail_sha256 TEXT NOT NULL CHECK (length(thumbnail_sha256) = 64),
|
||||||
|
thumbnail_byte_size INTEGER NOT NULL CHECK (thumbnail_byte_size > 0),
|
||||||
|
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||||
|
PRIMARY KEY (release_version, stable_id),
|
||||||
|
UNIQUE (release_version, part, order_index)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS current_sticker_release (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
release_version TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sticker_upload_receipts (
|
||||||
|
actor_id TEXT NOT NULL,
|
||||||
|
idempotency_key_digest TEXT NOT NULL CHECK (length(idempotency_key_digest) = 64),
|
||||||
|
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
|
||||||
|
release_version TEXT NOT NULL,
|
||||||
|
stable_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (actor_id, idempotency_key_digest)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sticker_managed_file_history (
|
||||||
|
managed_file_id TEXT NOT NULL,
|
||||||
|
stable_id TEXT NOT NULL,
|
||||||
|
resource_version TEXT NOT NULL,
|
||||||
|
file_kind TEXT NOT NULL CHECK (file_kind IN ('original', 'thumbnail')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (managed_file_id, file_kind),
|
||||||
|
FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_id)
|
||||||
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS sticker_releases_no_update
|
||||||
|
BEFORE UPDATE ON sticker_releases BEGIN SELECT RAISE(ABORT, 'sticker_releases_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS sticker_releases_no_delete
|
||||||
|
BEFORE DELETE ON sticker_releases BEGIN SELECT RAISE(ABORT, 'sticker_releases_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS sticker_release_items_no_update
|
||||||
|
BEFORE UPDATE ON sticker_release_items
|
||||||
|
WHEN EXISTS (SELECT 1 FROM sticker_releases WHERE release_version = OLD.release_version)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'sticker_release_items_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS sticker_release_items_no_delete
|
||||||
|
BEFORE DELETE ON sticker_release_items
|
||||||
|
WHEN EXISTS (SELECT 1 FROM sticker_releases WHERE release_version = OLD.release_version)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'sticker_release_items_immutable'); END;
|
||||||
|
`);
|
||||||
|
this.database.exec(`
|
||||||
|
INSERT OR IGNORE INTO sticker_managed_file_history (
|
||||||
|
managed_file_id, stable_id, resource_version, file_kind, created_at
|
||||||
|
)
|
||||||
|
SELECT original_file_id, stable_id, release_version, 'original', strftime('%s', 'now') * 1000
|
||||||
|
FROM sticker_release_items;
|
||||||
|
INSERT OR IGNORE INTO sticker_managed_file_history (
|
||||||
|
managed_file_id, stable_id, resource_version, file_kind, created_at
|
||||||
|
)
|
||||||
|
SELECT thumbnail_file_id, stable_id, release_version, 'thumbnail', strftime('%s', 'now') * 1000
|
||||||
|
FROM sticker_release_items;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createConnection } from "node:net";
|
import { createConnection } from "node:net";
|
||||||
|
|
||||||
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap"] as const;
|
import { RealAmapAdapter } from "./amap-adapter.js";
|
||||||
|
|
||||||
|
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
|
||||||
|
|
||||||
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
|
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -26,8 +28,15 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
|||||||
|
|
||||||
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
||||||
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
||||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
try {
|
||||||
if (!configured) throw new Error("API credential client initialization failed.");
|
if (!configured) throw new Error("API credential client initialization failed.");
|
||||||
|
return {
|
||||||
|
adminAllowlistPepper: Buffer.from(credentials["Dada/P0A/admin/pepper"], "utf8"),
|
||||||
|
amap: new RealAmapAdapter(credentials["Dada/P0A/api/amap"]),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
for (const name of API_CREDENTIALS) credentials[name] = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dada/asset-renderer": "workspace:*",
|
||||||
|
"@dada/static-sticker-catalog": "workspace:*",
|
||||||
"@dada/shared-contracts": "workspace:*",
|
"@dada/shared-contracts": "workspace:*",
|
||||||
|
"@dada/template-registry": "workspace:*",
|
||||||
"@vibrant/core": "4.0.4",
|
"@vibrant/core": "4.0.4",
|
||||||
"@vibrant/quantizer-mmcq": "4.0.4",
|
"@vibrant/quantizer-mmcq": "4.0.4",
|
||||||
"fabric": "7.4.0",
|
"fabric": "7.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
.settings-page,
|
||||||
|
.settings-loading {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #111111;
|
||||||
|
background: #f6f6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-loading {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-loading button {
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 5vw;
|
||||||
|
border-bottom: 1px solid #c8c8c3;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-brand {
|
||||||
|
color: #111111;
|
||||||
|
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header nav a,
|
||||||
|
.settings-header nav span {
|
||||||
|
color: #111111;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header nav span {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
padding: 64px max(5vw, calc((100vw - 1120px) / 2)) 38px;
|
||||||
|
border-bottom: 1px solid #c8c8c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title p,
|
||||||
|
.settings-dialog header p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section,
|
||||||
|
.settings-danger {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.4fr);
|
||||||
|
gap: 64px;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 46px 0;
|
||||||
|
border-bottom: 1px solid #c8c8c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-heading h2,
|
||||||
|
.settings-danger h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-heading p,
|
||||||
|
.settings-danger p,
|
||||||
|
.settings-detail {
|
||||||
|
margin: 0;
|
||||||
|
color: #585852;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form input,
|
||||||
|
.settings-dialog input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #989891;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form label,
|
||||||
|
.settings-dialog label {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-primary,
|
||||||
|
.settings-code-button,
|
||||||
|
.settings-delete-confirm,
|
||||||
|
.settings-danger-button {
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-primary {
|
||||||
|
width: 160px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
color: #111111;
|
||||||
|
background: #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-primary:disabled,
|
||||||
|
.settings-code-button:disabled,
|
||||||
|
.settings-delete-confirm:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-definition {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-definition div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 15px 0;
|
||||||
|
border-bottom: 1px solid #deded9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-definition dt {
|
||||||
|
color: #686862;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-definition dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-local {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-local > :not(.settings-section-heading) {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capacity {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capacity > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capacity-track {
|
||||||
|
height: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capacity-track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capacity[data-status="warning"] .settings-capacity-track span,
|
||||||
|
.settings-capacity[data-status="critical"] .settings-capacity-track span {
|
||||||
|
background: #e2b500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capacity[data-status="full"] .settings-capacity-track span,
|
||||||
|
.settings-capacity[data-status="unavailable"] .settings-capacity-track span {
|
||||||
|
background: #c7352b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-fixed-notice {
|
||||||
|
margin: 24px 0 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-left: 5px solid #111111;
|
||||||
|
background: #f2f500;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-detail {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-button {
|
||||||
|
width: 160px;
|
||||||
|
justify-self: start;
|
||||||
|
border: 1px solid #a6251d;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #b82f26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #a6251d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-saved {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #256227;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgb(17 17 17 / 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 10px 10px 0 #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-bottom: 1px solid #c8c8c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog header button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog-body {
|
||||||
|
padding: 26px 28px 30px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog-warning {
|
||||||
|
padding: 16px;
|
||||||
|
border-left: 5px solid #b82f26;
|
||||||
|
background: #fff2f0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-code-button {
|
||||||
|
width: 180px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-delete-confirm {
|
||||||
|
width: 180px;
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid #8e2019;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #b82f26;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.settings-header {
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header nav {
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
padding: 40px 20px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section,
|
||||||
|
.settings-danger {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 0 20px;
|
||||||
|
padding: 34px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-local > :not(.settings-section-heading) {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-definition div {
|
||||||
|
grid-template-columns: 88px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-capacity > div:first-child {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dialog {
|
||||||
|
box-shadow: 5px 5px 0 #111111;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { type FormEvent, useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
|
import "./account-settings.css";
|
||||||
|
|
||||||
|
interface SettingsPayload {
|
||||||
|
account: { email: string; status: "active" };
|
||||||
|
csrf_token: string;
|
||||||
|
local_data: {
|
||||||
|
backup_enabled: false;
|
||||||
|
capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
hard_limit_bytes: number;
|
||||||
|
location: "configured_local_data_root";
|
||||||
|
managed_content_bytes: number;
|
||||||
|
migration_supported: false;
|
||||||
|
};
|
||||||
|
profile: { creator_name: string; social_id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||||
|
return `${Math.max(0, bytes)} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacityLabel(status: SettingsPayload["local_data"]["capacity_status"]) {
|
||||||
|
if (status === "full") return "本机容量已满";
|
||||||
|
if (status === "unavailable") return "本机数据暂不可写";
|
||||||
|
if (status === "critical") return "本机容量接近上限";
|
||||||
|
if (status === "warning") return "本机容量需要关注";
|
||||||
|
return "本机容量正常";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSettingsPage() {
|
||||||
|
const creatorNameId = useId();
|
||||||
|
const socialId = useId();
|
||||||
|
const confirmationId = useId();
|
||||||
|
const deletionCodeId = useId();
|
||||||
|
const [settings, setSettings] = useState<SettingsPayload>();
|
||||||
|
const [creatorName, setCreatorName] = useState("");
|
||||||
|
const [socialHandle, setSocialHandle] = useState("");
|
||||||
|
const [loadingError, setLoadingError] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [deletionOpen, setDeletionOpen] = useState(false);
|
||||||
|
const [deletionId, setDeletionId] = useState<string>();
|
||||||
|
const [deletionCode, setDeletionCode] = useState("");
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [sendingCode, setSendingCode] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deletionError, setDeletionError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void fetch("/api/v1/account/settings", { credentials: "same-origin" })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error("settings_load_failed");
|
||||||
|
return response.json() as Promise<SettingsPayload>;
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
if (!active) return;
|
||||||
|
setSettings(payload);
|
||||||
|
setCreatorName(payload.profile.creator_name);
|
||||||
|
setSocialHandle(payload.profile.social_id);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setLoadingError(true);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function saveProfile(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settings || saving || !creatorName.trim() || !socialHandle.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(false);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/account/settings/profile", {
|
||||||
|
body: JSON.stringify({ creator_name: creatorName, social_id: socialHandle }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": settings.csrf_token },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("profile_save_failed");
|
||||||
|
setSaved(true);
|
||||||
|
} catch {
|
||||||
|
setSaveError(true);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDeletionCode() {
|
||||||
|
if (!settings || sendingCode || deleting) return;
|
||||||
|
setSendingCode(true);
|
||||||
|
setDeletionError(false);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/account/deletion/send", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-CSRF-Token": settings.csrf_token },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as { deletion_id?: string };
|
||||||
|
if (!response.ok || !body.deletion_id) throw new Error("deletion_code_failed");
|
||||||
|
setDeletionId(body.deletion_id);
|
||||||
|
} catch {
|
||||||
|
setDeletionError(true);
|
||||||
|
} finally {
|
||||||
|
setSendingCode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeDeletion(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settings || !deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setDeletionError(false);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/account/deletion/complete", {
|
||||||
|
body: JSON.stringify({ confirmation, deletion_id: deletionId, verification_code: deletionCode }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||||
|
"X-CSRF-Token": settings.csrf_token,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("account_deletion_failed");
|
||||||
|
window.location.assign("/");
|
||||||
|
} catch {
|
||||||
|
setDeletionError(true);
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeletion() {
|
||||||
|
if (deleting) return;
|
||||||
|
setDeletionOpen(false);
|
||||||
|
setDeletionId(undefined);
|
||||||
|
setDeletionCode("");
|
||||||
|
setConfirmation("");
|
||||||
|
setDeletionError(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings && !loadingError) {
|
||||||
|
return <main className="settings-loading" aria-live="polite">正在读取设置</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return (
|
||||||
|
<main className="settings-loading">
|
||||||
|
<p role="alert">设置暂时无法读取。</p>
|
||||||
|
<button onClick={() => window.location.reload()} type="button">重新读取</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacityPercent = Math.min(100, Math.round((settings.local_data.managed_content_bytes / settings.local_data.hard_limit_bytes) * 100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="settings-page">
|
||||||
|
<header className="settings-header">
|
||||||
|
<a className="settings-brand" href="/app">DADA</a>
|
||||||
|
<nav aria-label="账号导航">
|
||||||
|
<a href="/app">创作工作台</a>
|
||||||
|
<span aria-current="page">设置</span>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="settings-title">
|
||||||
|
<p>ACCOUNT / LOCAL DATA</p>
|
||||||
|
<h1>设置与本机数据</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="settings-section" aria-labelledby="profile-title">
|
||||||
|
<div className="settings-section-heading">
|
||||||
|
<h2 id="profile-title">个人资料</h2>
|
||||||
|
<p>用于生成内容中的署名信息。</p>
|
||||||
|
</div>
|
||||||
|
<form className="settings-form" onSubmit={saveProfile}>
|
||||||
|
<label htmlFor={creatorNameId}>创作署名</label>
|
||||||
|
<input id={creatorNameId} maxLength={80} onChange={(event) => setCreatorName(event.target.value)} value={creatorName} />
|
||||||
|
<label htmlFor={socialId}>社交 ID</label>
|
||||||
|
<input id={socialId} maxLength={80} onChange={(event) => setSocialHandle(event.target.value)} value={socialHandle} />
|
||||||
|
{saveError ? <p className="settings-error" role="alert">保存失败,当前输入已保留,请重试。</p> : null}
|
||||||
|
{saved ? <p className="settings-saved" role="status">资料已保存。</p> : null}
|
||||||
|
<button className="settings-primary" disabled={saving || !creatorName.trim() || !socialHandle.trim()} type="submit">
|
||||||
|
{saving ? "保存中" : "保存资料"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section" aria-labelledby="account-title">
|
||||||
|
<div className="settings-section-heading">
|
||||||
|
<h2 id="account-title">账号</h2>
|
||||||
|
<p>邮箱仅用于验证码认证。</p>
|
||||||
|
</div>
|
||||||
|
<dl className="settings-definition">
|
||||||
|
<div><dt>邮箱</dt><dd>{settings.account.email}</dd></div>
|
||||||
|
<div><dt>状态</dt><dd>正常</dd></div>
|
||||||
|
<div><dt>登录方式</dt><dd>邮箱验证码</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-section settings-local" aria-labelledby="local-title">
|
||||||
|
<div className="settings-section-heading">
|
||||||
|
<h2 id="local-title">本机数据</h2>
|
||||||
|
<p>逻辑位置:Dada 配置的本机数据目录</p>
|
||||||
|
</div>
|
||||||
|
<div className="settings-capacity" data-status={settings.local_data.capacity_status}>
|
||||||
|
<div>
|
||||||
|
<strong>{capacityLabel(settings.local_data.capacity_status)}</strong>
|
||||||
|
<span>{formatBytes(settings.local_data.managed_content_bytes)} / {formatBytes(settings.local_data.hard_limit_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-capacity-track" role="progressbar" aria-label="本机数据容量" aria-valuemax={100} aria-valuemin={0} aria-valuenow={capacityPercent}>
|
||||||
|
<span style={{ width: `${capacityPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="settings-fixed-notice">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。</p>
|
||||||
|
<p className="settings-detail">数据依赖当前 Windows 用户登录和文件系统权限,不提供 Dada 应用层加密或云备份。机器损坏、重装或删除本机数据目录后无法恢复。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-danger" aria-labelledby="danger-title">
|
||||||
|
<div>
|
||||||
|
<h2 id="danger-title">注销账号</h2>
|
||||||
|
<p>注销会立即删除账号资料、未使用点数和全部作品,并使所有会话失效。</p>
|
||||||
|
</div>
|
||||||
|
<button className="settings-danger-button" onClick={() => setDeletionOpen(true)} type="button">注销账号</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{deletionOpen ? (
|
||||||
|
<div className="settings-dialog-backdrop">
|
||||||
|
<section
|
||||||
|
aria-labelledby="delete-dialog-title"
|
||||||
|
aria-modal="true"
|
||||||
|
className="settings-dialog"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape" && !deleting) closeDeletion();
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p>DANGER ZONE</p>
|
||||||
|
<h2 id="delete-dialog-title">确认注销账号</h2>
|
||||||
|
</div>
|
||||||
|
<button aria-label="关闭注销确认" autoFocus disabled={deleting} onClick={closeDeletion} type="button">×</button>
|
||||||
|
</header>
|
||||||
|
<div className="settings-dialog-body">
|
||||||
|
<p className="settings-dialog-warning">账号、资料、项目、图片、坐标、成品和未使用点数将立即删除且不可恢复。</p>
|
||||||
|
<p>匿名生成与点数事件最多保留 180 天,只保留随机主体、时间、模型、结果、错误类别和点数变化;不会保留邮箱或作品内容。</p>
|
||||||
|
<button className="settings-code-button" disabled={sendingCode || deleting || Boolean(deletionId)} onClick={sendDeletionCode} type="button">
|
||||||
|
{sendingCode ? "发送中" : deletionId ? "验证码已发送" : "获取注销验证码"}
|
||||||
|
</button>
|
||||||
|
<form onSubmit={completeDeletion}>
|
||||||
|
<label htmlFor={deletionCodeId}>注销验证码</label>
|
||||||
|
<input disabled={deleting || !deletionId} id={deletionCodeId} inputMode="numeric" maxLength={6} onChange={(event) => setDeletionCode(event.target.value.replace(/\D/g, ""))} value={deletionCode} />
|
||||||
|
<label htmlFor={confirmationId}>确认词</label>
|
||||||
|
<input disabled={deleting} id={confirmationId} onChange={(event) => setConfirmation(event.target.value)} placeholder="输入:注销账号" value={confirmation} />
|
||||||
|
{deletionError ? <p className="settings-error" role="alert">注销请求未完成,请核对验证码后重试。</p> : null}
|
||||||
|
<button className="settings-delete-confirm" disabled={!deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting} type="submit">
|
||||||
|
{deleting ? "注销处理中" : "永久注销"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.admin-assets-page { min-height: 100vh; color: #111111; background: #f6f6f4; }
|
||||||
|
.admin-assets-page > main { width: min(1360px, calc(100% - 64px)); margin: 0 auto; padding: 36px 0 80px; }
|
||||||
|
.admin-assets-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid #999993; }
|
||||||
|
.admin-assets-heading p { margin: 0 0 4px; font: 700 11px Consolas, monospace; }
|
||||||
|
.admin-assets-heading h1 { margin: 0; font-size: 34px; }
|
||||||
|
.admin-assets-heading > strong { font: 700 13px Consolas, monospace; }
|
||||||
|
.admin-assets-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 22px 0; border-block: 1px solid #8c8c86; background: #ffffff; }
|
||||||
|
.admin-assets-summary > span { display: grid; min-width: 0; gap: 6px; padding: 17px 18px; border-right: 1px solid #c1c1ba; color: #65655f; font-size: 12px; }
|
||||||
|
.admin-assets-summary > span:last-child { border-right: 0; }
|
||||||
|
.admin-assets-summary strong { color: #111111; font-size: 15px; overflow-wrap: anywhere; }
|
||||||
|
.admin-assets-summary .is-active { color: #1f6639; }
|
||||||
|
.admin-assets-summary .is-full,
|
||||||
|
.admin-assets-summary .is-unavailable { color: #9b2c23; }
|
||||||
|
.admin-assets-upload,
|
||||||
|
.admin-assets-list { margin-top: 22px; border-block: 1px solid #8c8c86; background: #ffffff; }
|
||||||
|
.admin-assets-upload > header,
|
||||||
|
.admin-assets-list > header { display: flex; align-items: center; justify-content: space-between; min-height: 54px; padding: 0 16px; border-bottom: 1px solid #c1c1ba; background: #e7e7e2; }
|
||||||
|
.admin-assets-upload h2,
|
||||||
|
.admin-assets-list h2 { margin: 0; font-size: 16px; }
|
||||||
|
.admin-assets-upload header span,
|
||||||
|
.admin-assets-list header span { font: 700 11px Consolas, monospace; }
|
||||||
|
.admin-assets-form { display: grid; grid-template-columns: minmax(230px, 2fr) minmax(130px, 1fr) 84px 92px 130px auto; align-items: end; gap: 12px; padding: 18px 16px; }
|
||||||
|
.admin-assets-form label { display: grid; gap: 6px; min-width: 0; color: #4c4c47; font-size: 11px; font-weight: 800; }
|
||||||
|
.admin-assets-form input { width: 100%; min-height: 40px; padding: 7px 9px; border: 1px solid #777770; border-radius: 0; background: #ffffff; }
|
||||||
|
.admin-assets-form input[type="file"] { padding: 7px; }
|
||||||
|
.admin-assets-form .admin-assets-enabled { display: flex; min-height: 40px; align-items: center; gap: 8px; color: #111111; }
|
||||||
|
.admin-assets-enabled input { width: 18px; min-height: 18px; }
|
||||||
|
.admin-assets-form button,
|
||||||
|
.admin-assets-alert button { min-height: 42px; padding: 9px 14px; border: 1px solid #111111; border-radius: 0; background: #f2f500; font-weight: 900; }
|
||||||
|
.admin-assets-form button:disabled { color: #777770; background: #dfdfda; cursor: not-allowed; }
|
||||||
|
.admin-assets-blocked { margin: 0; padding: 12px 16px; border-top: 1px solid #e2b8b3; color: #812219; background: #fff1ef; font-weight: 700; }
|
||||||
|
.admin-assets-table-wrap { overflow-x: auto; }
|
||||||
|
.admin-assets-table-wrap table { width: 100%; min-width: 1120px; border-collapse: collapse; table-layout: fixed; }
|
||||||
|
.admin-assets-table-wrap th,
|
||||||
|
.admin-assets-table-wrap td { padding: 12px 10px; border-right: 1px solid #d0d0ca; border-bottom: 1px solid #d0d0ca; text-align: left; vertical-align: middle; font-size: 12px; }
|
||||||
|
.admin-assets-table-wrap thead th { background: #f1f1ed; font-weight: 900; }
|
||||||
|
.admin-assets-table-wrap th:first-child { width: 78px; }
|
||||||
|
.admin-assets-table-wrap th:nth-child(2) { width: 150px; }
|
||||||
|
.admin-assets-table-wrap th:nth-child(3) { width: 140px; }
|
||||||
|
.admin-assets-table-wrap th:nth-child(4) { width: 210px; }
|
||||||
|
.admin-assets-table-wrap th:nth-child(5) { width: 92px; }
|
||||||
|
.admin-assets-table-wrap th:nth-child(6),
|
||||||
|
.admin-assets-table-wrap th:nth-child(7) { width: 92px; }
|
||||||
|
.admin-assets-table-wrap th:last-child { width: 180px; }
|
||||||
|
.admin-assets-table-wrap img { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid #c1c1ba; background: #f6f6f4; }
|
||||||
|
.admin-assets-table-wrap strong,
|
||||||
|
.admin-assets-table-wrap small { display: block; }
|
||||||
|
.admin-assets-table-wrap small { margin-top: 4px; color: #65655f; font-size: 10px; overflow-wrap: anywhere; }
|
||||||
|
.admin-assets-table-wrap input[type="number"] { width: 70px; min-height: 34px; margin-top: 5px; padding: 5px 7px; border: 1px solid #777770; border-radius: 0; }
|
||||||
|
.admin-assets-table-wrap td:last-child { display: flex; gap: 6px; }
|
||||||
|
.admin-assets-table-wrap button { min-height: 34px; padding: 6px 8px; border: 1px solid #555550; border-radius: 0; background: #ffffff; font-weight: 800; }
|
||||||
|
.admin-assets-table-wrap button:disabled { color: #8a8a84; background: #ecece8; }
|
||||||
|
.admin-assets-table-wrap .is-enabled { color: #1f6639; font-weight: 800; }
|
||||||
|
.admin-assets-table-wrap .is-disabled { color: #812219; font-weight: 800; }
|
||||||
|
.admin-assets-empty { margin: 0; padding: 34px 16px; color: #65655f; }
|
||||||
|
.admin-assets-notice { margin: 16px 0 0; padding: 13px 16px; border-left: 4px solid #287b45; background: #edf8f0; font-weight: 800; }
|
||||||
|
.admin-assets-alert { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 24px; padding: 16px; border-left: 5px solid #d14a3b; background: #fff1ef; }
|
||||||
|
.admin-assets-loading { display: grid; gap: 10px; margin-top: 24px; }
|
||||||
|
.admin-assets-loading span { display: block; height: 62px; background: #dfdfda; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.admin-assets-page > main { width: 100%; padding-right: 16px; padding-left: 16px; }
|
||||||
|
.admin-assets-summary { grid-template-columns: 1fr; }
|
||||||
|
.admin-assets-summary > span { border-right: 0; border-bottom: 1px solid #c1c1ba; }
|
||||||
|
.admin-assets-form { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 580px) {
|
||||||
|
.admin-product-header { padding: 0 12px; overflow-x: auto; }
|
||||||
|
.admin-product-header nav a { min-width: 66px; }
|
||||||
|
.admin-assets-form { grid-template-columns: 1fr; }
|
||||||
|
.admin-assets-heading { align-items: start; flex-direction: column; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import "./admin-assets.css";
|
||||||
|
|
||||||
|
interface AdminSession { csrf_token: string }
|
||||||
|
interface StorageState {
|
||||||
|
capacity_notice_level: "normal" | "warning" | "critical";
|
||||||
|
hard_limit_bytes: number;
|
||||||
|
managed_content_bytes: number;
|
||||||
|
storage_status: "active" | "full" | "unavailable";
|
||||||
|
}
|
||||||
|
interface AdminSticker {
|
||||||
|
enabled: boolean;
|
||||||
|
file_state: "committed";
|
||||||
|
height: number;
|
||||||
|
mime_type: "image/png" | "image/webp";
|
||||||
|
order: number;
|
||||||
|
original_byte_size: number;
|
||||||
|
original_filename: string;
|
||||||
|
part: number;
|
||||||
|
resource_version: string;
|
||||||
|
stable_id: string;
|
||||||
|
thumbnail_byte_size: number;
|
||||||
|
thumbnail_reference: { url: string };
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
interface AdminAssetsResponse {
|
||||||
|
count: number;
|
||||||
|
items: AdminSticker[];
|
||||||
|
release_version: string | null;
|
||||||
|
storage: StorageState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function idempotencyKey() {
|
||||||
|
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesLabel(bytes: number) {
|
||||||
|
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(bytes / (1024 ** 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJson<T>(url: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||||
|
const body = response.headers.get("content-type")?.includes("application/json") ? await response.json() as T : undefined;
|
||||||
|
return { body, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAssetsPage() {
|
||||||
|
const [session, setSession] = useState<AdminSession>();
|
||||||
|
const [assets, setAssets] = useState<AdminAssetsResponse>();
|
||||||
|
const [loadingFailed, setLoadingFailed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [file, setFile] = useState<File>();
|
||||||
|
const [stableId, setStableId] = useState("STK1408");
|
||||||
|
const [part, setPart] = useState(25);
|
||||||
|
const [order, setOrder] = useState(184);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [orderDrafts, setOrderDrafts] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoadingFailed(false);
|
||||||
|
try {
|
||||||
|
const [sessionResult, assetsResult] = await Promise.all([
|
||||||
|
loadJson<AdminSession>("/api/v1/admin-auth/session"),
|
||||||
|
loadJson<AdminAssetsResponse>("/api/v1/admin/assets/static-stickers"),
|
||||||
|
]);
|
||||||
|
if (!sessionResult.response.ok || !assetsResult.response.ok || !sessionResult.body || !assetsResult.body) throw new Error("load_failed");
|
||||||
|
setSession(sessionResult.body);
|
||||||
|
setAssets(assetsResult.body);
|
||||||
|
setOrderDrafts(Object.fromEntries(assetsResult.body.items.map((item) => [item.stable_id, item.order])));
|
||||||
|
const numericIds = assetsResult.body.items.map((item) => Number(item.stable_id.slice(3))).filter(Number.isFinite);
|
||||||
|
setStableId(`STK${Math.max(1407, ...numericIds) + 1}`);
|
||||||
|
setOrder(Math.max(183, ...assetsResult.body.items.filter((item) => item.part === 25).map((item) => item.order)) + 1);
|
||||||
|
setNotice("");
|
||||||
|
} catch {
|
||||||
|
setLoadingFailed(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
const uploadBlocked = !assets || assets.storage.storage_status !== "active";
|
||||||
|
const formValid = useMemo(() => Boolean(
|
||||||
|
file && /^(image\/png|image\/webp)$/.test(file.type) && /^STK[0-9]{4,}$/.test(stableId)
|
||||||
|
&& Number.isSafeInteger(part) && part >= 1 && part <= 25 && Number.isSafeInteger(order) && order > 0,
|
||||||
|
), [file, order, part, stableId]);
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!file || !session || !formValid || uploadBlocked || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer())))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("stable_id", stableId);
|
||||||
|
form.append("part", String(part));
|
||||||
|
form.append("order", String(order));
|
||||||
|
form.append("enabled", String(enabled));
|
||||||
|
form.append("original_byte_size", String(file.size));
|
||||||
|
form.append("original_sha256", sha256);
|
||||||
|
form.append("sticker_file", file, file.name);
|
||||||
|
const response = await fetch("/api/v1/admin/assets/static-stickers", {
|
||||||
|
body: form,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Idempotency-Key": idempotencyKey(), "X-CSRF-Token": session.csrf_token },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setNotice(response.status === 507 ? "存储容量已满或暂不可用,未写入任何文件。" : response.status === 409 ? "稳定 ID 或 part 顺序已存在。" : "文件格式、内容或字段校验未通过。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFile(undefined);
|
||||||
|
await load();
|
||||||
|
setNotice("贴纸已生成缩略图并发布新资源版本。");
|
||||||
|
} catch {
|
||||||
|
setNotice("上传未完成,未发布新资源版本。");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(item: AdminSticker, change: { enabled?: boolean; order?: number }) {
|
||||||
|
if (!session || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const { response } = await loadJson(`/api/v1/admin/assets/static-stickers/${encodeURIComponent(item.stable_id)}`, {
|
||||||
|
body: JSON.stringify(change),
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||||
|
method: "PATCH",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("update_failed");
|
||||||
|
await load();
|
||||||
|
setNotice(change.enabled === false ? "贴纸已停用,新项目目录不再显示。" : change.enabled === true ? "贴纸已重新启用。" : "part 顺序已发布到新资源版本。");
|
||||||
|
} catch {
|
||||||
|
setNotice("素材状态未更新。");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="admin-assets-page">
|
||||||
|
<header className="admin-product-header">
|
||||||
|
<a href="/admin">DADA ADMIN</a>
|
||||||
|
<nav aria-label="后台导航"><a href="/admin/users">用户</a><a href="/admin/models">模型</a><a aria-current="page" href="/admin/assets">素材</a><a href="/admin/audit">审计</a></nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<header className="admin-assets-heading"><div><p>ASSET OPERATIONS</p><h1>普通贴纸</h1></div><strong>{assets?.release_version ?? "尚未发布"}</strong></header>
|
||||||
|
{!assets && !loadingFailed ? <div aria-label="贴纸素材加载中" className="admin-assets-loading"><span /><span /><span /></div> : null}
|
||||||
|
{loadingFailed ? <p className="admin-assets-alert" role="alert">素材状态暂时无法读取。<button onClick={() => void load()} type="button">重试</button></p> : null}
|
||||||
|
{assets ? <>
|
||||||
|
<div className="admin-assets-summary" aria-label="素材存储摘要">
|
||||||
|
<span>后台贴纸<strong>{assets.count}</strong></span>
|
||||||
|
<span>受管内容<strong>{bytesLabel(assets.storage.managed_content_bytes)} / {bytesLabel(assets.storage.hard_limit_bytes)} GB</strong></span>
|
||||||
|
<span>存储状态<strong className={`is-${assets.storage.storage_status}`}>{assets.storage.storage_status}</strong></span>
|
||||||
|
</div>
|
||||||
|
<section className="admin-assets-upload" aria-labelledby="asset-upload-title">
|
||||||
|
<header><h2 id="asset-upload-title">上传并发布</h2><span>PNG / WebP</span></header>
|
||||||
|
<div className="admin-assets-form">
|
||||||
|
<label>文件<input accept="image/png,image/webp" aria-label="贴纸文件" disabled={uploadBlocked || busy} key={file?.name ?? "empty"} onChange={(event) => setFile(event.target.files?.[0])} type="file" /></label>
|
||||||
|
<label>稳定 ID<input aria-label="稳定 ID" disabled={uploadBlocked || busy} onChange={(event) => setStableId(event.target.value.toUpperCase())} value={stableId} /></label>
|
||||||
|
<label>Part<input aria-label="Part" disabled={uploadBlocked || busy} max="25" min="1" onChange={(event) => setPart(Number(event.target.value))} type="number" value={part} /></label>
|
||||||
|
<label>顺序<input aria-label="顺序" disabled={uploadBlocked || busy} min="1" onChange={(event) => setOrder(Number(event.target.value))} type="number" value={order} /></label>
|
||||||
|
<label className="admin-assets-enabled"><input checked={enabled} disabled={uploadBlocked || busy} onChange={(event) => setEnabled(event.target.checked)} type="checkbox" />发布后启用</label>
|
||||||
|
<button disabled={!formValid || uploadBlocked || busy} onClick={() => void upload()} type="button">{busy ? "处理中" : "上传并发布"}</button>
|
||||||
|
</div>
|
||||||
|
{uploadBlocked ? <p className="admin-assets-blocked" role="status">当前存储状态禁止新增原图和缩略图。</p> : null}
|
||||||
|
</section>
|
||||||
|
<section className="admin-assets-list" aria-labelledby="asset-list-title">
|
||||||
|
<header><h2 id="asset-list-title">当前版本</h2><span>{assets.count} 项</span></header>
|
||||||
|
{assets.items.length === 0 ? <p className="admin-assets-empty">当前没有后台上传的普通贴纸。</p> : <div className="admin-assets-table-wrap"><table>
|
||||||
|
<thead><tr><th>预览</th><th>稳定 ID</th><th>Part / 顺序</th><th>原文件</th><th>尺寸</th><th>文件状态</th><th>发布状态</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>{assets.items.map((item) => <tr key={item.stable_id}>
|
||||||
|
<td><img alt="" src={item.thumbnail_reference.url} /></td>
|
||||||
|
<th scope="row"><strong>{item.stable_id}</strong><small>{item.resource_version}</small></th>
|
||||||
|
<td><span>part{item.part}</span><input aria-label={`${item.stable_id} 顺序`} min="1" onChange={(event) => setOrderDrafts((current) => ({ ...current, [item.stable_id]: Number(event.target.value) }))} type="number" value={orderDrafts[item.stable_id] ?? item.order} /></td>
|
||||||
|
<td><span>{item.original_filename}</span><small>{item.mime_type} · {item.original_byte_size.toLocaleString("zh-CN")} B</small></td>
|
||||||
|
<td>{item.width} x {item.height}</td>
|
||||||
|
<td>{item.file_state}</td>
|
||||||
|
<td><span className={item.enabled ? "is-enabled" : "is-disabled"}>{item.enabled ? "已启用" : "已停用"}</span></td>
|
||||||
|
<td><button disabled={busy || (orderDrafts[item.stable_id] ?? item.order) === item.order} onClick={() => void update(item, { order: orderDrafts[item.stable_id] ?? item.order })} type="button">更新顺序</button><button disabled={busy} onClick={() => void update(item, { enabled: !item.enabled })} type="button">{item.enabled ? "停用" : "启用"}</button></td>
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table></div>}
|
||||||
|
</section>
|
||||||
|
{notice ? <p className="admin-assets-notice" role="status">{notice}</p> : null}
|
||||||
|
</> : null}
|
||||||
|
</main>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
.admin-audit-page {
|
||||||
|
width: min(100% - 48px, 1440px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 0 40px;
|
||||||
|
color: #1a1a18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-heading {
|
||||||
|
display: flex;
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
border-bottom: 2px solid #1a1a18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-heading p,
|
||||||
|
.admin-audit-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-heading p {
|
||||||
|
color: #686861;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-heading h2 {
|
||||||
|
padding: 4px 0 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-heading time {
|
||||||
|
padding-bottom: 14px;
|
||||||
|
color: #686861;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 24px;
|
||||||
|
border-bottom: 1px solid #a9a9a2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-tabs button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
color: #4f4f49;
|
||||||
|
background: transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-tabs button[aria-selected="true"] {
|
||||||
|
border-bottom-color: #1a1a18;
|
||||||
|
color: #1a1a18;
|
||||||
|
background: #f4df32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-failure {
|
||||||
|
display: flex;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 4px solid #c92a24;
|
||||||
|
background: #fff1ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-failure button,
|
||||||
|
.admin-audit-pagination button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #1a1a18;
|
||||||
|
background: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-status {
|
||||||
|
margin: 0;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: #686861;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 1px solid #a9a9a2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-page table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1120px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-page th,
|
||||||
|
.admin-audit-page td {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #d7d7d1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-page th {
|
||||||
|
color: #55554f;
|
||||||
|
background: #efefeb;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-page th:nth-child(1) { width: 132px; }
|
||||||
|
.admin-audit-page th:nth-child(2) { width: 210px; }
|
||||||
|
.admin-audit-page th:nth-child(3) { width: 180px; }
|
||||||
|
.admin-audit-page th:nth-child(5) { width: 100px; }
|
||||||
|
.admin-audit-page th:nth-child(6) { width: 210px; }
|
||||||
|
|
||||||
|
.admin-audit-page td small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #686861;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-page td strong {
|
||||||
|
color: #16794b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-page td strong.is-failed {
|
||||||
|
color: #c92a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-retention {
|
||||||
|
margin: 24px 0 0;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #d7d7d1;
|
||||||
|
color: #686861;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-audit-page :focus-visible {
|
||||||
|
outline: 2px solid #005fcc;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.admin-audit-page { width: calc(100% - 24px); }
|
||||||
|
.admin-audit-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||||
|
.admin-audit-heading time { padding-bottom: 12px; }
|
||||||
|
.admin-audit-tabs { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
|
.admin-audit-tabs button { min-width: 0; padding: 8px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type {
|
||||||
|
AdminOperationAuditItem,
|
||||||
|
AdminOperationAuditResponse,
|
||||||
|
PrivateContentAccessAuditItem,
|
||||||
|
PrivateContentAccessAuditResponse,
|
||||||
|
} from "@dada/shared-contracts";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import "./admin-audit.css";
|
||||||
|
|
||||||
|
type AuditTab = "operations" | "private-content";
|
||||||
|
|
||||||
|
interface AuditPageState<Item> {
|
||||||
|
failed: boolean;
|
||||||
|
generatedAt: string | null;
|
||||||
|
items: Item[];
|
||||||
|
loading: boolean;
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyState = <Item,>(): AuditPageState<Item> => ({
|
||||||
|
failed: false,
|
||||||
|
generatedAt: null,
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSummary(item: AdminOperationAuditItem) {
|
||||||
|
if (item.after_summary) return item.after_summary;
|
||||||
|
if (item.before_summary) return item.before_summary;
|
||||||
|
return "无变更摘要";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAuditPage() {
|
||||||
|
const [tab, setTab] = useState<AuditTab>("operations");
|
||||||
|
const [operations, setOperations] = useState<AuditPageState<AdminOperationAuditItem>>(emptyState);
|
||||||
|
const [privateAccess, setPrivateAccess] = useState<AuditPageState<PrivateContentAccessAuditItem>>(emptyState);
|
||||||
|
|
||||||
|
const loadOperations = useCallback(async (cursor?: string, append = false) => {
|
||||||
|
setOperations((current) => ({ ...current, failed: false, loading: true }));
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({ limit: "50" });
|
||||||
|
if (cursor) query.set("cursor", cursor);
|
||||||
|
const response = await fetch(`/api/v1/admin/audit/operations?${query}`, { credentials: "same-origin" });
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error("admin_operation_audit_unavailable");
|
||||||
|
const body = await response.json() as AdminOperationAuditResponse;
|
||||||
|
setOperations((current) => ({
|
||||||
|
failed: false,
|
||||||
|
generatedAt: body.generated_at,
|
||||||
|
items: append ? [...current.items, ...body.items] : body.items,
|
||||||
|
loading: false,
|
||||||
|
nextCursor: body.next_cursor,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setOperations((current) => ({ ...current, failed: true, loading: false }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPrivateAccess = useCallback(async (cursor?: string, append = false) => {
|
||||||
|
setPrivateAccess((current) => ({ ...current, failed: false, loading: true }));
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({ limit: "50" });
|
||||||
|
if (cursor) query.set("cursor", cursor);
|
||||||
|
const response = await fetch(`/api/v1/admin/audit/private-content?${query}`, { credentials: "same-origin" });
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error("private_content_audit_unavailable");
|
||||||
|
const body = await response.json() as PrivateContentAccessAuditResponse;
|
||||||
|
setPrivateAccess((current) => ({
|
||||||
|
failed: false,
|
||||||
|
generatedAt: body.generated_at,
|
||||||
|
items: append ? [...current.items, ...body.items] : body.items,
|
||||||
|
loading: false,
|
||||||
|
nextCursor: body.next_cursor,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setPrivateAccess((current) => ({ ...current, failed: true, loading: false }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void loadOperations(); }, [loadOperations]);
|
||||||
|
|
||||||
|
function selectTab(next: AuditTab) {
|
||||||
|
setTab(next);
|
||||||
|
if (next === "private-content" && !privateAccess.generatedAt && !privateAccess.loading) void loadPrivateAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = tab === "operations" ? operations : privateAccess;
|
||||||
|
const reload = tab === "operations" ? loadOperations : loadPrivateAccess;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="admin-audit-page" id="admin-main">
|
||||||
|
<header className="admin-audit-heading">
|
||||||
|
<div><p>IMMUTABLE / 180 DAYS</p><h2>审计</h2></div>
|
||||||
|
{state.generatedAt ? <time dateTime={state.generatedAt}>读取于 {formatTime(state.generatedAt)}</time> : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div aria-label="审计类型" className="admin-audit-tabs" role="tablist">
|
||||||
|
<button aria-controls="operation-audit-panel" aria-selected={tab === "operations"} id="operation-audit-tab" onClick={() => selectTab("operations")} role="tab" type="button">后台操作审计</button>
|
||||||
|
<button aria-controls="private-audit-panel" aria-selected={tab === "private-content"} id="private-audit-tab" onClick={() => selectTab("private-content")} role="tab" type="button">私有内容访问审计</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.failed ? (
|
||||||
|
<div className="admin-audit-failure" role="alert">
|
||||||
|
<span>审计记录暂时无法读取{state.generatedAt ? ",已保留上次结果" : ""}。</span>
|
||||||
|
<button disabled={state.loading} onClick={() => void reload()} type="button">重试</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === "operations" ? (
|
||||||
|
<section aria-labelledby="operation-audit-tab" id="operation-audit-panel" role="tabpanel">
|
||||||
|
{operations.loading && operations.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">正在读取后台操作审计</p> : null}
|
||||||
|
{!operations.loading && !operations.failed && operations.items.length === 0 ? <p className="admin-audit-status">当前没有后台操作审计记录。</p> : null}
|
||||||
|
{operations.items.length > 0 ? (
|
||||||
|
<div className="admin-audit-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>时间</th><th>管理员</th><th>操作类型</th><th>对象安全摘要</th><th>结果</th><th>Operation ID</th></tr></thead>
|
||||||
|
<tbody>{operations.items.map((item) => (
|
||||||
|
<tr key={item.log_id}>
|
||||||
|
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
|
||||||
|
<td><code>{item.actor_ref}</code><small>{item.actor_type}</small></td>
|
||||||
|
<td><code>{item.operation_type}</code></td>
|
||||||
|
<td><code>{item.target_type}:{item.target_ref}</code><small>{operationSummary(item)}</small></td>
|
||||||
|
<td><strong className={`is-${item.result}`}>{item.result}</strong></td>
|
||||||
|
<td><code>{item.log_id}</code></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section aria-labelledby="private-audit-tab" id="private-audit-panel" role="tabpanel">
|
||||||
|
{privateAccess.loading && privateAccess.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">正在读取私有内容访问审计</p> : null}
|
||||||
|
{!privateAccess.loading && !privateAccess.failed && privateAccess.items.length === 0 ? <p className="admin-audit-status">当前没有私有内容访问审计记录。</p> : null}
|
||||||
|
{privateAccess.items.length > 0 ? (
|
||||||
|
<div className="admin-audit-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>时间</th><th>管理员</th><th>安全目标标识</th><th>内容类型</th><th>到期时间</th><th>Access ID</th></tr></thead>
|
||||||
|
<tbody>{privateAccess.items.map((item) => (
|
||||||
|
<tr key={item.log_id}>
|
||||||
|
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
|
||||||
|
<td><code>{item.actor_ref}</code></td>
|
||||||
|
<td><code>{item.target_ref}</code></td>
|
||||||
|
<td>{item.content_type}</td>
|
||||||
|
<td><time dateTime={item.expires_at}>{formatTime(item.expires_at)}</time></td>
|
||||||
|
<td><code>{item.log_id}</code></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.nextCursor ? (
|
||||||
|
<div className="admin-audit-pagination">
|
||||||
|
<button disabled={state.loading} onClick={() => void reload(state.nextCursor!, true)} type="button">{state.loading ? "正在读取" : "下一页"}</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="admin-audit-retention">记录保留 180 天。此页面不提供编辑、删除或清空能力。</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
:root {
|
||||||
|
color: #121212;
|
||||||
|
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #ffffff;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 8px 72px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-accent {
|
||||||
|
background: #eaff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-header {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #dedede;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-wordmark {
|
||||||
|
color: #111111;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel {
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
margin: 48px 20px 96px;
|
||||||
|
width: min(440px, calc(100vw - 40px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-kicker {
|
||||||
|
color: #606060;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel input {
|
||||||
|
border: 1px solid #b9b9b9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
height: 48px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel input:focus {
|
||||||
|
border-color: #111111;
|
||||||
|
box-shadow: 0 0 0 2px #eaff00;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel button {
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
width: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-code-field {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-error {
|
||||||
|
border-left: 3px solid #c93333;
|
||||||
|
color: #8d1717;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 20px 0 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-submit {
|
||||||
|
background: #151515;
|
||||||
|
border: 1px solid #151515;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 28px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-return {
|
||||||
|
color: #363636;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 24px;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-auth-header {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel {
|
||||||
|
align-self: start;
|
||||||
|
margin-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send {
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useId, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import "./admin-auth.css";
|
||||||
|
|
||||||
|
interface ErrorEnvelopeBody {
|
||||||
|
error?: {
|
||||||
|
details?: { field_errors?: Array<{ message_key?: string }> };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminErrorMessage(body: ErrorEnvelopeBody) {
|
||||||
|
const key = body.error?.details?.field_errors?.[0]?.message_key;
|
||||||
|
if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。";
|
||||||
|
if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。";
|
||||||
|
if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。";
|
||||||
|
if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。";
|
||||||
|
return "管理员登录暂时无法完成,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAuthPage() {
|
||||||
|
const emailId = useId();
|
||||||
|
const codeId = useId();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [registrationId, setRegistrationId] = useState<string>();
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown <= 0) return;
|
||||||
|
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
if (!emailValid || sending || countdown > 0) return;
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/admin-auth/login/send", {
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string };
|
||||||
|
if (!response.ok || !body.registration_id) {
|
||||||
|
setError(adminErrorMessage(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRegistrationId(body.registration_id);
|
||||||
|
setCountdown(60);
|
||||||
|
window.requestAnimationFrame(() => document.getElementById(codeId)?.focus());
|
||||||
|
} catch {
|
||||||
|
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeLogin(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/admin-auth/login/complete", {
|
||||||
|
body: JSON.stringify({ registration_id: registrationId, verification_code: code }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as ErrorEnvelopeBody;
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(adminErrorMessage(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign("/admin");
|
||||||
|
} catch {
|
||||||
|
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="admin-auth-page">
|
||||||
|
<div className="admin-auth-accent" aria-hidden="true" />
|
||||||
|
<header className="admin-auth-header">
|
||||||
|
<a className="admin-auth-wordmark" href="/" aria-label="Dada 普通用户登录">DADA</a>
|
||||||
|
</header>
|
||||||
|
<section className="admin-auth-panel">
|
||||||
|
<p className="admin-auth-kicker">ADMIN</p>
|
||||||
|
<h1 id="admin-auth-heading">管理员邮箱验证码登录</h1>
|
||||||
|
<form onSubmit={completeLogin} noValidate>
|
||||||
|
<label htmlFor={emailId}>管理员邮箱</label>
|
||||||
|
<div className="admin-auth-send-row">
|
||||||
|
<input
|
||||||
|
id={emailId}
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
onChange={(event) => {
|
||||||
|
setEmail(event.target.value);
|
||||||
|
setRegistrationId(undefined);
|
||||||
|
setCode("");
|
||||||
|
setCountdown(0);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="获取验证码"
|
||||||
|
className="admin-auth-send"
|
||||||
|
disabled={!emailValid || sending || countdown > 0}
|
||||||
|
onClick={sendCode}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sending ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{registrationId ? (
|
||||||
|
<div className="admin-auth-code-field">
|
||||||
|
<label htmlFor={codeId}>验证码</label>
|
||||||
|
<input
|
||||||
|
id={codeId}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
value={code}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="admin-auth-error" role="alert">{error}</p> : null}
|
||||||
|
<button className="admin-auth-submit" disabled={!registrationId || code.length !== 6 || submitting} type="submit">
|
||||||
|
{submitting ? "登录中" : "登录后台"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a className="admin-auth-return" href="/">返回普通用户登录</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.admin-generations { display: grid; gap: 20px; }
|
||||||
|
.admin-generations-refresh { align-self: start; }
|
||||||
|
.admin-generations-notice-panel { display: grid; gap: 14px; max-width: 760px; padding: 24px; border: 1px solid #d5b36a; background: #fffaf0; }
|
||||||
|
.admin-generations-notice-panel p { margin: 0; }
|
||||||
|
.admin-generations-notice-panel button { justify-self: start; }
|
||||||
|
.admin-generations-error, .admin-generations-notice { padding: 12px 16px; border: 1px solid #d46a6a; background: #fff4f4; }
|
||||||
|
.admin-generations-error button { margin-left: 12px; }
|
||||||
|
.admin-generations-table-wrap { overflow-x: auto; border: 1px solid #d9dde5; background: #fff; }
|
||||||
|
.admin-generations-table-wrap table { width: 100%; min-width: 1050px; border-collapse: collapse; }
|
||||||
|
.admin-generations-table-wrap th, .admin-generations-table-wrap td { padding: 12px 14px; border-bottom: 1px solid #e9ebef; text-align: left; vertical-align: top; }
|
||||||
|
.admin-generations-table-wrap th { background: #f5f6f8; color: #4d5664; font-size: 12px; }
|
||||||
|
.admin-generations-table-wrap small { color: #6c7481; }
|
||||||
|
.admin-generations-status { display: inline-block; padding: 3px 7px; border-radius: 4px; background: #edf0f4; }
|
||||||
|
.admin-generations-status.is-succeeded { color: #23623d; background: #e6f4ea; }
|
||||||
|
.admin-generations-status.is-failed, .admin-generations-status.is-rejected { color: #8b2b2b; background: #fff0f0; }
|
||||||
|
.admin-generations-status.is-running { color: #7a5a10; background: #fff5d8; }
|
||||||
|
.admin-generations-actions { display: grid; gap: 8px; min-width: 190px; }
|
||||||
|
.admin-generations-actions button { white-space: normal; }
|
||||||
|
.admin-generations-empty { margin: 0; padding: 28px; color: #6c7481; }
|
||||||
|
.admin-generations-opened { display: grid; gap: 10px; padding: 18px; border: 1px solid #cbd2dd; background: #fff; }
|
||||||
|
.admin-generations-opened header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.admin-generations-opened h3 { margin: 0; }
|
||||||
|
.admin-generations-opened pre { max-height: 360px; overflow: auto; margin: 0; padding: 14px; white-space: pre-wrap; background: #f6f7f9; }
|
||||||
|
.admin-generations-opened img { max-width: 100%; max-height: 620px; object-fit: contain; }
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import "./admin-generations.css";
|
||||||
|
|
||||||
|
interface AdminSession {
|
||||||
|
acknowledged_private_content_notice_version: string | null;
|
||||||
|
current_private_content_notice_version: string | null;
|
||||||
|
csrf_token: string;
|
||||||
|
notice_acknowledged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationRecord {
|
||||||
|
generation_id: string;
|
||||||
|
owner_ref: string;
|
||||||
|
project_id: string;
|
||||||
|
model_id: string;
|
||||||
|
ratio: string;
|
||||||
|
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
duration_ms: number | null;
|
||||||
|
confirmed_credit_cost: number;
|
||||||
|
reserved_credits: number;
|
||||||
|
final_credit_state: "committed" | "released" | null;
|
||||||
|
error_category: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationResponse { generated_at: string; items: GenerationRecord[] }
|
||||||
|
interface OpenedPrompt { generation_id: string; prompt: string }
|
||||||
|
|
||||||
|
function idempotencyKey() {
|
||||||
|
return `${crypto.randomUUID().replaceAll("-", "")}${crypto.randomUUID().replaceAll("-", "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactId(value: string) { return `${value.slice(0, 8)}...${value.slice(-4)}`; }
|
||||||
|
function formatTime(value: string | null) { return value ? new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "medium" }).format(new Date(value)) : "未完成"; }
|
||||||
|
function statusLabel(value: GenerationRecord["status"]) { return { queued: "排队", running: "运行中", succeeded: "成功", failed: "失败", rejected: "已拒绝" }[value]; }
|
||||||
|
|
||||||
|
export function AdminGenerationsPage() {
|
||||||
|
const [session, setSession] = useState<AdminSession>();
|
||||||
|
const [records, setRecords] = useState<GenerationRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [acknowledging, setAcknowledging] = useState(false);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [openedPrompt, setOpenedPrompt] = useState<OpenedPrompt>();
|
||||||
|
const [openedImage, setOpenedImage] = useState<{ generationId: string; url: string }>();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const sessionResponse = await fetch("/api/v1/admin-auth/session", { credentials: "same-origin" });
|
||||||
|
if (sessionResponse.status === 401) throw new Error("session_invalid");
|
||||||
|
if (!sessionResponse.ok) throw new Error("session_unavailable");
|
||||||
|
const current = await sessionResponse.json() as AdminSession;
|
||||||
|
setSession(current);
|
||||||
|
setNotice("");
|
||||||
|
if (!current.notice_acknowledged) {
|
||||||
|
setRecords([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const listResponse = await fetch("/api/v1/admin/generations", { credentials: "same-origin" });
|
||||||
|
if (!listResponse.ok) throw new Error("generation_list_unavailable");
|
||||||
|
setRecords((await listResponse.json() as GenerationResponse).items);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
useEffect(() => () => { if (openedImage) URL.revokeObjectURL(openedImage.url); }, [openedImage]);
|
||||||
|
|
||||||
|
async function acknowledge() {
|
||||||
|
if (!session?.current_private_content_notice_version || acknowledging) return;
|
||||||
|
setAcknowledging(true);
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/admin/private-content-notice/ack", {
|
||||||
|
body: JSON.stringify({ expected_notice_version: session.current_private_content_notice_version }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey(), "X-CSRF-Token": session.csrf_token },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("notice_ack_failed");
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
setNotice("告知版本已变化或确认未完成,请重新读取。 ");
|
||||||
|
} finally {
|
||||||
|
setAcknowledging(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPrompt(generationId: string) {
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/prompt`, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) throw new Error("prompt_unavailable");
|
||||||
|
setOpenedPrompt(await response.json() as OpenedPrompt);
|
||||||
|
} catch {
|
||||||
|
setNotice("内容读取未完成,访问审计未成功时不会返回内容。 ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openImage(generationId: string) {
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/image`, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) throw new Error("image_unavailable");
|
||||||
|
const url = URL.createObjectURL(await response.blob());
|
||||||
|
setOpenedImage((previous) => {
|
||||||
|
if (previous) URL.revokeObjectURL(previous.url);
|
||||||
|
return { generationId, url };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setNotice("内容读取未完成,访问审计未成功时不会返回内容。 ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="admin-generations" id="admin-main">
|
||||||
|
<header className="admin-page-heading"><div><p>OPERATIONS / GENERATION RECORDS</p><h2>生成记录</h2></div><button className="admin-generations-refresh" onClick={() => void load()} type="button">重新读取</button></header>
|
||||||
|
{loading ? <p aria-live="polite">正在读取生成记录</p> : null}
|
||||||
|
{failed ? <div className="admin-generations-error" role="alert">后台生成记录暂时无法读取。<button onClick={() => void load()} type="button">重试</button></div> : null}
|
||||||
|
{notice ? <p className="admin-generations-notice" role="alert">{notice}</p> : null}
|
||||||
|
{session && !session.notice_acknowledged ? (
|
||||||
|
<section aria-labelledby="private-content-notice-title" className="admin-generations-notice-panel">
|
||||||
|
<p>PRIVATE CONTENT ACCESS</p>
|
||||||
|
<h3 id="private-content-notice-title">查看私有内容前,请确认当前规则告知</h3>
|
||||||
|
<p>生成记录默认只显示安全元数据。打开图片或完整提示词时,系统会自动记录本次管理员、目标和内容类型访问审计。</p>
|
||||||
|
<button disabled={acknowledging} onClick={() => void acknowledge()} type="button">{acknowledging ? "确认中" : "确认并进入记录"}</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{session?.notice_acknowledged ? (
|
||||||
|
<section aria-label="生成记录元数据" className="admin-generations-table-wrap">
|
||||||
|
<table><thead><tr><th>任务</th><th>用户标识</th><th>模型 / 比例</th><th>状态</th><th>创建 / 完成</th><th>点数</th><th>私有内容</th></tr></thead><tbody>
|
||||||
|
{records.map((record) => <tr key={record.generation_id}>
|
||||||
|
<td><code>{compactId(record.generation_id)}</code></td>
|
||||||
|
<td><code>{compactId(record.owner_ref)}</code></td>
|
||||||
|
<td>{record.model_id}<br /><small>{record.ratio}</small></td>
|
||||||
|
<td><span className={`admin-generations-status is-${record.status}`}>{statusLabel(record.status)}</span>{record.error_category ? <small>{record.error_category}</small> : null}</td>
|
||||||
|
<td><time dateTime={record.created_at}>{formatTime(record.created_at)}</time><br /><small>{formatTime(record.completed_at)}</small></td>
|
||||||
|
<td>{record.confirmed_credit_cost} / {record.final_credit_state ?? "冻结"}</td>
|
||||||
|
<td className="admin-generations-actions"><button onClick={() => void openPrompt(record.generation_id)} type="button">打开提示词并记录审计</button><button disabled={record.status !== "succeeded"} onClick={() => void openImage(record.generation_id)} type="button">打开图片并记录审计</button></td>
|
||||||
|
</tr>)}
|
||||||
|
</tbody></table>
|
||||||
|
{!records.length && !loading ? <p className="admin-generations-empty">当前无生成记录</p> : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{openedPrompt ? <section aria-label="已审计的完整提示词" className="admin-generations-opened"><header><h3>已记录审计的完整提示词</h3><button onClick={() => setOpenedPrompt(undefined)} type="button">关闭</button></header><p><code>{compactId(openedPrompt.generation_id)}</code></p><pre>{openedPrompt.prompt}</pre></section> : null}
|
||||||
|
{openedImage ? <section aria-label="已审计的生成图片" className="admin-generations-opened"><header><h3>已记录审计的生成图片</h3><button onClick={() => { URL.revokeObjectURL(openedImage.url); setOpenedImage(undefined); }} type="button">关闭</button></header><img alt="已记录审计的生成图片" src={openedImage.url} /></section> : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
.admin-models-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #111111;
|
||||||
|
background: #f6f6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-page > main {
|
||||||
|
width: min(1440px, calc(100% - 64px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 36px 0 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid #999993;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-heading p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-heading > strong {
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin: 22px 0;
|
||||||
|
border-block: 1px solid #8c8c86;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-summary > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 18px;
|
||||||
|
border-right: 1px solid #c1c1ba;
|
||||||
|
color: #65655f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-summary > span:last-child { border-right: 0; }
|
||||||
|
.admin-models-summary strong { overflow-wrap: anywhere; color: #111111; font-size: 15px; }
|
||||||
|
|
||||||
|
.admin-models-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #8c8c86;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-table-wrap table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1220px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-table-wrap th,
|
||||||
|
.admin-models-table-wrap td {
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-right: 1px solid #d0d0ca;
|
||||||
|
border-bottom: 1px solid #d0d0ca;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-table-wrap thead th {
|
||||||
|
background: #e7e7e2;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-table-wrap th:first-child { width: 210px; }
|
||||||
|
.admin-models-table-wrap th:nth-child(2),
|
||||||
|
.admin-models-table-wrap th:nth-child(3) { width: 62px; text-align: center; }
|
||||||
|
.admin-models-table-wrap th:nth-child(4) { width: 112px; }
|
||||||
|
.admin-models-table-wrap th:nth-child(5) { width: 96px; }
|
||||||
|
.admin-models-table-wrap th:nth-child(6) { width: 170px; }
|
||||||
|
.admin-models-table-wrap th:nth-child(7) { width: 90px; }
|
||||||
|
.admin-models-table-wrap th:nth-child(8) { width: 180px; }
|
||||||
|
.admin-models-table-wrap th:nth-child(9) { width: 84px; }
|
||||||
|
|
||||||
|
.admin-models-table-wrap tbody th strong,
|
||||||
|
.admin-models-table-wrap tbody th code,
|
||||||
|
.admin-models-table-wrap td small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-table-wrap tbody th code,
|
||||||
|
.admin-models-table-wrap td small {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #65655f;
|
||||||
|
font-size: 10px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-table-wrap td:nth-child(2),
|
||||||
|
.admin-models-table-wrap td:nth-child(3) { text-align: center; }
|
||||||
|
|
||||||
|
.admin-models-table-wrap input[type="number"] {
|
||||||
|
width: 76px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #777770;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-table-wrap input[type="checkbox"],
|
||||||
|
.admin-models-table-wrap input[type="radio"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-state { display: inline-block; padding: 4px 6px; border: 1px solid #7d7d77; font-family: Consolas, monospace; }
|
||||||
|
.model-state.is-available,
|
||||||
|
.model-state.is-verified { border-color: #287b45; color: #1f6639; background: #edf8f0; }
|
||||||
|
.model-state.is-unavailable,
|
||||||
|
.model-state.is-unverified,
|
||||||
|
.model-state.is-blocked { border-color: #a66c18; color: #7c4a06; background: #fff7df; }
|
||||||
|
|
||||||
|
.admin-models-actions {
|
||||||
|
display: flex;
|
||||||
|
min-height: 74px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid #999993;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-actions > div:last-child { display: flex; gap: 8px; }
|
||||||
|
.admin-models-actions button,
|
||||||
|
.admin-models-alert button {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f2f500;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.admin-models-actions button:disabled { color: #777770; background: #dfdfda; cursor: not-allowed; }
|
||||||
|
.admin-models-validation,
|
||||||
|
.admin-models-notice { margin: 0; font-weight: 700; }
|
||||||
|
.admin-models-validation { color: #a52e24; }
|
||||||
|
.admin-models-notice { color: #1f6639; }
|
||||||
|
|
||||||
|
.admin-models-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
border-left: 5px solid #d14a3b;
|
||||||
|
background: #fff1ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-models-loading { display: grid; gap: 10px; margin-top: 24px; }
|
||||||
|
.admin-models-loading span { display: block; height: 62px; background: #dfdfda; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.admin-models-page > main { width: 100%; padding-right: 16px; padding-left: 16px; }
|
||||||
|
.admin-models-summary { grid-template-columns: 1fr; }
|
||||||
|
.admin-models-summary > span { border-right: 0; border-bottom: 1px solid #c1c1ba; }
|
||||||
|
.admin-models-actions { align-items: stretch; flex-direction: column; }
|
||||||
|
.admin-models-actions > div:last-child { display: grid; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import "./admin-models.css";
|
||||||
|
|
||||||
|
interface AdminSession { csrf_token: string }
|
||||||
|
interface RuntimeAvailability {
|
||||||
|
available_for_new_jobs: boolean;
|
||||||
|
checked_at: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
interface ModelConfig {
|
||||||
|
config_version: number;
|
||||||
|
contract_evidence_ref: string | null;
|
||||||
|
contract_validation_status: "blocked" | "unverified" | "verified";
|
||||||
|
credit_cost: number;
|
||||||
|
display_name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
error_mapping_profile: Record<string, string>;
|
||||||
|
gateway_account_ref: string;
|
||||||
|
is_default: boolean;
|
||||||
|
model_id: string;
|
||||||
|
prompt_max_length: number;
|
||||||
|
recommendation_priority: number;
|
||||||
|
reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number };
|
||||||
|
route_profile: Record<string, unknown>;
|
||||||
|
runtime_availability: RuntimeAvailability;
|
||||||
|
safety_source: string;
|
||||||
|
supported_ratios: string[];
|
||||||
|
}
|
||||||
|
interface ModelConfiguration {
|
||||||
|
config_set_version: number;
|
||||||
|
configured_default_model_id: string;
|
||||||
|
models: ModelConfig[];
|
||||||
|
recommended_model_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseJson<T>(url: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||||
|
const body = await response.json() as T;
|
||||||
|
return { body, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
function idempotencyKey() {
|
||||||
|
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableModel(model: ModelConfig) {
|
||||||
|
const { config_version: _configVersion, contract_evidence_ref: _evidence, contract_validation_status: _status, runtime_availability: _runtime, ...editable } = model;
|
||||||
|
return editable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeLabel(runtime: RuntimeAvailability) {
|
||||||
|
return runtime.available_for_new_jobs ? "可用于新任务" : `不可用 · ${runtime.reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminModelsPage() {
|
||||||
|
const [session, setSession] = useState<AdminSession>();
|
||||||
|
const [configuration, setConfiguration] = useState<ModelConfiguration>();
|
||||||
|
const [draft, setDraft] = useState<ModelConfig[]>();
|
||||||
|
const [loadingFailed, setLoadingFailed] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [conflicted, setConflicted] = useState(false);
|
||||||
|
const priorityRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoadingFailed(false);
|
||||||
|
setConflicted(false);
|
||||||
|
try {
|
||||||
|
const [sessionResult, modelResult] = await Promise.all([
|
||||||
|
responseJson<AdminSession>("/api/v1/admin-auth/session"),
|
||||||
|
responseJson<ModelConfiguration>("/api/v1/models"),
|
||||||
|
]);
|
||||||
|
if (!sessionResult.response.ok || !modelResult.response.ok) throw new Error("load_failed");
|
||||||
|
setSession(sessionResult.body);
|
||||||
|
setConfiguration(modelResult.body);
|
||||||
|
setDraft(structuredClone(modelResult.body.models));
|
||||||
|
setNotice("");
|
||||||
|
} catch {
|
||||||
|
setLoadingFailed(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof EventSource === "undefined") return undefined;
|
||||||
|
const source = new EventSource("/api/v1/events");
|
||||||
|
source.onmessage = () => { void load(); };
|
||||||
|
return () => source.close();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const validation = useMemo(() => {
|
||||||
|
if (!draft) return { valid: false, message: "" };
|
||||||
|
const invalidPriority = draft.find((model) => !Number.isSafeInteger(model.recommendation_priority) || model.recommendation_priority <= 0);
|
||||||
|
if (invalidPriority) return { field: invalidPriority.model_id, kind: "priority" as const, message: "推荐优先级必须是正整数。", valid: false };
|
||||||
|
const duplicatePriority = draft.find((model, index) => draft.findIndex((item) => item.recommendation_priority === model.recommendation_priority) !== index);
|
||||||
|
if (duplicatePriority) return { field: duplicatePriority.model_id, kind: "priority" as const, message: "推荐优先级不能重复。", valid: false };
|
||||||
|
const defaults = draft.filter((model) => model.enabled && model.is_default);
|
||||||
|
if (defaults.length !== 1) return { field: draft.find((model) => model.is_default)?.model_id ?? draft[0]?.model_id, kind: "default" as const, message: "必须指定一个已启用的默认模型。", valid: false };
|
||||||
|
return { message: "", valid: true };
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
function updateModel(modelId: string, change: Partial<ModelConfig>) {
|
||||||
|
setDraft((current) => current?.map((model) => model.model_id === modelId ? { ...model, ...change } : model));
|
||||||
|
setNotice("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDefault(modelId: string) {
|
||||||
|
setDraft((current) => current?.map((model) => ({ ...model, is_default: model.model_id === modelId })));
|
||||||
|
setNotice("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!configuration || !draft || !session || !validation.valid || saving || conflicted) return;
|
||||||
|
setSaving(true);
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const { body, response } = await responseJson<ModelConfiguration & { error?: { code?: string } }>("/api/v1/admin/models/configuration", {
|
||||||
|
body: JSON.stringify({ expected_config_set_version: configuration.config_set_version, models: draft.map(editableModel) }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": idempotencyKey(),
|
||||||
|
"X-CSRF-Token": session.csrf_token,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const code = body.error?.code;
|
||||||
|
if (code === "MODEL_CONFIG_VERSION_CONFLICT") {
|
||||||
|
setConflicted(true);
|
||||||
|
setNotice("配置已被其他管理员更新,请刷新最新配置后重新编辑。");
|
||||||
|
} else if (code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" || code === "MODEL_RECOMMENDATION_PRIORITY_CONFLICT") {
|
||||||
|
const modelId = validation.field ?? draft[0]?.model_id;
|
||||||
|
if (modelId) priorityRefs.current[modelId]?.focus();
|
||||||
|
setNotice("请修正推荐优先级后再保存。");
|
||||||
|
} else if (code === "MODEL_DEFAULT_REPLACEMENT_REQUIRED" || code === "MODEL_DEFAULT_REPLACEMENT_INVALID") {
|
||||||
|
const modelId = validation.field ?? draft[0]?.model_id;
|
||||||
|
if (modelId) defaultRefs.current[modelId]?.focus();
|
||||||
|
setNotice("请指定一个已启用的替代默认模型。");
|
||||||
|
} else setNotice("操作未完成。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfiguration(body);
|
||||||
|
setDraft(structuredClone(body.models));
|
||||||
|
setNotice("模型配置已原子保存并记录审计。");
|
||||||
|
} catch {
|
||||||
|
setNotice("操作未完成。");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-models-page">
|
||||||
|
<main id="admin-main">
|
||||||
|
<header className="admin-models-heading">
|
||||||
|
<div><p>MODEL OPERATIONS</p><h1>模型配置</h1></div>
|
||||||
|
{configuration ? <strong>配置集合 v{configuration.config_set_version}</strong> : null}
|
||||||
|
</header>
|
||||||
|
{!configuration && !loadingFailed ? <div aria-label="模型配置加载中" className="admin-models-loading"><span /><span /><span /></div> : null}
|
||||||
|
{loadingFailed ? <p className="admin-models-alert" role="alert">模型配置暂时无法读取。<button onClick={() => void load()} type="button">重试</button></p> : null}
|
||||||
|
{configuration && draft ? (
|
||||||
|
<>
|
||||||
|
<div className="admin-models-summary" aria-label="模型配置摘要">
|
||||||
|
<span>配置默认 <strong>{configuration.configured_default_model_id}</strong></span>
|
||||||
|
<span>当前推荐 <strong>{configuration.recommended_model_id ?? "无"}</strong></span>
|
||||||
|
<span>运行时可用 <strong>{configuration.models.filter((model) => model.runtime_availability.available_for_new_jobs).length} / 3</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-models-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>模型</th><th>启用</th><th>默认</th><th>推荐优先级</th><th>契约</th><th>运行时可用</th><th>当前推荐</th><th>能力</th><th>配置版本</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{draft.map((model) => (
|
||||||
|
<tr key={model.model_id}>
|
||||||
|
<th scope="row"><strong>{model.display_name}</strong><code>{model.model_id}</code></th>
|
||||||
|
<td><input aria-label={`启用 ${model.display_name}`} checked={model.enabled} onChange={(event) => updateModel(model.model_id, { enabled: event.target.checked })} type="checkbox" /></td>
|
||||||
|
<td><input aria-label={`设为默认 ${model.display_name}`} checked={model.is_default} disabled={!model.enabled} name="configured-default" onChange={() => chooseDefault(model.model_id)} ref={(node) => { defaultRefs.current[model.model_id] = node; }} type="radio" /></td>
|
||||||
|
<td><input aria-label={`${model.display_name} 推荐优先级`} inputMode="numeric" min="1" onChange={(event) => updateModel(model.model_id, { recommendation_priority: Number(event.target.value) })} ref={(node) => { priorityRefs.current[model.model_id] = node; }} type="number" value={model.recommendation_priority} /></td>
|
||||||
|
<td><span className={`model-state is-${model.contract_validation_status}`}>{model.contract_validation_status}</span></td>
|
||||||
|
<td><span className={`model-state ${model.runtime_availability.available_for_new_jobs ? "is-available" : "is-unavailable"}`}>{runtimeLabel(model.runtime_availability)}</span><small>{new Date(model.runtime_availability.checked_at).toLocaleString("zh-CN")}</small></td>
|
||||||
|
<td>{configuration.recommended_model_id === model.model_id ? "是" : "否"}</td>
|
||||||
|
<td><span>{model.credit_cost} 点</span><small>{model.supported_ratios.join(" / ")} · 最多 {model.reference_limits.max_files} 张参考图</small></td>
|
||||||
|
<td>v{model.config_version}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<footer className="admin-models-actions">
|
||||||
|
<div aria-live="polite">{validation.message ? <p className="admin-models-validation">{validation.message}</p> : null}{notice ? <p className="admin-models-notice" role={conflicted ? "alert" : "status"}>{notice}</p> : null}</div>
|
||||||
|
<div>{conflicted ? <button onClick={() => void load()} type="button">刷新最新配置</button> : null}<button disabled={!validation.valid || saving || conflicted} onClick={() => void save()} type="button">{saving ? "保存中" : "保存完整配置集合"}</button></div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.admin-services-storage { max-width: 1180px; }
|
||||||
|
.admin-services-heading { align-items: end; }
|
||||||
|
.admin-services-heading-actions { align-items: center; display: flex; gap: 16px; }
|
||||||
|
.admin-services-heading-actions button, .admin-diagnostics-section button { background: #111827; border: 0; color: #fff; cursor: pointer; font: inherit; padding: 10px 14px; }
|
||||||
|
.admin-health-section { border-top: 1px solid #d9dde5; margin-top: 26px; padding-top: 22px; }
|
||||||
|
.admin-health-section > header { align-items: center; display: flex; justify-content: space-between; margin-bottom: 18px; }
|
||||||
|
.admin-health-section h3 { margin: 4px 0 0; }
|
||||||
|
.admin-health-section header p { color: #7b8493; font-size: 11px; letter-spacing: .12em; margin: 0; }
|
||||||
|
.admin-safe-note { color: #667085; font-size: 13px; }
|
||||||
|
.admin-service-grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.admin-service-card { background: #fff; border: 1px solid #e1e5ea; min-height: 160px; padding: 18px; }
|
||||||
|
.admin-service-card.is-degraded, .admin-service-card.is-paused_quota, .admin-service-card.is-paused_provider, .admin-service-card.is-unavailable { border-color: #e5b6b6; }
|
||||||
|
.admin-service-card-heading { align-items: center; display: flex; justify-content: space-between; }
|
||||||
|
.admin-service-card-heading span, .admin-storage-state { color: #147a50; font-size: 13px; }
|
||||||
|
.admin-service-card.is-degraded .admin-service-card-heading span, .admin-service-card.is-paused_quota .admin-service-card-heading span, .admin-service-card.is-paused_provider .admin-service-card-heading span, .admin-service-card.is-unavailable .admin-service-card-heading span { color: #b42318; }
|
||||||
|
.admin-service-card dl, .admin-storage-details { display: grid; gap: 10px; margin: 18px 0 0; }
|
||||||
|
.admin-service-card dl div, .admin-storage-details div { align-items: baseline; display: flex; justify-content: space-between; }
|
||||||
|
.admin-service-card dt, .admin-storage-details dt { color: #667085; font-size: 12px; }
|
||||||
|
.admin-service-card dd, .admin-storage-details dd { margin: 0; text-align: right; }
|
||||||
|
.admin-storage-state.is-full, .admin-storage-state.is-unavailable { color: #b42318; }
|
||||||
|
.admin-storage-metrics { display: grid; gap: 16px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.admin-storage-metrics div { background: #f7f8fa; padding: 14px 16px; }
|
||||||
|
.admin-storage-metrics span { color: #667085; display: block; font-size: 12px; }
|
||||||
|
.admin-storage-metrics strong { display: block; font-size: 20px; margin-top: 6px; }
|
||||||
|
.admin-storage-progress { background: #e5e7eb; height: 8px; margin-top: 18px; overflow: hidden; }
|
||||||
|
.admin-storage-progress span { background: #147a50; display: block; height: 100%; }
|
||||||
|
.admin-storage-description { color: #667085; font-size: 13px; line-height: 1.7; max-width: 780px; }
|
||||||
|
.admin-storage-description code { color: #344054; }
|
||||||
|
.admin-diagnostics-section > header button:disabled { background: #98a2b3; cursor: not-allowed; }
|
||||||
|
.admin-diagnostics-section > p { color: #667085; font-size: 13px; }
|
||||||
|
.admin-diagnostics-section pre { background: #111827; color: #d1fadf; font: 12px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; margin: 16px 0 0; max-height: 280px; overflow: auto; padding: 16px; white-space: pre-wrap; }
|
||||||
|
.admin-services-loading { display: grid; gap: 12px; grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.admin-services-loading span, .admin-diagnostics-placeholder { background: #eef1f4; display: block; height: 160px; }
|
||||||
|
.admin-services-failure { align-items: center; background: #fff4f2; color: #b42318; display: flex; gap: 16px; justify-content: space-between; padding: 14px 16px; }
|
||||||
|
.admin-services-failure button { background: transparent; border: 1px solid #b42318; color: #b42318; cursor: pointer; padding: 6px 12px; }
|
||||||
|
@media (max-width: 900px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 620px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: 1fr; } .admin-services-heading-actions { align-items: flex-end; flex-direction: column; gap: 8px; } }
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import "./admin-services-storage.css";
|
||||||
|
|
||||||
|
const serviceLabels: Record<AdminServicesStorageResponse["services"][number]["service_id"], string> = {
|
||||||
|
ai_gateway: "AI 网关",
|
||||||
|
amap: "高德",
|
||||||
|
api: "API",
|
||||||
|
asset_root: "素材根",
|
||||||
|
resend: "Resend",
|
||||||
|
worker: "Worker",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<AdminServicesStorageResponse["services"][number]["status"], string> = {
|
||||||
|
active: "正常",
|
||||||
|
degraded: "有异常",
|
||||||
|
disabled: "已停用",
|
||||||
|
paused_provider: "供应商暂停",
|
||||||
|
paused_quota: "额度暂停",
|
||||||
|
unavailable: "不可用",
|
||||||
|
};
|
||||||
|
|
||||||
|
const impactLabels: Record<AdminServicesStorageResponse["services"][number]["impact_scope"], string> = {
|
||||||
|
account: "账户模型",
|
||||||
|
api: "后台接口",
|
||||||
|
authentication: "认证",
|
||||||
|
generation: "生成",
|
||||||
|
location: "定位",
|
||||||
|
model: "单模型",
|
||||||
|
none: "无",
|
||||||
|
storage: "存储",
|
||||||
|
unknown: "未知范围",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(value: string | null) {
|
||||||
|
if (!value) return "未记录";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(url: string) {
|
||||||
|
const response = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (response.status === 401) window.dispatchEvent(new Event("dada:session-invalid"));
|
||||||
|
if (!response.ok) throw new Error("admin_state_unavailable");
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminServicesStoragePage() {
|
||||||
|
const [state, setState] = useState<AdminServicesStorageResponse>();
|
||||||
|
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsResponse>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const [nextState, nextDiagnostics] = await Promise.all([
|
||||||
|
getJson<AdminServicesStorageResponse>("/api/v1/admin/services-storage"),
|
||||||
|
getJson<AdminDiagnosticsResponse>("/api/v1/admin/diagnostics"),
|
||||||
|
]);
|
||||||
|
setState(nextState);
|
||||||
|
setDiagnostics(nextDiagnostics);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
async function copyDiagnostics() {
|
||||||
|
if (!diagnostics) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(diagnostics.diagnostic_text);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1800);
|
||||||
|
} catch {
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="admin-services-storage" id="admin-main">
|
||||||
|
<header className="admin-page-heading admin-services-heading">
|
||||||
|
<div><p>OPERATIONS / HEALTH</p><h2>服务与存储</h2></div>
|
||||||
|
<div className="admin-services-heading-actions">
|
||||||
|
{state ? <time dateTime={state.generated_at}>更新于 {formatTime(state.generated_at)}</time> : null}
|
||||||
|
<button aria-label="重新读取服务与存储状态" onClick={() => void load()} type="button">重新读取</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{loading && !state ? <div aria-label="服务与存储状态加载中" className="admin-services-loading"><span /><span /><span /><span /></div> : null}
|
||||||
|
{failed ? <div className="admin-services-failure" role="alert"><span>状态暂时无法读取{state ? `,保留 ${formatTime(state.generated_at)} 的结果` : ""}。</span><button onClick={() => void load()} type="button">重试</button></div> : null}
|
||||||
|
{state ? (
|
||||||
|
<>
|
||||||
|
<section aria-labelledby="admin-services-list-heading" className="admin-health-section">
|
||||||
|
<header><div><p>SERVICE STATUS</p><h3 id="admin-services-list-heading">外部服务与本机组件</h3></div><span className="admin-safe-note">仅显示脱敏状态</span></header>
|
||||||
|
<div className="admin-service-grid">
|
||||||
|
{state.services.map((service) => (
|
||||||
|
<article className={`admin-service-card is-${service.status}`} key={service.service_id}>
|
||||||
|
<div className="admin-service-card-heading"><strong>{serviceLabels[service.service_id]}</strong><span>{statusLabels[service.status]}</span></div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>配置状态</dt><dd>{service.configured ? "已配置" : "未配置"}</dd></div>
|
||||||
|
<div><dt>影响范围</dt><dd>{impactLabels[service.impact_scope]}</dd></div>
|
||||||
|
<div><dt>最近检查</dt><dd>{formatTime(service.checked_at)}</dd></div>
|
||||||
|
{service.pause_reason ? <div><dt>安全原因</dt><dd>{service.pause_reason}</dd></div> : null}
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="admin-storage-heading" className="admin-health-section">
|
||||||
|
<header><div><p>LOCAL DATA ROOT</p><h3 id="admin-storage-heading">本机内容容量</h3></div><span className={`admin-storage-state is-${state.storage.status}`}>{state.storage.status === "active" ? "可写" : state.storage.status === "full" ? "已满" : "不可用"}</span></header>
|
||||||
|
<div className="admin-storage-metrics">
|
||||||
|
<div><span>已用内容</span><strong>{(state.storage.managed_content_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
|
||||||
|
<div><span>固定上限</span><strong>{(state.storage.hard_limit_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
|
||||||
|
<div><span>容量提醒</span><strong>{state.storage.capacity_notice_level}</strong></div>
|
||||||
|
<div><span>清理队列</span><strong>{state.storage.cleanup_pending_count}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-storage-progress" aria-label={`本机内容容量 ${(state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100).toFixed(1)}%`}><span style={{ width: `${Math.min(100, state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100)}%` }} /></div>
|
||||||
|
<p className="admin-storage-description">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。当前 Windows 用户的 Dada 本机数据目录引用:<code>{state.storage.data_root_ref}</code>。只读规范素材库不计入 5 GB 内容额度。</p>
|
||||||
|
<dl className="admin-storage-details"><div><dt>最后计量</dt><dd>{formatTime(state.storage.last_measured_at)}</dd></div><div><dt>重新计量</dt><dd>{state.storage.remeasurement_required ? "需要完成" : "无需等待"}</dd></div></dl>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="admin-diagnostics-heading" className="admin-health-section admin-diagnostics-section">
|
||||||
|
<header><div><p>DIAGNOSTICS</p><h3 id="admin-diagnostics-heading">脱敏诊断</h3></div><button disabled={!diagnostics} onClick={() => void copyDiagnostics()} type="button">{copied ? "已复制" : "复制诊断"}</button></header>
|
||||||
|
<p>诊断内容只包含固定版本、组件状态、逻辑位置和容量计量,不包含密钥、邮箱、绝对路径、提示词、图片或供应商原文。</p>
|
||||||
|
{diagnostics ? <pre aria-label="脱敏诊断内容">{diagnostics.diagnostic_text}</pre> : <div aria-label="诊断加载中" className="admin-diagnostics-placeholder" />}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif;
|
||||||
|
background: #f3f3ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #171715;
|
||||||
|
background: #f3f3ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
top: 8px;
|
||||||
|
left: 228px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #171715;
|
||||||
|
transform: translateY(-160%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
display: grid;
|
||||||
|
width: 216px;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #171715;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-wordmark {
|
||||||
|
display: grid;
|
||||||
|
min-height: 104px;
|
||||||
|
align-content: center;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-bottom: 1px solid #494944;
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-wordmark span {
|
||||||
|
font-family: "Arial Black", "Segoe UI", sans-serif;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-wordmark small {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #d9dc00;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar nav {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar nav a {
|
||||||
|
display: grid;
|
||||||
|
min-height: 48px;
|
||||||
|
grid-template-columns: 38px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
color: #d5d5cf;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar nav a > span {
|
||||||
|
color: #85857d;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar nav a:hover,
|
||||||
|
.admin-sidebar nav a:focus-visible {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #2c2c29;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar nav a[aria-current="page"] {
|
||||||
|
border-left-color: #e8eb00;
|
||||||
|
color: #171715;
|
||||||
|
background: #eef000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar nav a[aria-current="page"] > span {
|
||||||
|
color: #4d4d00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-foot {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-top: 1px solid #494944;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-foot span {
|
||||||
|
color: #a5a59d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-foot strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-workspace {
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 216px;
|
||||||
|
padding-top: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 15;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 216px;
|
||||||
|
display: flex;
|
||||||
|
height: 52px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 28px;
|
||||||
|
border-bottom: 1px solid #b7b7b0;
|
||||||
|
background: rgb(255 255 255 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
color: #62625c;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-status span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-status i {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #777770;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-status code {
|
||||||
|
color: #171715;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-session-gate {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
place-items: center;
|
||||||
|
color: #171715;
|
||||||
|
background: #f3f3ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-session-gate p,
|
||||||
|
.admin-session-gate div {
|
||||||
|
padding: 22px;
|
||||||
|
border-left: 5px solid #171715;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-session-gate div {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-session-gate button,
|
||||||
|
.admin-overview-failure button,
|
||||||
|
.admin-placeholder-toolbar button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #171715;
|
||||||
|
border-radius: 0;
|
||||||
|
color: #171715;
|
||||||
|
background: #eef000;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-overview,
|
||||||
|
.admin-placeholder {
|
||||||
|
width: min(1320px, calc(100% - 64px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 34px 0 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-heading {
|
||||||
|
display: flex;
|
||||||
|
min-height: 74px;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid #8c8c85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-heading p,
|
||||||
|
.admin-status-section header p,
|
||||||
|
.admin-operation-strip header p {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-heading time {
|
||||||
|
color: #66665f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-capacity-alert {
|
||||||
|
display: grid;
|
||||||
|
min-height: 44px;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-bottom: 1px solid #171715;
|
||||||
|
color: #171715;
|
||||||
|
background: #eef000;
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-capacity-alert.is-full,
|
||||||
|
.admin-capacity-alert.is-unavailable {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #b33a2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-overview-loading {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin-top: 22px;
|
||||||
|
border-block: 1px solid #b7b7b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-overview-loading span {
|
||||||
|
height: 130px;
|
||||||
|
border-right: 1px solid #c7c7c0;
|
||||||
|
background: #e2e2dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-overview-failure {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-left: 5px solid #b33a2f;
|
||||||
|
background: #fff0ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-band {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin-top: 22px;
|
||||||
|
border-block: 1px solid #8c8c85;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-band a {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 132px;
|
||||||
|
align-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 20px;
|
||||||
|
border-right: 1px solid #c3c3bc;
|
||||||
|
color: #171715;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-band a:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-band span,
|
||||||
|
.admin-metric-band small {
|
||||||
|
color: #65655f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-band strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-overview-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section,
|
||||||
|
.admin-operation-strip,
|
||||||
|
.admin-placeholder > section {
|
||||||
|
border-top: 3px solid #171715;
|
||||||
|
border-bottom: 1px solid #8c8c85;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section > header,
|
||||||
|
.admin-operation-strip > header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 64px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #c3c3bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section h3,
|
||||||
|
.admin-operation-strip h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section header a,
|
||||||
|
.admin-operation-strip header a {
|
||||||
|
color: #171715;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section dl {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section dl > div {
|
||||||
|
display: grid;
|
||||||
|
min-height: 52px;
|
||||||
|
grid-template-columns: 126px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #ddddD7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section dl > div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section dt {
|
||||||
|
color: #65655f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-section dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-service-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-service-list li {
|
||||||
|
display: grid;
|
||||||
|
min-height: 42px;
|
||||||
|
grid-template-columns: 1fr 84px 76px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #ddddd7;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-service-list li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-service-list strong {
|
||||||
|
color: #1f6639;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-service-list strong.is-degraded,
|
||||||
|
.admin-service-list strong.is-paused {
|
||||||
|
color: #8b5608;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-service-list strong.is-unavailable {
|
||||||
|
color: #a52e24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-service-list time {
|
||||||
|
color: #65655f;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-operation-strip {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-operation-strip > p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 22px 16px;
|
||||||
|
color: #65655f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-operation-strip table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-operation-strip th,
|
||||||
|
.admin-operation-strip td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #ddddd7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-operation-strip th {
|
||||||
|
color: #65655f;
|
||||||
|
background: #efefeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-placeholder > section {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-placeholder-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid #c3c3bc;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-placeholder-toolbar button:disabled {
|
||||||
|
color: #777770;
|
||||||
|
background: #dfdfda;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-placeholder > section > p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 44px 16px;
|
||||||
|
color: #65655f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.admin-shell, .admin-session-gate) :focus-visible {
|
||||||
|
outline: 2px solid #225dd8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.admin-overview,
|
||||||
|
.admin-placeholder {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-band {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-metric-band a:nth-child(2) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-overview-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import type { AdminOverviewResponse } from "@dada/shared-contracts";
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import "./admin-shell.css";
|
||||||
|
|
||||||
|
interface AdminSession {
|
||||||
|
admin: { role: "super_admin"; status: "active"; user_id: string };
|
||||||
|
audience: "admin";
|
||||||
|
authenticated: true;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminProtectedRouteProps {
|
||||||
|
children: ReactNode;
|
||||||
|
currentPath: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminNavigation = [
|
||||||
|
{ href: "/admin", label: "总览", marker: "01" },
|
||||||
|
{ href: "/admin/users", label: "用户与点数", marker: "02" },
|
||||||
|
{ href: "/admin/invites", label: "邀请码", marker: "03" },
|
||||||
|
{ href: "/admin/models", label: "模型", marker: "04" },
|
||||||
|
{ href: "/admin/assets", label: "素材", marker: "05" },
|
||||||
|
{ href: "/admin/preview", label: "内部预览", marker: "06" },
|
||||||
|
{ href: "/admin/generations", label: "生成记录", marker: "07" },
|
||||||
|
{ href: "/admin/services-storage", label: "服务与存储", marker: "08" },
|
||||||
|
{ href: "/admin/audit", label: "审计", marker: "09" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function redirectToAdminLogin() {
|
||||||
|
window.location.replace("/admin/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminProtectedRoute({ children, currentPath, title }: AdminProtectedRouteProps) {
|
||||||
|
const [session, setSession] = useState<AdminSession>();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setFailed(false);
|
||||||
|
void fetch("/api/v1/admin-auth/session", { credentials: "same-origin", signal: controller.signal })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (response.status === 401) {
|
||||||
|
redirectToAdminLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error("admin_session_unavailable");
|
||||||
|
const body = await response.json() as AdminSession;
|
||||||
|
if (body.audience !== "admin" || body.admin.role !== "super_admin" || body.admin.status !== "active") {
|
||||||
|
redirectToAdminLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSession(body);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!(error instanceof DOMException && error.name === "AbortError")) setFailed(true);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [revision]);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<main className="admin-session-gate">
|
||||||
|
{failed ? (
|
||||||
|
<div role="alert">
|
||||||
|
<strong>管理员会话暂时无法确认</strong>
|
||||||
|
<button onClick={() => setRevision((value) => value + 1)} type="button">重试</button>
|
||||||
|
</div>
|
||||||
|
) : <p aria-live="polite">正在确认管理员会话</p>}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<a className="admin-skip-link" href="#admin-main">跳到主要内容</a>
|
||||||
|
<aside className="admin-sidebar">
|
||||||
|
<a className="admin-wordmark" href="/admin" aria-label="Dada 后台总览">
|
||||||
|
<span>DADA</span>
|
||||||
|
<small>OPERATIONS</small>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="后台主导航">
|
||||||
|
{adminNavigation.map((item) => (
|
||||||
|
<a aria-current={currentPath === item.href ? "page" : undefined} href={item.href} key={item.href}>
|
||||||
|
<span aria-hidden="true">{item.marker}</span>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="admin-sidebar-foot">
|
||||||
|
<span>LOCAL P0-A</span>
|
||||||
|
<strong>独立管理员会话</strong>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="admin-shell-workspace">
|
||||||
|
<header className="admin-topbar">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<div className="admin-topbar-status">
|
||||||
|
<span><i aria-hidden="true" />状态摘要</span>
|
||||||
|
<code>{session.admin.user_id.slice(0, 8)}</code>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="admin-shell-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceLabels: Record<AdminOverviewResponse["services"][number]["service_id"], string> = {
|
||||||
|
ai_gateway: "AI 网关",
|
||||||
|
amap: "高德",
|
||||||
|
asset_root: "素材根",
|
||||||
|
resend: "Resend",
|
||||||
|
worker: "Worker",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateLabels = {
|
||||||
|
available: "正常",
|
||||||
|
degraded: "有异常",
|
||||||
|
paused: "已暂停",
|
||||||
|
unavailable: "不可用",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function formatTime(value: string | null) {
|
||||||
|
if (!value) return "未记录";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", month: "2-digit", day: "2-digit" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminOverviewPage() {
|
||||||
|
const [summary, setSummary] = useState<AdminOverviewResponse>();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/admin/overview", { credentials: "same-origin" });
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error("admin_overview_unavailable");
|
||||||
|
setSummary(await response.json() as AdminOverviewResponse);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const storagePercent = summary
|
||||||
|
? Math.min(100, (summary.storage.managed_content_bytes / summary.storage.limit_bytes) * 100)
|
||||||
|
: 0;
|
||||||
|
const hasServiceIssue = summary?.services.some((service) => service.status !== "available") ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="admin-overview" id="admin-main">
|
||||||
|
<header className="admin-page-heading">
|
||||||
|
<div><p>OPERATIONS / LIVE SUMMARY</p><h2>运营总览</h2></div>
|
||||||
|
{summary ? <time dateTime={summary.generated_at}>更新于 {formatTime(summary.generated_at)}</time> : null}
|
||||||
|
</header>
|
||||||
|
{summary && summary.storage.status !== "normal" ? (
|
||||||
|
<a className={`admin-capacity-alert is-${summary.storage.status}`} href="/admin/services-storage">
|
||||||
|
<span>本机内容容量</span>
|
||||||
|
<strong>{storagePercent.toFixed(1)}%</strong>
|
||||||
|
<span>{summary.storage.status === "critical" ? "接近上限" : summary.storage.status === "full" ? "已满" : "不可用"}</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{loading && !summary ? (
|
||||||
|
<div aria-label="运营摘要加载中" className="admin-overview-loading"><span /><span /><span /><span /></div>
|
||||||
|
) : null}
|
||||||
|
{failed ? (
|
||||||
|
<div className="admin-overview-failure" role="alert">
|
||||||
|
<span>运营摘要暂时无法读取{summary ? `,当前保留 ${formatTime(summary.generated_at)} 的结果` : ""}。</span>
|
||||||
|
<button onClick={() => void load()} type="button">重试</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary ? (
|
||||||
|
<>
|
||||||
|
<section aria-label="关键运营指标" className="admin-metric-band">
|
||||||
|
<a href="/admin/users"><span>普通用户名额</span><strong>{summary.user_slots.active_and_suspended} / {summary.user_slots.limit}</strong><small>active + suspended</small></a>
|
||||||
|
<a href="/admin/generations"><span>进行中任务</span><strong>{summary.generation_jobs.queued + summary.generation_jobs.running}</strong><small>排队 {summary.generation_jobs.queued} · 运行 {summary.generation_jobs.running}</small></a>
|
||||||
|
<a href="/admin/generations"><span>成本核对</span><strong>待人工核对 {summary.generation_jobs.pending_manual_review}</strong><small>最早 {formatTime(summary.generation_jobs.pending_manual_review_oldest_at)}</small></a>
|
||||||
|
<a href="/admin/assets"><span>清理任务</span><strong>{summary.asset_cleanup.pending_jobs}</strong><small>等待处理</small></a>
|
||||||
|
</section>
|
||||||
|
<div className="admin-overview-columns">
|
||||||
|
<section className="admin-status-section" aria-labelledby="model-status-heading">
|
||||||
|
<header><div><p>MODEL STATE</p><h3 id="model-status-heading">模型状态</h3></div><a href="/admin/models">查看</a></header>
|
||||||
|
<dl>
|
||||||
|
<div><dt>配置默认</dt><dd>{summary.models.configured_default_model_id ?? "无"}</dd></div>
|
||||||
|
<div><dt>运行时可用</dt><dd>{summary.models.runtime_available_count} / {summary.models.configured_model_count}</dd></div>
|
||||||
|
<div><dt>当前推荐</dt><dd>{summary.models.recommended_model_id ?? "无"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section className="admin-status-section" aria-labelledby="service-status-heading">
|
||||||
|
<header><div><p>SERVICE STATE</p><h3 id="service-status-heading">服务状态</h3></div><a href="/admin/services-storage">{hasServiceIssue ? "有异常" : "全部正常"}</a></header>
|
||||||
|
<ul className="admin-service-list">
|
||||||
|
{summary.services.map((service) => <li key={service.service_id}><span>{serviceLabels[service.service_id]}</span><strong className={`is-${service.status}`}>{stateLabels[service.status]}</strong><time dateTime={service.checked_at ?? undefined}>{formatTime(service.checked_at)}</time></li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section className="admin-operation-strip" aria-labelledby="recent-operation-heading">
|
||||||
|
<header><div><p>AUDIT SNAPSHOT</p><h3 id="recent-operation-heading">最近后台操作</h3></div><a href="/admin/audit">查看全部</a></header>
|
||||||
|
{summary.recent_operations.length === 0 ? <p>当前无近期操作</p> : (
|
||||||
|
<table><thead><tr><th>时间</th><th>操作</th><th>对象摘要</th><th>结果</th></tr></thead><tbody>{summary.recent_operations.map((operation) => <tr key={operation.operation_id}><td>{formatTime(operation.created_at)}</td><td>{operation.operation_type}</td><td><code>{operation.target_ref}</code></td><td>{operation.result}</td></tr>)}</tbody></table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPlaceholderPage({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<main className="admin-placeholder" id="admin-main">
|
||||||
|
<header className="admin-page-heading"><div><p>OPERATIONS</p><h2>{title}</h2></div></header>
|
||||||
|
<section aria-label={`${title}安全摘要`}>
|
||||||
|
<div className="admin-placeholder-toolbar"><span>安全摘要</span><button disabled type="button">新建</button></div>
|
||||||
|
<p>当前无记录</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
.admin-users-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #111111;
|
||||||
|
background: #f6f6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-product-header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 64px;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 max(3vw, 32px);
|
||||||
|
border-bottom: 1px solid #8c8c86;
|
||||||
|
background: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-product-header > a,
|
||||||
|
.admin-product-header nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 900;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-product-header nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-product-header nav a {
|
||||||
|
min-width: 84px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-product-header nav a[aria-current="page"] {
|
||||||
|
color: #111111;
|
||||||
|
background: #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-page > main {
|
||||||
|
width: min(1180px, calc(100% - 64px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 42px 0 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 22px;
|
||||||
|
border-bottom: 1px solid #999993;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-heading p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-heading button,
|
||||||
|
.adjustment-actions button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f2f500;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.6fr 1fr 1fr;
|
||||||
|
margin-top: 28px;
|
||||||
|
border-block: 1px solid #85857f;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 140px;
|
||||||
|
align-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 22px;
|
||||||
|
border-right: 1px solid #b3b3ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary > div:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary span {
|
||||||
|
color: #65655f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary strong {
|
||||||
|
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||||
|
font-size: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-empty,
|
||||||
|
.admin-users-error,
|
||||||
|
.admin-users-notice {
|
||||||
|
padding: 18px;
|
||||||
|
border-left: 5px solid #85857f;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-error {
|
||||||
|
border-color: #d14a3b;
|
||||||
|
background: #fff1ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-notice {
|
||||||
|
border-color: #287b45;
|
||||||
|
background: #edf8f0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-overlay {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgb(17 17 17 / 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-dialog {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
padding: 28px;
|
||||||
|
border: 2px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 10px 10px 0 #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-dialog > p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-dialog h2 {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjustment-direction {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjustment-direction label {
|
||||||
|
display: flex;
|
||||||
|
min-height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjustment-direction label + label {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-dialog form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-dialog form > label {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-dialog input[type="number"],
|
||||||
|
.credit-adjustment-dialog textarea {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-adjustment-dialog textarea {
|
||||||
|
min-height: 92px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjustment-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border-left: 5px solid #111111;
|
||||||
|
background: #e7e7e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjustment-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjustment-actions button:last-child {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjustment-actions button:disabled {
|
||||||
|
color: #777770;
|
||||||
|
background: #dfdfda;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-negative {
|
||||||
|
color: #a52e24;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.admin-users-page > main {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary,
|
||||||
|
.adjustment-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-credit-summary > div {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #b3b3ad;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import "./admin-users.css";
|
||||||
|
|
||||||
|
interface AdminSession {
|
||||||
|
csrf_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditBalance {
|
||||||
|
available_balance: number;
|
||||||
|
reserved_balance: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(url: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||||
|
if (response.status === 401) throw new Error("admin_session_invalid");
|
||||||
|
if (!response.ok) throw new Error("request_failed");
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function idempotencyKey() {
|
||||||
|
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminUsersPage() {
|
||||||
|
const amountId = useId();
|
||||||
|
const reasonId = useId();
|
||||||
|
const userId = new URLSearchParams(window.location.search).get("userId") ?? "";
|
||||||
|
const [session, setSession] = useState<AdminSession>();
|
||||||
|
const [balance, setBalance] = useState<CreditBalance>();
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [adjusting, setAdjusting] = useState(false);
|
||||||
|
const [direction, setDirection] = useState<"increase" | "decrease">("increase");
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [adjustmentId, setAdjustmentId] = useState("");
|
||||||
|
const [adjustmentKey, setAdjustmentKey] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(userId)) return;
|
||||||
|
Promise.all([
|
||||||
|
readJson<AdminSession>("/api/v1/admin-auth/session"),
|
||||||
|
readJson<CreditBalance>(`/api/v1/admin/users/${userId}/credits`),
|
||||||
|
]).then(([nextSession, nextBalance]) => {
|
||||||
|
setSession(nextSession);
|
||||||
|
setBalance(nextBalance);
|
||||||
|
}).catch(() => setFailed(true));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const signedAmount = useMemo(() => {
|
||||||
|
const parsed = Number(amount);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) return 0;
|
||||||
|
return direction === "increase" ? parsed : -parsed;
|
||||||
|
}, [amount, direction]);
|
||||||
|
const projected = balance ? balance.available_balance + signedAmount : 0;
|
||||||
|
|
||||||
|
function openAdjustment() {
|
||||||
|
setDirection("increase");
|
||||||
|
setAmount("");
|
||||||
|
setReason("");
|
||||||
|
setAdjustmentId(crypto.randomUUID());
|
||||||
|
setAdjustmentKey(idempotencyKey());
|
||||||
|
setAdjusting(true);
|
||||||
|
setNotice("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdjustment(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!session || !balance || !adjustmentId || !adjustmentKey || signedAmount === 0 || !reason.trim() || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await readJson<CreditBalance & { adjustment_id: string; status: "adjusted" }>(
|
||||||
|
`/api/v1/admin/users/${userId}/credit-adjustments`,
|
||||||
|
{
|
||||||
|
body: JSON.stringify({ adjustment_id: adjustmentId, amount: signedAmount, reason: reason.trim() }),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": adjustmentKey,
|
||||||
|
"X-CSRF-Token": session.csrf_token,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setBalance({
|
||||||
|
available_balance: result.available_balance,
|
||||||
|
reserved_balance: result.reserved_balance,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setAdjusting(false);
|
||||||
|
setNotice("点数调整已完成并记录审计");
|
||||||
|
} catch {
|
||||||
|
setNotice("点数调整未完成,请检查输入后重试");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-users-page">
|
||||||
|
<main id="admin-main">
|
||||||
|
<header className="admin-users-heading">
|
||||||
|
<div><p>USER OPERATIONS</p><h1>用户点数</h1></div>
|
||||||
|
{balance ? <button onClick={openAdjustment} type="button">调整点数</button> : null}
|
||||||
|
</header>
|
||||||
|
{!userId ? <p className="admin-users-empty">从用户列表选择用户后调整点数。</p> : null}
|
||||||
|
{failed ? <p className="admin-users-error" role="alert">用户点数暂时无法读取。</p> : null}
|
||||||
|
{userId && !balance && !failed ? <p className="admin-users-empty" aria-live="polite">正在读取用户点数</p> : null}
|
||||||
|
{balance ? (
|
||||||
|
<section className="admin-credit-summary" aria-label="用户点数摘要">
|
||||||
|
<div><span>内部用户标识</span><code>{userId}</code></div>
|
||||||
|
<div><span>可用点数</span><strong className={balance.available_balance < 0 ? "is-negative" : undefined}>{balance.available_balance}</strong></div>
|
||||||
|
<div><span>冻结点数</span><strong>{balance.reserved_balance}</strong></div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{notice ? <p className="admin-users-notice" role="status">{notice}</p> : null}
|
||||||
|
</main>
|
||||||
|
{adjusting && balance ? (
|
||||||
|
<div className="credit-adjustment-overlay">
|
||||||
|
<section aria-labelledby="adjustment-title" aria-modal="true" className="credit-adjustment-dialog" role="dialog">
|
||||||
|
<p>CREDIT ADJUSTMENT</p>
|
||||||
|
<h2 id="adjustment-title">调整点数</h2>
|
||||||
|
<div className="adjustment-direction" role="radiogroup" aria-label="调整方向">
|
||||||
|
<label><input checked={direction === "increase"} name="direction" onChange={() => setDirection("increase")} type="radio" />增加</label>
|
||||||
|
<label><input checked={direction === "decrease"} name="direction" onChange={() => setDirection("decrease")} type="radio" />扣减</label>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submitAdjustment}>
|
||||||
|
<label htmlFor={amountId}>调整数量</label>
|
||||||
|
<input id={amountId} inputMode="numeric" min="1" onChange={(event) => setAmount(event.target.value.replace(/\D/g, ""))} type="number" value={amount} />
|
||||||
|
<label htmlFor={reasonId}>调整原因</label>
|
||||||
|
<textarea id={reasonId} maxLength={500} onChange={(event) => setReason(event.target.value)} value={reason} />
|
||||||
|
<div className="adjustment-preview">
|
||||||
|
<span>调整前可用点数 {balance.available_balance}</span>
|
||||||
|
<strong>调整后可用点数 {projected}</strong>
|
||||||
|
<span>冻结点数保持 {balance.reserved_balance}</span>
|
||||||
|
</div>
|
||||||
|
<div className="adjustment-actions">
|
||||||
|
<button disabled={signedAmount === 0 || !reason.trim() || submitting} type="submit">{submitting ? "处理中" : "确认调整"}</button>
|
||||||
|
<button disabled={submitting} onClick={() => setAdjusting(false)} type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
|
||||||
|
|
||||||
|
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
|
||||||
|
const previewHalfSize = {
|
||||||
|
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
|
||||||
|
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
const context = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !context) return;
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = "#30343b";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const half = previewHalfSize[definition.styleId];
|
||||||
|
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
|
||||||
|
context.translate(canvas.width / 2, canvas.height / 2);
|
||||||
|
context.scale(scale, scale);
|
||||||
|
drawColorCard(context, createColorCardElement(definition, previewPalette, {
|
||||||
|
createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000001",
|
||||||
|
}, 0));
|
||||||
|
}, [definition]);
|
||||||
|
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorCardPanel(props: { canAdd: boolean; hasBackground: boolean; onAdd: (definition: ColorCardDefinition) => void }) {
|
||||||
|
return <section className="editor-provider-panel">
|
||||||
|
<header><h2>色卡</h2><button aria-label="色卡说明" className="editor-info-button" title="色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化" type="button">i</button></header>
|
||||||
|
<p className="editor-provider-note">基于当前原始底图</p>
|
||||||
|
<div className="editor-provider-grid">
|
||||||
|
{P0A_COLOR_CARDS.map((definition) => <button
|
||||||
|
aria-label={`添加色卡 ${definition.cardId} ${definition.displayName}`}
|
||||||
|
disabled={!props.canAdd || !props.hasBackground}
|
||||||
|
key={definition.cardId}
|
||||||
|
onClick={() => props.onAdd(definition)}
|
||||||
|
type="button"
|
||||||
|
><ColorCardPreview definition={definition} /><strong>{definition.cardId}</strong><span>{definition.displayName}</span></button>)}
|
||||||
|
</div>
|
||||||
|
{!props.hasBackground ? <p className="editor-limit" role="status">需要先有原始底图才能提取色卡。</p> : null}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
.credits-page {
|
||||||
|
width: min(1280px, calc(100% - 64px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 44px 0 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid #9c9c96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-heading p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-heading time {
|
||||||
|
color: #676761;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr minmax(260px, 1.2fr);
|
||||||
|
align-items: stretch;
|
||||||
|
margin: 28px 0;
|
||||||
|
border-block: 1px solid #85857f;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-summary > div {
|
||||||
|
display: grid;
|
||||||
|
min-height: 142px;
|
||||||
|
align-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 22px;
|
||||||
|
border-right: 1px solid #b3b3ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-summary span,
|
||||||
|
.credit-summary small {
|
||||||
|
color: #65655f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-summary strong {
|
||||||
|
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||||
|
font-size: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-summary > p {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
align-content: center;
|
||||||
|
padding: 22px;
|
||||||
|
background: #f2f500;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-negative {
|
||||||
|
color: #a52e24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-positive {
|
||||||
|
color: #287b45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-error {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 5px solid #d14a3b;
|
||||||
|
background: #fff1ef;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-ledger > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-ledger h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-filters label {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
color: #5d5d57;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-filters select,
|
||||||
|
.credit-filters input {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-table-wrap table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 880px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-table-wrap th,
|
||||||
|
.credit-table-wrap td {
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-bottom: 1px solid #d0d0ca;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-table-wrap th {
|
||||||
|
background: #e7e7e2;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-table-wrap td {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-table-wrap td small {
|
||||||
|
display: block;
|
||||||
|
max-width: 260px;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #666660;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 280px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed #969690;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-more,
|
||||||
|
.ledger-retry {
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.credits-page {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-heading,
|
||||||
|
.credit-ledger > header {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-summary > div {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #b3b3ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { LocalOnlyFooter, ProductHeader } from "./project-pages.js";
|
||||||
|
|
||||||
|
import "./credits-page.css";
|
||||||
|
|
||||||
|
type EntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||||
|
|
||||||
|
interface CreditBalance {
|
||||||
|
available_balance: number;
|
||||||
|
reserved_balance: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LedgerEntry {
|
||||||
|
amount: number;
|
||||||
|
available_after: number;
|
||||||
|
available_before: number;
|
||||||
|
created_at: string;
|
||||||
|
entry_id: string;
|
||||||
|
entry_type: EntryType;
|
||||||
|
model_id: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
reference_id: string | null;
|
||||||
|
reference_type: "registration" | "generation" | "admin_adjustment" | null;
|
||||||
|
reserved_after: number;
|
||||||
|
reserved_before: number;
|
||||||
|
status: "succeeded" | "frozen" | "committed" | "released";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LedgerResponse {
|
||||||
|
credits: Pick<CreditBalance, "available_balance" | "reserved_balance">;
|
||||||
|
entries: LedgerEntry[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryLabels: Record<EntryType, string> = {
|
||||||
|
admin_adjustment: "管理员调整",
|
||||||
|
generation_commit: "生成结算",
|
||||||
|
generation_release: "生成释放",
|
||||||
|
generation_reserve: "生成冻结",
|
||||||
|
registration_grant: "注册赠点",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
committed: "已结算",
|
||||||
|
frozen: "已冻结",
|
||||||
|
released: "已释放",
|
||||||
|
succeeded: "已完成",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function readJson<T>(url: string) {
|
||||||
|
const response = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||||
|
throw new Error("session_invalid");
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error("request_failed");
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreditsPage() {
|
||||||
|
const [balance, setBalance] = useState<CreditBalance>();
|
||||||
|
const [entries, setEntries] = useState<LedgerEntry[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [eventType, setEventType] = useState<EntryType | "">("");
|
||||||
|
const [from, setFrom] = useState("");
|
||||||
|
const [to, setTo] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (cursor?: string) => {
|
||||||
|
const query = new URLSearchParams({ limit: "20" });
|
||||||
|
if (cursor) query.set("cursor", cursor);
|
||||||
|
if (eventType) query.set("event_type", eventType);
|
||||||
|
if (from) query.set("from", new Date(`${from}T00:00:00`).toISOString());
|
||||||
|
if (to) query.set("to", new Date(`${to}T23:59:59.999`).toISOString());
|
||||||
|
if (cursor) setLoadingMore(true); else setLoading(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
const [nextBalance, ledger] = await Promise.all([
|
||||||
|
readJson<CreditBalance>("/api/v1/me/credits"),
|
||||||
|
readJson<LedgerResponse>(`/api/v1/me/credit-ledger?${query}`),
|
||||||
|
]);
|
||||||
|
setBalance(nextBalance);
|
||||||
|
setEntries((current) => cursor ? [...current, ...ledger.entries] : ledger.entries);
|
||||||
|
setNextCursor(ledger.next_cursor);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error) || error.message !== "session_invalid") setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [eventType, from, to]);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="product-page">
|
||||||
|
<ProductHeader current="credits" />
|
||||||
|
<main className="credits-page">
|
||||||
|
<header className="credits-heading">
|
||||||
|
<div><p>ACCOUNT CREDIT</p><h1>点数明细</h1></div>
|
||||||
|
{balance ? <time dateTime={balance.updated_at}>更新于 {localTime(balance.updated_at)}</time> : null}
|
||||||
|
</header>
|
||||||
|
{balance ? (
|
||||||
|
<section className="credit-summary" aria-label="点数余额">
|
||||||
|
<div><span>当前可用点数</span><strong className={balance.available_balance < 0 ? "is-negative" : undefined}>{balance.available_balance}</strong></div>
|
||||||
|
<div><span>冻结点数</span><strong>{balance.reserved_balance}</strong><small>已冻结 {balance.reserved_balance} 点</small></div>
|
||||||
|
<p>可用点数不足时,请联系管理员调整点数。</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{failed ? <p className="credits-error" role="alert">点数读取失败。{balance ? "当前保留上次余额,提交时仍以服务端实时校验为准。" : "请重新读取。"}</p> : null}
|
||||||
|
<section className="credit-ledger" aria-labelledby="ledger-title">
|
||||||
|
<header>
|
||||||
|
<h2 id="ledger-title">变动记录</h2>
|
||||||
|
<div className="credit-filters">
|
||||||
|
<label>事件类型
|
||||||
|
<select value={eventType} onChange={(event) => setEventType(event.target.value as EntryType | "")}>
|
||||||
|
<option value="">全部事件</option>
|
||||||
|
{Object.entries(entryLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>开始日期<input type="date" value={from} onChange={(event) => setFrom(event.target.value)} /></label>
|
||||||
|
<label>结束日期<input type="date" value={to} onChange={(event) => setTo(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{loading && entries.length === 0 ? <p className="ledger-empty" aria-live="polite">正在读取点数记录</p> : null}
|
||||||
|
{!loading && entries.length === 0 && !failed ? <p className="ledger-empty">暂无点数变动</p> : null}
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<div className="credit-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>时间</th><th>事件</th><th>状态</th><th>变动</th><th>关联</th><th>余额</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<tr key={entry.entry_id}>
|
||||||
|
<td><time dateTime={entry.created_at}>{localTime(entry.created_at)}</time></td>
|
||||||
|
<td><strong>{entryLabels[entry.entry_type]}</strong>{entry.reason ? <small>{entry.reason}</small> : null}</td>
|
||||||
|
<td>{statusLabels[entry.status]}</td>
|
||||||
|
<td className={entry.amount < 0 ? "is-negative" : "is-positive"}>变动 {entry.amount > 0 ? `+${entry.amount}` : entry.amount}</td>
|
||||||
|
<td>{entry.reference_type === "generation" ? "生成任务" : entry.reference_type === "admin_adjustment" ? "管理操作" : "注册"}</td>
|
||||||
|
<td>可用 {entry.available_after} / 冻结 {entry.reserved_after}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{nextCursor ? <button className="ledger-more" disabled={loadingMore} onClick={() => void load(nextCursor)} type="button">{loadingMore ? "加载中" : "加载更多"}</button> : null}
|
||||||
|
{failed ? <button className="ledger-retry" onClick={() => void load()} type="button">重新读取</button> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<LocalOnlyFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useLayoutEffect, useRef, type RefObject } from "react";
|
||||||
|
|
||||||
|
const focusableSelector = [
|
||||||
|
"a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])",
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
function focusableElements(dialog: HTMLElement) {
|
||||||
|
return [...dialog.querySelectorAll<HTMLElement>(focusableSelector)].filter((element) => !element.hidden && element.getClientRects().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialogFocus(onClose: () => void): RefObject<HTMLElement | null> {
|
||||||
|
const dialogRef = useRef<HTMLElement>(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return undefined;
|
||||||
|
const returnTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const initial = dialog.querySelector<HTMLElement>("[data-dialog-initial-focus]") ?? focusableElements(dialog)[0] ?? dialog;
|
||||||
|
initial.focus();
|
||||||
|
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const focusable = focusableElements(dialog);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialog.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0]!;
|
||||||
|
const last = focusable.at(-1)!;
|
||||||
|
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && (document.activeElement === last || !dialog.contains(document.activeElement))) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
if (returnTarget?.isConnected) returnTarget.focus();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return dialogRef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
|
export function DynamicInspector(props: { element: CanvasElement; onCommit: (value: string) => void }) {
|
||||||
|
const [value, setValue] = useState(props.element.formatted_value ?? "");
|
||||||
|
useEffect(() => setValue(props.element.formatted_value ?? ""), [props.element.element_id, props.element.formatted_value]);
|
||||||
|
return <section className="editor-dynamic-inspector">
|
||||||
|
{props.element.template_or_asset_id === "DYN012" ? <p className="editor-font-substitution" role="note">原 DIN_MediumAlternate.otf 缺失,当前明确使用 FONT081 · Lexend Deca 替代。</p> : null}
|
||||||
|
<label>显示文字<textarea aria-label="动态贴纸显示文字" onChange={(event) => setValue(event.target.value)} value={value} /></label>
|
||||||
|
<button className="editor-wide-command" disabled={!value.trim() || value === props.element.formatted_value} onClick={() => props.onCommit(value)} type="button">应用显示文字</button>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
|
||||||
|
|
||||||
|
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
||||||
|
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||||
|
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
|
export type DynamicCategory = "identity" | "location" | "other" | "time";
|
||||||
|
export type DynamicTemplateId = typeof P0A_DYNAMIC_STICKER_IDS[number];
|
||||||
|
|
||||||
|
export interface DynamicStickerDefinition {
|
||||||
|
category: DynamicCategory;
|
||||||
|
displayName: string;
|
||||||
|
requiresLocationConsent: boolean;
|
||||||
|
templateId: DynamicTemplateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicProviderContext {
|
||||||
|
location?: { formattedValue: string; latitude?: number; longitude?: number };
|
||||||
|
now: Date;
|
||||||
|
profile: { creatorName: string; socialId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declarative conversion of l_shijian7.prefab. The browser never executes the Android Prefab or Lua.
|
||||||
|
export const DYN012_RENDER_LAYOUT = {
|
||||||
|
background: "transparent",
|
||||||
|
divider: { color: "#FFFFFF", height: 42, width: 7, x: 0, y: 0 },
|
||||||
|
hour: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: -60, y: 0 },
|
||||||
|
meridiem: { color: "#FFFFFF", fontId: "FONT081", fontSize: 20, x: 94, y: -65 },
|
||||||
|
minute: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: 60, y: 0 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function dyn012DisplayParts(element: CanvasElement) {
|
||||||
|
if (element.type !== "dynamic_sticker" || element.template_or_asset_id !== "DYN012") throw new Error("dyn012_element_required");
|
||||||
|
const formatted = element.formatted_value?.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
const hour = formatted?.[1]?.padStart(2, "0") ?? String(element.dynamic_fields?.hour ?? "00").padStart(2, "0");
|
||||||
|
const minute = formatted?.[2] ?? String(element.dynamic_fields?.minute ?? "00").padStart(2, "0");
|
||||||
|
const hourNumber = Number(hour);
|
||||||
|
const minuteNumber = Number(minute);
|
||||||
|
return {
|
||||||
|
hour,
|
||||||
|
meridiem: hourNumber > 12 || (hourNumber === 12 && minuteNumber > 0) ? "PM" : "AM",
|
||||||
|
minute,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicDefinitions: readonly DynamicStickerDefinition[] = [
|
||||||
|
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
|
||||||
|
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
|
||||||
|
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
|
||||||
|
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
|
||||||
|
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
|
||||||
|
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
|
||||||
|
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
|
||||||
|
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
|
||||||
|
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
|
||||||
|
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
|
||||||
|
const definition = dynamicDefinitions.find((item) => item.templateId === templateId);
|
||||||
|
if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`);
|
||||||
|
return definition;
|
||||||
|
});
|
||||||
|
|
||||||
|
function twoDigits(value: number) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSocialId(value: string) {
|
||||||
|
return `@${value.trim().replace(/^@+/, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderContext) {
|
||||||
|
const year = String(context.now.getFullYear());
|
||||||
|
const month = twoDigits(context.now.getMonth() + 1);
|
||||||
|
const day = twoDigits(context.now.getDate());
|
||||||
|
const hour = twoDigits(context.now.getHours());
|
||||||
|
const minute = twoDigits(context.now.getMinutes());
|
||||||
|
if (templateId === "DYN001") return { fields: { title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "输入地点" };
|
||||||
|
if (templateId === "DYN002") return { fields: { city_en: context.location?.formattedValue ?? "LOCATION", title: context.location?.formattedValue ?? "输入地点" }, value: (context.location?.formattedValue ?? "LOCATION").toUpperCase() };
|
||||||
|
if (templateId === "DYN003") return { fields: { city: context.location?.formattedValue ?? "城市", title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "城市 · 输入地点" };
|
||||||
|
if (templateId === "DYN004") {
|
||||||
|
if (context.location?.latitude === undefined || context.location.longitude === undefined) throw new Error("dynamic_location_consent_required");
|
||||||
|
return {
|
||||||
|
fields: { city: context.location.formattedValue, latitude: context.location.latitude, longitude: context.location.longitude },
|
||||||
|
value: `${context.location.formattedValue}\n${context.location.latitude.toFixed(4)}, ${context.location.longitude.toFixed(4)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (templateId === "DYN007") {
|
||||||
|
const nickname = normalizeSocialId(context.profile.socialId);
|
||||||
|
return { fields: { nickname }, value: nickname };
|
||||||
|
}
|
||||||
|
if (templateId === "DYN008") return { fields: { hour, minute, month }, value: `${month}月 · ${hour}:${minute}` };
|
||||||
|
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
|
||||||
|
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
|
||||||
|
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
|
||||||
|
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDynamicStickerElement(
|
||||||
|
templateId: DynamicTemplateId,
|
||||||
|
context: DynamicProviderContext,
|
||||||
|
identity: CanvasElementIdentity,
|
||||||
|
zIndex: number,
|
||||||
|
): CanvasElement {
|
||||||
|
if (!P0A_DYNAMIC_STICKERS.some((item) => item.templateId === templateId)) throw new Error("dynamic_template_unavailable");
|
||||||
|
const snapshot = snapshotFor(templateId, context);
|
||||||
|
return {
|
||||||
|
created_at: identity.createdAt,
|
||||||
|
dynamic_fields: snapshot.fields,
|
||||||
|
element_id: identity.elementId,
|
||||||
|
...(templateId === "DYN004" && context.location?.latitude !== undefined && context.location.longitude !== undefined
|
||||||
|
? { coordinates: { latitude: context.location.latitude, longitude: context.location.longitude } }
|
||||||
|
: {}),
|
||||||
|
...(templateId === "DYN012" ? { font_override: "FONT081" } : {}),
|
||||||
|
formatted_value: snapshot.value,
|
||||||
|
opacity: 1,
|
||||||
|
position: { x: 0.5, y: 0.5 },
|
||||||
|
resource_version: DYNAMIC_RESOURCE_VERSION,
|
||||||
|
rotation: 0,
|
||||||
|
scale: { x: 1, y: 1 },
|
||||||
|
style_parameters: {
|
||||||
|
provider: "typescript-declarative-v1",
|
||||||
|
...(templateId === "DYN012" ? { font_substitution_disclosure: "DIN_MediumAlternate.otf missing; FONT081 Lexend Deca substitute" } : {}),
|
||||||
|
},
|
||||||
|
template_or_asset_id: templateId,
|
||||||
|
type: "dynamic_sticker",
|
||||||
|
z_index: zIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overrideDynamicStickerValue(element: CanvasElement, formattedValue: string): CanvasElement {
|
||||||
|
if (element.type !== "dynamic_sticker") throw new Error("dynamic_element_required");
|
||||||
|
const value = formattedValue.trim();
|
||||||
|
if (!value) throw new Error("dynamic_value_required");
|
||||||
|
return {
|
||||||
|
...structuredClone(element),
|
||||||
|
dynamic_fields: { ...structuredClone(element.dynamic_fields ?? {}), display_override: value },
|
||||||
|
formatted_value: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationResult {
|
||||||
|
formattedValue: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocationConsentGate {
|
||||||
|
constructor(private readonly adapter: {
|
||||||
|
geolocate: () => Promise<{ latitude: number; longitude: number }>;
|
||||||
|
reverseGeocode: (coordinates: { latitude: number; longitude: number }) => Promise<string>;
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
reject() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirm(): Promise<LocationResult> {
|
||||||
|
const coordinates = await this.adapter.geolocate();
|
||||||
|
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|
||||||
|
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
|
||||||
|
throw new Error("dynamic_location_invalid");
|
||||||
|
}
|
||||||
|
const formattedValue = (await this.adapter.reverseGeocode(coordinates)).trim();
|
||||||
|
if (!formattedValue) throw new Error("dynamic_location_unavailable");
|
||||||
|
return { ...coordinates, formattedValue };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function browserGeolocate() {
|
||||||
|
return new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
reject(new Error("dynamic_location_unsupported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude }),
|
||||||
|
() => reject(new Error("dynamic_location_denied")),
|
||||||
|
{ enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
import { P0A_COMPLEX_RELEASE_VERSION } from "@dada/template-registry";
|
||||||
|
|
||||||
|
import { fontOption, type FontOption } from "./text-assets.js";
|
||||||
|
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||||
|
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
|
export type DynamicTextValue =
|
||||||
|
| "city"
|
||||||
|
| "city_en"
|
||||||
|
| "day"
|
||||||
|
| "hour"
|
||||||
|
| "latitude_dms"
|
||||||
|
| "longitude_dms"
|
||||||
|
| "meridiem"
|
||||||
|
| "minute"
|
||||||
|
| "month"
|
||||||
|
| "month_en"
|
||||||
|
| "nickname"
|
||||||
|
| "time"
|
||||||
|
| "title"
|
||||||
|
| "year_short";
|
||||||
|
|
||||||
|
export interface DynamicImageLayer {
|
||||||
|
assetId: string;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicTextLayer {
|
||||||
|
align: "center" | "left";
|
||||||
|
color: string;
|
||||||
|
fontId: string;
|
||||||
|
fontSize: number;
|
||||||
|
value: DynamicTextValue;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicRenderModel {
|
||||||
|
halfSize: { height: number; width: number };
|
||||||
|
imageLayers: readonly DynamicImageLayer[];
|
||||||
|
sourceCandidateId: string;
|
||||||
|
textLayers: readonly DynamicTextLayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DYNAMIC_RESOURCE_VERSION = P0A_COMPLEX_RELEASE_VERSION;
|
||||||
|
|
||||||
|
const dynamicFont = (fontId: string): FontOption => ({
|
||||||
|
displayName: fontId,
|
||||||
|
fontId,
|
||||||
|
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
|
||||||
|
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
|
||||||
|
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
|
||||||
|
dynamicFont("53ca6b704728520da50c145eabb2e635"),
|
||||||
|
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
|
||||||
|
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
|
||||||
|
dynamicFont("e4210c9872f0c279b35273f230809821"),
|
||||||
|
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
|
||||||
|
DYN001: {
|
||||||
|
halfSize: { height: 42, width: 130 },
|
||||||
|
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
|
||||||
|
sourceCandidateId: "l_POI01",
|
||||||
|
textLayers: [{ align: "center", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 21, value: "title", x: 0, y: 0 }],
|
||||||
|
},
|
||||||
|
DYN002: {
|
||||||
|
halfSize: { height: 48, width: 215 },
|
||||||
|
imageLayers: [{ assetId: "DYN002-image29", height: 77, width: 232, x: 94.5, y: 0.5 }],
|
||||||
|
sourceCandidateId: "l_POI02",
|
||||||
|
textLayers: [
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 19, value: "title", x: -94.5, y: 10.5 },
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 10, value: "city_en", x: 0, y: 36 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DYN003: {
|
||||||
|
halfSize: { height: 54, width: 215 },
|
||||||
|
imageLayers: [{ assetId: "DYN003-image30", height: 85, width: 235, x: -93.857, y: -23.635 }],
|
||||||
|
sourceCandidateId: "l_POI03",
|
||||||
|
textLayers: [
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 18, value: "title", x: -54, y: 9 },
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 24, value: "city", x: -53, y: -16 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DYN004: {
|
||||||
|
halfSize: { height: 48, width: 180 },
|
||||||
|
imageLayers: [{ assetId: "DYN004-image32", height: 76, width: 39, x: -21.067, y: -1 }],
|
||||||
|
sourceCandidateId: "l_POI04",
|
||||||
|
textLayers: [
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 26, value: "city", x: -28, y: 0 },
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "latitude_dms", x: -29.844, y: 36 },
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "longitude_dms", x: 71.523, y: 36 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DYN007: {
|
||||||
|
halfSize: { height: 34, width: 190 },
|
||||||
|
imageLayers: [],
|
||||||
|
sourceCandidateId: "diaoyu",
|
||||||
|
textLayers: [{ align: "center", color: "#295E8B", fontId: "53ca6b704728520da50c145eabb2e635", fontSize: 50, value: "nickname", x: 0, y: 0 }],
|
||||||
|
},
|
||||||
|
DYN008: {
|
||||||
|
halfSize: { height: 180, width: 140 },
|
||||||
|
imageLayers: [{ assetId: "DYN008-backendui0", height: 185, width: 5, x: -125, y: 0 }],
|
||||||
|
sourceCandidateId: "l_shijian2",
|
||||||
|
textLayers: [
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 70, value: "month_en", x: -112, y: -32 },
|
||||||
|
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 130, value: "time", x: -114.024, y: 97 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DYN011: {
|
||||||
|
halfSize: { height: 40, width: 130 },
|
||||||
|
imageLayers: [{ assetId: "DYN011-backendui0", height: 12, width: 8, x: -120, y: -19 }],
|
||||||
|
sourceCandidateId: "l_shijian6",
|
||||||
|
textLayers: [
|
||||||
|
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "year_short", x: -85, y: 0 },
|
||||||
|
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "month", x: 0, y: 0 },
|
||||||
|
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "day", x: 85, y: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DYN012: {
|
||||||
|
halfSize: { height: 78, width: 118 },
|
||||||
|
imageLayers: [],
|
||||||
|
sourceCandidateId: "l_shijian7",
|
||||||
|
textLayers: [],
|
||||||
|
},
|
||||||
|
DYN015: {
|
||||||
|
halfSize: { height: 42, width: 300 },
|
||||||
|
imageLayers: [{ assetId: "DYN015-imager2", height: 60, width: 60, x: -140, y: 0 }],
|
||||||
|
sourceCandidateId: "0721userna",
|
||||||
|
textLayers: [{ align: "left", color: "#666666", fontId: "cca5efc0e02fb1bf62349bd68ef30fc1", fontSize: 40, value: "nickname", x: -100, y: 0 }],
|
||||||
|
},
|
||||||
|
DYN016: {
|
||||||
|
halfSize: { height: 48, width: 160 },
|
||||||
|
imageLayers: [{ assetId: "DYN016-image21", height: 59, width: 186, x: -61.842, y: 16.483 }],
|
||||||
|
sourceCandidateId: "l_username00",
|
||||||
|
textLayers: [{ align: "left", color: "#FFFFFF", fontId: "46f8336813e4c48d06a1aef294fdccf6", fontSize: 27, value: "nickname", x: 0, y: 0 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dynamicFontOptionsFor(templateId: string) {
|
||||||
|
if (templateId === "DYN012") {
|
||||||
|
const replacement = fontOption("FONT081");
|
||||||
|
return replacement ? [replacement] : [];
|
||||||
|
}
|
||||||
|
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
|
||||||
|
if (!model) return [];
|
||||||
|
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
|
||||||
|
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
|
||||||
|
return `/api/v1/assets/public/${encodeURIComponent(resourceVersion)}/${encodeURIComponent(assetId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericField(element: CanvasElement, key: string) {
|
||||||
|
const value = element.dynamic_fields?.[key];
|
||||||
|
return typeof value === "number" ? value : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinate(value: number, positive: string, negative: string) {
|
||||||
|
if (!Number.isFinite(value)) return "";
|
||||||
|
const absolute = Math.abs(value);
|
||||||
|
const degrees = Math.floor(absolute);
|
||||||
|
const minutesFloat = (absolute - degrees) * 60;
|
||||||
|
const minutes = Math.floor(minutesFloat);
|
||||||
|
const seconds = (minutesFloat - minutes) * 60;
|
||||||
|
return `${degrees}°${String(minutes).padStart(2, "0")}'${seconds.toFixed(2).padStart(5, "0")}\"${value >= 0 ? positive : negative}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dynamicTextValue(element: CanvasElement, value: DynamicTextValue) {
|
||||||
|
const fields = element.dynamic_fields ?? {};
|
||||||
|
const text = (key: string) => String(fields[key] ?? "");
|
||||||
|
const override = fields.display_override;
|
||||||
|
if (typeof override === "string" && (value === "nickname" || value === "title")) return element.formatted_value ?? override;
|
||||||
|
if (value === "time") return `${text("hour")}:${text("minute")}`;
|
||||||
|
if (value === "month_en") {
|
||||||
|
const month = Math.max(1, Math.min(12, numericField(element, "month")));
|
||||||
|
return `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][month - 1]}.`;
|
||||||
|
}
|
||||||
|
if (value === "year_short") return text("year").slice(-2);
|
||||||
|
if (value === "latitude_dms") return coordinate(numericField(element, "latitude"), "N", "S");
|
||||||
|
if (value === "longitude_dms") return coordinate(numericField(element, "longitude"), "E", "W");
|
||||||
|
if (value === "meridiem") {
|
||||||
|
const hour = numericField(element, "hour");
|
||||||
|
const minute = numericField(element, "minute");
|
||||||
|
return hour > 12 || (hour === 12 && minute > 0) ? "PM" : "AM";
|
||||||
|
}
|
||||||
|
return text(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { createDynamicStickerElement, P0A_DYNAMIC_STICKERS, type DynamicTemplateId } from "./dynamic-provider.js";
|
||||||
|
import { DYNAMIC_RENDER_MODELS, dynamicImageUrl } from "./dynamic-render-models.js";
|
||||||
|
import { drawDynamicSticker } from "./editor-stage.js";
|
||||||
|
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
|
|
||||||
|
const categoryLabels = { identity: "身份", location: "地点", other: "综合", time: "时间" } as const;
|
||||||
|
|
||||||
|
function loadImage(url: string) {
|
||||||
|
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => resolve(undefined);
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; templateId: DynamicTemplateId }) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
const statusKey = Object.entries(props.fontStatuses).map(([id, status]) => `${id}:${status}`).join("|");
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const model = DYNAMIC_RENDER_MODELS[props.templateId];
|
||||||
|
const element = createDynamicStickerElement(props.templateId, {
|
||||||
|
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
|
||||||
|
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
|
||||||
|
}, { createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000002" }, 0);
|
||||||
|
void Promise.all(model.imageLayers.map(async (layer) => [layer.assetId, await loadImage(dynamicImageUrl(element.resource_version, layer.assetId))] as const)).then((entries) => {
|
||||||
|
if (!active) return;
|
||||||
|
const canvas = ref.current;
|
||||||
|
const context = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !context) return;
|
||||||
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = "#30343b";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const scale = Math.min(1, 146 / (model.halfSize.width * 2), 62 / (model.halfSize.height * 2));
|
||||||
|
context.translate(canvas.width / 2, canvas.height / 2);
|
||||||
|
context.scale(scale, scale);
|
||||||
|
const images = Object.fromEntries(entries.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
|
||||||
|
drawDynamicSticker(context, element, props.fontStatuses, images);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [props.templateId, statusKey]);
|
||||||
|
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicStickerPanel(props: { canAdd: boolean; fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; onAdd: (templateId: DynamicTemplateId) => void }) {
|
||||||
|
return <section className="editor-provider-panel">
|
||||||
|
<h2>动态贴纸</h2>
|
||||||
|
<div className="editor-provider-groups">
|
||||||
|
{(["location", "time", "identity", "other"] as const).map((category) => <section key={category}>
|
||||||
|
<h3>{categoryLabels[category]}</h3>
|
||||||
|
<div className="editor-provider-grid">
|
||||||
|
{P0A_DYNAMIC_STICKERS.filter((item) => item.category === category).map((item) => <button
|
||||||
|
aria-label={`添加动态贴纸 ${item.templateId} ${item.displayName}`}
|
||||||
|
disabled={!props.canAdd}
|
||||||
|
key={item.templateId}
|
||||||
|
onClick={() => props.onAdd(item.templateId)}
|
||||||
|
type="button"
|
||||||
|
><DynamicPreview fontStatuses={props.fontStatuses} templateId={item.templateId} /><strong>{item.templateId}</strong><span>{item.displayName}</span>{item.templateId === "DYN012" ? <small>FONT081 替代字体</small> : null}</button>)}
|
||||||
|
</div>
|
||||||
|
</section>)}
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
|
||||||
|
|
||||||
|
const elementKeys = new Set([
|
||||||
|
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
|
||||||
|
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
|
||||||
|
"template_or_asset_id", "type", "z_index",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function defaultBackgroundAdjustments(): BackgroundAdjustments {
|
||||||
|
return { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelsForRatio(ratio: CanvasState["ratio"]) {
|
||||||
|
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
|
||||||
|
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
|
||||||
|
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
|
||||||
|
: { pixelHeight: 1920, pixelWidth: 1080 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEditorCanvasState(input: { assetId: string | null; ratio: CanvasState["ratio"] }): CanvasState {
|
||||||
|
const pixels = pixelsForRatio(input.ratio);
|
||||||
|
return {
|
||||||
|
background: { adjustments: defaultBackgroundAdjustments(), asset_id: input.assetId },
|
||||||
|
elements: [],
|
||||||
|
pixel_height: pixels.pixelHeight,
|
||||||
|
pixel_width: pixels.pixelWidth,
|
||||||
|
ratio: input.ratio,
|
||||||
|
schema_version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function checked(state: CanvasState): CanvasState {
|
||||||
|
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
||||||
|
return structuredClone(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBackgroundAdjustments(state: CanvasState, patch: Partial<BackgroundAdjustments>): CanvasState {
|
||||||
|
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: { ...state.background.adjustments, ...patch } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetBackgroundAdjustments(state: CanvasState): CanvasState {
|
||||||
|
return checked({ ...structuredClone(state), background: { ...state.background, adjustments: defaultBackgroundAdjustments() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function switchBackground(state: CanvasState, assetId: string, palette: readonly string[]): CanvasState {
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(assetId)) throw new Error("canvas_background_asset_invalid");
|
||||||
|
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.background = { asset_id: assetId, adjustments: defaultBackgroundAdjustments() };
|
||||||
|
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...palette] } : element);
|
||||||
|
return checked(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CanvasEditHistory {
|
||||||
|
private current: CanvasState;
|
||||||
|
private readonly past: CanvasState[] = [];
|
||||||
|
private readonly future: CanvasState[] = [];
|
||||||
|
|
||||||
|
constructor(initial: CanvasState) { this.current = checked(initial); }
|
||||||
|
get value() { return structuredClone(this.current); }
|
||||||
|
get canUndo() { return this.past.length > 0; }
|
||||||
|
get canRedo() { return this.future.length > 0; }
|
||||||
|
|
||||||
|
commit(next: CanvasState) {
|
||||||
|
this.past.push(this.value);
|
||||||
|
this.current = checked(next);
|
||||||
|
this.future.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
const previous = this.past.pop();
|
||||||
|
if (!previous) return undefined;
|
||||||
|
this.future.push(this.value);
|
||||||
|
this.current = previous;
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
redo() {
|
||||||
|
const next = this.future.pop();
|
||||||
|
if (!next) return undefined;
|
||||||
|
this.past.push(this.value);
|
||||||
|
this.current = next;
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeFabricCanvas(input: unknown): CanvasState | undefined {
|
||||||
|
if (!input || typeof input !== "object") return undefined;
|
||||||
|
const value = input as Record<string, unknown>;
|
||||||
|
if (!value.background || typeof value.background !== "object" || !Array.isArray(value.elements)) return undefined;
|
||||||
|
const elements = value.elements.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Object.keys(entry).some((key) => !elementKeys.has(key))) return undefined;
|
||||||
|
return structuredClone(entry);
|
||||||
|
});
|
||||||
|
if (elements.some((entry) => entry === undefined)) return undefined;
|
||||||
|
const candidate = {
|
||||||
|
background: structuredClone(value.background),
|
||||||
|
elements,
|
||||||
|
pixel_height: value.pixel_height,
|
||||||
|
pixel_width: value.pixel_width,
|
||||||
|
ratio: value.ratio,
|
||||||
|
schema_version: value.schema_version,
|
||||||
|
};
|
||||||
|
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
|
||||||
|
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
|
||||||
|
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
|
||||||
|
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||||
|
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
|
export interface CanvasPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasRect extends CanvasPoint {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasElementIdentity {
|
||||||
|
createdAt: string;
|
||||||
|
elementId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CanvasLayerCommand = "back" | "backward" | "forward" | "front";
|
||||||
|
|
||||||
|
interface StaticStickerInput {
|
||||||
|
assetId: string;
|
||||||
|
identity: CanvasElementIdentity;
|
||||||
|
opacity?: number;
|
||||||
|
position: CanvasPoint;
|
||||||
|
resourceVersion: string;
|
||||||
|
rotation?: number;
|
||||||
|
scale?: CanvasElement["scale"];
|
||||||
|
style_parameters?: CanvasElement["style_parameters"];
|
||||||
|
z_index?: number;
|
||||||
|
zIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapThreshold = 0.015;
|
||||||
|
const hitHalfExtent = 0.08;
|
||||||
|
|
||||||
|
function styleNumber(element: CanvasElement, key: string, fallback: number) {
|
||||||
|
const value = element.style_parameters?.[key];
|
||||||
|
return typeof value === "number" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLineUnits(line: string) {
|
||||||
|
return Array.from(line).reduce((total, character) => total + (/^[\x00-\x7F]$/.test(character) ? 0.62 : 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
|
||||||
|
if (element.type === "color_card") {
|
||||||
|
const vertical = element.style_id === "style_01" || element.style_id === "style_02";
|
||||||
|
return {
|
||||||
|
x: Math.max(hitHalfExtent, (vertical ? 62 : 175) / state.pixel_width) * element.scale.x,
|
||||||
|
y: Math.max(hitHalfExtent, (vertical ? 155 : 50) / state.pixel_height) * element.scale.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (element.type === "dynamic_sticker") {
|
||||||
|
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||||
|
const halfWidth = model?.halfSize.width ?? 170;
|
||||||
|
const halfHeight = model?.halfSize.height ?? 62;
|
||||||
|
return {
|
||||||
|
x: Math.max(hitHalfExtent, halfWidth / state.pixel_width) * element.scale.x,
|
||||||
|
y: Math.max(hitHalfExtent, halfHeight / state.pixel_height) * element.scale.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (element.type !== "text_template") {
|
||||||
|
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
|
||||||
|
}
|
||||||
|
const lines = (element.content ?? "").split("\n");
|
||||||
|
const fontSize = element.font_size ?? 48;
|
||||||
|
const letterSpacing = styleNumber(element, "letter_spacing", 1);
|
||||||
|
const lineHeight = styleNumber(element, "line_height", 1.2);
|
||||||
|
const strokeWidth = styleNumber(element, "stroke_width", 0);
|
||||||
|
const longestLine = Math.max(1, ...lines.map((line) => textLineUnits(line)));
|
||||||
|
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
|
||||||
|
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
|
||||||
|
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
|
||||||
|
return {
|
||||||
|
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
|
||||||
|
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneState(state: CanvasState) {
|
||||||
|
return structuredClone(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireState(state: CanvasState) {
|
||||||
|
if (!isCanvasState(state)) throw new Error("canvas_state_invalid");
|
||||||
|
return cloneState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number) {
|
||||||
|
return Math.max(minimum, Math.min(maximum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedRotation(value: number) {
|
||||||
|
const normalized = ((value + 180) % 360 + 360) % 360 - 180;
|
||||||
|
return normalized === -180 ? 180 : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePoint(left: CanvasPoint | undefined, right: CanvasPoint) {
|
||||||
|
return Boolean(left && Math.abs(left.x - right.x) < 0.001 && Math.abs(left.y - right.y) < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reindex(elements: CanvasElement[]) {
|
||||||
|
return elements.map((element, zIndex) => ({ ...element, z_index: zIndex }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStaticStickerElement(input: StaticStickerInput): CanvasElement {
|
||||||
|
return {
|
||||||
|
created_at: input.identity.createdAt,
|
||||||
|
element_id: input.identity.elementId,
|
||||||
|
opacity: input.opacity ?? 1,
|
||||||
|
position: structuredClone(input.position),
|
||||||
|
resource_version: input.resourceVersion,
|
||||||
|
rotation: input.rotation ?? 0,
|
||||||
|
scale: structuredClone(input.scale ?? { x: 1, y: 1 }),
|
||||||
|
style_parameters: structuredClone(input.style_parameters ?? { flip_horizontal: false }),
|
||||||
|
template_or_asset_id: input.assetId,
|
||||||
|
type: "static_sticker",
|
||||||
|
z_index: input.z_index ?? input.zIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CanvasElementController {
|
||||||
|
private current: CanvasState;
|
||||||
|
private selection: string[] = [];
|
||||||
|
private cyclePoint: CanvasPoint | undefined = undefined;
|
||||||
|
private cycleSignature = "";
|
||||||
|
private cycleIndex = -1;
|
||||||
|
|
||||||
|
constructor(initial: CanvasState) {
|
||||||
|
this.current = requireState(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
get value() {
|
||||||
|
return cloneState(this.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedIds() {
|
||||||
|
return [...this.selection];
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceState(next: CanvasState) {
|
||||||
|
this.current = requireState(next);
|
||||||
|
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
||||||
|
this.selection = this.selection.filter((elementId) => existing.has(elementId));
|
||||||
|
this.pointerMoved();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
this.selection = [];
|
||||||
|
this.pointerMoved();
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerMoved() {
|
||||||
|
this.cyclePoint = undefined;
|
||||||
|
this.cycleSignature = "";
|
||||||
|
this.cycleIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectById(elementId: string, append = false) {
|
||||||
|
if (!this.current.elements.some((element) => element.element_id === elementId)) return this.selectedIds;
|
||||||
|
if (!append) this.selection = [elementId];
|
||||||
|
else if (this.selection.includes(elementId)) this.selection = this.selection.filter((selected) => selected !== elementId);
|
||||||
|
else this.selection = [...this.selection, elementId];
|
||||||
|
return this.selectedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectIds(elementIds: readonly string[]) {
|
||||||
|
const existing = new Set(this.current.elements.map((element) => element.element_id));
|
||||||
|
this.selection = [...new Set(elementIds.filter((elementId) => existing.has(elementId)))];
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.selectedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidatesAt(point: CanvasPoint) {
|
||||||
|
return this.current.elements
|
||||||
|
.filter((element) => {
|
||||||
|
const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element);
|
||||||
|
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
|
||||||
|
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
|
||||||
|
})
|
||||||
|
.sort((left, right) => right.z_index - left.z_index)
|
||||||
|
.map((element) => structuredClone(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
|
||||||
|
const candidates = this.candidatesAt(point);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
if (!options.append) this.selection = [];
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.selectedIds;
|
||||||
|
}
|
||||||
|
if (options.append) {
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.selectById(candidates[0]!.element_id, true);
|
||||||
|
}
|
||||||
|
const signature = candidates.map((candidate) => candidate.element_id).join("|");
|
||||||
|
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
|
||||||
|
else this.cycleIndex = 0;
|
||||||
|
this.cyclePoint = { ...point };
|
||||||
|
this.cycleSignature = signature;
|
||||||
|
this.selection = [candidates[this.cycleIndex]!.element_id];
|
||||||
|
return this.selectedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
marqueeSelect(rectangle: CanvasRect, append = false) {
|
||||||
|
const minimumX = Math.min(rectangle.x, rectangle.x + rectangle.width);
|
||||||
|
const maximumX = Math.max(rectangle.x, rectangle.x + rectangle.width);
|
||||||
|
const minimumY = Math.min(rectangle.y, rectangle.y + rectangle.height);
|
||||||
|
const maximumY = Math.max(rectangle.y, rectangle.y + rectangle.height);
|
||||||
|
const hits = this.current.elements
|
||||||
|
.filter((element) => element.position.x >= minimumX && element.position.x <= maximumX
|
||||||
|
&& element.position.y >= minimumY && element.position.y <= maximumY)
|
||||||
|
.sort((left, right) => left.z_index - right.z_index)
|
||||||
|
.map((element) => element.element_id);
|
||||||
|
this.selection = append ? [...new Set([...this.selection, ...hits])] : hits;
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.selectedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(element: CanvasElement) {
|
||||||
|
if (this.current.elements.length >= 50) throw new Error("canvas_element_limit_reached");
|
||||||
|
const nextElement = structuredClone(element);
|
||||||
|
nextElement.z_index = this.current.elements.length;
|
||||||
|
this.current = requireState({ ...this.current, elements: [...this.current.elements, nextElement] });
|
||||||
|
this.selection = [nextElement.element_id];
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceElement(element: CanvasElement) {
|
||||||
|
const index = this.current.elements.findIndex((candidate) => candidate.element_id === element.element_id);
|
||||||
|
if (index < 0) throw new Error("canvas_element_not_found");
|
||||||
|
const elements = [...this.current.elements];
|
||||||
|
elements[index] = structuredClone(element);
|
||||||
|
this.current = requireState({ ...this.current, elements });
|
||||||
|
this.selection = [element.element_id];
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
|
||||||
|
const selected = new Set(this.selection);
|
||||||
|
this.current = requireState({
|
||||||
|
...this.current,
|
||||||
|
elements: this.current.elements.map((element) => selected.has(element.element_id) ? mapper(structuredClone(element)) : element),
|
||||||
|
});
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
|
||||||
|
const selected = new Set(this.selection);
|
||||||
|
const primary = this.current.elements.find((element) => selected.has(element.element_id));
|
||||||
|
if (!primary) return { guides: [] as string[], state: this.value };
|
||||||
|
let nextX = primary.position.x + delta.x;
|
||||||
|
let nextY = primary.position.y + delta.y;
|
||||||
|
const guides: string[] = [];
|
||||||
|
const snapAxis = (value: number, axis: "x" | "y") => {
|
||||||
|
const canvasTargets = [0, 0.5, 1];
|
||||||
|
const target = canvasTargets.find((candidate) => Math.abs(value - candidate) <= snapThreshold);
|
||||||
|
if (target !== undefined) {
|
||||||
|
guides.push(target === 0.5 ? `canvas-center-${axis}` : `canvas-edge-${axis}`);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
const other = this.current.elements.find((element) => !selected.has(element.element_id) && Math.abs(value - element.position[axis]) <= snapThreshold);
|
||||||
|
if (other) {
|
||||||
|
guides.push(`element-${axis}:${other.element_id}`);
|
||||||
|
return other.position[axis];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
if (options.snap !== false) {
|
||||||
|
nextX = snapAxis(nextX, "x");
|
||||||
|
nextY = snapAxis(nextY, "y");
|
||||||
|
}
|
||||||
|
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
|
||||||
|
const state = this.updateSelected((element) => ({
|
||||||
|
...element,
|
||||||
|
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
|
||||||
|
}));
|
||||||
|
return { guides, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
scaleSelected(factor: number) {
|
||||||
|
if (!Number.isFinite(factor) || factor <= 0) throw new Error("canvas_scale_invalid");
|
||||||
|
return this.updateSelected((element) => ({
|
||||||
|
...element,
|
||||||
|
scale: { x: clamp(element.scale.x * factor, 0.01, 100), y: clamp(element.scale.y * factor, 0.01, 100) },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateSelected(degrees: number) {
|
||||||
|
if (!Number.isFinite(degrees)) throw new Error("canvas_rotation_invalid");
|
||||||
|
return this.updateSelected((element) => ({ ...element, rotation: normalizedRotation(element.rotation + degrees) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
flipSelectedHorizontal() {
|
||||||
|
return this.updateSelected((element) => element.type === "static_sticker" ? {
|
||||||
|
...element,
|
||||||
|
style_parameters: { ...element.style_parameters, flip_horizontal: element.style_parameters?.flip_horizontal !== true },
|
||||||
|
} : element);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedOpacity(opacity: number) {
|
||||||
|
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("canvas_opacity_invalid");
|
||||||
|
return this.updateSelected((element) => element.type === "static_sticker" ? { ...element, opacity } : element);
|
||||||
|
}
|
||||||
|
|
||||||
|
copySelected() {
|
||||||
|
const selected = new Set(this.selection);
|
||||||
|
return this.current.elements.filter((element) => selected.has(element.element_id)).map((element) => structuredClone(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
pasteElements(elements: readonly CanvasElement[], identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
||||||
|
if (this.current.elements.length + elements.length > 50) throw new Error("canvas_element_limit_reached");
|
||||||
|
const pasted = elements.map((source, index) => {
|
||||||
|
const identity = identityFactory(source, index);
|
||||||
|
return {
|
||||||
|
...structuredClone(source),
|
||||||
|
created_at: identity.createdAt,
|
||||||
|
element_id: identity.elementId,
|
||||||
|
position: { x: clamp(source.position.x + 0.02, 0, 1), y: clamp(source.position.y + 0.02, 0, 1) },
|
||||||
|
z_index: this.current.elements.length + index,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.current = requireState({ ...this.current, elements: [...this.current.elements, ...pasted] });
|
||||||
|
this.selection = pasted.map((element) => element.element_id);
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateSelected(identityFactory: (source: CanvasElement, index: number) => CanvasElementIdentity) {
|
||||||
|
return this.pasteElements(this.copySelected(), identityFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSelected() {
|
||||||
|
const selected = new Set(this.selection);
|
||||||
|
this.current = requireState({ ...this.current, elements: reindex(this.current.elements.filter((element) => !selected.has(element.element_id))) });
|
||||||
|
this.selection = [];
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLayer(command: CanvasLayerCommand) {
|
||||||
|
const selected = new Set(this.selection);
|
||||||
|
const ordered = [...this.current.elements].sort((left, right) => left.z_index - right.z_index);
|
||||||
|
if (command === "front" || command === "back") {
|
||||||
|
const selectedElements = ordered.filter((element) => selected.has(element.element_id));
|
||||||
|
const unselectedElements = ordered.filter((element) => !selected.has(element.element_id));
|
||||||
|
ordered.splice(0, ordered.length, ...(command === "front" ? [...unselectedElements, ...selectedElements] : [...selectedElements, ...unselectedElements]));
|
||||||
|
}
|
||||||
|
else if (command === "forward") {
|
||||||
|
for (let index = ordered.length - 2; index >= 0; index -= 1) {
|
||||||
|
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index + 1]!.element_id)) [ordered[index], ordered[index + 1]] = [ordered[index + 1]!, ordered[index]!];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let index = 1; index < ordered.length; index += 1) {
|
||||||
|
if (selected.has(ordered[index]!.element_id) && !selected.has(ordered[index - 1]!.element_id)) [ordered[index - 1], ordered[index]] = [ordered[index]!, ordered[index - 1]!];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.current = requireState({ ...this.current, elements: reindex(ordered) });
|
||||||
|
this.pointerMoved();
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
.editor-page-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||||
|
background: #e8e8e5;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-page-shell :focus-visible {
|
||||||
|
outline: 2px solid #005fcc;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 2px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
font-weight: 700;
|
||||||
|
transform: translateY(-160%);
|
||||||
|
}
|
||||||
|
.editor-skip-link:focus { transform: translateY(0); }
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(160px, 1fr) auto minmax(128px, 160px) auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-controls { display: contents; }
|
||||||
|
|
||||||
|
.editor-toolbar button,
|
||||||
|
.editor-back,
|
||||||
|
.editor-inspector button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
border-radius: 0;
|
||||||
|
color: #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar button:disabled,
|
||||||
|
.editor-inspector button:disabled {
|
||||||
|
border-color: #b9b9b3;
|
||||||
|
color: #62625d;
|
||||||
|
background: #e8e8e5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-back {
|
||||||
|
display: grid;
|
||||||
|
width: 40px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: inherit;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title span,
|
||||||
|
.editor-statusbar,
|
||||||
|
.editor-inspector header p,
|
||||||
|
.editor-confirm p:first-child {
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-history-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-history-actions button {
|
||||||
|
width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-save-status {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 128px;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-left: 3px solid #16794b;
|
||||||
|
background: #f7f7f5;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-save-status.dirty,
|
||||||
|
.editor-save-status.saving { border-color: #1769aa; }
|
||||||
|
.editor-save-status.failed,
|
||||||
|
.editor-save-status.conflicted { border-color: #c92a24; color: #8f1d14; }
|
||||||
|
|
||||||
|
.editor-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-assets-panel,
|
||||||
|
.editor-inspector {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f7f7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-assets-panel { border-right: 1px solid #b9b9b3; }
|
||||||
|
.editor-inspector { border-left: 1px solid #b9b9b3; padding: 20px; }
|
||||||
|
|
||||||
|
.editor-asset-tabs {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #b9b9b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-asset-tabs button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
color: #111111;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-asset-tabs button[aria-current="page"] { border-left-color: #111111; background: #ffffff; }
|
||||||
|
.editor-asset-tabs button:disabled { color: #62625d; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.editor-assets-panel section { padding: 18px 16px; border-bottom: 1px solid #b9b9b3; }
|
||||||
|
.editor-assets-panel h2 { margin: 0 0 10px; font-size: 16px; }
|
||||||
|
|
||||||
|
.editor-source {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #b9b9b3;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-source + .editor-source { margin-top: 8px; }
|
||||||
|
.editor-source.active { border: 2px solid #111111; padding: 7px; }
|
||||||
|
.editor-thumb { width: 52px; height: 68px; flex: 0 0 auto; border: 1px solid #b9b9b3; background: #e8e8e5 center / cover no-repeat; }
|
||||||
|
.editor-muted { color: #62625d; font-size: 12px; }
|
||||||
|
|
||||||
|
.editor-sticker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sticker-virtual-list {
|
||||||
|
height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-right: 4px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sticker-count { margin: 0 0 8px; color: #62625d; font-size: 12px; }
|
||||||
|
|
||||||
|
.editor-sticker-grid button {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 132px;
|
||||||
|
place-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-sticker-grid button:disabled { color: #62625d; background: #e8e8e5; cursor: not-allowed; }
|
||||||
|
.editor-sticker-grid button > span:last-child { overflow-wrap: anywhere; font-size: 11px; }
|
||||||
|
.editor-sticker-preview { display: block; width: 58px; height: 58px; object-fit: contain; border: 1px solid #b9b9b3; background: #30343b; }
|
||||||
|
.editor-limit { margin: 12px 0 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 12px; }
|
||||||
|
|
||||||
|
.editor-workspace {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 40px minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px 32px 32px;
|
||||||
|
background: #e8e8e5;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas-tools {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas-tools button {
|
||||||
|
min-width: 76px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas-tools button.active { border-color: #005fcc; background: #ffffff; box-shadow: inset 0 -3px #005fcc; }
|
||||||
|
.editor-canvas-tools button:disabled { color: #62625d; background: #d8d8d3; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.editor-canvas-frame {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: min(100%, 720px);
|
||||||
|
max-height: calc(100vh - 168px);
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #62625d;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 8px 8px 0 #b9b9b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas { display: block; width: 100%; height: 100%; outline: none; }
|
||||||
|
.editor-canvas:focus-visible { outline: 2px solid #005fcc; outline-offset: 2px; }
|
||||||
|
|
||||||
|
.editor-candidates {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
width: min(260px, 80%);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 2px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 6px 6px 0 #111111;
|
||||||
|
transform: translate(10px, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-candidates button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #b9b9b3;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-snap-status {
|
||||||
|
position: absolute;
|
||||||
|
align-self: end;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border: 1px solid #005fcc;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #005fcc;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-inspector header { padding-bottom: 16px; border-bottom: 1px solid #b9b9b3; }
|
||||||
|
.editor-inspector header p { margin: 0 0 4px; }
|
||||||
|
.editor-inspector h2 { margin: 0; font-size: 20px; }
|
||||||
|
.editor-object-id { margin: 12px 0 0; font-family: Consolas, monospace; font-size: 12px; }
|
||||||
|
.editor-inspector > label { display: grid; gap: 6px; margin-top: 18px; font-size: 13px; font-weight: 700; }
|
||||||
|
.editor-inspector select,
|
||||||
|
.editor-inspector input[type="range"] { width: 100%; }
|
||||||
|
.editor-inspector select { min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; font: inherit; }
|
||||||
|
.editor-range > span { display: flex; justify-content: space-between; }
|
||||||
|
.editor-range output { font-family: Consolas, monospace; }
|
||||||
|
.editor-inspector-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 24px; }
|
||||||
|
.editor-primary { background: #f2f400 !important; }
|
||||||
|
|
||||||
|
.editor-object-moves,
|
||||||
|
.editor-object-tools,
|
||||||
|
.editor-layer-tools,
|
||||||
|
.editor-object-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-object-moves { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.editor-object-tools,
|
||||||
|
.editor-layer-tools { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.editor-object-actions { grid-template-columns: 1fr 1fr; padding-top: 18px; border-top: 1px solid #b9b9b3; }
|
||||||
|
.editor-object-moves button,
|
||||||
|
.editor-object-tools button,
|
||||||
|
.editor-layer-tools button,
|
||||||
|
.editor-object-actions button,
|
||||||
|
.editor-wide-command {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.editor-wide-command { width: 100%; margin-top: 18px; }
|
||||||
|
.editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; }
|
||||||
|
|
||||||
|
.editor-text-assets { min-width: 0; }
|
||||||
|
.editor-template-search {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.editor-template-categories { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin-bottom: 12px; }
|
||||||
|
.editor-template-categories button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.editor-template-categories button.active { border-color: #005fcc; box-shadow: inset 0 -3px #005fcc; font-weight: 700; }
|
||||||
|
.editor-template-retry { width: 100%; min-height: 36px; margin-bottom: 10px; border: 1px solid #8f1d14; border-radius: 0; background: #ffffff; color: #8f1d14; font: inherit; font-weight: 700; }
|
||||||
|
.editor-template-recent { margin-bottom: 12px; padding: 10px 0; border-block: 1px solid #b9b9b3; }
|
||||||
|
.editor-template-recent h3 { margin: 0 0 8px; font-size: 12px; }
|
||||||
|
.editor-template-recent > div { display: flex; gap: 6px; overflow-x: auto; }
|
||||||
|
.editor-template-recent span { display: grid; flex: 0 0 116px; min-height: 48px; align-content: center; padding: 5px 7px; border-left: 4px solid #f2f400; background: #ffffff; font-size: 10px; }
|
||||||
|
.editor-template-recent strong { font-family: Consolas, monospace; font-size: 10px; }
|
||||||
|
.editor-template-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||||
|
.editor-template-grid button {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 116px;
|
||||||
|
grid-template-rows: 48px 16px minmax(18px, auto) 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||||
|
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
||||||
|
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
||||||
|
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
||||||
|
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
||||||
|
.editor-template-mark.tag { background: #dbeafe; }
|
||||||
|
.editor-template-mark.simple { background: #ffffff; }
|
||||||
|
|
||||||
|
.editor-provider-panel > header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.editor-provider-panel > header h2 { margin: 0; }
|
||||||
|
.editor-info-button { width: 28px; height: 28px; border: 1px solid #111111; border-radius: 50%; background: #ffffff; font: 700 13px Georgia, serif; }
|
||||||
|
.editor-provider-note { margin: 8px 0 12px; color: #62625d; font-size: 11px; }
|
||||||
|
.editor-provider-groups { display: grid; gap: 18px; }
|
||||||
|
.editor-provider-groups section { display: grid; gap: 8px; }
|
||||||
|
.editor-provider-groups h3 { margin: 0; font-size: 12px; }
|
||||||
|
.editor-provider-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||||
|
.editor-provider-grid button {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 112px;
|
||||||
|
grid-template-rows: 48px 16px minmax(18px, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #85857f;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111111;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-provider-grid button:disabled { background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||||
|
.editor-provider-grid strong { font-family: Consolas, monospace; font-size: 10px; }
|
||||||
|
.editor-provider-grid small { color: #8f1d14; font-size: 9px; }
|
||||||
|
.editor-color-card-preview { display: flex; width: 100%; height: 42px; align-items: stretch; border: 1px solid #111111; background: #ffffff; }
|
||||||
|
.editor-color-card-preview i { flex: 1; }
|
||||||
|
.editor-color-card-preview.style_01,
|
||||||
|
.editor-color-card-preview.style_02 { width: 30px; height: 48px; flex-direction: column; justify-self: center; }
|
||||||
|
.editor-dynamic-preview { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #ffffff; font-size: 15px; font-weight: 800; }
|
||||||
|
.editor-dynamic-preview.location { border-left: 6px solid #1769aa; }
|
||||||
|
.editor-dynamic-preview.time { background: #FFE62C; }
|
||||||
|
.editor-dynamic-preview.identity { background: #1769aa; color: #ffffff; }
|
||||||
|
.editor-dynamic-preview.dyn012 { position: relative; display: flex; gap: 7px; justify-content: center; border-color: #111111; background: #30343b; color: #ffffff; font-family: Dada_FONT081, sans-serif; font-size: 17px; font-weight: 400; }
|
||||||
|
.editor-dynamic-preview.dyn012 i { width: 2px; height: 15px; background: #ffffff; }
|
||||||
|
.editor-dynamic-preview.dyn012 small { position: absolute; top: 3px; right: 6px; color: #ffffff; font-size: 6px; }
|
||||||
|
.editor-source-preview-canvas { display: block; width: 100%; height: 72px; background: #30343b; }
|
||||||
|
|
||||||
|
.editor-text-inspector { display: grid; gap: 14px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||||
|
.editor-text-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||||
|
.editor-text-inspector textarea,
|
||||||
|
.editor-text-inspector input[type="number"],
|
||||||
|
.editor-text-inspector select { width: 100%; min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; color: #111111; font: inherit; }
|
||||||
|
.editor-text-inspector textarea { min-height: 96px; resize: vertical; }
|
||||||
|
.editor-text-number { grid-template-columns: minmax(0, 1fr) 68px; }
|
||||||
|
.editor-text-number > span { grid-column: 1 / -1; }
|
||||||
|
.editor-text-number input[type="range"] { align-self: center; }
|
||||||
|
.editor-text-number input[type="number"] { min-width: 0; }
|
||||||
|
.editor-color-fields { display: grid; gap: 8px; margin: 0; padding: 10px; border: 1px solid #b9b9b3; }
|
||||||
|
.editor-color-fields legend { padding-inline: 4px; font-size: 12px; font-weight: 700; }
|
||||||
|
.editor-text-inspector input[type="color"] { width: 100%; height: 40px; padding: 2px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; }
|
||||||
|
.editor-color-swatches { display: flex; gap: 6px; }
|
||||||
|
.editor-color-swatches button { width: 32px; height: 32px; border: 1px solid #111111; border-radius: 0; }
|
||||||
|
.editor-check { display: flex !important; grid-template-columns: 18px 1fr; align-items: center; }
|
||||||
|
.editor-check input { width: 18px; height: 18px; margin: 0; }
|
||||||
|
.editor-dynamic-inspector { display: grid; gap: 10px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||||
|
.editor-dynamic-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||||
|
.editor-dynamic-inspector textarea { min-height: 82px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; resize: vertical; font: inherit; }
|
||||||
|
.editor-dynamic-inspector .editor-wide-command { margin-top: 0; }
|
||||||
|
.editor-font-substitution { margin: 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 11px; }
|
||||||
|
|
||||||
|
.editor-statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-top: 1px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #62625d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-notice {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 4;
|
||||||
|
top: 68px;
|
||||||
|
left: 50%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #1769aa;
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialog-backdrop { position: fixed; z-index: 5; inset: 0; display: grid; place-items: center; padding: 24px; background: rgb(17 17 17 / 60%); }
|
||||||
|
.editor-confirm { width: min(480px, 100%); padding: 24px; border: 2px solid #111111; background: #ffffff; box-shadow: 8px 8px 0 #111111; }
|
||||||
|
.editor-confirm h2 { margin: 4px 0 12px; font-size: 22px; }
|
||||||
|
.editor-confirm p { margin: 0; }
|
||||||
|
.editor-confirm > div { display: flex; gap: 8px; margin-top: 24px; }
|
||||||
|
.editor-confirm button { min-height: 40px; padding: 8px 14px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 700; }
|
||||||
|
.editor-location-consent label { display: grid; gap: 6px; margin-top: 18px; font-size: 12px; font-weight: 700; }
|
||||||
|
.editor-location-consent input { min-height: 40px; padding: 7px 9px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||||
|
.editor-location-actions { display: grid !important; grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.editor-export-dialog {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: min(760px, calc(100vh - 48px));
|
||||||
|
overflow: auto;
|
||||||
|
border: 2px solid #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 8px 8px 0 #111111;
|
||||||
|
}
|
||||||
|
.editor-export-dialog > header { display: flex; min-height: 82px; align-items: center; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
|
||||||
|
.editor-export-dialog > header p { margin: 0 0 3px; color: #62625d; font-size: 10px; font-weight: 800; }
|
||||||
|
.editor-export-dialog > header h2 { margin: 0; font-size: 22px; }
|
||||||
|
.editor-export-section { padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
|
||||||
|
.editor-export-section h3 { margin: 0 0 10px; font-size: 13px; }
|
||||||
|
.editor-export-segments { display: grid; grid-template-columns: 1fr 1fr; }
|
||||||
|
.editor-export-segments button { min-height: 42px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; }
|
||||||
|
.editor-export-segments button + button { border-left: 0; }
|
||||||
|
.editor-export-segments button[aria-pressed="true"] { background: #111111; color: #ffffff; }
|
||||||
|
.editor-export-label { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.editor-export-label h3 { margin: 0; }
|
||||||
|
.editor-export-label output { min-width: 36px; font-weight: 800; text-align: right; }
|
||||||
|
.editor-export-quality { display: grid; grid-template-columns: minmax(0, 1fr) 72px; gap: 14px; align-items: center; margin-top: 10px; }
|
||||||
|
.editor-export-quality input[type="number"] { min-width: 0; height: 38px; padding: 5px 7px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||||
|
.editor-export-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 20px; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; font-size: 12px; }
|
||||||
|
.editor-export-summary span { color: #62625d; }
|
||||||
|
.editor-export-summary strong { text-align: right; }
|
||||||
|
.editor-export-pending { display: grid; gap: 10px; margin: 18px 22px 0; padding: 14px; border-left: 4px solid #c92a24; background: #f7f7f4; font-size: 12px; }
|
||||||
|
.editor-export-pending span { display: flex; gap: 9px; align-items: center; }
|
||||||
|
.editor-export-pending input { width: 18px; height: 18px; margin: 0; }
|
||||||
|
.editor-export-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 20px 22px; }
|
||||||
|
.editor-export-dialog > footer button,
|
||||||
|
.editor-export-result button { min-height: 42px; padding: 8px 16px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; }
|
||||||
|
.editor-export-result { display: grid; gap: 12px; padding: 28px 22px 22px; }
|
||||||
|
.editor-export-result > p { margin: 0; padding: 14px; border-left: 4px solid #12805c; background: #effaf5; font-weight: 800; }
|
||||||
|
.editor-export-result.failed > p { border-left-color: #c92a24; background: #fff2f0; color: #8f1d14; }
|
||||||
|
.editor-export-result small { color: #62625d; }
|
||||||
|
.editor-export-result > div { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||||
|
|
||||||
|
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.editor-layout { grid-template-columns: 220px minmax(0, 1fr) 280px; }
|
||||||
|
.editor-workspace { padding: 20px; }
|
||||||
|
.editor-toolbar { grid-template-columns: 40px minmax(120px, 1fr) auto minmax(110px, 128px) auto auto; gap: 8px; padding: 0 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||||
|
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
||||||
|
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
||||||
|
.editor-history-actions { order: 3; }
|
||||||
|
.editor-save-status { order: 4; flex: 1 1 128px; }
|
||||||
|
.editor-toolbar-controls > button { display: block; order: 5; }
|
||||||
|
.editor-layout { grid-template-columns: 1fr; }
|
||||||
|
.editor-assets-panel, .editor-inspector { border: 0; }
|
||||||
|
.editor-assets-panel { order: 2; }
|
||||||
|
.editor-inspector { order: 3; }
|
||||||
|
.editor-workspace { min-height: 55vh; order: 1; padding: 16px; }
|
||||||
|
.editor-canvas-frame { width: min(100%, 480px); }
|
||||||
|
.editor-statusbar { flex-wrap: wrap; min-height: 32px; gap: 8px 16px; padding: 8px 12px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,967 @@
|
|||||||
|
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
|
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { ConflictExportGuard, ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
|
||||||
|
import {
|
||||||
|
CanvasEditHistory,
|
||||||
|
createEditorCanvasState,
|
||||||
|
defaultBackgroundAdjustments,
|
||||||
|
resetBackgroundAdjustments,
|
||||||
|
switchBackground,
|
||||||
|
updateBackgroundAdjustments,
|
||||||
|
} from "./editor-canvas.js";
|
||||||
|
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
|
||||||
|
import { EditorStage } from "./editor-stage.js";
|
||||||
|
import { useDialogFocus } from "./dialog-focus.js";
|
||||||
|
import { ExportDialog } from "./export-dialog.js";
|
||||||
|
import {
|
||||||
|
completePendingTextForExport,
|
||||||
|
composeCanvasExport,
|
||||||
|
downloadExportBlob,
|
||||||
|
exportFilename,
|
||||||
|
resolveExportSettings,
|
||||||
|
saveLatestExport,
|
||||||
|
type ExportFormat,
|
||||||
|
} from "./export-compositor.js";
|
||||||
|
import { runExportFlow, type ExportFlowStatus } from "./export-flow.js";
|
||||||
|
import { TextInspector } from "./text-inspector.js";
|
||||||
|
import { createBrowserArchivedFontLoader, type ArchivedFontLoader, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
|
import {
|
||||||
|
fontIdForTextElement,
|
||||||
|
fontOption,
|
||||||
|
P0A_TEXT_TEMPLATES,
|
||||||
|
TextEditSession,
|
||||||
|
createTextTemplateElement,
|
||||||
|
type TextStylePatch,
|
||||||
|
type TextTemplateCategory,
|
||||||
|
type TextTemplateDefinition,
|
||||||
|
} from "./text-assets.js";
|
||||||
|
import { TextTemplatePanel } from "./text-template-panel.js";
|
||||||
|
import { ColorCardPanel } from "./color-card-panel.js";
|
||||||
|
import { DynamicInspector } from "./dynamic-inspector.js";
|
||||||
|
import { DynamicStickerPanel } from "./dynamic-sticker-panel.js";
|
||||||
|
import {
|
||||||
|
LocationConsentGate,
|
||||||
|
P0A_DYNAMIC_STICKERS,
|
||||||
|
browserGeolocate,
|
||||||
|
createDynamicStickerElement,
|
||||||
|
overrideDynamicStickerValue,
|
||||||
|
type DynamicTemplateId,
|
||||||
|
} from "./dynamic-provider.js";
|
||||||
|
import { dynamicFontOptionsFor } from "./dynamic-render-models.js";
|
||||||
|
import { P0A_STATIC_STICKER_CATALOG, stickerWindow, type StaticStickerCatalogItem } from "./static-sticker-catalog.js";
|
||||||
|
import {
|
||||||
|
createColorCardElement,
|
||||||
|
extractPaletteFromImage,
|
||||||
|
type ColorCardDefinition,
|
||||||
|
} from "./palette-provider.js";
|
||||||
|
|
||||||
|
import "./editor-page.css";
|
||||||
|
|
||||||
|
type Ratio = CanvasState["ratio"];
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
type EditorAssetPanel = "background" | "color" | "dynamic" | "history" | "stickers" | "text";
|
||||||
|
|
||||||
|
interface TextEditState {
|
||||||
|
draft: CanvasElement;
|
||||||
|
elementId: string;
|
||||||
|
originalTemplateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorSession {
|
||||||
|
csrf_token: string;
|
||||||
|
user: { creator_name: string; social_id: string; user_id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationDialogState {
|
||||||
|
error?: string;
|
||||||
|
manualValue: string;
|
||||||
|
pending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorExportResult {
|
||||||
|
blob: Blob | null;
|
||||||
|
format: ExportFormat;
|
||||||
|
quality?: number;
|
||||||
|
status: ExportFlowStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorProject {
|
||||||
|
canvas_state?: CanvasState;
|
||||||
|
created_at: string;
|
||||||
|
current_image_id: string | null;
|
||||||
|
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
|
||||||
|
name: string;
|
||||||
|
pixel_height?: number;
|
||||||
|
pixel_width?: number;
|
||||||
|
project_id: string;
|
||||||
|
ratio: Ratio;
|
||||||
|
state_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEditorJson<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||||
|
throw new Error("session_invalid");
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error("editor_request_failed");
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function newElementIdentity(): CanvasElementIdentity {
|
||||||
|
return { createdAt: new Date().toISOString(), elementId: crypto.randomUUID() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStatusLabel(status: ProjectSaveStatus) {
|
||||||
|
return { conflicted: "版本冲突", dirty: "有未保存修改", failed: "保存失败", saved: "已保存", saving: "正在保存" }[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorDialog(props: { children: ReactNode; className?: string; labelledBy: string; onClose: () => void }) {
|
||||||
|
const dialogRef = useDialogFocus(props.onClose);
|
||||||
|
return <div className="editor-dialog-backdrop"><section aria-labelledby={props.labelledBy} aria-modal="true" className={props.className ?? "editor-confirm"} ref={dialogRef} role="dialog" tabIndex={-1}>{props.children}</section></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorPage({ projectId }: { projectId: string }) {
|
||||||
|
const [session, setSession] = useState<EditorSession>();
|
||||||
|
const [project, setProject] = useState<EditorProject>();
|
||||||
|
const [canvasState, setCanvasState] = useState<CanvasState>();
|
||||||
|
const [draftAdjustments, setDraftAdjustments] = useState(defaultBackgroundAdjustments);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
|
||||||
|
const [pendingBackground, setPendingBackground] = useState<string>();
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const [activePanel, setActivePanel] = useState<EditorAssetPanel>("background");
|
||||||
|
const [stickerScrollTop, setStickerScrollTop] = useState(0);
|
||||||
|
const [stickerCatalog, setStickerCatalog] = useState<StaticStickerCatalogItem[]>(P0A_STATIC_STICKER_CATALOG);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [guides, setGuides] = useState<string[]>([]);
|
||||||
|
const [multiMode, setMultiMode] = useState(false);
|
||||||
|
const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>();
|
||||||
|
const [templateCategory, setTemplateCategory] = useState<TextTemplateCategory>();
|
||||||
|
const [templateQuery, setTemplateQuery] = useState("");
|
||||||
|
const [recentTextIds, setRecentTextIds] = useState<string[]>([]);
|
||||||
|
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
|
||||||
|
const [textEdit, setTextEdit] = useState<TextEditState>();
|
||||||
|
const [locationDialog, setLocationDialog] = useState<LocationDialogState>();
|
||||||
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const [exportBusy, setExportBusy] = useState(false);
|
||||||
|
const [exportResult, setExportResult] = useState<EditorExportResult>();
|
||||||
|
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||||
|
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
||||||
|
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||||
|
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||||
|
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||||
|
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||||
|
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||||
|
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||||
|
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
Promise.all([
|
||||||
|
readEditorJson<EditorSession>("/api/v1/auth/session"),
|
||||||
|
readEditorJson<EditorProject>(`/api/v1/projects/${projectId}`),
|
||||||
|
]).then(([nextSession, nextProject]) => {
|
||||||
|
if (!active) return;
|
||||||
|
const initial = nextProject.canvas_state ?? createEditorCanvasState({ assetId: nextProject.current_image_id, ratio: nextProject.ratio });
|
||||||
|
setSession(nextSession);
|
||||||
|
setProject(nextProject);
|
||||||
|
setCanvasState(initial);
|
||||||
|
setDraftAdjustments(initial.background.adjustments);
|
||||||
|
historyRef.current = new CanvasEditHistory(initial);
|
||||||
|
elementControllerRef.current = new CanvasElementController(initial);
|
||||||
|
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) return;
|
||||||
|
let active = true;
|
||||||
|
readEditorJson<{ items: Array<{ asset_id: string }> }>("/api/v1/assets/recent?asset_kind=text_template")
|
||||||
|
.then((response) => { if (active) setRecentTextIds(response.items.map((item) => item.asset_id)); })
|
||||||
|
.catch(() => { if (active) setRecentTextIds([]); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [session?.user.user_id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
readEditorJson<{ items: StaticStickerCatalogItem[] }>("/api/v1/static-stickers/current")
|
||||||
|
.then((response) => {
|
||||||
|
if (!active) return;
|
||||||
|
const uploaded = response.items.filter((item) => item.enabled && item.origin === "admin_uploaded");
|
||||||
|
setStickerCatalog([...P0A_STATIC_STICKER_CATALOG, ...uploaded].sort((left, right) => left.part - right.part || left.order - right.order || left.stable_id.localeCompare(right.stable_id)));
|
||||||
|
})
|
||||||
|
.catch(() => { if (active) setStickerCatalog(P0A_STATIC_STICKER_CATALOG); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!project || !session || !canvasState) return undefined;
|
||||||
|
const queue = new ProjectAutoSaveQueue({
|
||||||
|
initialState: { canvas_state: canvasState, name: project.name },
|
||||||
|
initialVersion: project.state_version,
|
||||||
|
onStatus: setSaveStatus,
|
||||||
|
onSaved: (_snapshot, stateVersion) => {
|
||||||
|
setProject((current) => current ? { ...current, state_version: stateVersion } : current);
|
||||||
|
},
|
||||||
|
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
|
||||||
|
const response = await fetch(`/api/v1/projects/${projectId}/state`, {
|
||||||
|
body: JSON.stringify(snapshot),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json", "Idempotency-Key": operationId, "If-Match": String(stateVersion), "X-CSRF-Token": session.csrf_token },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (response.status === 412) throw new Error("project_state_conflict");
|
||||||
|
if (!response.ok) throw new Error("project_save_failed");
|
||||||
|
const saved = await response.json() as { state_version: number };
|
||||||
|
return { stateVersion: saved.state_version };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
queueRef.current = queue;
|
||||||
|
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
|
||||||
|
}, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasState) return;
|
||||||
|
for (const element of canvasState.elements) {
|
||||||
|
const options = element.type === "text_template"
|
||||||
|
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
|
||||||
|
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
|
||||||
|
for (const option of options) void ensureFont(option.fontId, option.url);
|
||||||
|
}
|
||||||
|
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activePanel !== "dynamic") return;
|
||||||
|
const options = P0A_DYNAMIC_STICKERS.flatMap((definition) => dynamicFontOptionsFor(definition.templateId));
|
||||||
|
for (const option of options) void ensureFont(option.fontId, option.url);
|
||||||
|
}, [activePanel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (candidateMenu) candidateMenuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
|
||||||
|
}, [candidateMenu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasState || selectedIds.length !== 1) {
|
||||||
|
setTextEdit(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = canvasState.elements.find((candidate) => candidate.element_id === selectedIds[0]);
|
||||||
|
if (!element || element.type !== "text_template") {
|
||||||
|
setTextEdit(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTextEdit((current) => current?.elementId === element.element_id ? current : {
|
||||||
|
draft: structuredClone(element), elementId: element.element_id, originalTemplateId: element.template_or_asset_id,
|
||||||
|
});
|
||||||
|
}, [canvasState, selectedIds.join("|")]);
|
||||||
|
|
||||||
|
async function ensureFont(fontId: string, url: string, retry = false) {
|
||||||
|
const current = fontStatuses[fontId];
|
||||||
|
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
||||||
|
const loader = fontLoaderRef.current ?? createBrowserArchivedFontLoader();
|
||||||
|
fontLoaderRef.current = loader;
|
||||||
|
setFontStatuses((statuses) => ({ ...statuses, [fontId]: "loading" }));
|
||||||
|
const status = retry ? await loader.retry({ fontId, url }) : await loader.ensure({ fontId, url });
|
||||||
|
setFontStatuses((statuses) => ({ ...statuses, [fontId]: status }));
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryTextFonts() {
|
||||||
|
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
|
||||||
|
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitCanvas(next: CanvasState) {
|
||||||
|
if (!project || saveStatus === "conflicted") return;
|
||||||
|
historyRef.current?.commit(next);
|
||||||
|
elementControllerRef.current?.replaceState(next);
|
||||||
|
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||||
|
setCanvasState(next);
|
||||||
|
setDraftAdjustments(next.background.adjustments);
|
||||||
|
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||||
|
setTextEdit(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreview() {
|
||||||
|
if (!canvasState) return;
|
||||||
|
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||||
|
setNotice("底图调整已提交");
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
const previous = historyRef.current?.undo();
|
||||||
|
if (previous) {
|
||||||
|
elementControllerRef.current?.replaceState(previous);
|
||||||
|
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||||
|
setCanvasState(previous);
|
||||||
|
setDraftAdjustments(previous.background.adjustments);
|
||||||
|
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
|
||||||
|
setTextEdit(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
const next = historyRef.current?.redo();
|
||||||
|
if (next) {
|
||||||
|
elementControllerRef.current?.replaceState(next);
|
||||||
|
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||||
|
setCanvasState(next);
|
||||||
|
setDraftAdjustments(next.background.adjustments);
|
||||||
|
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||||
|
setTextEdit(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function paletteForAsset(assetId: string) {
|
||||||
|
const image = new Image();
|
||||||
|
const loaded = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("palette_image_unavailable"));
|
||||||
|
});
|
||||||
|
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
|
||||||
|
return extractPaletteFromImage(await loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBackground() {
|
||||||
|
if (!canvasState || !pendingBackground) return;
|
||||||
|
try {
|
||||||
|
const palette = await paletteForAsset(pendingBackground);
|
||||||
|
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||||
|
setPendingBackground(undefined);
|
||||||
|
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||||
|
} catch {
|
||||||
|
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function controllerForCurrent() {
|
||||||
|
const controller = elementControllerRef.current;
|
||||||
|
if (!controller) return undefined;
|
||||||
|
if (controller.selectedIds.length !== selectedIds.length || controller.selectedIds.some((elementId, index) => elementId !== selectedIds[index])) controller.selectIds(selectedIds);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitElementOperation(controller: CanvasElementController, message: string) {
|
||||||
|
const next = controller.value;
|
||||||
|
setSelectedIds(controller.selectedIds);
|
||||||
|
commitCanvas(next);
|
||||||
|
setGuides([]);
|
||||||
|
setCandidateMenu(undefined);
|
||||||
|
setNotice(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSticker(sticker: StaticStickerCatalogItem) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller || !canvasState) return;
|
||||||
|
try {
|
||||||
|
controller.add(createStaticStickerElement({
|
||||||
|
assetId: sticker.stable_id,
|
||||||
|
identity: newElementIdentity(),
|
||||||
|
position: { x: 0.5, y: 0.5 },
|
||||||
|
resourceVersion: sticker.resource_version,
|
||||||
|
zIndex: canvasState.elements.length,
|
||||||
|
}));
|
||||||
|
commitElementOperation(controller, "贴纸已加入画布");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
|
else setNotice("贴纸未能加入画布");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addColorCard(definition: ColorCardDefinition) {
|
||||||
|
const assetId = canvasState?.background.asset_id;
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!assetId || !canvasState || !controller) return;
|
||||||
|
try {
|
||||||
|
const palette = await paletteForAsset(assetId);
|
||||||
|
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
||||||
|
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
|
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDynamicSticker(templateId: DynamicTemplateId, location?: { formattedValue: string; latitude?: number; longitude?: number }) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!canvasState || !controller || !session) return;
|
||||||
|
if (templateId === "DYN012") {
|
||||||
|
const font = fontOption("FONT081");
|
||||||
|
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||||
|
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
controller.add(createDynamicStickerElement(templateId, {
|
||||||
|
...(location ? { location } : {}),
|
||||||
|
now: new Date(),
|
||||||
|
profile: { creatorName: session.user.creator_name, socialId: session.user.social_id },
|
||||||
|
}, newElementIdentity(), canvasState.elements.length));
|
||||||
|
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||||
|
setLocationDialog(undefined);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
|
else setNotice("动态贴纸未能加入画布");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDynamicSticker(templateId: DynamicTemplateId) {
|
||||||
|
if (templateId === "DYN004") {
|
||||||
|
setLocationDialog({ manualValue: "", pending: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void addDynamicSticker(templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAutomaticLocation() {
|
||||||
|
if (!session) return;
|
||||||
|
setLocationDialog((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const { error: _error, ...rest } = current;
|
||||||
|
return { ...rest, pending: true };
|
||||||
|
});
|
||||||
|
const gate = new LocationConsentGate({
|
||||||
|
geolocate: browserGeolocate,
|
||||||
|
reverseGeocode: async (coordinates) => {
|
||||||
|
const response = await fetch("/api/v1/location/reverse-geocode", {
|
||||||
|
body: JSON.stringify(coordinates),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("dynamic_location_service_unavailable");
|
||||||
|
const payload = await response.json() as { formatted_value: string };
|
||||||
|
return payload.formatted_value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await gate.confirm();
|
||||||
|
await addDynamicSticker("DYN004", result);
|
||||||
|
} catch {
|
||||||
|
setLocationDialog((current) => current ? { ...current, error: "自动定位不可用,请改用手动地点贴纸。", pending: false } : current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDynamicOverride(element: CanvasElement, value: string) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller) return;
|
||||||
|
try {
|
||||||
|
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||||
|
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||||
|
} catch {
|
||||||
|
setNotice("动态贴纸显示文字不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
|
||||||
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/assets/recent", {
|
||||||
|
body: JSON.stringify({ asset_id: templateId, asset_kind: "text_template", resource_version: resourceVersion }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
setRecentTextIds((current) => [templateId, ...current.filter((id) => id !== templateId)].slice(0, 12));
|
||||||
|
} catch {
|
||||||
|
// Recent-use metadata does not roll back an otherwise successful canvas edit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTextTemplate(template: TextTemplateDefinition) {
|
||||||
|
if (!template.fontUrl || !canvasState) return;
|
||||||
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
|
if (status !== "ready") {
|
||||||
|
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller) return;
|
||||||
|
try {
|
||||||
|
controller.add(createTextTemplateElement(template, newElementIdentity(), canvasState.elements.length));
|
||||||
|
commitElementOperation(controller, "文字模板已加入画布");
|
||||||
|
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
|
else setNotice("文字模板未能加入画布");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTextDraft(action: (session: TextEditSession) => void) {
|
||||||
|
setTextEdit((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
try {
|
||||||
|
const edit = new TextEditSession(current.draft, P0A_TEXT_TEMPLATES);
|
||||||
|
action(edit);
|
||||||
|
return { ...current, draft: edit.value };
|
||||||
|
} catch {
|
||||||
|
setNotice("文字参数不在允许范围内");
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeTextTemplate(templateId: string) {
|
||||||
|
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||||||
|
if (!template?.fontUrl) return;
|
||||||
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
|
if (status !== "ready") {
|
||||||
|
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeTextFont(fontId: string | null) {
|
||||||
|
if (!fontId) {
|
||||||
|
updateTextDraft((edit) => edit.setStyle({ fontOverride: null }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = fontOption(fontId);
|
||||||
|
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||||
|
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeTextEdit() {
|
||||||
|
if (!textEdit) return;
|
||||||
|
try {
|
||||||
|
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||||||
|
const complete = edit.complete();
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller) return;
|
||||||
|
controller.replaceElement(complete);
|
||||||
|
commitElementOperation(controller, "文字编辑已完成");
|
||||||
|
if (complete.template_or_asset_id !== textEdit.originalTemplateId) {
|
||||||
|
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||||||
|
else setNotice("文字编辑未能完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTextEdit() {
|
||||||
|
if (canvasState && textEdit) {
|
||||||
|
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||||||
|
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||||||
|
}
|
||||||
|
setNotice("已取消未提交的文字修改");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingTextDraft() {
|
||||||
|
if (!canvasState || !textEdit) return undefined;
|
||||||
|
const committed = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||||||
|
return committed && JSON.stringify(committed) !== JSON.stringify(textEdit.draft) ? textEdit : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExportDialog() {
|
||||||
|
setExportResult(undefined);
|
||||||
|
setExportOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeExport(options: { format: ExportFormat; quality?: number }, reuseBlob?: Blob) {
|
||||||
|
if (!canvasState || !project || !session) return;
|
||||||
|
setExportBusy(true);
|
||||||
|
try {
|
||||||
|
let exportState = canvasState;
|
||||||
|
const pending = reuseBlob ? undefined : pendingTextDraft();
|
||||||
|
if (pending) {
|
||||||
|
exportState = completePendingTextForExport(canvasState, pending.draft);
|
||||||
|
commitCanvas(exportState);
|
||||||
|
const completed = exportState.elements.find((element) => element.element_id === pending.elementId);
|
||||||
|
if (completed && completed.template_or_asset_id !== pending.originalTemplateId) {
|
||||||
|
void recordRecentTextTemplate(completed.template_or_asset_id, completed.resource_version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = queueRef.current;
|
||||||
|
const projectSaved = saveStatus !== "conflicted" && Boolean(queue && await queue.saveNow());
|
||||||
|
const settings = resolveExportSettings({ format: options.format, ratio: exportState.ratio, ...(options.quality === undefined ? {} : { quality: options.quality }) });
|
||||||
|
const result = await runExportFlow({
|
||||||
|
compose: async () => reuseBlob ?? composeCanvasExport({
|
||||||
|
canvasState: exportState,
|
||||||
|
fontStatuses,
|
||||||
|
format: options.format,
|
||||||
|
projectId,
|
||||||
|
...(options.quality === undefined ? {} : { quality: options.quality }),
|
||||||
|
}),
|
||||||
|
download: (blob) => downloadExportBlob(blob, exportFilename(project.name, options.format)),
|
||||||
|
persist: async (blob) => {
|
||||||
|
if (!projectSaved || !queue) throw new Error("project_state_not_saved");
|
||||||
|
await saveLatestExport({
|
||||||
|
blob,
|
||||||
|
csrfToken: session.csrf_token,
|
||||||
|
format: options.format,
|
||||||
|
height: settings.height,
|
||||||
|
projectId,
|
||||||
|
stateVersion: queue.stateVersion,
|
||||||
|
width: settings.width,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setExportResult({ blob: result.blob, format: options.format, status: result.status, ...(options.quality === undefined ? {} : { quality: options.quality }) });
|
||||||
|
} catch {
|
||||||
|
setExportResult({ blob: null, format: options.format, status: "composition_failed", ...(options.quality === undefined ? {} : { quality: options.quality }) });
|
||||||
|
} finally {
|
||||||
|
setExportBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startExport(options: { format: ExportFormat; quality?: number }) {
|
||||||
|
if (saveStatus !== "conflicted") {
|
||||||
|
await executeExport(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
||||||
|
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryExportDownload() {
|
||||||
|
if (!exportResult?.blob) return;
|
||||||
|
await executeExport({ format: exportResult.format, ...(exportResult.quality === undefined ? {} : { quality: exportResult.quality }) }, exportResult.blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller || selectedIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
action(controller);
|
||||||
|
commitElementOperation(controller, message);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
|
else setNotice("对象操作未完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginOpacityGesture() {
|
||||||
|
if (!canvasState || selectedIds.length === 0) return;
|
||||||
|
opacityGestureRef.current = { base: canvasState, last: canvasState, selectedIds: [...selectedIds] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeOpacity(value: number) {
|
||||||
|
const gesture = opacityGestureRef.current;
|
||||||
|
if (!gesture) {
|
||||||
|
transformSelection((controller) => { controller.setSelectedOpacity(value); }, "贴纸透明度已提交");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewController = new CanvasElementController(gesture.base);
|
||||||
|
previewController.selectIds(gesture.selectedIds);
|
||||||
|
previewController.setSelectedOpacity(value);
|
||||||
|
gesture.last = previewController.value;
|
||||||
|
setCanvasState(gesture.last);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitOpacityGesture() {
|
||||||
|
const gesture = opacityGestureRef.current;
|
||||||
|
if (!gesture) return;
|
||||||
|
opacityGestureRef.current = undefined;
|
||||||
|
if (gesture.last === gesture.base) return;
|
||||||
|
commitCanvas(gesture.last);
|
||||||
|
setNotice("贴纸透明度已提交");
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSelection() {
|
||||||
|
transformSelection((controller) => controller.duplicateSelected(() => newElementIdentity()), "已复制选中对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSelectionLayer(command: CanvasLayerCommand) {
|
||||||
|
const labels: Record<CanvasLayerCommand, string> = { back: "已置底", backward: "已下移一层", forward: "已上移一层", front: "已置顶" };
|
||||||
|
transformSelection((controller) => { controller.changeLayer(command); }, labels[command]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelection() {
|
||||||
|
transformSelection((controller) => { controller.deleteSelected(); }, "已删除选中对象");
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySelection() {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller || selectedIds.length === 0) return;
|
||||||
|
clipboardRef.current = controller.copySelected();
|
||||||
|
setNotice("已复制到画布剪贴板");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteSelection() {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller || clipboardRef.current.length === 0) return;
|
||||||
|
try {
|
||||||
|
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||||
|
commitElementOperation(controller, "已粘贴画布对象");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAt(point: CanvasPoint, append: boolean) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller || !canvasState) return false;
|
||||||
|
const candidates = controller.candidatesAt(point);
|
||||||
|
const selection = controller.selectAt(point, { append: append || multiMode });
|
||||||
|
setSelectedIds(selection);
|
||||||
|
setCandidateMenu(undefined);
|
||||||
|
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
|
||||||
|
return candidates.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewMove(delta: CanvasPoint) {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
const previewController = new CanvasElementController(drag.base);
|
||||||
|
previewController.selectIds(drag.selectedIds);
|
||||||
|
const preview = previewController.moveSelected(delta);
|
||||||
|
drag.last = preview.state;
|
||||||
|
setCanvasState(preview.state);
|
||||||
|
setGuides(preview.guides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitMove() {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
commitCanvas(drag.last);
|
||||||
|
setNotice("对象位置已提交");
|
||||||
|
setGuides([]);
|
||||||
|
dragRef.current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller) return;
|
||||||
|
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
||||||
|
setCandidateMenu(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCandidates(point: CanvasPoint) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller) return;
|
||||||
|
const elements = controller.candidatesAt(point);
|
||||||
|
if (elements.length > 0) setCandidateMenu({ elements, point });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAllCandidates() {
|
||||||
|
if (!canvasState) return;
|
||||||
|
const elements = [...canvasState.elements].sort((left, right) => right.z_index - left.z_index);
|
||||||
|
if (elements.length > 0) setCandidateMenu({ elements, point: { x: 0.5, y: 0.08 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseCandidate(elementId: string) {
|
||||||
|
const controller = controllerForCurrent();
|
||||||
|
if (!controller) return;
|
||||||
|
setSelectedIds(controller.selectById(elementId, multiMode));
|
||||||
|
setCandidateMenu(undefined);
|
||||||
|
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCandidateMenu() {
|
||||||
|
setCandidateMenu(undefined);
|
||||||
|
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCandidateMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeCandidateMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||||
|
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
|
||||||
|
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||||
|
const next = (current + delta + items.length) % items.length;
|
||||||
|
event.preventDefault();
|
||||||
|
items[next]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
elementControllerRef.current?.clearSelection();
|
||||||
|
setSelectedIds([]);
|
||||||
|
setCandidateMenu(undefined);
|
||||||
|
setGuides([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||||||
|
const renderedCanvasState = textEdit ? {
|
||||||
|
...canvasState,
|
||||||
|
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||||||
|
} : canvasState;
|
||||||
|
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||||
|
const canEdit = saveStatus !== "conflicted";
|
||||||
|
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||||
|
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type !== "text_template")
|
||||||
|
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<div className="editor-page-shell">
|
||||||
|
<a className="editor-skip-link" href="#editor-main">跳到主要内容</a>
|
||||||
|
<header aria-label="编辑器顶部区域" className="editor-toolbar">
|
||||||
|
<div aria-label="编辑器顶部工具栏" className="editor-toolbar-controls" role="toolbar">
|
||||||
|
<a aria-label="返回项目" className="editor-back" href={`/app/projects/${projectId}`}>←</a>
|
||||||
|
<div className="editor-title"><h1>{project.name}</h1><span>{project.ratio}</span></div>
|
||||||
|
<div className="editor-history-actions">
|
||||||
|
<button aria-label="撤销" disabled={!historyRef.current?.canUndo || !canEdit} onClick={undo} title="撤销" type="button">↶</button>
|
||||||
|
<button aria-label="重做" disabled={!historyRef.current?.canRedo || !canEdit} onClick={redo} title="重做" type="button">↷</button>
|
||||||
|
</div>
|
||||||
|
<span aria-live={saveStatus === "failed" || saveStatus === "conflicted" ? "assertive" : "polite"} className={`editor-save-status ${saveStatus}`}>{saveStatusLabel(saveStatus)}</span>
|
||||||
|
<button disabled type="button">预览</button>
|
||||||
|
<button disabled={!canvasState.background.asset_id || (saveStatus === "conflicted" && conflictExportGuardRef.current.used)} onClick={openExportDialog} type="button">导出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{notice ? <p aria-live="polite" className="editor-notice" role="status">{notice}</p> : null}
|
||||||
|
<main className="editor-layout" id="editor-main" tabIndex={-1}>
|
||||||
|
<aside aria-label="素材与底图来源" className="editor-assets-panel">
|
||||||
|
<nav aria-label="编辑器素材分类" className="editor-asset-tabs">
|
||||||
|
{([
|
||||||
|
{ label: "底图", panel: "background" as const },
|
||||||
|
{ label: "历史", panel: "history" as const },
|
||||||
|
{ label: "文字模板", panel: "text" as const },
|
||||||
|
{ label: "普通贴纸", panel: "stickers" as const },
|
||||||
|
{ label: "色卡", panel: "color" as const },
|
||||||
|
{ label: "动态贴纸", panel: "dynamic" as const },
|
||||||
|
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => { setActivePanel(item.panel); if (item.panel === "stickers") setStickerScrollTop(0); }} type="button">{item.label}</button>)}
|
||||||
|
</nav>
|
||||||
|
{activePanel === "background" ? <section><h2>底图</h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span>当前底图</span></button></section> : null}
|
||||||
|
{activePanel === "history" ? <section><h2>历史</h2><div className="editor-history-list">
|
||||||
|
{project.images.length === 0 ? <p className="editor-muted">暂无其他成功图</p> : project.images.map((image) => <button className="editor-source" key={image.image_id} onClick={() => setPendingBackground(image.image_id)} type="button"><span className="editor-thumb" style={{ backgroundImage: `url(/api/v1/private-assets/projects/${projectId}/images/${image.image_id})` }} /><span>生成图 · {formatDate(image.created_at)}</span></button>)}
|
||||||
|
</div></section> : null}
|
||||||
|
{activePanel === "text" ? <TextTemplatePanel
|
||||||
|
canAdd={canEdit && canvasState.elements.length < 50}
|
||||||
|
fontStatuses={fontStatuses}
|
||||||
|
onAdd={(template) => { void addTextTemplate(template); }}
|
||||||
|
onCategory={setTemplateCategory}
|
||||||
|
onQuery={setTemplateQuery}
|
||||||
|
onRetry={() => { void retryTextFonts(); }}
|
||||||
|
query={templateQuery}
|
||||||
|
recentIds={recentTextIds}
|
||||||
|
templates={P0A_TEXT_TEMPLATES}
|
||||||
|
{...(templateCategory ? { category: templateCategory } : {})}
|
||||||
|
/> : null}
|
||||||
|
{activePanel === "stickers" ? (() => {
|
||||||
|
const visibleStickers = stickerWindow(stickerCatalog, stickerScrollTop, 280);
|
||||||
|
return <section><h2>普通贴纸</h2><p aria-live="polite" className="editor-sticker-count">共 {stickerCatalog.length.toLocaleString("zh-CN")} 张</p><div className="editor-sticker-virtual-list" data-testid="static-sticker-list" onScroll={(event) => setStickerScrollTop(event.currentTarget.scrollTop)} role="list">
|
||||||
|
<div style={{ paddingTop: visibleStickers.top_spacer_px, paddingBottom: visibleStickers.bottom_spacer_px }}>
|
||||||
|
<div className="editor-sticker-grid">
|
||||||
|
{visibleStickers.items.map((sticker) => <button aria-label={`添加贴纸 ${sticker.stable_id}`} data-sticker-id={sticker.stable_id} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.stable_id} onClick={() => addSticker(sticker)} type="button"><img alt="" className="editor-sticker-preview" decoding="async" loading="lazy" src={sticker.thumbnail_reference.url} /><strong>{sticker.stable_id}</strong><span>part{sticker.part} · {sticker.order}</span></button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status">画布最多 50 个元素,请先删除现有元素。</p> : null}</section>;
|
||||||
|
})() : null}
|
||||||
|
{activePanel === "color" ? <ColorCardPanel canAdd={canEdit && canvasState.elements.length < 50} hasBackground={Boolean(canvasState.background.asset_id)} onAdd={(definition) => { void addColorCard(definition); }} /> : null}
|
||||||
|
{activePanel === "dynamic" ? <DynamicStickerPanel canAdd={canEdit && canvasState.elements.length < 50} fontStatuses={fontStatuses} onAdd={chooseDynamicSticker} /> : null}
|
||||||
|
</aside>
|
||||||
|
<section aria-label="画布工作区" className="editor-workspace">
|
||||||
|
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
|
||||||
|
<button aria-pressed={multiMode} className={multiMode ? "active" : ""} disabled={!canEdit} onClick={() => setMultiMode((current) => !current)} type="button">多选模式</button>
|
||||||
|
<button disabled={canvasState.elements.length === 0} onClick={showAllCandidates} ref={candidateTriggerRef} type="button">循环选择</button>
|
||||||
|
<button disabled={selectedElements.length === 0 || !canEdit} onClick={copySelection} title="复制" type="button">复制</button>
|
||||||
|
<button disabled={clipboardRef.current.length === 0 || !canEdit} onClick={pasteSelection} title="粘贴" type="button">粘贴</button>
|
||||||
|
</div>
|
||||||
|
<div className="editor-canvas-frame" style={{
|
||||||
|
aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}`,
|
||||||
|
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
|
||||||
|
}}>
|
||||||
|
<EditorStage
|
||||||
|
assetId={canvasState.background.asset_id}
|
||||||
|
canvasState={renderedCanvasState}
|
||||||
|
fontStatuses={fontStatuses}
|
||||||
|
guides={guides}
|
||||||
|
onCandidates={showCandidates}
|
||||||
|
onClearSelection={clearSelection}
|
||||||
|
onCopy={copySelection}
|
||||||
|
onDelete={deleteSelection}
|
||||||
|
onMarquee={marqueeSelect}
|
||||||
|
onMoveCommit={commitMove}
|
||||||
|
onMovePreview={previewMove}
|
||||||
|
onNudge={(delta) => transformSelection((controller) => { controller.moveSelected(delta, { snap: false }); }, "对象位置已提交")}
|
||||||
|
onPaste={pasteSelection}
|
||||||
|
onPointerMoved={() => elementControllerRef.current?.pointerMoved()}
|
||||||
|
onSelect={selectAt}
|
||||||
|
projectId={projectId}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
/>
|
||||||
|
{candidateMenu ? <div aria-label="画布对象候选" className="editor-candidates" onKeyDown={handleCandidateMenuKeyDown} ref={candidateMenuRef} role="menu" style={{ left: `${candidateMenu.point.x * 100}%`, top: `${candidateMenu.point.y * 100}%` }}>
|
||||||
|
{candidateMenu.elements.map((element) => <button key={element.element_id} onClick={() => chooseCandidate(element.element_id)} role="menuitem" type="button">{element.content || `${element.type} · ${element.template_or_asset_id}`}</button>)}
|
||||||
|
</div> : null}
|
||||||
|
</div>
|
||||||
|
{guides.length > 0 ? <span aria-live="polite" className="editor-snap-status">已吸附</span> : null}
|
||||||
|
</section>
|
||||||
|
<aside aria-label={selectedElements.length > 0 ? "对象参数" : "底图参数"} className="editor-inspector">
|
||||||
|
{selectedElements.length === 0 ? <>
|
||||||
|
<header><p>BACKGROUND</p><h2>底图参数</h2></header>
|
||||||
|
<label>适配方式<select disabled={!canEdit} value={draftAdjustments.fit} onChange={(event) => setDraftAdjustments((current) => ({ ...current, fit: event.target.value as typeof current.fit }))}><option value="fill">填充</option><option value="fit">适配</option><option value="crop">裁剪</option></select></label>
|
||||||
|
<label>滤镜<select disabled={!canEdit} value={draftAdjustments.filter} onChange={(event) => setDraftAdjustments((current) => ({ ...current, filter: event.target.value }))}><option value="none">无滤镜</option><option value="grayscale">黑白</option><option value="sepia">复古</option></select></label>
|
||||||
|
{(["brightness", "contrast", "saturation", "temperature", "sharpness"] as const).map((key) => <label className="editor-range" key={key}><span>{({ brightness: "亮度", contrast: "对比度", saturation: "饱和度", temperature: "色温", sharpness: "锐度" } as const)[key]}<output>{draftAdjustments[key]}</output></span><input disabled={!canEdit} max={100} min={key === "sharpness" ? 0 : -100} onChange={(event) => setDraftAdjustments((current) => ({ ...current, [key]: Number(event.target.value) }))} type="range" value={draftAdjustments[key]} /></label>)}
|
||||||
|
<div className="editor-inspector-actions"><button disabled={!canEdit} onClick={() => { if (canvasState) setDraftAdjustments(resetBackgroundAdjustments(canvasState).background.adjustments); }} type="button">恢复原图</button><button className="editor-primary" disabled={!canEdit} onClick={applyPreview} type="button">应用调整</button></div>
|
||||||
|
</> : <>
|
||||||
|
<header><p>{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}</p><h2>{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}</h2></header>
|
||||||
|
{selectedElements.length === 1 ? <p className="editor-object-id">{selectedElements[0]?.template_or_asset_id}</p> : null}
|
||||||
|
{textEdit && selectedElements.length === 1 ? canEdit ? <TextInspector
|
||||||
|
draft={textEdit.draft}
|
||||||
|
onCancel={cancelTextEdit}
|
||||||
|
onComplete={completeTextEdit}
|
||||||
|
onContent={(content) => updateTextDraft((edit) => edit.setContent(content))}
|
||||||
|
onFont={(fontId) => { void changeTextFont(fontId); }}
|
||||||
|
onFontSize={(value) => updateTextDraft((edit) => edit.setEffectiveFontSize(value))}
|
||||||
|
onStyle={(style: TextStylePatch) => updateTextDraft((edit) => edit.setStyle(style))}
|
||||||
|
onTemplate={(templateId) => { void changeTextTemplate(templateId); }}
|
||||||
|
templates={P0A_TEXT_TEMPLATES}
|
||||||
|
/> : <p className="editor-muted">版本冲突,文字属性只读</p> : null}
|
||||||
|
{selectedElements.length === 1 && selectedElements[0]?.type === "dynamic_sticker" && canEdit
|
||||||
|
? <DynamicInspector element={selectedElements[0]} onCommit={(value) => commitDynamicOverride(selectedElements[0]!, value)} />
|
||||||
|
: null}
|
||||||
|
<div className="editor-object-moves" role="group" aria-label="移动对象">
|
||||||
|
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button">←</button>
|
||||||
|
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button">↑</button>
|
||||||
|
<button aria-label="向下移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: 0.01 }, { snap: false }); }, "对象位置已提交")} title="向下移动" type="button">↓</button>
|
||||||
|
<button aria-label="向右移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向右移动" type="button">→</button>
|
||||||
|
</div>
|
||||||
|
<div className="editor-object-tools">
|
||||||
|
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(0.9); }, "对象尺寸已提交")} type="button">缩小</button>
|
||||||
|
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(1.1); }, "对象尺寸已提交")} type="button">放大</button>
|
||||||
|
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(-15); }, "对象旋转已提交")} type="button">逆时针</button>
|
||||||
|
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(15); }, "对象旋转已提交")} type="button">顺时针</button>
|
||||||
|
</div>
|
||||||
|
{selectedStickerOpacity !== undefined ? <>
|
||||||
|
<button className="editor-wide-command" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.flipSelectedHorizontal(); }, "贴纸翻转已提交")} type="button">水平翻转</button>
|
||||||
|
<label className="editor-range"><span>透明度<output>{selectedStickerOpacity}%</output></span><input disabled={!canEdit} max={100} min={0} onBlur={commitOpacityGesture} onChange={(event) => changeOpacity(Number(event.target.value) / 100)} onPointerCancel={commitOpacityGesture} onPointerDown={beginOpacityGesture} onPointerUp={commitOpacityGesture} type="range" value={selectedStickerOpacity} /></label>
|
||||||
|
</> : null}
|
||||||
|
<div className="editor-layer-tools" role="group" aria-label="对象层级">
|
||||||
|
<button disabled={!canEdit} onClick={() => changeSelectionLayer("front")} type="button">置顶</button>
|
||||||
|
<button disabled={!canEdit} onClick={() => changeSelectionLayer("forward")} type="button">上移一层</button>
|
||||||
|
<button disabled={!canEdit} onClick={() => changeSelectionLayer("backward")} type="button">下移一层</button>
|
||||||
|
<button disabled={!canEdit} onClick={() => changeSelectionLayer("back")} type="button">置底</button>
|
||||||
|
</div>
|
||||||
|
<div className="editor-object-actions"><button disabled={!canEdit} onClick={duplicateSelection} type="button">复制</button><button className="editor-danger" disabled={!canEdit} onClick={deleteSelection} type="button">删除</button></div>
|
||||||
|
</>}
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
<footer aria-label="画布状态" className="editor-statusbar"><span>画布 {canvasState.pixel_width} × {canvasState.pixel_height}</span><span>对象 {canvasState.elements.length} / 50</span><span>缩放 100%</span><span>本机保存 · state version {project.state_version}</span></footer>
|
||||||
|
{pendingBackground ? <EditorDialog labelledBy="editor-background-confirm" onClose={() => setPendingBackground(undefined)}><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm">更换底图</h2><p>覆盖元素会保留,底图编辑参数将重置。</p><div><button className="editor-primary" data-dialog-initial-focus onClick={() => { void confirmBackground(); }} type="button">确认更换</button><button onClick={() => setPendingBackground(undefined)} type="button">取消</button></div></EditorDialog> : null}
|
||||||
|
{locationDialog ? <EditorDialog className="editor-confirm editor-location-consent" labelledBy="editor-location-consent" onClose={() => { if (!locationDialog.pending) setLocationDialog(undefined); }}>
|
||||||
|
<p>LOCATION PRIVACY</p><h2 id="editor-location-consent">使用自动定位</h2>
|
||||||
|
<p>原始经纬度会保存到当前项目并显示在导出成品中。只有确认后才会请求浏览器定位,并由本地后端调用地点服务。</p>
|
||||||
|
{locationDialog.error ? <p className="editor-limit" role="alert">{locationDialog.error}</p> : null}
|
||||||
|
<label>手动地点文字<input aria-label="手动地点文字" disabled={locationDialog.pending} onChange={(event) => setLocationDialog((current) => current ? { ...current, manualValue: event.target.value } : current)} value={locationDialog.manualValue} /></label>
|
||||||
|
<div className="editor-location-actions"><button className="editor-primary" data-dialog-initial-focus disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button">改用手动地点贴纸</button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button">暂不定位</button></div>
|
||||||
|
</EditorDialog> : null}
|
||||||
|
{exportOpen ? <ExportDialog
|
||||||
|
busy={exportBusy}
|
||||||
|
onClose={() => { if (!exportBusy) { setExportOpen(false); setExportResult(undefined); } }}
|
||||||
|
onExport={(options) => { void startExport(options); }}
|
||||||
|
onRetry={() => { void retryExportDownload(); }}
|
||||||
|
pendingEdit={Boolean(pendingTextDraft())}
|
||||||
|
ratio={canvasState.ratio}
|
||||||
|
{...(exportResult?.blob ? { byteSize: exportResult.blob.size } : {})}
|
||||||
|
{...(exportResult ? { result: exportResult.status } : {})}
|
||||||
|
/> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
|
||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { cssFilterForBackground } from "./editor-canvas.js";
|
||||||
|
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
|
||||||
|
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||||
|
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
|
||||||
|
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
||||||
|
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
|
import { fontIdForTextElement } from "./text-assets.js";
|
||||||
|
import { drawColorCard } from "./palette-provider.js";
|
||||||
|
|
||||||
|
interface Gesture {
|
||||||
|
append: boolean;
|
||||||
|
hit: boolean;
|
||||||
|
longPressOpened: boolean;
|
||||||
|
pointerId: number;
|
||||||
|
start: CanvasPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorStageProps {
|
||||||
|
assetId: string | null;
|
||||||
|
canvasState: CanvasState;
|
||||||
|
guides: readonly string[];
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
|
onCandidates: (point: CanvasPoint) => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
||||||
|
onMoveCommit: () => void;
|
||||||
|
onMovePreview: (delta: CanvasPoint) => void;
|
||||||
|
onNudge: (delta: CanvasPoint) => void;
|
||||||
|
onPaste: () => void;
|
||||||
|
onPointerMoved: () => void;
|
||||||
|
onSelect: (point: CanvasPoint, append: boolean) => boolean;
|
||||||
|
projectId: string;
|
||||||
|
selectedIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||||
|
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleValue<T extends string | number | boolean>(element: CanvasState["elements"][number], key: string, fallback: T): T {
|
||||||
|
const value = element.style_parameters?.[key];
|
||||||
|
return typeof value === typeof fallback ? value as T : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextPixelGeometry {
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
||||||
|
const fontSize = element.font_size ?? 48;
|
||||||
|
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||||
|
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
|
||||||
|
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string): TextPixelGeometry {
|
||||||
|
prepareTextContext(context, element, fontId);
|
||||||
|
const fontSize = element.font_size ?? 48;
|
||||||
|
const lineHeight = styleValue(element, "line_height", 1.2);
|
||||||
|
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||||
|
const strokeWidth = styleValue(element, "stroke_width", 0);
|
||||||
|
const lines = (element.content ?? "").split("\n");
|
||||||
|
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
|
||||||
|
return {
|
||||||
|
height: Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight) + 32 + strokeWidth * 2,
|
||||||
|
width: Math.max(1, ...widths) + 32 + strokeWidth * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
|
||||||
|
const fontId = fontIdForTextElement(element);
|
||||||
|
if (!fontId || fontStatuses[fontId] !== "ready") {
|
||||||
|
context.fillStyle = "#e5e7eb";
|
||||||
|
context.fillRect(-110, -34, 220, 68);
|
||||||
|
context.fillStyle = "#9f1d1d";
|
||||||
|
context.font = "600 22px Microsoft YaHei UI, sans-serif";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.fillText("字体不可用", 0, 8);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fontSize = element.font_size ?? 48;
|
||||||
|
const lineHeight = styleValue(element, "line_height", 1.2);
|
||||||
|
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||||
|
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
|
||||||
|
const lines = (element.content ?? "").split("\n");
|
||||||
|
prepareTextContext(context, element, fontId);
|
||||||
|
context.textAlign = align;
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
|
||||||
|
const textWidth = Math.max(1, ...widths);
|
||||||
|
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
|
||||||
|
const padding = 16;
|
||||||
|
const backgroundEnabled = styleValue(element, "background_enabled", false);
|
||||||
|
if (backgroundEnabled) {
|
||||||
|
const elementOpacity = context.globalAlpha;
|
||||||
|
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
|
||||||
|
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
|
||||||
|
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
|
||||||
|
context.globalAlpha = elementOpacity;
|
||||||
|
}
|
||||||
|
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
|
||||||
|
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
|
||||||
|
context.fillStyle = styleValue(element, "fill_color", "#111111");
|
||||||
|
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
|
||||||
|
context.lineWidth = styleValue(element, "stroke_width", 0);
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const y = firstY + index * fontSize * lineHeight;
|
||||||
|
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
|
||||||
|
context.fillText(line, anchorX, y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
|
||||||
|
context.fillStyle = "#e5e7eb";
|
||||||
|
context.fillRect(-140, -42, 280, 84);
|
||||||
|
context.fillStyle = "#9f1d1d";
|
||||||
|
context.font = "600 18px Microsoft YaHei UI, sans-serif";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText(message, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawDynamicSticker(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
element: CanvasState["elements"][number],
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||||
|
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||||
|
) {
|
||||||
|
const id = element.template_or_asset_id as DynamicTemplateId;
|
||||||
|
const model = DYNAMIC_RENDER_MODELS[id];
|
||||||
|
if (!model) {
|
||||||
|
drawDynamicUnavailable(context, "动态贴纸模型不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dynamicFontOptionsFor(id).some((font) => fontStatuses[font.fontId] !== "ready")) {
|
||||||
|
drawDynamicUnavailable(context, id === "DYN012" ? "FONT081 替代字体不可用" : "原版字体不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (model.imageLayers.some((layer) => !resourceImages[layer.assetId])) {
|
||||||
|
drawDynamicUnavailable(context, "原版图片资源不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
for (const layer of model.imageLayers) {
|
||||||
|
const image = resourceImages[layer.assetId]!;
|
||||||
|
context.drawImage(image, layer.x - layer.width / 2, layer.y - layer.height / 2, layer.width, layer.height);
|
||||||
|
}
|
||||||
|
if (id === "DYN012") {
|
||||||
|
const parts = dyn012DisplayParts(element);
|
||||||
|
const layout = DYN012_RENDER_LAYOUT;
|
||||||
|
context.fillStyle = layout.divider.color;
|
||||||
|
context.fillRect(layout.divider.x - layout.divider.width / 2, layout.divider.y - layout.divider.height / 2, layout.divider.width, layout.divider.height);
|
||||||
|
for (const [part, layer] of [[parts.hour, layout.hour], [parts.minute, layout.minute], [parts.meridiem, layout.meridiem]] as const) {
|
||||||
|
context.fillStyle = layer.color;
|
||||||
|
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.fillText(part, layer.x, layer.y);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const layer of model.textLayers) {
|
||||||
|
context.fillStyle = layer.color;
|
||||||
|
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||||
|
context.textAlign = layer.align;
|
||||||
|
context.fillText(dynamicTextValue(element, layer.value), layer.x, layer.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementSelectionHalfSize(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
element: CanvasState["elements"][number],
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||||
|
) {
|
||||||
|
if (element.type === "color_card") {
|
||||||
|
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
|
||||||
|
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
|
||||||
|
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
|
||||||
|
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
|
||||||
|
}
|
||||||
|
if (element.type === "dynamic_sticker") {
|
||||||
|
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||||
|
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
|
||||||
|
}
|
||||||
|
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
|
||||||
|
const fontId = fontIdForTextElement(element);
|
||||||
|
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
|
||||||
|
context.save();
|
||||||
|
const geometry = measureTextElement(context, element, fontId);
|
||||||
|
context.restore();
|
||||||
|
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawElement(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
element: CanvasState["elements"][number],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||||
|
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||||
|
) {
|
||||||
|
const x = element.position.x * width;
|
||||||
|
const y = element.position.y * height;
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha = element.opacity;
|
||||||
|
context.translate(x, y);
|
||||||
|
context.rotate((element.rotation * Math.PI) / 180);
|
||||||
|
const flip = element.style_parameters?.flip_horizontal === true ? -1 : 1;
|
||||||
|
context.scale(element.scale.x * flip, element.scale.y);
|
||||||
|
if (element.type === "color_card") drawColorCard(context, element);
|
||||||
|
else if (element.type === "static_sticker") {
|
||||||
|
const image = resourceImages[element.template_or_asset_id];
|
||||||
|
if (!image) drawDynamicUnavailable(context, "原版贴纸资源不可用");
|
||||||
|
else {
|
||||||
|
const scale = 156 / Math.max(image.naturalWidth, image.naturalHeight);
|
||||||
|
const width = image.naturalWidth * scale;
|
||||||
|
const height = image.naturalHeight * scale;
|
||||||
|
context.drawImage(image, -width / 2, -height / 2, width, height);
|
||||||
|
}
|
||||||
|
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
|
||||||
|
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCanvasImage(url: string) {
|
||||||
|
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => resolve(undefined);
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
||||||
|
const imageReferences = new Map<string, string>();
|
||||||
|
for (const element of canvasState.elements) {
|
||||||
|
if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id));
|
||||||
|
if (element.type === "dynamic_sticker") {
|
||||||
|
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||||
|
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imageReferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
|
||||||
|
const background = canvasState.background.asset_id
|
||||||
|
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
||||||
|
: Promise.resolve(undefined);
|
||||||
|
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||||
|
const [image, loaded] = await Promise.all([background, resources]);
|
||||||
|
return {
|
||||||
|
background: image,
|
||||||
|
resourceImages: Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditorScene(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
canvasState: CanvasState,
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||||
|
background: HTMLImageElement | undefined,
|
||||||
|
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||||
|
) {
|
||||||
|
context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||||
|
context.filter = cssFilterForBackground(canvasState.background.adjustments);
|
||||||
|
if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||||
|
context.filter = "none";
|
||||||
|
for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) {
|
||||||
|
drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredFontIds(canvasState: CanvasState) {
|
||||||
|
return canvasState.elements.flatMap((element) => {
|
||||||
|
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
|
||||||
|
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderCanvasStateForExport(input: {
|
||||||
|
canvasState: CanvasState;
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
const missingFont = requiredFontIds(input.canvasState).find((fontId) => input.fontStatuses[fontId] !== "ready");
|
||||||
|
if (missingFont) throw new Error("export_font_unavailable");
|
||||||
|
await document.fonts.ready;
|
||||||
|
const resources = await loadSceneResources(input.canvasState, input.projectId);
|
||||||
|
if (input.canvasState.background.asset_id && !resources.background) throw new Error("export_background_unavailable");
|
||||||
|
if (Object.keys(resources.resourceImages).length !== resourceUrlsForCanvas(input.canvasState).size) throw new Error("export_asset_unavailable");
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = input.canvasState.pixel_width;
|
||||||
|
canvas.height = input.canvasState.pixel_height;
|
||||||
|
const context = canvas.getContext("2d", { colorSpace: "srgb" });
|
||||||
|
if (!context) throw new Error("export_canvas_unavailable");
|
||||||
|
renderEditorScene(context, input.canvasState, input.fontStatuses, resources.background, resources.resourceImages);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorStage(props: EditorStageProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||||
|
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return undefined;
|
||||||
|
canvas.width = props.canvasState.pixel_width;
|
||||||
|
canvas.height = props.canvasState.pixel_height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return undefined;
|
||||||
|
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||||
|
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
||||||
|
context.lineWidth = 4;
|
||||||
|
context.strokeStyle = "#005fcc";
|
||||||
|
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||||
|
const { width: halfWidth, height: halfHeight } = elementSelectionHalfSize(context, element, props.fontStatuses);
|
||||||
|
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
|
||||||
|
}
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = "#005fcc";
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.setLineDash([12, 8]);
|
||||||
|
if (props.guides.some((guide) => guide.includes("-x"))) {
|
||||||
|
context.beginPath(); context.moveTo(canvas.width / 2, 0); context.lineTo(canvas.width / 2, canvas.height); context.stroke();
|
||||||
|
}
|
||||||
|
if (props.guides.some((guide) => guide.includes("-y"))) {
|
||||||
|
context.beginPath(); context.moveTo(0, canvas.height / 2); context.lineTo(canvas.width, canvas.height / 2); context.stroke();
|
||||||
|
}
|
||||||
|
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
|
||||||
|
if (!active) return;
|
||||||
|
render(background, resourceImages);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
||||||
|
|
||||||
|
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
const start = pointFromEvent(event);
|
||||||
|
const append = event.shiftKey;
|
||||||
|
const hit = props.onSelect(start, append);
|
||||||
|
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
longPressRef.current = setTimeout(() => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (!gesture || !gesture.hit) return;
|
||||||
|
gesture.longPressOpened = true;
|
||||||
|
props.onCandidates(gesture.start);
|
||||||
|
}, 550);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent<HTMLCanvasElement>) {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) {
|
||||||
|
props.onPointerMoved();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = pointFromEvent(event);
|
||||||
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
|
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
|
||||||
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
|
props.onPointerMoved();
|
||||||
|
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
||||||
|
else if (!gesture.hit) setMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent<HTMLCanvasElement>) {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
|
const point = pointFromEvent(event);
|
||||||
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
|
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
|
||||||
|
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
||||||
|
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||||
|
setMarquee(undefined);
|
||||||
|
gestureRef.current = undefined;
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLCanvasElement>) {
|
||||||
|
const step = event.shiftKey ? 0.01 : 0.002;
|
||||||
|
if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); props.onDelete(); }
|
||||||
|
else if (event.key === "Escape") { event.preventDefault(); props.onClearSelection(); }
|
||||||
|
else if (event.ctrlKey && event.key.toLowerCase() === "c") { event.preventDefault(); props.onCopy(); }
|
||||||
|
else if (event.ctrlKey && event.key.toLowerCase() === "v") { event.preventDefault(); props.onPaste(); }
|
||||||
|
else if (event.key.startsWith("Arrow")) {
|
||||||
|
event.preventDefault();
|
||||||
|
props.onNudge({ x: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0, y: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <canvas aria-label="编辑画布" className="editor-canvas" onKeyDown={handleKeyDown} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} ref={canvasRef} tabIndex={0} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { renderCanvasStateForExport } from "./editor-stage.js";
|
||||||
|
import { P0A_TEXT_TEMPLATES, TextEditSession } from "./text-assets.js";
|
||||||
|
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
|
|
||||||
|
export type ExportFormat = "jpg" | "png";
|
||||||
|
export type ExportRatio = CanvasState["ratio"];
|
||||||
|
|
||||||
|
export const EXPORT_DIMENSIONS: Readonly<Record<ExportRatio, { height: number; width: number }>> = {
|
||||||
|
"1:1": { height: 1080, width: 1080 },
|
||||||
|
"3:4": { height: 1440, width: 1080 },
|
||||||
|
"4:3": { height: 1080, width: 1440 },
|
||||||
|
"9:16": { height: 1920, width: 1080 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ExportSettings {
|
||||||
|
format: ExportFormat;
|
||||||
|
height: number;
|
||||||
|
mimeType: "image/jpeg" | "image/png";
|
||||||
|
quality?: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExportSettings(input: { format: ExportFormat; quality?: number; ratio: ExportRatio }): ExportSettings {
|
||||||
|
const dimensions = EXPORT_DIMENSIONS[input.ratio];
|
||||||
|
if (input.format === "png") return { format: "png", height: dimensions.height, mimeType: "image/png", width: dimensions.width };
|
||||||
|
const quality = input.quality ?? 92;
|
||||||
|
if (!Number.isInteger(quality) || quality < 80 || quality > 100) throw new Error("export_quality_invalid");
|
||||||
|
return { format: "jpg", height: dimensions.height, mimeType: "image/jpeg", quality, width: dimensions.width };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completePendingTextForExport(canvasState: CanvasState, draft: CanvasState["elements"][number]) {
|
||||||
|
if (draft.type !== "text_template") throw new Error("export_pending_text_invalid");
|
||||||
|
const index = canvasState.elements.findIndex((element) => element.element_id === draft.element_id && element.type === "text_template");
|
||||||
|
if (index < 0) throw new Error("export_pending_text_missing");
|
||||||
|
const complete = new TextEditSession(draft, P0A_TEXT_TEMPLATES).complete();
|
||||||
|
const next = structuredClone(canvasState);
|
||||||
|
next.elements[index] = complete;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sha256Blob(blob: Blob) {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
||||||
|
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeCanvasExport(canvas: HTMLCanvasElement, settings: ExportSettings) {
|
||||||
|
if (canvas.width !== settings.width || canvas.height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
|
||||||
|
return new Promise<Blob>((resolve, reject) => {
|
||||||
|
const quality = settings.format === "jpg" ? settings.quality! / 100 : undefined;
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob || blob.type !== settings.mimeType) reject(new Error("export_encode_failed"));
|
||||||
|
else resolve(blob);
|
||||||
|
}, settings.mimeType, quality);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function composeCanvasExport(input: {
|
||||||
|
canvasState: CanvasState;
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
|
format: ExportFormat;
|
||||||
|
projectId: string;
|
||||||
|
quality?: number;
|
||||||
|
}) {
|
||||||
|
const settings = resolveExportSettings({ format: input.format, ratio: input.canvasState.ratio, ...(input.quality === undefined ? {} : { quality: input.quality }) });
|
||||||
|
if (input.canvasState.pixel_width !== settings.width || input.canvasState.pixel_height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
|
||||||
|
const canvas = await renderCanvasStateForExport({ canvasState: input.canvasState, fontStatuses: input.fontStatuses, projectId: input.projectId });
|
||||||
|
return encodeCanvasExport(canvas, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadExportBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.style.display = "none";
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportFilename(projectName: string, format: ExportFormat) {
|
||||||
|
const safeName = projectName.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_").slice(0, 80) || "Dada成品";
|
||||||
|
return `${safeName}.${format === "jpg" ? "jpg" : "png"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLatestExport(input: {
|
||||||
|
blob: Blob;
|
||||||
|
csrfToken: string;
|
||||||
|
format: ExportFormat;
|
||||||
|
height: number;
|
||||||
|
projectId: string;
|
||||||
|
stateVersion: number;
|
||||||
|
width: number;
|
||||||
|
}) {
|
||||||
|
const sha256 = await sha256Blob(input.blob);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("format", input.format);
|
||||||
|
form.append("export_id", crypto.randomUUID());
|
||||||
|
form.append("sha256", sha256);
|
||||||
|
form.append("byte_size", String(input.blob.size));
|
||||||
|
form.append("pixel_width", String(input.width));
|
||||||
|
form.append("pixel_height", String(input.height));
|
||||||
|
form.append("state_version", String(input.stateVersion));
|
||||||
|
form.append("export_file", input.blob, `latest.${input.format}`);
|
||||||
|
const response = await fetch(`/api/v1/projects/${encodeURIComponent(input.projectId)}/latest-exports/${input.format}`, {
|
||||||
|
body: form,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-CSRF-Token": input.csrfToken },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(response.status === 409 ? "latest_export_conflict" : "latest_export_failed");
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useDialogFocus } from "./dialog-focus.js";
|
||||||
|
import { exportResultCopy, type ExportFlowStatus } from "./export-flow.js";
|
||||||
|
import { EXPORT_DIMENSIONS, type ExportFormat, type ExportRatio } from "./export-compositor.js";
|
||||||
|
|
||||||
|
export function ExportDialog(props: {
|
||||||
|
busy: boolean;
|
||||||
|
byteSize?: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onExport: (input: { format: ExportFormat; quality?: number }) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
pendingEdit: boolean;
|
||||||
|
ratio: ExportRatio;
|
||||||
|
result?: ExportFlowStatus;
|
||||||
|
}) {
|
||||||
|
const [format, setFormat] = useState<ExportFormat>("jpg");
|
||||||
|
const [quality, setQuality] = useState(92);
|
||||||
|
const [pendingConfirmed, setPendingConfirmed] = useState(false);
|
||||||
|
const dimensions = EXPORT_DIMENSIONS[props.ratio];
|
||||||
|
const failure = props.result && props.result !== "downloaded_and_saved";
|
||||||
|
const dialogRef = useDialogFocus(props.onClose);
|
||||||
|
return <div className="editor-dialog-backdrop"><section aria-labelledby="editor-export-title" aria-modal="true" className="editor-export-dialog" ref={dialogRef} role="dialog" tabIndex={-1}>
|
||||||
|
<header><div><p>EXPORT</p><h2 id="editor-export-title">导出成品</h2></div></header>
|
||||||
|
{props.result ? <div className={`editor-export-result ${failure ? "failed" : "succeeded"}`}>
|
||||||
|
<p aria-live={failure ? "assertive" : "polite"} role={failure ? "alert" : "status"}>{exportResultCopy[props.result]}</p>
|
||||||
|
{props.byteSize ? <small>{(props.byteSize / 1024 / 1024).toFixed(2)} MB</small> : null}
|
||||||
|
<div>{props.result === "download_failed" ? <button disabled={props.busy} onClick={props.onRetry} type="button">重新下载同一次合成</button> : null}<button disabled={props.busy} onClick={props.onClose} type="button">关闭</button></div>
|
||||||
|
</div> : <>
|
||||||
|
<section className="editor-export-section"><h3>文件格式</h3><div aria-label="导出格式" className="editor-export-segments" role="group">
|
||||||
|
<button aria-pressed={format === "jpg"} data-dialog-initial-focus onClick={() => setFormat("jpg")} type="button">JPG</button>
|
||||||
|
<button aria-pressed={format === "png"} onClick={() => setFormat("png")} type="button">PNG</button>
|
||||||
|
</div></section>
|
||||||
|
{format === "jpg" ? <section className="editor-export-section"><div className="editor-export-label"><h3>JPG 质量</h3><output>{quality}</output></div><div className="editor-export-quality"><input aria-label="JPG 质量" max="100" min="80" onChange={(event) => setQuality(Number(event.target.value))} step="1" type="range" value={quality} /><input aria-label="JPG 质量数值" max="100" min="80" onChange={(event) => setQuality(Math.max(80, Math.min(100, Number(event.target.value))))} step="1" type="number" value={quality} /></div></section> : null}
|
||||||
|
<section className="editor-export-summary"><span>输出尺寸</span><strong>{dimensions.width} × {dimensions.height} px</strong><span>色彩空间</span><strong>sRGB</strong><span>预计文件大小</span><strong>合成后显示</strong></section>
|
||||||
|
{props.pendingEdit ? <label className="editor-export-pending"><strong>导出前需要提交当前修改</strong><span><input checked={pendingConfirmed} onChange={(event) => setPendingConfirmed(event.target.checked)} type="checkbox" />将应用当前修改并导出</span></label> : null}
|
||||||
|
<footer><button className="editor-primary" disabled={props.busy || (props.pendingEdit && !pendingConfirmed)} onClick={() => props.onExport({ format, ...(format === "jpg" ? { quality } : {}) })} type="button">{props.busy ? "正在导出" : "导出并下载"}</button><button disabled={props.busy} onClick={props.onClose} type="button">取消</button></footer>
|
||||||
|
</>}
|
||||||
|
</section></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type ExportFlowStatus = "composition_failed" | "download_failed" | "downloaded_not_saved" | "downloaded_and_saved";
|
||||||
|
|
||||||
|
export const exportResultCopy: Record<ExportFlowStatus, string> = {
|
||||||
|
composition_failed: "合成失败,请返回编辑器检查",
|
||||||
|
download_failed: "浏览器下载失败,可重新下载同一次合成结果",
|
||||||
|
downloaded_not_saved: "文件已下载,但未保存为最新成品",
|
||||||
|
downloaded_and_saved: "已下载并保存为最新成品",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runExportFlow(input: {
|
||||||
|
compose: () => Promise<Blob>;
|
||||||
|
download: (blob: Blob) => Promise<void>;
|
||||||
|
persist: (blob: Blob) => Promise<void>;
|
||||||
|
}): Promise<{ blob: Blob | null; status: ExportFlowStatus }> {
|
||||||
|
let blob: Blob;
|
||||||
|
try {
|
||||||
|
blob = await input.compose();
|
||||||
|
} catch {
|
||||||
|
return { blob: null, status: "composition_failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await input.download(blob);
|
||||||
|
} catch {
|
||||||
|
return { blob, status: "download_failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await input.persist(blob);
|
||||||
|
return { blob, status: "downloaded_and_saved" };
|
||||||
|
} catch {
|
||||||
|
return { blob, status: "downloaded_not_saved" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +1,427 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
|
import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOperationAuditResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, PrivateContentAccessAuditResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js";
|
||||||
|
|
||||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||||
|
|
||||||
export async function checkBrowserSupport(body: {
|
export async function ackPrivateContentNotice(body: PrivateContentNoticeAckRequest, options: ClientOptions = {}): Promise<PrivateContentNoticeAckResponse> {
|
||||||
"brands": Array<{
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
"brand": string;
|
const headers = new Headers(options.headers);
|
||||||
"version": string;
|
headers.set("Content-Type", "application/json");
|
||||||
}>;
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content-notice/ack`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
"full_version_list": Array<{
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
"brand": string;
|
return response.json() as Promise<PrivateContentNoticeAckResponse>;
|
||||||
"version": string;
|
}
|
||||||
}>;
|
|
||||||
"platform": string;
|
export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> {
|
||||||
}, options: ClientOptions = {}): Promise<{
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
"app_version": string;
|
const headers = new Headers(options.headers);
|
||||||
"browser": {
|
headers.set("Content-Type", "application/json");
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credit-adjustments`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
"major": number;
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
};
|
return response.json() as Promise<CreditAdjustmentResponse>;
|
||||||
"status": "supported";
|
}
|
||||||
"supported_browsers": Array<{
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
export async function checkAdminServiceHealth(body: AdminServiceHealthCheckRequest, options: ClientOptions = {}): Promise<{
|
||||||
"major": number;
|
"available": boolean;
|
||||||
}>;
|
"check_id": string;
|
||||||
|
"checked_at": string;
|
||||||
}> {
|
}> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/health-check`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<{
|
||||||
|
"available": boolean;
|
||||||
|
"check_id": string;
|
||||||
|
"checked_at": string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise<BrowserSupportSuccess> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/support/check`, { body: JSON.stringify(body), method: "POST", headers });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/support/check`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<BrowserSupportSuccess>;
|
||||||
"app_version": string;
|
|
||||||
"browser": {
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
|
||||||
"major": number;
|
|
||||||
};
|
|
||||||
"status": "supported";
|
|
||||||
"supported_browsers": Array<{
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
|
||||||
"major": number;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise<AccountDeletionResponse> {
|
||||||
"app_version": string;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
"dependencies": Array<{
|
const headers = new Headers(options.headers);
|
||||||
"service_id": string;
|
headers.set("Content-Type", "application/json");
|
||||||
"status": "available" | "paused" | "degraded" | "unavailable";
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
}>;
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
"model_summary": {
|
return response.json() as Promise<AccountDeletionResponse>;
|
||||||
"config_set_version": number | null;
|
}
|
||||||
"configured_default_model_id": string | null;
|
|
||||||
"recommended_model_id": string | null;
|
export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
|
||||||
"runtime_availability_version": number | null;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
};
|
const headers = new Headers(options.headers);
|
||||||
"public_features": Array<{
|
headers.set("Content-Type", "application/json");
|
||||||
"feature_id": string;
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
"status": "enabled" | "disabled" | "paused";
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
}>;
|
return response.json() as Promise<AdminLoginCompleteResponse>;
|
||||||
}> {
|
}
|
||||||
|
|
||||||
|
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<LoginCompleteResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<LoginCompleteResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise<RegistrationCompleteResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/register/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<RegistrationCompleteResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGeneration(body: FormData, options: ClientOptions = {}): Promise<GenerationCreateResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { body: body, method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<GenerationCreateResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadLatestExport(options: ClientOptions = {}): Promise<Blob> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.blob() as Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadOriginalGeneration(options: ClientOptions = {}): Promise<Blob> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/private-assets/projects/{projectId}/images/{imageId}`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.blob() as Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AccountSettingsResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminDiagnostics(options: ClientOptions = {}): Promise<AdminDiagnosticsResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/diagnostics`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminDiagnosticsResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminOperationAudit(options: ClientOptions = {}): Promise<AdminOperationAuditResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/operations`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminOperationAuditResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminOverviewResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminServices(options: ClientOptions = {}): Promise<AdminServicesResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminServicesResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminServicesStorage(options: ClientOptions = {}): Promise<AdminServicesStorageResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services-storage`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminServicesStorageResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminSessionResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminUserCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credits`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<CreditBalanceResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBootstrap(options: ClientOptions = {}): Promise<BootstrapResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/bootstrap`, { method: "GET", headers: options.headers ?? {} });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<BootstrapResponse>;
|
||||||
"app_version": string;
|
}
|
||||||
"dependencies": Array<{
|
|
||||||
"service_id": string;
|
export async function getCurrentGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
|
||||||
"status": "available" | "paused" | "degraded" | "unavailable";
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
}>;
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/current`, { method: "GET", headers: options.headers ?? {} });
|
||||||
"model_summary": {
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
"config_set_version": number | null;
|
return response.json() as Promise<GenerationTaskResponse>;
|
||||||
"configured_default_model_id": string | null;
|
|
||||||
"recommended_model_id": string | null;
|
|
||||||
"runtime_availability_version": number | null;
|
|
||||||
};
|
|
||||||
"public_features": Array<{
|
|
||||||
"feature_id": string;
|
|
||||||
"status": "enabled" | "disabled" | "paused";
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
|
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
|
||||||
return `${options.baseUrl ?? ""}/api/v1/events`;
|
return `${options.baseUrl ?? ""}/api/v1/events`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/{generationId}`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<GenerationTaskResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModel(options: ClientOptions = {}): Promise<ModelConfig> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/models/{model_id}`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ModelConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModels(options: ClientOptions = {}): Promise<ModelConfigurationResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/models`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ModelConfigurationResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<CreditLedgerResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credits`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<CreditBalanceResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrivateContentAccessAudit(options: ClientOptions = {}): Promise<PrivateContentAccessAuditResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/private-content`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<PrivateContentAccessAuditResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ProjectDetailResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserSession(options: ClientOptions = {}): Promise<UserSessionResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<UserSessionResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminGenerations(options: ClientOptions = {}): Promise<AdminGenerationListResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/generations`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminGenerationListResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ProjectListResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecentAssets(options: ClientOptions = {}): Promise<RecentAssetListResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/assets/recent`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<RecentAssetListResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<LogoutResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAdminGenerationImage(options: ClientOptions = {}): Promise<Blob> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/image`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.blob() as Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAdminGenerationPrompt(options: ClientOptions = {}): Promise<PrivateContentPromptResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/prompt`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<PrivateContentPromptResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ProjectPurgeResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordRecentAsset(body: RecentAssetRecordRequest, options: ClientOptions = {}): Promise<RecentAssetRecordResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/assets/recent`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<RecentAssetRecordResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverAdminService(body: AdminServiceRecoveryRequest, options: ClientOptions = {}): Promise<{
|
||||||
|
"status": "active";
|
||||||
|
}> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/recover`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<{
|
||||||
|
"status": "active";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { body: JSON.stringify(body), method: "PATCH", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ProjectRenameResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceModelConfiguration(body: ModelConfigUpdateRequest, options: ClientOptions = {}): Promise<ModelConfigurationResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/models/configuration`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ModelConfigurationResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ProjectRestoreResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reverseGeocodeLocation(body: ReverseGeocodeRequest, options: ClientOptions = {}): Promise<ReverseGeocodeResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/location/reverse-geocode`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ReverseGeocodeResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLatestExport(body: FormData, options: ClientOptions = {}): Promise<LatestExportSaveResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { body: body, method: "PUT", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<LatestExportSaveResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/state`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ProjectStateSaveResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AccountDeletionSendResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<RegistrationSendResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<RegistrationSendResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/register/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<RegistrationSendResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, options: ClientOptions = {}): Promise<FailedEmptyTrashResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/failed-empty/trash`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<FailedEmptyTrashResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trashProject(options: ClientOptions = {}): Promise<ProjectTrashResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/trash`, { method: "POST", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<ProjectTrashResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings/profile`, { body: JSON.stringify(body), method: "PUT", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AccountProfileUpdateResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminServiceHardLimit(body: AdminServiceLimitRequest, options: ClientOptions = {}): Promise<AdminServicesResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/limits`, { body: JSON.stringify(body), method: "PATCH", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminServicesResponse>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,248 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
|
export type AccountDeletionCompleteRequest = {
|
||||||
|
"confirmation": "注销账号";
|
||||||
|
"deletion_id": string;
|
||||||
|
"verification_code": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountDeletionResponse = {
|
||||||
|
"status": "deleted";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountDeletionSendResponse = {
|
||||||
|
"challenge_expires_at": string;
|
||||||
|
"deletion_id": string;
|
||||||
|
"resend_available_at": string;
|
||||||
|
"status": "verification_sent";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountProfileUpdateRequest = {
|
||||||
|
"creator_name": string;
|
||||||
|
"social_id": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountProfileUpdateResponse = {
|
||||||
|
"profile": {
|
||||||
|
"creator_name": string;
|
||||||
|
"social_id": string;
|
||||||
|
};
|
||||||
|
"status": "saved";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountSettingsResponse = {
|
||||||
|
"account": {
|
||||||
|
"email": string;
|
||||||
|
"status": "active";
|
||||||
|
};
|
||||||
|
"csrf_token": string;
|
||||||
|
"local_data": {
|
||||||
|
"backup_enabled": false;
|
||||||
|
"capacity_status": "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
"hard_limit_bytes": number;
|
||||||
|
"location": "configured_local_data_root";
|
||||||
|
"managed_content_bytes": number;
|
||||||
|
"migration_supported": false;
|
||||||
|
};
|
||||||
|
"profile": {
|
||||||
|
"creator_name": string;
|
||||||
|
"social_id": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminAuditQuery = {
|
||||||
|
"cursor"?: string;
|
||||||
|
"limit"?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminAuthenticatedUser = {
|
||||||
|
"role": "super_admin";
|
||||||
|
"status": "active";
|
||||||
|
"user_id": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminCreditParams = {
|
||||||
|
"userId": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDiagnosticsResponse = {
|
||||||
|
"diagnostic_text": string;
|
||||||
|
"generated_at": string;
|
||||||
|
"services": AdminServicesStorageResponse;
|
||||||
|
"system": {
|
||||||
|
"api_status": "ready" | "degraded" | "unavailable";
|
||||||
|
"app_version": string;
|
||||||
|
"browser_support": Array<{
|
||||||
|
"brand": "Google Chrome" | "Microsoft Edge";
|
||||||
|
"major": number;
|
||||||
|
}>;
|
||||||
|
"worker_status": "ready" | "degraded" | "unavailable";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminGenerationListResponse = {
|
||||||
|
"generated_at": string;
|
||||||
|
"items": Array<AdminGenerationRecord>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminGenerationRecord = {
|
||||||
|
"completed_at": string | null;
|
||||||
|
"confirmed_credit_cost": number;
|
||||||
|
"created_at": string;
|
||||||
|
"duration_ms": number | null;
|
||||||
|
"error_category": "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable" | null;
|
||||||
|
"final_credit_state": "committed" | "released" | null;
|
||||||
|
"generation_id": string;
|
||||||
|
"model_id": string;
|
||||||
|
"owner_ref": string;
|
||||||
|
"project_id": string;
|
||||||
|
"ratio": "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
|
"reserved_credits": number;
|
||||||
|
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminLoginCompleteRequest = {
|
||||||
|
"registration_id": string;
|
||||||
|
"verification_code": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminLoginCompleteResponse = {
|
||||||
|
"admin": AdminAuthenticatedUser;
|
||||||
|
"audience": "admin";
|
||||||
|
"session_expires_at": string;
|
||||||
|
"status": "authenticated";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminLoginSendRequest = {
|
||||||
|
"email": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOperationAuditItem = {
|
||||||
|
"actor_ref": string;
|
||||||
|
"actor_type": "system" | "super_admin";
|
||||||
|
"after_summary": string | null;
|
||||||
|
"before_summary": string | null;
|
||||||
|
"expires_at": string;
|
||||||
|
"log_id": string;
|
||||||
|
"occurred_at": string;
|
||||||
|
"operation_type": string;
|
||||||
|
"result": "succeeded" | "failed";
|
||||||
|
"target_ref": string;
|
||||||
|
"target_type": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOperationAuditResponse = {
|
||||||
|
"generated_at": string;
|
||||||
|
"items": Array<AdminOperationAuditItem>;
|
||||||
|
"next_cursor": string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOverviewResponse = {
|
||||||
|
"asset_cleanup": {
|
||||||
|
"pending_jobs": number;
|
||||||
|
};
|
||||||
|
"generated_at": string;
|
||||||
|
"generation_jobs": {
|
||||||
|
"pending_manual_review": number;
|
||||||
|
"pending_manual_review_oldest_at": string | null;
|
||||||
|
"queued": number;
|
||||||
|
"running": number;
|
||||||
|
};
|
||||||
|
"models": {
|
||||||
|
"configured_default_model_id": string | null;
|
||||||
|
"configured_model_count": number;
|
||||||
|
"recommended_model_id": string | null;
|
||||||
|
"runtime_available_count": number;
|
||||||
|
};
|
||||||
|
"recent_operations": Array<{
|
||||||
|
"created_at": string;
|
||||||
|
"operation_id": string;
|
||||||
|
"operation_type": string;
|
||||||
|
"result": "succeeded" | "rejected" | "failed";
|
||||||
|
"target_ref": string;
|
||||||
|
}>;
|
||||||
|
"services": Array<{
|
||||||
|
"checked_at": string | null;
|
||||||
|
"service_id": "resend" | "amap" | "ai_gateway" | "worker" | "asset_root";
|
||||||
|
"status": "available" | "degraded" | "paused" | "unavailable";
|
||||||
|
}>;
|
||||||
|
"storage": {
|
||||||
|
"last_measured_at": string | null;
|
||||||
|
"limit_bytes": number;
|
||||||
|
"managed_content_bytes": number;
|
||||||
|
"status": "normal" | "critical" | "full" | "unavailable";
|
||||||
|
};
|
||||||
|
"user_slots": {
|
||||||
|
"active_and_suspended": number;
|
||||||
|
"limit": number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminServiceHealthCheckRequest = {
|
||||||
|
"available": boolean;
|
||||||
|
"reason"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminServiceLimitRequest = {
|
||||||
|
"hard_limit": number;
|
||||||
|
"period_type": ExternalServicePeriodType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminServiceParams = {
|
||||||
|
"service_id": ExternalServiceId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminServiceRecoveryRequest = {
|
||||||
|
"check_id": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminServicesResponse = {
|
||||||
|
"services": Array<ExternalServiceUsage>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminServicesStorageResponse = {
|
||||||
|
"generated_at": string;
|
||||||
|
"services": Array<{
|
||||||
|
"checked_at": string | null;
|
||||||
|
"configured": boolean;
|
||||||
|
"impact_scope": "none" | "authentication" | "location" | "generation" | "storage" | "api" | "model" | "account" | "unknown";
|
||||||
|
"pause_reason": string | null;
|
||||||
|
"service_id": "resend" | "amap" | "ai_gateway" | "worker" | "api" | "asset_root";
|
||||||
|
"status": "active" | "paused_quota" | "paused_provider" | "disabled" | "degraded" | "unavailable";
|
||||||
|
}>;
|
||||||
|
"storage": {
|
||||||
|
"capacity_notice_level": "normal" | "warning" | "critical";
|
||||||
|
"cleanup_pending_count": number;
|
||||||
|
"data_root_ref": "configured_local_data_root";
|
||||||
|
"hard_limit_bytes": number;
|
||||||
|
"last_measured_at": string | null;
|
||||||
|
"managed_content_bytes": number;
|
||||||
|
"remeasurement_required": boolean;
|
||||||
|
"status": "active" | "full" | "unavailable";
|
||||||
|
"storage_backend": "local_filesystem";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminSessionResponse = {
|
||||||
|
"acknowledged_private_content_notice_version": string | null;
|
||||||
|
"admin": AdminAuthenticatedUser;
|
||||||
|
"audience": "admin";
|
||||||
|
"authenticated": true;
|
||||||
|
"csrf_token": string;
|
||||||
|
"current_private_content_notice_message_key"?: string;
|
||||||
|
"current_private_content_notice_version": string | null;
|
||||||
|
"expires_at": string;
|
||||||
|
"notice_acknowledged": boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthenticatedUser = {
|
||||||
|
"creator_name": string;
|
||||||
|
"role": "user";
|
||||||
|
"social_id": string;
|
||||||
|
"status": "active";
|
||||||
|
"user_id": string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BootstrapResponse = {
|
export type BootstrapResponse = {
|
||||||
"app_version": string;
|
"app_version": string;
|
||||||
"dependencies": Array<{
|
"dependencies": Array<{
|
||||||
@@ -45,10 +288,140 @@ export type BrowserSupportSuccess = {
|
|||||||
|
|
||||||
export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
|
export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
|
||||||
|
|
||||||
|
export type CanvasBackgroundAdjustments = {
|
||||||
|
"brightness": number;
|
||||||
|
"contrast": number;
|
||||||
|
"crop": {
|
||||||
|
"height": number;
|
||||||
|
"width": number;
|
||||||
|
"x": number;
|
||||||
|
"y": number;
|
||||||
|
} | null;
|
||||||
|
"filter": string;
|
||||||
|
"fit": "fill" | "fit" | "crop";
|
||||||
|
"saturation": number;
|
||||||
|
"sharpness": number;
|
||||||
|
"temperature": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasElement = {
|
||||||
|
"colors"?: Array<string>;
|
||||||
|
"content"?: string;
|
||||||
|
"coordinates"?: {
|
||||||
|
"latitude": number;
|
||||||
|
"longitude": number;
|
||||||
|
};
|
||||||
|
"created_at": string;
|
||||||
|
"dynamic_fields"?: Record<string, string | number | boolean | null>;
|
||||||
|
"element_id": string;
|
||||||
|
"font_override"?: string;
|
||||||
|
"font_size"?: number;
|
||||||
|
"formatted_value"?: string;
|
||||||
|
"opacity": number;
|
||||||
|
"position": {
|
||||||
|
"x": number;
|
||||||
|
"y": number;
|
||||||
|
};
|
||||||
|
"resource_version": string;
|
||||||
|
"rotation": number;
|
||||||
|
"scale": {
|
||||||
|
"x": number;
|
||||||
|
"y": number;
|
||||||
|
};
|
||||||
|
"style_id"?: string;
|
||||||
|
"style_parameters"?: Record<string, string | number | boolean | null>;
|
||||||
|
"template_or_asset_id": string;
|
||||||
|
"type": "text_template" | "static_sticker" | "color_card" | "dynamic_sticker";
|
||||||
|
"z_index": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasState = {
|
||||||
|
"background": {
|
||||||
|
"adjustments": CanvasBackgroundAdjustments;
|
||||||
|
"asset_id": string | null;
|
||||||
|
};
|
||||||
|
"elements": Array<CanvasElement>;
|
||||||
|
"pixel_height": number;
|
||||||
|
"pixel_width": number;
|
||||||
|
"ratio": "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
|
"schema_version": 1;
|
||||||
|
};
|
||||||
|
|
||||||
export type CorrelationId = string;
|
export type CorrelationId = string;
|
||||||
|
|
||||||
|
export type CreditAdjustmentHeaders = {
|
||||||
|
"idempotency-key": string;
|
||||||
|
"x-csrf-token": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditAdjustmentRequest = {
|
||||||
|
"adjustment_id": string;
|
||||||
|
"amount": number | number;
|
||||||
|
"reason": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditAdjustmentResponse = {
|
||||||
|
"adjustment_id": string;
|
||||||
|
"available_balance": number;
|
||||||
|
"reserved_balance": number;
|
||||||
|
"status": "adjusted";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditBalanceResponse = {
|
||||||
|
"available_balance": number;
|
||||||
|
"reserved_balance": number;
|
||||||
|
"updated_at": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditEntryStatus = "succeeded" | "frozen" | "committed" | "released";
|
||||||
|
|
||||||
|
export type CreditEntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||||
|
|
||||||
|
export type CreditLedgerEntry = {
|
||||||
|
"amount": number;
|
||||||
|
"available_after": number;
|
||||||
|
"available_before": number;
|
||||||
|
"created_at": string;
|
||||||
|
"entry_id": string;
|
||||||
|
"entry_type": CreditEntryType;
|
||||||
|
"model_id": string | null;
|
||||||
|
"reason": string | null;
|
||||||
|
"reference_id": string | null;
|
||||||
|
"reference_type": CreditReferenceType | null;
|
||||||
|
"reserved_after": number;
|
||||||
|
"reserved_before": number;
|
||||||
|
"status": CreditEntryStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditLedgerQuery = {
|
||||||
|
"cursor"?: string;
|
||||||
|
"event_type"?: CreditEntryType;
|
||||||
|
"from"?: string;
|
||||||
|
"limit"?: number;
|
||||||
|
"to"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditLedgerResponse = {
|
||||||
|
"credits": CreditSummary;
|
||||||
|
"entries": Array<CreditLedgerEntry>;
|
||||||
|
"next_cursor": string | null;
|
||||||
|
"updated_at": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreditReferenceType = "registration" | "generation" | "admin_adjustment";
|
||||||
|
|
||||||
|
export type CreditSummary = {
|
||||||
|
"available_balance": number;
|
||||||
|
"reserved_balance": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CsrfHeaders = {
|
||||||
|
"x-csrf-token": string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ErrorDetails = {
|
export type ErrorDetails = {
|
||||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
"conflict_model_ids"?: Array<string>;
|
||||||
"current_task_ref"?: string;
|
"current_task_ref"?: string;
|
||||||
"field_errors"?: Array<{
|
"field_errors"?: Array<{
|
||||||
"field": string;
|
"field": string;
|
||||||
@@ -65,10 +438,11 @@ export type ErrorDetails = {
|
|||||||
|
|
||||||
export type ErrorEnvelope = {
|
export type ErrorEnvelope = {
|
||||||
"error": {
|
"error": {
|
||||||
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
|
||||||
"correlation_id": string;
|
"correlation_id": string;
|
||||||
"details": {
|
"details": {
|
||||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||||
|
"conflict_model_ids"?: Array<string>;
|
||||||
"current_task_ref"?: string;
|
"current_task_ref"?: string;
|
||||||
"field_errors"?: Array<{
|
"field_errors"?: Array<{
|
||||||
"field": string;
|
"field": string;
|
||||||
@@ -87,8 +461,196 @@ export type ErrorEnvelope = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExportFormat = "jpg" | "png";
|
||||||
|
|
||||||
|
export type ExternalServiceId = "resend_email" | "amap_web_service";
|
||||||
|
|
||||||
|
export type ExternalServicePeriodType = "daily" | "monthly";
|
||||||
|
|
||||||
|
export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled";
|
||||||
|
|
||||||
|
export type ExternalServiceUsage = {
|
||||||
|
"hard_limit": number;
|
||||||
|
"pause_reason": string | null;
|
||||||
|
"period_start": string;
|
||||||
|
"period_type": ExternalServicePeriodType;
|
||||||
|
"service_id": ExternalServiceId;
|
||||||
|
"service_status": ExternalServiceStatus;
|
||||||
|
"updated_at": string;
|
||||||
|
"used_count": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FailedEmptyTrashRequest = {
|
||||||
|
"project_ids": Array<ProjectId>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FailedEmptyTrashResponse = {
|
||||||
|
"ignored_project_ids": Array<ProjectId>;
|
||||||
|
"trashed_project_ids": Array<ProjectId>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationCreateHeaders = {
|
||||||
|
"idempotency-key": string;
|
||||||
|
"x-csrf-token": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationCreateResponse = {
|
||||||
|
"created": boolean;
|
||||||
|
"task": GenerationTaskResponse;
|
||||||
|
};
|
||||||
|
|
||||||
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
|
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
|
||||||
|
|
||||||
|
export type GenerationMultipartBody = {
|
||||||
|
"client_submission_id": string;
|
||||||
|
"confirmed_credit_cost": string;
|
||||||
|
"creation_mode": "new_project" | "existing_project";
|
||||||
|
"existing_reference_asset_ids"?: string;
|
||||||
|
"model_config_version": string;
|
||||||
|
"model_id": string;
|
||||||
|
"project_id"?: string;
|
||||||
|
"prompt": string;
|
||||||
|
"ratio": ProjectRatio;
|
||||||
|
"reference_files"?: Array<string>;
|
||||||
|
"reference_manifest"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationParams = {
|
||||||
|
"generationId": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationProjectItem = {
|
||||||
|
"created_at": string;
|
||||||
|
"error_category": GenerationErrorCategory | null;
|
||||||
|
"generation_id": string;
|
||||||
|
"prompt": string;
|
||||||
|
"ratio": ProjectRatio;
|
||||||
|
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
"updated_at": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationTaskResponse = {
|
||||||
|
"confirmed_credit_cost": number;
|
||||||
|
"created_at": string;
|
||||||
|
"error_category": GenerationErrorCategory | null;
|
||||||
|
"generation_id": string;
|
||||||
|
"model_config_version": number;
|
||||||
|
"model_id": string;
|
||||||
|
"project_id": ProjectId;
|
||||||
|
"prompt": string;
|
||||||
|
"ratio": ProjectRatio;
|
||||||
|
"reference_asset_ids": Array<string>;
|
||||||
|
"reference_count": number;
|
||||||
|
"reserved_credits": number;
|
||||||
|
"status": GenerationTaskStatus;
|
||||||
|
"updated_at": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerationTaskStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
|
||||||
|
export type LatestExportItem = {
|
||||||
|
"byte_size": number;
|
||||||
|
"created_at": string;
|
||||||
|
"download_url": string;
|
||||||
|
"export_id": string;
|
||||||
|
"format": ExportFormat;
|
||||||
|
"pixel_height": number;
|
||||||
|
"pixel_width": number;
|
||||||
|
"sha256": string;
|
||||||
|
"state_version": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LatestExportMultipartBody = {
|
||||||
|
"byte_size": string;
|
||||||
|
"export_file": string;
|
||||||
|
"export_id": string;
|
||||||
|
"format": ExportFormat;
|
||||||
|
"pixel_height": string;
|
||||||
|
"pixel_width": string;
|
||||||
|
"sha256": string;
|
||||||
|
"state_version": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LatestExportParams = {
|
||||||
|
"format": ExportFormat;
|
||||||
|
"projectId": ProjectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LatestExportSaveResponse = {
|
||||||
|
"byte_size": number;
|
||||||
|
"created_at": string;
|
||||||
|
"download_url": string;
|
||||||
|
"export_id": string;
|
||||||
|
"format": ExportFormat;
|
||||||
|
"pixel_height": number;
|
||||||
|
"pixel_width": number;
|
||||||
|
"sha256": string;
|
||||||
|
"state_version": number;
|
||||||
|
"status": "saved";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginCompleteRequest = {
|
||||||
|
"registration_id": string;
|
||||||
|
"verification_code": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginCompleteResponse = {
|
||||||
|
"audience": "user";
|
||||||
|
"credits": CreditSummary;
|
||||||
|
"session_expires_at": string;
|
||||||
|
"status": "authenticated";
|
||||||
|
"user": AuthenticatedUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginSendRequest = {
|
||||||
|
"email": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogoutHeaders = {
|
||||||
|
"idempotency-key": string;
|
||||||
|
"x-csrf-token": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogoutResponse = {
|
||||||
|
"status": "logged_out";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelConfig = {
|
||||||
|
"config_version": number;
|
||||||
|
"contract_evidence_ref": string | null;
|
||||||
|
"contract_validation_status": ModelContractValidationStatus;
|
||||||
|
"credit_cost": number;
|
||||||
|
"display_name": string;
|
||||||
|
"enabled": boolean;
|
||||||
|
"error_mapping_profile": Record<string, string>;
|
||||||
|
"gateway_account_ref": string;
|
||||||
|
"is_default": boolean;
|
||||||
|
"model_id": ModelId;
|
||||||
|
"prompt_max_length": number;
|
||||||
|
"recommendation_priority": number;
|
||||||
|
"reference_limits": ModelReferenceLimits;
|
||||||
|
"route_profile": Record<string, unknown>;
|
||||||
|
"runtime_availability": ModelRuntimeAvailability;
|
||||||
|
"safety_source": string;
|
||||||
|
"supported_ratios": Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelConfigCandidate = {
|
||||||
|
"credit_cost": number;
|
||||||
|
"display_name": string;
|
||||||
|
"enabled": boolean;
|
||||||
|
"error_mapping_profile": Record<string, string>;
|
||||||
|
"gateway_account_ref": string;
|
||||||
|
"is_default": boolean;
|
||||||
|
"model_id": ModelId;
|
||||||
|
"prompt_max_length": number;
|
||||||
|
"recommendation_priority": number;
|
||||||
|
"reference_limits": ModelReferenceLimits;
|
||||||
|
"route_profile": Record<string, unknown>;
|
||||||
|
"safety_source": string;
|
||||||
|
"supported_ratios": Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ModelConfigSseEvent = {
|
export type ModelConfigSseEvent = {
|
||||||
"config_set_version": number;
|
"config_set_version": number;
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
@@ -97,6 +659,45 @@ export type ModelConfigSseEvent = {
|
|||||||
"occurred_at": string;
|
"occurred_at": string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ModelConfigUpdateHeaders = {
|
||||||
|
"idempotency-key": string;
|
||||||
|
"x-csrf-token": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelConfigUpdateRequest = {
|
||||||
|
"expected_config_set_version": number;
|
||||||
|
"models": Array<ModelConfigCandidate>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelConfigurationResponse = {
|
||||||
|
"config_set_version": number;
|
||||||
|
"configured_default_model_id": ModelId;
|
||||||
|
"models": Array<ModelConfig>;
|
||||||
|
"recommended_model_id": ModelId | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelContractValidationStatus = "blocked" | "unverified" | "verified";
|
||||||
|
|
||||||
|
export type ModelId = string;
|
||||||
|
|
||||||
|
export type ModelParams = {
|
||||||
|
"model_id": ModelId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelReferenceLimits = {
|
||||||
|
"max_file_bytes": number;
|
||||||
|
"max_files": number;
|
||||||
|
"max_total_bytes": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelRuntimeAvailability = {
|
||||||
|
"available_for_new_jobs": boolean;
|
||||||
|
"checked_at": string;
|
||||||
|
"reason": ModelRuntimeReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelRuntimeReason = "available" | "configured_disabled" | "contract_unverified" | "contract_blocked" | "gateway_balance_insufficient" | "gateway_paused" | "worker_degraded";
|
||||||
|
|
||||||
export type ModelRuntimeSseEvent = {
|
export type ModelRuntimeSseEvent = {
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
"event_id": number;
|
"event_id": number;
|
||||||
@@ -105,6 +706,226 @@ export type ModelRuntimeSseEvent = {
|
|||||||
"runtime_availability_version": number;
|
"runtime_availability_version": number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PrivateContentAccessAuditItem = {
|
||||||
|
"actor_ref": string;
|
||||||
|
"content_type": "image" | "prompt";
|
||||||
|
"expires_at": string;
|
||||||
|
"log_id": string;
|
||||||
|
"occurred_at": string;
|
||||||
|
"target_ref": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivateContentAccessAuditResponse = {
|
||||||
|
"generated_at": string;
|
||||||
|
"items": Array<PrivateContentAccessAuditItem>;
|
||||||
|
"next_cursor": string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivateContentGenerationParams = {
|
||||||
|
"generationId": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivateContentNoticeAckRequest = {
|
||||||
|
"expected_notice_version": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivateContentNoticeAckResponse = {
|
||||||
|
"acknowledged_at": string;
|
||||||
|
"notice_version": string;
|
||||||
|
"status": "acknowledged";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivateContentPromptResponse = {
|
||||||
|
"content_type": "prompt";
|
||||||
|
"generation_id": string;
|
||||||
|
"prompt": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectDetailResponse = {
|
||||||
|
"canvas_state": CanvasState;
|
||||||
|
"created_at": string;
|
||||||
|
"current_image_id": ProjectId | null;
|
||||||
|
"deleted_at": string | null;
|
||||||
|
"draft_prompt": string;
|
||||||
|
"generations": Array<GenerationProjectItem>;
|
||||||
|
"images": Array<ProjectImageItem>;
|
||||||
|
"latest_exports": Array<LatestExportItem>;
|
||||||
|
"name": string;
|
||||||
|
"pixel_height": number;
|
||||||
|
"pixel_width": number;
|
||||||
|
"project_id": ProjectId;
|
||||||
|
"purge_at": string | null;
|
||||||
|
"ratio": ProjectRatio;
|
||||||
|
"save_status": "saved";
|
||||||
|
"state_version": number;
|
||||||
|
"status": ProjectViewStatus;
|
||||||
|
"successful_image_count": number;
|
||||||
|
"updated_at": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectEditableState = {
|
||||||
|
"canvas_state": CanvasState;
|
||||||
|
"name": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectId = string;
|
||||||
|
|
||||||
|
export type ProjectImageItem = {
|
||||||
|
"created_at": string;
|
||||||
|
"generation_id": string;
|
||||||
|
"image_id": ProjectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectImageParams = {
|
||||||
|
"imageId": ProjectId;
|
||||||
|
"projectId": ProjectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectListQuery = {
|
||||||
|
"status"?: "active" | "trashed";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectListResponse = {
|
||||||
|
"active_count": number;
|
||||||
|
"active_limit": 20;
|
||||||
|
"projects": Array<ProjectSummary>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectParams = {
|
||||||
|
"projectId": ProjectId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectPurgeResponse = {
|
||||||
|
"project_id": ProjectId;
|
||||||
|
"status": "purged";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectRatio = "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
|
|
||||||
|
export type ProjectRenameRequest = {
|
||||||
|
"name": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectRenameResponse = {
|
||||||
|
"name": string;
|
||||||
|
"state_version": number;
|
||||||
|
"status": "renamed";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectRestoreResponse = {
|
||||||
|
"deleted_at": null;
|
||||||
|
"project_id": ProjectId;
|
||||||
|
"purge_at": null;
|
||||||
|
"status": "active";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectStateConflictResponse = {
|
||||||
|
"latest_state_version": number;
|
||||||
|
"save_status": "conflicted";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectStateSaveHeaders = {
|
||||||
|
"idempotency-key": string;
|
||||||
|
"if-match": string;
|
||||||
|
"x-csrf-token": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectStateSaveResponse = {
|
||||||
|
"save_status": "saved";
|
||||||
|
"state_version": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectSummary = {
|
||||||
|
"current_image_id": ProjectId | null;
|
||||||
|
"deleted_at": string | null;
|
||||||
|
"name": string;
|
||||||
|
"project_id": ProjectId;
|
||||||
|
"purge_at": string | null;
|
||||||
|
"ratio": ProjectRatio;
|
||||||
|
"state_version": number;
|
||||||
|
"status": ProjectViewStatus;
|
||||||
|
"successful_image_count": number;
|
||||||
|
"updated_at": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTrashResponse = {
|
||||||
|
"deleted_at": string;
|
||||||
|
"project_id": ProjectId;
|
||||||
|
"purge_at": string;
|
||||||
|
"status": "trashed";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
|
||||||
|
|
||||||
|
export type RecentAssetItem = {
|
||||||
|
"asset_id": string;
|
||||||
|
"asset_kind": RecentAssetKind;
|
||||||
|
"resource_version": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecentAssetKind = "static_sticker" | "text_template";
|
||||||
|
|
||||||
|
export type RecentAssetListResponse = {
|
||||||
|
"items": Array<RecentAssetItem>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecentAssetQuery = {
|
||||||
|
"asset_kind": RecentAssetKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecentAssetRecordRequest = {
|
||||||
|
"asset_id": string;
|
||||||
|
"asset_kind": RecentAssetKind;
|
||||||
|
"resource_version": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecentAssetRecordResponse = {
|
||||||
|
"status": "recorded";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationCompleteHeaders = {
|
||||||
|
"idempotency-key": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationCompleteRequest = {
|
||||||
|
"creator_name": string;
|
||||||
|
"privacy_consent_accepted": boolean;
|
||||||
|
"privacy_notice_version": string;
|
||||||
|
"registration_id": string;
|
||||||
|
"social_id": string;
|
||||||
|
"verification_code": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationCompleteResponse = {
|
||||||
|
"credits": CreditSummary;
|
||||||
|
"session_expires_at": string;
|
||||||
|
"status": "registered";
|
||||||
|
"user": AuthenticatedUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationSendRequest = {
|
||||||
|
"email": string;
|
||||||
|
"invite_code": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationSendResponse = {
|
||||||
|
"challenge_expires_at": string;
|
||||||
|
"registration_id": string;
|
||||||
|
"resend_available_at": string;
|
||||||
|
"status": "verification_sent";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReverseGeocodeRequest = {
|
||||||
|
"latitude": number;
|
||||||
|
"longitude": number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReverseGeocodeResponse = {
|
||||||
|
"formatted_value": string;
|
||||||
|
"service_mode": "mock" | "real";
|
||||||
|
"status": "resolved";
|
||||||
|
};
|
||||||
|
|
||||||
export type SseEvent = {
|
export type SseEvent = {
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
"event_id": number;
|
"event_id": number;
|
||||||
@@ -125,7 +946,7 @@ export type SseEvent = {
|
|||||||
"runtime_availability_version": number;
|
"runtime_availability_version": number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
|
||||||
|
|
||||||
export type StateSseEvent = {
|
export type StateSseEvent = {
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
@@ -134,3 +955,12 @@ export type StateSseEvent = {
|
|||||||
"occurred_at": string;
|
"occurred_at": string;
|
||||||
"state_version": number;
|
"state_version": number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserSessionResponse = {
|
||||||
|
"audience": "user";
|
||||||
|
"authenticated": true;
|
||||||
|
"credits": CreditSummary;
|
||||||
|
"csrf_token": string;
|
||||||
|
"expires_at": string;
|
||||||
|
"user": AuthenticatedUser;
|
||||||
|
};
|
||||||
|
|||||||
+62
-7
@@ -1,8 +1,20 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode, type ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import { ToolchainProbe } from "./toolchain-probe.js";
|
|
||||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||||
|
import { AdminAuthPage } from "./admin-auth.js";
|
||||||
|
import { UserAuthPage } from "./user-auth.js";
|
||||||
|
import { AccountSettingsPage } from "./account-settings.js";
|
||||||
|
import { AdminUsersPage } from "./admin-users.js";
|
||||||
|
import { AdminModelsPage } from "./admin-models.js";
|
||||||
|
import { AdminAssetsPage } from "./admin-assets.js";
|
||||||
|
import { AdminGenerationsPage } from "./admin-generations.js";
|
||||||
|
import { AdminServicesStoragePage } from "./admin-services-storage.js";
|
||||||
|
import { AdminAuditPage } from "./admin-audit.js";
|
||||||
|
import { CreditsPage } from "./credits-page.js";
|
||||||
|
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||||
|
import { EditorPage } from "./editor-page.js";
|
||||||
|
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
@@ -13,8 +25,51 @@ if (!root) {
|
|||||||
// Cache failure leaves public assets network-backed and must not create alternate persistence.
|
// Cache failure leaves public assets network-backed and must not create alternate persistence.
|
||||||
void registerPublicAssetServiceWorker().catch(() => undefined);
|
void registerPublicAssetServiceWorker().catch(() => undefined);
|
||||||
|
|
||||||
createRoot(root).render(
|
const appRoot = createRoot(root);
|
||||||
<StrictMode>
|
let authRevision = 0;
|
||||||
<ToolchainProbe />
|
|
||||||
</StrictMode>,
|
function renderAuthenticationEntry() {
|
||||||
);
|
authRevision += 1;
|
||||||
|
const editorProject = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})\/editor$/i);
|
||||||
|
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
|
||||||
|
let authenticationPage;
|
||||||
|
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
|
||||||
|
else if (window.location.pathname === "/app/credits") authenticationPage = <CreditsPage key={authRevision} />;
|
||||||
|
else if (editorProject?.[1]) authenticationPage = <EditorPage key={authRevision} projectId={editorProject[1]} />;
|
||||||
|
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
|
||||||
|
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
|
||||||
|
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
|
||||||
|
else if (window.location.pathname === "/admin/login") authenticationPage = <AdminAuthPage key={authRevision} />;
|
||||||
|
else if (window.location.pathname.startsWith("/admin")) {
|
||||||
|
const adminPages: Record<string, { content: ReactNode; title: string }> = {
|
||||||
|
"/admin": { content: <AdminOverviewPage />, title: "运营总览" },
|
||||||
|
"/admin/assets": { content: <AdminAssetsPage />, title: "素材" },
|
||||||
|
"/admin/audit": { content: <AdminAuditPage />, title: "审计" },
|
||||||
|
"/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
|
||||||
|
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
|
||||||
|
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
|
||||||
|
"/admin/preview": { content: <AdminPlaceholderPage title="内部预览" />, title: "内部预览" },
|
||||||
|
"/admin/services-storage": { content: <AdminServicesStoragePage />, title: "服务与存储" },
|
||||||
|
"/admin/users": { content: <AdminUsersPage />, title: "用户与点数" },
|
||||||
|
};
|
||||||
|
const page = adminPages[window.location.pathname] ?? adminPages["/admin"]!;
|
||||||
|
authenticationPage = (
|
||||||
|
<AdminProtectedRoute currentPath={window.location.pathname} key={authRevision} title={page.title}>
|
||||||
|
{page.content}
|
||||||
|
</AdminProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else authenticationPage = <UserAuthPage key={authRevision} />;
|
||||||
|
appRoot.render(
|
||||||
|
<StrictMode>
|
||||||
|
{authenticationPage}
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any authenticated surface can dispatch this after a revoked/invalid session response.
|
||||||
|
window.addEventListener("dada:session-invalid", () => {
|
||||||
|
if (window.location.pathname.startsWith("/app")) window.history.replaceState(null, "", "/");
|
||||||
|
renderAuthenticationEntry();
|
||||||
|
});
|
||||||
|
renderAuthenticationEntry();
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { MMCQ } from "@vibrant/quantizer-mmcq";
|
||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
import { P0A_COLOR_CARD_DEFINITIONS, type ColorCardDefinition } from "@dada/asset-renderer";
|
||||||
|
|
||||||
|
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||||
|
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
|
export type { ColorCardDefinition } from "@dada/asset-renderer";
|
||||||
|
|
||||||
|
export interface PaletteColor {
|
||||||
|
hex: string;
|
||||||
|
population: number;
|
||||||
|
rgbValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = P0A_COLOR_CARD_DEFINITIONS;
|
||||||
|
|
||||||
|
// Coordinates are translated from the archived 320x320 renderer around its 160px center.
|
||||||
|
export const COLOR_CARD_SOURCE_GEOMETRY = {
|
||||||
|
style_01: {
|
||||||
|
bounds: { bottom: 76, left: -26, right: 26, top: -74 },
|
||||||
|
palette: { bottom: 37, left: -23, right: 23, top: -71 },
|
||||||
|
},
|
||||||
|
style_02: { bounds: { bottom: 69, left: -18, right: 18, top: -77 } },
|
||||||
|
style_08: { bounds: { bottom: 10, left: -73, right: 73, top: -9 } },
|
||||||
|
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function normalizedHex(value: string) {
|
||||||
|
return value.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMmcqPalette(pixels: Uint8ClampedArray): PaletteColor[] {
|
||||||
|
const swatches = MMCQ(pixels, { colorCount: 10 });
|
||||||
|
if (swatches.length < 5) throw new Error("palette_five_colors_required");
|
||||||
|
return swatches
|
||||||
|
.map((swatch) => ({
|
||||||
|
hex: normalizedHex(swatch.hex),
|
||||||
|
population: swatch.population,
|
||||||
|
rgbValue: (Math.round(swatch.r) << 16) + (Math.round(swatch.g) << 8) + Math.round(swatch.b),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.population - left.population || left.rgbValue - right.rgbValue)
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPaletteFromImage(image: CanvasImageSource & { height: number; width: number }) {
|
||||||
|
const longestEdge = Math.max(image.width, image.height);
|
||||||
|
if (longestEdge <= 0) throw new Error("palette_image_invalid");
|
||||||
|
const scale = Math.min(1, 256 / longestEdge);
|
||||||
|
const width = Math.max(1, Math.round(image.width * scale));
|
||||||
|
const height = Math.max(1, Math.round(image.height * scale));
|
||||||
|
const surface = document.createElement("canvas");
|
||||||
|
surface.width = width;
|
||||||
|
surface.height = height;
|
||||||
|
const context = surface.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) throw new Error("palette_canvas_unavailable");
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
return extractMmcqPalette(context.getImageData(0, 0, width, height).data).map((entry) => entry.hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePalette(palette: readonly string[]) {
|
||||||
|
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||||
|
return palette.map(normalizedHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createColorCardElement(
|
||||||
|
definition: ColorCardDefinition,
|
||||||
|
palette: readonly string[],
|
||||||
|
identity: CanvasElementIdentity,
|
||||||
|
zIndex: number,
|
||||||
|
): CanvasElement {
|
||||||
|
return {
|
||||||
|
colors: validatePalette(palette),
|
||||||
|
created_at: identity.createdAt,
|
||||||
|
element_id: identity.elementId,
|
||||||
|
opacity: 1,
|
||||||
|
position: { x: 0.5, y: 0.5 },
|
||||||
|
resource_version: "wp4-provider-v1",
|
||||||
|
rotation: 0,
|
||||||
|
scale: { x: 1, y: 1 },
|
||||||
|
style_id: definition.styleId,
|
||||||
|
style_parameters: {
|
||||||
|
mapping_status: definition.mappingStatus,
|
||||||
|
palette_algorithm_version: "mmcq-v1",
|
||||||
|
renderer_name: definition.rendererName,
|
||||||
|
},
|
||||||
|
template_or_asset_id: definition.cardId,
|
||||||
|
type: "color_card",
|
||||||
|
z_index: zIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshColorCards(state: CanvasState, palette: readonly string[]): CanvasState {
|
||||||
|
const colors = validatePalette(palette);
|
||||||
|
const next = structuredClone(state);
|
||||||
|
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...colors] } : element);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawColorCard(context: CanvasRenderingContext2D, element: CanvasElement) {
|
||||||
|
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||||
|
if (element.style_id === "style_01") {
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(-26, -74, 52, 145);
|
||||||
|
colors.forEach((color, index) => {
|
||||||
|
const top = -71 + index * 21.6;
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.fillRect(-23, top, 46, index === colors.length - 1 ? 37 - top : 21.6);
|
||||||
|
});
|
||||||
|
context.fillStyle = "#222222";
|
||||||
|
context.font = "700 5px Arial, sans-serif";
|
||||||
|
context.textAlign = "left";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
context.fillText("COLOR", -20, 44);
|
||||||
|
context.fillStyle = "#979797";
|
||||||
|
context.fillRect(-20, 54, 24, 1);
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
const left = -26 + index * 6.5;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(left, 71);
|
||||||
|
context.lineTo(left + 3.25, 76);
|
||||||
|
context.lineTo(left + 6.5, 71);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.style_id === "style_02") {
|
||||||
|
context.font = "4px Arial, sans-serif";
|
||||||
|
context.textAlign = "left";
|
||||||
|
context.textBaseline = "top";
|
||||||
|
context.fillStyle = "#222222";
|
||||||
|
context.fillText("C O L O R", -17, -77);
|
||||||
|
colors.forEach((color, index) => {
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.fillRect(-18, -71 + index * 28, 36, 27);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.style_id === "style_08") {
|
||||||
|
colors.forEach((color, index) => {
|
||||||
|
const left = -73 + index * 29.2;
|
||||||
|
const center = -59 + index * 29;
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.fillRect(left, -2, index === colors.length - 1 ? 73 - left : 29.2, 4);
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(center, -7, 2, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = "#ffffff";
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.stroke();
|
||||||
|
context.fillStyle = "#222222";
|
||||||
|
context.font = "4px Arial, sans-serif";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText(`0${index + 1}`, center, 8);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(-78, -9);
|
||||||
|
for (let index = 0; index < 4; index += 1) {
|
||||||
|
const y = -9 + index * 4.5;
|
||||||
|
context.lineTo(-74, y + 2.25);
|
||||||
|
context.lineTo(-78, y + 4.5);
|
||||||
|
}
|
||||||
|
context.lineTo(-70, 9);
|
||||||
|
context.lineTo(-70, -9);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
context.fillStyle = "#222222";
|
||||||
|
context.fillRect(-72, -9, 17, 18);
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.font = "700 5px Arial, sans-serif";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText("C", -63.5, 0);
|
||||||
|
const stripColors = [...colors, "#f2f2f2"];
|
||||||
|
stripColors.forEach((color, index) => {
|
||||||
|
const left = -55 + index * (133 / stripColors.length);
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.fillRect(left, -9, index === stripColors.length - 1 ? 78 - left : 133 / stripColors.length, 18);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import type { ProjectEditableState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
export type ProjectSaveStatus = "dirty" | "saving" | "saved" | "failed" | "conflicted";
|
||||||
|
|
||||||
|
export class ProjectStateConflict extends Error {
|
||||||
|
readonly latestStateVersion: number;
|
||||||
|
|
||||||
|
constructor(latestStateVersion: number) {
|
||||||
|
super("project_state_conflict");
|
||||||
|
this.latestStateVersion = latestStateVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictExportGuard {
|
||||||
|
used = false;
|
||||||
|
|
||||||
|
async run(action: () => Promise<void>) {
|
||||||
|
if (this.used) return false;
|
||||||
|
this.used = true;
|
||||||
|
await action();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionHistory<T> {
|
||||||
|
private current: T;
|
||||||
|
private readonly past: T[] = [];
|
||||||
|
private readonly future: T[] = [];
|
||||||
|
|
||||||
|
constructor(initial: T) {
|
||||||
|
this.current = structuredClone(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
get canRedo() { return this.future.length > 0; }
|
||||||
|
get canUndo() { return this.past.length > 0; }
|
||||||
|
get value() { return structuredClone(this.current); }
|
||||||
|
|
||||||
|
commit(next: T) {
|
||||||
|
this.past.push(structuredClone(this.current));
|
||||||
|
this.current = structuredClone(next);
|
||||||
|
this.future.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
const previous = this.past.pop();
|
||||||
|
if (previous === undefined) return undefined;
|
||||||
|
this.future.push(structuredClone(this.current));
|
||||||
|
this.current = previous;
|
||||||
|
return structuredClone(this.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
redo() {
|
||||||
|
const next = this.future.pop();
|
||||||
|
if (next === undefined) return undefined;
|
||||||
|
this.past.push(structuredClone(this.current));
|
||||||
|
this.current = next;
|
||||||
|
return structuredClone(this.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingSave {
|
||||||
|
operationId: string;
|
||||||
|
snapshot: ProjectEditableState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectAutoSaveQueue {
|
||||||
|
status: ProjectSaveStatus = "saved";
|
||||||
|
stateVersion: number;
|
||||||
|
conflictVersion?: number;
|
||||||
|
|
||||||
|
private disposed = false;
|
||||||
|
private inFlight: Promise<boolean> | undefined;
|
||||||
|
private lastSaved: ProjectEditableState;
|
||||||
|
private pending: PendingSave | undefined;
|
||||||
|
private retryIndex = 0;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private readonly debounceMs: number;
|
||||||
|
private readonly onConflict: ((latestVersion: number) => void) | undefined;
|
||||||
|
private readonly onSaved: ((snapshot: ProjectEditableState, stateVersion: number) => void) | undefined;
|
||||||
|
private readonly onStatus: ((status: ProjectSaveStatus) => void) | undefined;
|
||||||
|
private readonly retryDelaysMs: number[];
|
||||||
|
private readonly save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
||||||
|
|
||||||
|
constructor(input: {
|
||||||
|
debounceMs?: number;
|
||||||
|
initialState: ProjectEditableState;
|
||||||
|
initialVersion: number;
|
||||||
|
onConflict?: (latestVersion: number) => void;
|
||||||
|
onSaved?: (snapshot: ProjectEditableState, stateVersion: number) => void;
|
||||||
|
onStatus?: (status: ProjectSaveStatus) => void;
|
||||||
|
retryDelaysMs?: number[];
|
||||||
|
save: (snapshot: ProjectEditableState, stateVersion: number, operationId: string) => Promise<{ stateVersion: number }>;
|
||||||
|
}) {
|
||||||
|
this.debounceMs = input.debounceMs ?? 1_000;
|
||||||
|
this.lastSaved = structuredClone(input.initialState);
|
||||||
|
this.onConflict = input.onConflict;
|
||||||
|
this.onSaved = input.onSaved;
|
||||||
|
this.onStatus = input.onStatus;
|
||||||
|
this.retryDelaysMs = input.retryDelaysMs ?? [1_000, 2_000, 4_000, 8_000, 15_000];
|
||||||
|
this.save = input.save;
|
||||||
|
this.stateVersion = input.initialVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(snapshot: ProjectEditableState) {
|
||||||
|
if (this.disposed || this.status === "conflicted") return;
|
||||||
|
this.pending = { operationId: crypto.randomUUID(), snapshot: structuredClone(snapshot) };
|
||||||
|
if (!this.inFlight) {
|
||||||
|
this.setStatus("dirty");
|
||||||
|
this.schedule(this.debounceMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNow() {
|
||||||
|
if (this.disposed || (this.status as ProjectSaveStatus) === "conflicted") return false;
|
||||||
|
this.clearTimer();
|
||||||
|
if (this.inFlight) await this.inFlight;
|
||||||
|
if (this.disposed || this.status === "conflicted") return false;
|
||||||
|
if (!this.pending) return this.status === "saved";
|
||||||
|
return this.startSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(delay: number) {
|
||||||
|
this.clearTimer();
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = undefined;
|
||||||
|
void this.startSave();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSave() {
|
||||||
|
if (this.inFlight || !this.pending || this.disposed || this.status === "conflicted") {
|
||||||
|
return this.inFlight ?? Promise.resolve(false);
|
||||||
|
}
|
||||||
|
const request = this.pending;
|
||||||
|
this.pending = undefined;
|
||||||
|
this.setStatus("saving");
|
||||||
|
const attempt = this.save(structuredClone(request.snapshot), this.stateVersion, request.operationId)
|
||||||
|
.then((result) => {
|
||||||
|
this.stateVersion = result.stateVersion;
|
||||||
|
this.lastSaved = structuredClone(request.snapshot);
|
||||||
|
this.retryIndex = 0;
|
||||||
|
this.onSaved?.(structuredClone(request.snapshot), this.stateVersion);
|
||||||
|
if (this.pending) {
|
||||||
|
this.setStatus("dirty");
|
||||||
|
this.schedule(this.debounceMs);
|
||||||
|
} else {
|
||||||
|
this.setStatus("saved");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (error instanceof ProjectStateConflict) {
|
||||||
|
this.pending = undefined;
|
||||||
|
this.conflictVersion = error.latestStateVersion;
|
||||||
|
this.setStatus("conflicted");
|
||||||
|
this.onConflict?.(error.latestStateVersion);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.pending) this.pending = request;
|
||||||
|
this.setStatus("failed");
|
||||||
|
const delay = this.retryDelaysMs[Math.min(this.retryIndex, this.retryDelaysMs.length - 1)] ?? 15_000;
|
||||||
|
this.retryIndex += 1;
|
||||||
|
this.schedule(delay);
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.inFlight === attempt) this.inFlight = undefined;
|
||||||
|
});
|
||||||
|
this.inFlight = attempt;
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: ProjectSaveStatus) {
|
||||||
|
this.status = status;
|
||||||
|
this.onStatus?.(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
getVirtualStickerWindow,
|
||||||
|
staticStickerOriginalUrl,
|
||||||
|
staticStickerThumbnailUrl,
|
||||||
|
type StaticStickerCatalogItem,
|
||||||
|
type VirtualStickerWindow,
|
||||||
|
} from "@dada/static-sticker-catalog";
|
||||||
|
import { P0A_STATIC_STICKER_RELEASE_VERSION } from "@dada/template-registry";
|
||||||
|
|
||||||
|
const resourceVersion = P0A_STATIC_STICKER_RELEASE_VERSION;
|
||||||
|
const partCounts = [203, 36, 27, 48, 38, 75, 37, 67, 48, 24, 40, 30, 27, 51, 62, 19, 36, 45, 92, 53, 69, 31, 36, 30, 183] as const;
|
||||||
|
|
||||||
|
function buildCatalog(): StaticStickerCatalogItem[] {
|
||||||
|
let index = 0;
|
||||||
|
return partCounts.flatMap((count, partIndex) => Array.from({ length: count }, (_, orderIndex) => {
|
||||||
|
index += 1;
|
||||||
|
const stableId = `STK${String(index).padStart(index < 1_000 ? 3 : 4, "0")}`;
|
||||||
|
const item = {
|
||||||
|
enabled: true,
|
||||||
|
height: 0,
|
||||||
|
mime: "image/png" as const,
|
||||||
|
mime_type: "image/png" as const,
|
||||||
|
order: orderIndex + 1,
|
||||||
|
original_filename: `${stableId}.png`,
|
||||||
|
original_reference: "",
|
||||||
|
origin: "bundled_read_only" as const,
|
||||||
|
part: partIndex + 1,
|
||||||
|
relative_path: `sticker_part${partIndex + 1}/${stableId}.png`,
|
||||||
|
resource_version: resourceVersion,
|
||||||
|
sha256: "",
|
||||||
|
stable_id: stableId,
|
||||||
|
thumbnail_reference: { media: "thumbnail" as const, resource_id: stableId, resource_version: resourceVersion, url: "" },
|
||||||
|
width: 0,
|
||||||
|
} satisfies StaticStickerCatalogItem;
|
||||||
|
item.original_reference = staticStickerOriginalUrl(item);
|
||||||
|
item.thumbnail_reference.url = staticStickerThumbnailUrl(item);
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const P0A_STATIC_STICKER_CATALOG = buildCatalog();
|
||||||
|
export const P0A_STATIC_STICKER_COUNT = P0A_STATIC_STICKER_CATALOG.length;
|
||||||
|
|
||||||
|
export function stickerWindow(items: readonly StaticStickerCatalogItem[], scrollTop: number, viewportHeight: number): VirtualStickerWindow<StaticStickerCatalogItem> {
|
||||||
|
return getVirtualStickerWindow(items, { columns: 2, itemHeight: 144, overscanScreens: 2, scrollTop, viewportHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { staticStickerOriginalUrl, staticStickerThumbnailUrl };
|
||||||
|
export type { StaticStickerCatalogItem };
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
import { P0A_COMPLEX_RELEASE_VERSION, P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
|
||||||
|
|
||||||
|
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||||
|
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
|
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
|
||||||
|
export type TextAlign = "center" | "left" | "right";
|
||||||
|
|
||||||
|
export interface TextTemplateDefinition {
|
||||||
|
available: boolean;
|
||||||
|
catalogOrder: number;
|
||||||
|
category: TextTemplateCategory;
|
||||||
|
defaultFontId: string;
|
||||||
|
defaultFontSize: number;
|
||||||
|
defaultText: string;
|
||||||
|
displayName: string;
|
||||||
|
fontUrl?: string;
|
||||||
|
resourceClass: "parameter_only" | "zip_template";
|
||||||
|
resourceVersion: string;
|
||||||
|
templateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FontOption {
|
||||||
|
displayName: string;
|
||||||
|
fontId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextStylePatch {
|
||||||
|
backgroundColor?: string;
|
||||||
|
backgroundEnabled?: boolean;
|
||||||
|
backgroundOpacity?: number;
|
||||||
|
fillColor?: string;
|
||||||
|
fontOverride?: string | null;
|
||||||
|
letterSpacing?: number;
|
||||||
|
lineHeight?: number;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeEnabled?: boolean;
|
||||||
|
strokeWidth?: number;
|
||||||
|
textAlign?: TextAlign;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceVersion = P0A_COMPLEX_RELEASE_VERSION;
|
||||||
|
const defaults = {
|
||||||
|
background_color: "#FFE62C",
|
||||||
|
background_enabled: false,
|
||||||
|
background_opacity: 1,
|
||||||
|
fill_color: "#111111",
|
||||||
|
letter_spacing: 1,
|
||||||
|
line_height: 1.2,
|
||||||
|
stroke_color: "#000000",
|
||||||
|
stroke_enabled: false,
|
||||||
|
stroke_width: 0,
|
||||||
|
text_align: "center",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
|
||||||
|
|
||||||
|
const seeds: readonly CatalogSeed[] = [
|
||||||
|
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
|
||||||
|
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
|
||||||
|
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
|
||||||
|
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
|
||||||
|
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
|
||||||
|
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
|
||||||
|
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
|
||||||
|
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
|
||||||
|
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
|
||||||
|
["H002", "title", "30°C", "30°C", "H002_FONT"],
|
||||||
|
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
|
||||||
|
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
|
||||||
|
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
|
||||||
|
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
|
||||||
|
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
|
||||||
|
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
|
||||||
|
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
|
||||||
|
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
|
||||||
|
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||||
|
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
|
||||||
|
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
|
||||||
|
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
|
||||||
|
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||||
|
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
|
||||||
|
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
|
||||||
|
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
|
||||||
|
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
|
||||||
|
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
|
||||||
|
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
|
||||||
|
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
|
||||||
|
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
|
||||||
|
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const seedById = new Map(seeds.map((seed) => [seed[0], seed]));
|
||||||
|
|
||||||
|
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
||||||
|
const seed = seedById.get(templateId);
|
||||||
|
if (!seed) throw new Error(`missing text template definition ${templateId}`);
|
||||||
|
return {
|
||||||
|
available: seed[5] === true,
|
||||||
|
catalogOrder,
|
||||||
|
category: seed[1],
|
||||||
|
defaultFontId: seed[4],
|
||||||
|
defaultFontSize: 48,
|
||||||
|
defaultText: seed[3],
|
||||||
|
displayName: seed[2],
|
||||||
|
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${resourceVersion}/${seed[4]}` } : {}),
|
||||||
|
resourceClass: seed[6] ?? "zip_template",
|
||||||
|
resourceVersion,
|
||||||
|
templateId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontOptionDefinitions: Readonly<Record<typeof P0A_REQUIRED_FONT_PANEL_IDS[number], string>> = {
|
||||||
|
FONT005: "Rammetto",
|
||||||
|
FONT008: "正圆体",
|
||||||
|
FONT011: "默陌手写",
|
||||||
|
FONT021: "喜月体",
|
||||||
|
FONT022: "素白体",
|
||||||
|
FONT027: "锐正圆",
|
||||||
|
FONT039: "字由油漆",
|
||||||
|
FONT043: "喜脉体",
|
||||||
|
FONT046: "可口可乐",
|
||||||
|
FONT052: "Oraqle Script",
|
||||||
|
FONT081: "Lexend Deca",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({
|
||||||
|
displayName: fontOptionDefinitions[fontId],
|
||||||
|
fontId,
|
||||||
|
url: `/api/v1/assets/public/${resourceVersion}/${fontId}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function fontOption(fontId: string) {
|
||||||
|
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fontIdForTextElement(element: CanvasElement) {
|
||||||
|
const defaultFontId = element.style_parameters?.default_font_id;
|
||||||
|
return element.font_override ?? (typeof defaultFontId === "string" ? defaultFontId : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneElement(element: CanvasElement): CanvasElement {
|
||||||
|
return structuredClone(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateById(templates: readonly TextTemplateDefinition[], templateId: string) {
|
||||||
|
const template = templates.find((candidate) => candidate.templateId === templateId);
|
||||||
|
if (!template) throw new Error("text_template_not_found");
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedColor(value: string) {
|
||||||
|
if (!/^#[0-9A-F]{6}$/i.test(value)) throw new Error("text_color_invalid");
|
||||||
|
return value.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStep(value: number, minimum: number, step: number) {
|
||||||
|
return Math.abs((value - minimum) / step - Math.round((value - minimum) / step)) < 1e-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
|
||||||
|
return { ...defaults, default_font_id: template.defaultFontId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchTextTemplates(
|
||||||
|
templates: readonly TextTemplateDefinition[],
|
||||||
|
input: { category?: TextTemplateCategory; query?: string },
|
||||||
|
) {
|
||||||
|
const query = input.query?.trim().toLocaleLowerCase("zh-CN") ?? "";
|
||||||
|
return templates
|
||||||
|
.filter((template) => !input.category || template.category === input.category)
|
||||||
|
.filter((template) => !query || template.displayName.toLocaleLowerCase("zh-CN").includes(query))
|
||||||
|
.toSorted((left, right) => left.catalogOrder - right.catalogOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextTemplateElement(
|
||||||
|
template: TextTemplateDefinition,
|
||||||
|
identity: CanvasElementIdentity,
|
||||||
|
zIndex: number,
|
||||||
|
transform: Partial<Pick<CanvasElement, "position" | "rotation" | "scale">> = {},
|
||||||
|
): CanvasElement {
|
||||||
|
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||||
|
return {
|
||||||
|
content: template.defaultText,
|
||||||
|
created_at: identity.createdAt,
|
||||||
|
element_id: identity.elementId,
|
||||||
|
font_size: template.defaultFontSize,
|
||||||
|
opacity: 1,
|
||||||
|
position: transform.position ?? { x: 0.5, y: 0.5 },
|
||||||
|
resource_version: template.resourceVersion,
|
||||||
|
rotation: transform.rotation ?? 0,
|
||||||
|
scale: transform.scale ?? { x: 1, y: 1 },
|
||||||
|
style_parameters: templateStyle(template),
|
||||||
|
template_or_asset_id: template.templateId,
|
||||||
|
type: "text_template",
|
||||||
|
z_index: zIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveFontSize(element: CanvasElement) {
|
||||||
|
return (element.font_size ?? 48) * element.scale.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextEditSession {
|
||||||
|
readonly original: CanvasElement;
|
||||||
|
private draft: CanvasElement;
|
||||||
|
private readonly templates: readonly TextTemplateDefinition[];
|
||||||
|
|
||||||
|
constructor(element: CanvasElement, templates: readonly TextTemplateDefinition[]) {
|
||||||
|
if (element.type !== "text_template") throw new Error("text_element_required");
|
||||||
|
this.original = cloneElement(element);
|
||||||
|
this.draft = cloneElement(element);
|
||||||
|
this.templates = templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
get value() { return cloneElement(this.draft); }
|
||||||
|
|
||||||
|
cancel() { return cloneElement(this.original); }
|
||||||
|
|
||||||
|
complete() {
|
||||||
|
if (!(this.draft.content ?? "").trim()) throw new Error("text_content_required");
|
||||||
|
return cloneElement(this.draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(content: string) {
|
||||||
|
this.draft.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffectiveFontSize(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value < 1 || value > 512) throw new Error("text_font_size_invalid");
|
||||||
|
const base = this.draft.font_size ?? 48;
|
||||||
|
const factor = value / base;
|
||||||
|
this.draft.scale = { x: factor, y: factor };
|
||||||
|
}
|
||||||
|
|
||||||
|
setStyle(patch: TextStylePatch) {
|
||||||
|
const style = { ...(this.draft.style_parameters ?? {}) };
|
||||||
|
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
|
||||||
|
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
|
||||||
|
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
|
||||||
|
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
|
||||||
|
if (patch.backgroundEnabled !== undefined) style.background_enabled = patch.backgroundEnabled;
|
||||||
|
if (patch.strokeWidth !== undefined) {
|
||||||
|
if (!Number.isFinite(patch.strokeWidth) || patch.strokeWidth < 0 || patch.strokeWidth > 12) throw new Error("text_stroke_width_invalid");
|
||||||
|
style.stroke_width = patch.strokeWidth;
|
||||||
|
}
|
||||||
|
if (patch.backgroundOpacity !== undefined) {
|
||||||
|
if (!Number.isFinite(patch.backgroundOpacity) || patch.backgroundOpacity < 0 || patch.backgroundOpacity > 1) throw new Error("text_background_opacity_invalid");
|
||||||
|
style.background_opacity = patch.backgroundOpacity;
|
||||||
|
}
|
||||||
|
if (patch.textAlign !== undefined) {
|
||||||
|
if (!["center", "left", "right"].includes(patch.textAlign)) throw new Error("text_align_invalid");
|
||||||
|
style.text_align = patch.textAlign;
|
||||||
|
}
|
||||||
|
if (patch.lineHeight !== undefined) {
|
||||||
|
if (patch.lineHeight < 1 || patch.lineHeight > 1.9 || !isStep(patch.lineHeight, 1, 0.1)) throw new Error("text_line_height_invalid");
|
||||||
|
style.line_height = patch.lineHeight;
|
||||||
|
}
|
||||||
|
if (patch.letterSpacing !== undefined) {
|
||||||
|
if (patch.letterSpacing < 1 || patch.letterSpacing > 20 || !isStep(patch.letterSpacing, 1, 1)) throw new Error("text_letter_spacing_invalid");
|
||||||
|
style.letter_spacing = patch.letterSpacing;
|
||||||
|
}
|
||||||
|
if (patch.fontOverride !== undefined) {
|
||||||
|
if (patch.fontOverride === null) delete this.draft.font_override;
|
||||||
|
else this.draft.font_override = patch.fontOverride;
|
||||||
|
}
|
||||||
|
this.draft.style_parameters = style;
|
||||||
|
}
|
||||||
|
|
||||||
|
switchTemplate(templateId: string) {
|
||||||
|
const template = templateById(this.templates, templateId);
|
||||||
|
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||||
|
const content = this.draft.content;
|
||||||
|
this.draft = {
|
||||||
|
...this.draft,
|
||||||
|
font_size: template.defaultFontSize,
|
||||||
|
resource_version: template.resourceVersion,
|
||||||
|
style_parameters: templateStyle(template),
|
||||||
|
template_or_asset_id: template.templateId,
|
||||||
|
};
|
||||||
|
if (content !== undefined) this.draft.content = content;
|
||||||
|
delete this.draft.font_override;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export type ArchivedFontStatus = "idle" | "loading" | "ready" | "unavailable";
|
||||||
|
|
||||||
|
interface LoadableFontFace {
|
||||||
|
load: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FontSetPort {
|
||||||
|
add: (face: unknown) => unknown;
|
||||||
|
check: (font: string) => boolean;
|
||||||
|
ready: Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchivedFontReference {
|
||||||
|
fontId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fontFamilyName(fontId: string) {
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(fontId)) throw new Error("font_id_invalid");
|
||||||
|
return `Dada_${fontId.replaceAll(/[^A-Za-z0-9_]/g, "_")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ArchivedFontLoader {
|
||||||
|
private readonly createFace: (family: string, source: string) => LoadableFontFace;
|
||||||
|
private readonly fontSet: FontSetPort;
|
||||||
|
private readonly pending = new Map<string, Promise<ArchivedFontStatus>>();
|
||||||
|
private readonly statuses = new Map<string, ArchivedFontStatus>();
|
||||||
|
|
||||||
|
constructor(input: { createFace: (family: string, source: string) => LoadableFontFace; fontSet: FontSetPort }) {
|
||||||
|
this.createFace = input.createFace;
|
||||||
|
this.fontSet = input.fontSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
status(fontId: string): ArchivedFontStatus {
|
||||||
|
return this.statuses.get(fontId) ?? "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
|
||||||
|
const existing = this.pending.get(reference.fontId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const operation = this.load(reference);
|
||||||
|
this.pending.set(reference.fontId, operation);
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(reference: ArchivedFontReference) {
|
||||||
|
this.pending.delete(reference.fontId);
|
||||||
|
this.statuses.delete(reference.fontId);
|
||||||
|
return this.ensure(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
|
||||||
|
const family = fontFamilyName(reference.fontId);
|
||||||
|
this.statuses.set(reference.fontId, "loading");
|
||||||
|
try {
|
||||||
|
const face = this.createFace(family, `url("${reference.url}")`);
|
||||||
|
const loaded = await face.load();
|
||||||
|
this.fontSet.add(loaded);
|
||||||
|
await this.fontSet.ready;
|
||||||
|
if (!this.fontSet.check(`16px "${family}"`)) throw new Error("font_not_ready");
|
||||||
|
this.statuses.set(reference.fontId, "ready");
|
||||||
|
return "ready";
|
||||||
|
} catch {
|
||||||
|
this.statuses.set(reference.fontId, "unavailable");
|
||||||
|
return "unavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBrowserArchivedFontLoader() {
|
||||||
|
return new ArchivedFontLoader({
|
||||||
|
createFace: (family, source) => new FontFace(family, source),
|
||||||
|
fontSet: document.fonts as unknown as FontSetPort,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { effectiveFontSize, P0A_FONT_OPTIONS, type TextStylePatch, type TextTemplateDefinition } from "./text-assets.js";
|
||||||
|
|
||||||
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
|
function scalar<T extends string | number | boolean>(element: CanvasElement, key: string, fallback: T): T {
|
||||||
|
const value = element.style_parameters?.[key];
|
||||||
|
return typeof value === typeof fallback ? value as T : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberAndRange(props: { label: string; max: number; min: number; onChange: (value: number) => void; step: number; value: number }) {
|
||||||
|
return <label className="editor-range editor-text-number"><span>{props.label}<output>{props.value}</output></span><input aria-label={`${props.label}滑杆`} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="range" value={props.value} /><input aria-label={props.label} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="number" value={props.value} /></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInspector(props: {
|
||||||
|
draft: CanvasElement;
|
||||||
|
onCancel: () => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onContent: (content: string) => void;
|
||||||
|
onFont: (fontId: string | null) => void;
|
||||||
|
onFontSize: (value: number) => void;
|
||||||
|
onStyle: (patch: TextStylePatch) => void;
|
||||||
|
onTemplate: (templateId: string) => void;
|
||||||
|
templates: readonly TextTemplateDefinition[];
|
||||||
|
}) {
|
||||||
|
const style = props.draft.style_parameters ?? {};
|
||||||
|
const backgroundOpacity = Math.round(scalar(props.draft, "background_opacity", 1) * 100);
|
||||||
|
const strokeEnabled = scalar(props.draft, "stroke_enabled", false);
|
||||||
|
const backgroundEnabled = scalar(props.draft, "background_enabled", false);
|
||||||
|
return <div className="editor-text-inspector">
|
||||||
|
<label>文字模板切换<select aria-label="文字模板切换" onChange={(event) => props.onTemplate(event.target.value)} value={props.draft.template_or_asset_id}>
|
||||||
|
{props.templates.map((template) => <option disabled={!template.available} key={template.templateId} value={template.templateId}>{template.templateId} · {template.displayName}{template.available ? "" : "(素材暂不可用)"}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label>文字内容<textarea aria-label="文字内容" onChange={(event) => props.onContent(event.target.value)} rows={4} value={props.draft.content ?? ""} /></label>
|
||||||
|
<label>字体覆盖<select aria-label="字体覆盖" onChange={(event) => props.onFont(event.target.value || null)} value={props.draft.font_override ?? ""}><option value="">模板默认字体</option>{P0A_FONT_OPTIONS.map((font) => <option key={font.fontId} value={font.fontId}>{font.fontId} · {font.displayName}</option>)}</select></label>
|
||||||
|
<NumberAndRange label="有效字号" max={256} min={1} onChange={props.onFontSize} step={1} value={Math.round(effectiveFontSize(props.draft))} />
|
||||||
|
<fieldset className="editor-color-fields"><legend>文字颜色</legend>
|
||||||
|
<label>文字填充色<input aria-label="文字填充色" onChange={(event) => props.onStyle({ fillColor: event.target.value })} type="color" value={scalar(props.draft, "fill_color", "#111111")} /></label>
|
||||||
|
<div className="editor-color-swatches" role="group" aria-label="文字参考色"><button aria-label="红色填充" onClick={() => props.onStyle({ fillColor: "#FA5751" })} style={{ background: "#FA5751" }} type="button" /><button aria-label="黑色填充" onClick={() => props.onStyle({ fillColor: "#000000" })} style={{ background: "#000000" }} type="button" /></div>
|
||||||
|
</fieldset>
|
||||||
|
<label className="editor-check"><input aria-label="启用描边" checked={strokeEnabled} onChange={(event) => props.onStyle({ strokeEnabled: event.target.checked })} type="checkbox" />启用描边</label>
|
||||||
|
<NumberAndRange label="描边宽度" max={12} min={0} onChange={(value) => props.onStyle({ strokeWidth: value })} step={1} value={scalar(props.draft, "stroke_width", 0)} />
|
||||||
|
<label>描边颜色<input aria-label="描边颜色" onChange={(event) => props.onStyle({ strokeColor: event.target.value })} type="color" value={scalar(props.draft, "stroke_color", "#000000")} /></label>
|
||||||
|
<label className="editor-check"><input aria-label="启用文字背景" checked={backgroundEnabled} onChange={(event) => props.onStyle({ backgroundEnabled: event.target.checked })} type="checkbox" />启用文字背景</label>
|
||||||
|
<label>文字背景色<input aria-label="文字背景色" onChange={(event) => props.onStyle({ backgroundColor: event.target.value })} type="color" value={scalar(props.draft, "background_color", "#FFE62C")} /></label>
|
||||||
|
<NumberAndRange label="背景透明度" max={100} min={0} onChange={(value) => props.onStyle({ backgroundOpacity: value / 100 })} step={1} value={backgroundOpacity} />
|
||||||
|
<label>文字对齐<select aria-label="文字对齐" onChange={(event) => props.onStyle({ textAlign: event.target.value as "center" | "left" | "right" })} value={scalar(props.draft, "text_align", "center")}><option value="left">左对齐</option><option value="center">居中</option><option value="right">右对齐</option></select></label>
|
||||||
|
<NumberAndRange label="行距" max={1.9} min={1} onChange={(value) => props.onStyle({ lineHeight: value })} step={0.1} value={scalar(props.draft, "line_height", 1.2)} />
|
||||||
|
<NumberAndRange label="字距" max={20} min={1} onChange={(value) => props.onStyle({ letterSpacing: value })} step={1} value={scalar(props.draft, "letter_spacing", 1)} />
|
||||||
|
<div className="editor-inspector-actions"><button onClick={props.onCancel} type="button">取消</button><button aria-label="完成文字编辑" className="editor-primary" onClick={props.onComplete} type="button">完成</button></div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
|
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
||||||
|
|
||||||
|
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
||||||
|
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TextTemplatePanel(props: {
|
||||||
|
canAdd: boolean;
|
||||||
|
category?: TextTemplateCategory;
|
||||||
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
|
onAdd: (template: TextTemplateDefinition) => void;
|
||||||
|
onCategory: (category?: TextTemplateCategory) => void;
|
||||||
|
onQuery: (query: string) => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
query: string;
|
||||||
|
recentIds: readonly string[];
|
||||||
|
templates: readonly TextTemplateDefinition[];
|
||||||
|
}) {
|
||||||
|
const visible = searchTextTemplates(props.templates, { ...(props.category ? { category: props.category } : {}), query: props.query });
|
||||||
|
const recent = props.recentIds.map((id) => props.templates.find((template) => template.templateId === id)).filter((item): item is TextTemplateDefinition => Boolean(item));
|
||||||
|
const loadFailed = props.templates.some((template) => template.available && props.fontStatuses[template.defaultFontId] === "unavailable");
|
||||||
|
return <section className="editor-text-assets">
|
||||||
|
<h2>文字模板</h2>
|
||||||
|
<input aria-label="搜索文字模板显示名称" className="editor-template-search" onChange={(event) => props.onQuery(event.target.value)} placeholder="搜索文字模板显示名称" type="search" value={props.query} />
|
||||||
|
<div aria-label="文字模板分类" className="editor-template-categories" role="group">
|
||||||
|
{categories.map((item) => <button aria-pressed={item.id === props.category || (!item.id && !props.category)} className={item.id === props.category || (!item.id && !props.category) ? "active" : ""} key={item.label} onClick={() => props.onCategory(item.id)} type="button">{item.label}</button>)}
|
||||||
|
</div>
|
||||||
|
{loadFailed ? <button className="editor-template-retry" onClick={props.onRetry} type="button">重试文字素材</button> : null}
|
||||||
|
{recent.length > 0 ? <div aria-label="最近使用文字模板" className="editor-template-recent"><h3>最近使用</h3><div>{recent.map((template) => <span key={template.templateId}><strong>{template.templateId}</strong>{template.displayName}</span>)}</div></div> : null}
|
||||||
|
<div className="editor-template-grid">
|
||||||
|
{visible.map((template) => {
|
||||||
|
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
|
||||||
|
const unavailable = !template.available || status === "unavailable";
|
||||||
|
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
|
||||||
|
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
|
||||||
|
<strong>{template.templateId}</strong>
|
||||||
|
<span>{template.displayName}</span>
|
||||||
|
{unavailable ? <small>素材暂不可用</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
||||||
|
</button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{visible.length === 0 ? <p className="editor-muted">没有匹配的文字模板</p> : null}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
:root {
|
||||||
|
color: #111111;
|
||||||
|
background: #f6f6f4;
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #f6f6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
input {
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 230px minmax(520px, 1fr) 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 30%) 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid #c8c8c3;
|
||||||
|
background: #eeeee9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-wordmark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 48px;
|
||||||
|
color: #111111;
|
||||||
|
background: #f2f500;
|
||||||
|
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||||
|
font-size: 96px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-copy {
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 54px;
|
||||||
|
padding: 26px 54px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-copy span {
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-copy strong {
|
||||||
|
max-width: 520px;
|
||||||
|
font-size: 27px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-block {
|
||||||
|
position: absolute;
|
||||||
|
top: 90px;
|
||||||
|
right: 16%;
|
||||||
|
width: 31%;
|
||||||
|
height: 92px;
|
||||||
|
background: #b9bab4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-line {
|
||||||
|
position: absolute;
|
||||||
|
right: 4%;
|
||||||
|
bottom: 38px;
|
||||||
|
width: 48%;
|
||||||
|
height: 48px;
|
||||||
|
border-right: 1px solid #111111;
|
||||||
|
border-bottom: 1px solid #111111;
|
||||||
|
background: #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-content {
|
||||||
|
position: relative;
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 28px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-admin-link {
|
||||||
|
position: absolute;
|
||||||
|
top: 34px;
|
||||||
|
right: 30px;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
width: min(480px, 100%);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
border: 1px solid #8a8a86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
border: 0;
|
||||||
|
color: #222222;
|
||||||
|
background: #f6f6f4;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab + .auth-tab {
|
||||||
|
border-left: 1px solid #8a8a86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab[aria-selected="true"] {
|
||||||
|
background: #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 350px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form h1 {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 27px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-registration-form {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label {
|
||||||
|
margin: 0 0 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #777773;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0 13px;
|
||||||
|
color: #111111;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input:focus {
|
||||||
|
border-color: #111111;
|
||||||
|
outline: 2px solid #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-code-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 142px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-code-row input {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-secondary,
|
||||||
|
.auth-primary {
|
||||||
|
height: 46px;
|
||||||
|
border: 1px solid #777773;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-primary {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #111111;
|
||||||
|
background: #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-secondary:not(:disabled),
|
||||||
|
.auth-primary:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-secondary:disabled,
|
||||||
|
.auth-primary:disabled {
|
||||||
|
color: #777773;
|
||||||
|
background: #deded9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status,
|
||||||
|
.auth-error {
|
||||||
|
min-height: 20px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
color: #3c5b32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-registration-status {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin: -52px 0 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #f2f500;
|
||||||
|
background: #111111;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-verified-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-verified-grid > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 64px;
|
||||||
|
border: 1px solid #b4b4af;
|
||||||
|
padding: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-verified-grid strong {
|
||||||
|
color: #2f6b45;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-verified-grid span {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-text-action {
|
||||||
|
align-self: flex-end;
|
||||||
|
border: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
color: #075f8f;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-registration-form > .auth-text-action {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-consent {
|
||||||
|
border: 1px solid #b4b4af;
|
||||||
|
padding: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-consent-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-checkbox {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 1fr;
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form .auth-checkbox input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin: 1px 0 0;
|
||||||
|
accent-color: #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-consent p {
|
||||||
|
margin: 8px 0 0 28px;
|
||||||
|
color: #8d281b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(17, 17, 17, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
width: min(720px, 100%);
|
||||||
|
max-height: min(760px, calc(100vh - 56px));
|
||||||
|
border: 2px solid #111111;
|
||||||
|
background: #f6f6f4;
|
||||||
|
box-shadow: 12px 12px 0 #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog > header {
|
||||||
|
border-bottom: 1px solid #111111;
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog header p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog-content section {
|
||||||
|
border-bottom: 1px solid #ccccb9;
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog-content h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog > footer {
|
||||||
|
border-top: 1px solid #111111;
|
||||||
|
padding: 0 24px 18px;
|
||||||
|
background: #f6f6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
border-left: 4px solid #c7432f;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #8d281b;
|
||||||
|
background: #fff0ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-local-notice {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 18px;
|
||||||
|
color: #f2f500;
|
||||||
|
background: #111111;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.auth-page {
|
||||||
|
grid-template-rows: 150px minmax(560px, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art {
|
||||||
|
grid-template-columns: 42% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-wordmark {
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-copy {
|
||||||
|
gap: 30px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-copy span {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-copy strong {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-block {
|
||||||
|
top: 54px;
|
||||||
|
right: 8%;
|
||||||
|
width: 33%;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-art-line {
|
||||||
|
right: 2%;
|
||||||
|
bottom: 20px;
|
||||||
|
width: 44%;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-content {
|
||||||
|
padding: 56px 20px 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-admin-link {
|
||||||
|
top: 22px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-registration-status {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-verified-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog-backdrop {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog {
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
box-shadow: 6px 6px 0 #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-dialog-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-code-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 126px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-local-notice {
|
||||||
|
min-height: 56px;
|
||||||
|
padding-block: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.auth-code-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-secondary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import { registrationNotice } from "@dada/shared-contracts";
|
||||||
|
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
import "./user-auth.css";
|
||||||
|
|
||||||
|
type AuthMode = "login" | "register";
|
||||||
|
type SendState = "idle" | "sending" | "sent" | "error";
|
||||||
|
|
||||||
|
interface ErrorEnvelopeBody {
|
||||||
|
error?: {
|
||||||
|
details?: { field_errors?: Array<{ message_key?: string }> };
|
||||||
|
message_key?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageByKey: Record<string, string> = {
|
||||||
|
"auth.account.suspended": "账号已暂停,请联系管理员。",
|
||||||
|
"auth.challenge.expired": "验证码已过期,请重新获取。",
|
||||||
|
"auth.challenge.invalid": "验证码不正确,请检查后重试。",
|
||||||
|
"auth.challenge.resend_too_soon": "请等待倒计时结束后重新获取验证码。",
|
||||||
|
"auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。",
|
||||||
|
"auth.login.admin_required": "此邮箱需从管理员登录入口进入。",
|
||||||
|
"auth.login.registration_required": "该邮箱尚未注册,请切换到注册。",
|
||||||
|
"auth.email.already_registered": "该邮箱已注册,请切换到登录。",
|
||||||
|
"auth.invite.disabled": "邀请码已停用,请更换邀请码。",
|
||||||
|
"auth.invite.exhausted": "邀请码使用次数已耗尽,请更换邀请码。",
|
||||||
|
"auth.invite.expired": "邀请码已过期,请更换邀请码。",
|
||||||
|
"auth.invite.not_found": "邀请码无效,请检查后重试。",
|
||||||
|
"auth.privacy.consent_required": "请阅读并同意《内测使用与隐私告知》。",
|
||||||
|
"auth.privacy.notice_version_invalid": "告知版本已更新,请重新阅读后同意。",
|
||||||
|
"auth.profile.invalid": "请检查创作署名和社交 ID。",
|
||||||
|
"auth.registration.login_required": "该邮箱已注册,请切换到登录。",
|
||||||
|
"auth.registration.stage_limit_reached": "本轮内测名额已满,请返回登录。",
|
||||||
|
"auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。",
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorMessage(body: ErrorEnvelopeBody) {
|
||||||
|
const key = body.error?.details?.field_errors?.[0]?.message_key ?? body.error?.message_key;
|
||||||
|
return key ? (messageByKey[key] ?? "请求未完成,请检查后重试。") : "请求未完成,请检查后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskedEmail(email: string) {
|
||||||
|
const [local = "", domain = ""] = email.split("@", 2);
|
||||||
|
const visible = local.slice(0, Math.min(2, local.length));
|
||||||
|
return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskedRegistrationEmail(email: string) {
|
||||||
|
const [local = "", domain = ""] = email.split("@", 2);
|
||||||
|
return `${local.slice(0, 1)}***@${domain.slice(0, 1)}***`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskedInvite(inviteCode: string) {
|
||||||
|
return `••••${inviteCode.slice(-3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAuthPage() {
|
||||||
|
const emailId = useId();
|
||||||
|
const codeId = useId();
|
||||||
|
const inviteId = useId();
|
||||||
|
const creatorNameId = useId();
|
||||||
|
const socialId = useId();
|
||||||
|
const loginTab = useRef<HTMLButtonElement>(null);
|
||||||
|
const registerTab = useRef<HTMLButtonElement>(null);
|
||||||
|
const noticeButton = useRef<HTMLButtonElement>(null);
|
||||||
|
const noticeCloseButton = useRef<HTMLButtonElement>(null);
|
||||||
|
const noticeDialog = useRef<HTMLElement>(null);
|
||||||
|
const [mode, setMode] = useState<AuthMode>("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
|
const [creatorName, setCreatorName] = useState("");
|
||||||
|
const [socialHandle, setSocialHandle] = useState("");
|
||||||
|
const [privacyConsentAccepted, setPrivacyConsentAccepted] = useState(false);
|
||||||
|
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||||
|
const [registrationId, setRegistrationId] = useState<string>();
|
||||||
|
const [sendState, setSendState] = useState<SendState>("idle");
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||||
|
const registrationReady = Boolean(
|
||||||
|
registrationId
|
||||||
|
&& /^[0-9]{6}$/.test(code)
|
||||||
|
&& creatorName.trim()
|
||||||
|
&& socialHandle.trim()
|
||||||
|
&& privacyConsentAccepted,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown <= 0) return;
|
||||||
|
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!noticeOpen) return;
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
noticeCloseButton.current?.focus();
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [noticeOpen]);
|
||||||
|
|
||||||
|
function resetRegistrationDetails() {
|
||||||
|
setCode("");
|
||||||
|
setCreatorName("");
|
||||||
|
setSocialHandle("");
|
||||||
|
setPrivacyConsentAccepted(false);
|
||||||
|
setNoticeOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode(nextMode: AuthMode) {
|
||||||
|
if (nextMode === mode) return;
|
||||||
|
setMode(nextMode);
|
||||||
|
resetRegistrationDetails();
|
||||||
|
setInviteCode("");
|
||||||
|
setRegistrationId(undefined);
|
||||||
|
setSendState("idle");
|
||||||
|
setCountdown(0);
|
||||||
|
setError(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifyRegistrationEntry() {
|
||||||
|
resetRegistrationDetails();
|
||||||
|
setRegistrationId(undefined);
|
||||||
|
setSendState("idle");
|
||||||
|
setCountdown(0);
|
||||||
|
setError(undefined);
|
||||||
|
window.requestAnimationFrame(() => document.getElementById(inviteId)?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNotice() {
|
||||||
|
setNoticeOpen(false);
|
||||||
|
window.requestAnimationFrame(() => noticeButton.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNoticeKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeNotice();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const focusable = Array.from(
|
||||||
|
noticeDialog.current?.querySelectorAll<HTMLElement>("button, [href], input, [tabindex]:not([tabindex='-1'])") ?? [],
|
||||||
|
).filter((element) => !element.hasAttribute("disabled"));
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable.at(-1);
|
||||||
|
if (!first || !last) return;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(nextMode: AuthMode) {
|
||||||
|
switchMode(nextMode);
|
||||||
|
window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
if (!emailValid || sendState === "sending" || countdown > 0) return;
|
||||||
|
setSendState("sending");
|
||||||
|
setError(undefined);
|
||||||
|
const endpoint = mode === "login" ? "/api/v1/auth/login/send" : "/api/v1/auth/register/send";
|
||||||
|
const payload = mode === "login" ? { email } : { email, invite_code: inviteCode };
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string };
|
||||||
|
if (!response.ok || !body.registration_id) {
|
||||||
|
setSendState("error");
|
||||||
|
setError(errorMessage(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRegistrationId(body.registration_id);
|
||||||
|
setSendState("sent");
|
||||||
|
setCountdown(60);
|
||||||
|
} catch {
|
||||||
|
setSendState("error");
|
||||||
|
setError(messageByKey["auth.service.unavailable"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/auth/login/complete", {
|
||||||
|
body: JSON.stringify({ registration_id: registrationId, verification_code: code }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as ErrorEnvelopeBody;
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(errorMessage(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign("/app");
|
||||||
|
} catch {
|
||||||
|
setError("登录请求未完成,请重试。");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeRegistration(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!registrationReady || !registrationId || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/auth/register/complete", {
|
||||||
|
body: JSON.stringify({
|
||||||
|
creator_name: creatorName,
|
||||||
|
privacy_consent_accepted: privacyConsentAccepted,
|
||||||
|
privacy_notice_version: registrationNotice.version,
|
||||||
|
registration_id: registrationId,
|
||||||
|
social_id: socialHandle,
|
||||||
|
verification_code: code,
|
||||||
|
}),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as ErrorEnvelopeBody;
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(errorMessage(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign("/app");
|
||||||
|
} catch {
|
||||||
|
setError("注册请求未完成,请重试。");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main className="auth-page">
|
||||||
|
<section className="auth-art" aria-label="Dada 创作艺术带">
|
||||||
|
<div className="auth-wordmark">DADA</div>
|
||||||
|
<div className="auth-art-copy">
|
||||||
|
<span>LOCAL CREATIVE SYSTEM / WINDOWS P0-A</span>
|
||||||
|
<strong>在本机展开你的创作</strong>
|
||||||
|
</div>
|
||||||
|
<div className="auth-art-block" aria-hidden="true" />
|
||||||
|
<div className="auth-art-line" aria-hidden="true" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="auth-content">
|
||||||
|
<a className="auth-admin-link" href="/admin/login">管理员登录</a>
|
||||||
|
<div className="auth-panel">
|
||||||
|
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
||||||
|
<button
|
||||||
|
aria-selected={mode === "login"}
|
||||||
|
className="auth-tab"
|
||||||
|
onClick={() => switchMode("login")}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
switchTab("register");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={loginTab}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={mode === "login" ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-selected={mode === "register"}
|
||||||
|
className="auth-tab"
|
||||||
|
onClick={() => switchMode("register")}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
switchTab("login");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={registerTab}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={mode === "register" ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "login" ? (
|
||||||
|
<form className="auth-form" onSubmit={login}>
|
||||||
|
<h1>邮箱验证码登录</h1>
|
||||||
|
<label htmlFor={emailId}>邮箱</label>
|
||||||
|
<input
|
||||||
|
autoComplete="email"
|
||||||
|
id={emailId}
|
||||||
|
inputMode="email"
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<label htmlFor={codeId}>验证码</label>
|
||||||
|
<div className="auth-code-row">
|
||||||
|
<input
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
id={codeId}
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
value={code}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="auth-secondary"
|
||||||
|
disabled={!emailValid || sendState === "sending" || countdown > 0}
|
||||||
|
onClick={sendCode}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sendState === "sent" ? (
|
||||||
|
<p className="auth-status" role="status">验证码已发送至 {maskedEmail(email)}</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||||
|
<button
|
||||||
|
className="auth-primary"
|
||||||
|
disabled={!registrationId || code.length !== 6 || submitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{submitting ? "登录中" : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="auth-form auth-registration-form" onSubmit={completeRegistration}>
|
||||||
|
<h1>{registrationId ? "完善注册资料" : "邀请码注册"}</h1>
|
||||||
|
{!registrationId ? (
|
||||||
|
<>
|
||||||
|
<label htmlFor={inviteId}>邀请码</label>
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
id={inviteId}
|
||||||
|
onChange={(event) => setInviteCode(event.target.value)}
|
||||||
|
value={inviteCode}
|
||||||
|
/>
|
||||||
|
<label htmlFor={emailId}>邮箱</label>
|
||||||
|
<input
|
||||||
|
autoComplete="email"
|
||||||
|
id={emailId}
|
||||||
|
inputMode="email"
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="auth-primary"
|
||||||
|
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
|
||||||
|
onClick={sendCode}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="auth-registration-status">验证码已发送</div>
|
||||||
|
<div className="auth-verified-grid">
|
||||||
|
<div>
|
||||||
|
<strong>邀请码 · 已验证</strong>
|
||||||
|
<span>{maskedInvite(inviteCode)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>邮箱 · 已验证</strong>
|
||||||
|
<span>{maskedRegistrationEmail(email)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="auth-text-action" onClick={modifyRegistrationEntry} type="button">
|
||||||
|
修改邀请码和邮箱
|
||||||
|
</button>
|
||||||
|
<label htmlFor={codeId}>验证码</label>
|
||||||
|
<input
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
id={codeId}
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
|
||||||
|
placeholder="输入 6 位验证码"
|
||||||
|
value={code}
|
||||||
|
/>
|
||||||
|
<label htmlFor={creatorNameId}>创作署名</label>
|
||||||
|
<input
|
||||||
|
autoComplete="nickname"
|
||||||
|
id={creatorNameId}
|
||||||
|
maxLength={80}
|
||||||
|
onChange={(event) => setCreatorName(event.target.value)}
|
||||||
|
placeholder="成品中显示的名称"
|
||||||
|
value={creatorName}
|
||||||
|
/>
|
||||||
|
<label htmlFor={socialId}>社交 ID</label>
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
id={socialId}
|
||||||
|
maxLength={80}
|
||||||
|
onChange={(event) => setSocialHandle(event.target.value)}
|
||||||
|
placeholder="成品中显示的账号文本"
|
||||||
|
value={socialHandle}
|
||||||
|
/>
|
||||||
|
<div className="auth-consent">
|
||||||
|
<div className="auth-consent-title">
|
||||||
|
<strong>《内测使用与隐私告知》</strong>
|
||||||
|
<button
|
||||||
|
className="auth-text-action"
|
||||||
|
onClick={() => setNoticeOpen(true)}
|
||||||
|
ref={noticeButton}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
查看全文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="auth-checkbox">
|
||||||
|
<input
|
||||||
|
checked={privacyConsentAccepted}
|
||||||
|
onChange={(event) => setPrivacyConsentAccepted(event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
我已阅读并同意《内测使用与隐私告知》
|
||||||
|
</label>
|
||||||
|
{!privacyConsentAccepted ? <p>完成注册前必须阅读并勾选同意。</p> : null}
|
||||||
|
</div>
|
||||||
|
<button className="auth-primary" disabled={!registrationReady || submitting} type="submit">
|
||||||
|
{submitting ? "注册中" : "注册并进入 Dada"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="auth-local-notice">
|
||||||
|
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
{noticeOpen ? (
|
||||||
|
<div className="auth-dialog-backdrop">
|
||||||
|
<section
|
||||||
|
aria-labelledby="registration-notice-title"
|
||||||
|
aria-modal="true"
|
||||||
|
className="auth-dialog"
|
||||||
|
onKeyDown={handleNoticeKeyDown}
|
||||||
|
ref={noticeDialog}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<p>版本 {registrationNotice.version} · 生效日期 {registrationNotice.effectiveAt}</p>
|
||||||
|
<h2 id="registration-notice-title">{registrationNotice.title}</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="auth-dialog-content">
|
||||||
|
{registrationNotice.sections.map((section) => (
|
||||||
|
<section key={section.title}>
|
||||||
|
<h3>{section.title}</h3>
|
||||||
|
<p>{section.body}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button className="auth-primary" onClick={closeNotice} ref={noticeCloseButton} type="button">
|
||||||
|
我已阅读
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "13.0.1",
|
"better-sqlite3": "13.0.1",
|
||||||
"drizzle-orm": "0.45.2"
|
"drizzle-orm": "0.45.2",
|
||||||
|
"sharp": "0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { GenerationErrorCategory } from "./generation-error-registry.js";
|
||||||
|
|
||||||
|
export interface GenerationAdapterRequest {
|
||||||
|
configSnapshot: Readonly<Record<string, unknown>>;
|
||||||
|
generationId: string;
|
||||||
|
modelId: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
|
referenceAssetIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedGenerationOutput {
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: "image/jpeg" | "image/png" | "image/webp";
|
||||||
|
pixelHeight: number;
|
||||||
|
pixelWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerationAdapterResult =
|
||||||
|
| { outputs: readonly NormalizedGenerationOutput[]; status: "completed" }
|
||||||
|
| { status: "pending"; upstreamJobReference: string }
|
||||||
|
| {
|
||||||
|
balanceSignal?: { gatewayAccountRef: string; impactScope: "model" | "account" | "unknown" };
|
||||||
|
category: GenerationErrorCategory;
|
||||||
|
sourceCategory: string;
|
||||||
|
status: "failed";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GenerationAdapter {
|
||||||
|
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
|
||||||
|
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockResult = GenerationAdapterResult & { unsafeRaw?: string };
|
||||||
|
|
||||||
|
export class MockGenerationAdapter implements GenerationAdapter {
|
||||||
|
readonly calls: Array<{ generationId: string; modelId: string }> = [];
|
||||||
|
private readonly result: MockResult;
|
||||||
|
|
||||||
|
constructor(result: MockResult) {
|
||||||
|
this.result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
|
||||||
|
this.calls.push({ generationId: request.generationId, modelId: request.modelId });
|
||||||
|
if (this.result.status === "pending") return { status: "pending", upstreamJobReference: this.result.upstreamJobReference };
|
||||||
|
if (this.result.status === "completed") {
|
||||||
|
return { outputs: this.result.outputs.map((output) => ({ ...output, bytes: Buffer.from(output.bytes) })), status: "completed" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(this.result.balanceSignal ? { balanceSignal: { ...this.result.balanceSignal } } : {}),
|
||||||
|
category: this.result.category,
|
||||||
|
sourceCategory: this.result.sourceCategory,
|
||||||
|
status: "failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||||
|
import { normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
|
||||||
|
import {
|
||||||
|
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||||
|
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||||
|
type ModelAdapter, supportedAdapterRatios,
|
||||||
|
} from "./model-adapter.js";
|
||||||
|
|
||||||
|
const expectedContract = {
|
||||||
|
endpoint: "https://mock.invalid/gemini/flash",
|
||||||
|
fixture_id: "gemini-flash-mock-v1",
|
||||||
|
mode: "sync",
|
||||||
|
protocol_version: "gemini-flash-v1",
|
||||||
|
response_shape: "candidates.inline_data",
|
||||||
|
supports_reference_images: true,
|
||||||
|
supported_ratios: [...supportedAdapterRatios],
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultTransport(): AdapterTransport {
|
||||||
|
return {
|
||||||
|
async start(request) {
|
||||||
|
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||||
|
return { candidates: [{ inline_data: { data: mockPngBytes("flash").toString("base64"), mime_type: "image/png" }, ...dimensions }] };
|
||||||
|
},
|
||||||
|
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeminiFlashAdapter implements ModelAdapter {
|
||||||
|
readonly contractVersion = "gemini-flash-v1";
|
||||||
|
readonly fixtureId = "gemini-flash-mock-v1";
|
||||||
|
readonly modelId = "gemini-3.1-flash-image-preview";
|
||||||
|
private readonly transport: AdapterTransport;
|
||||||
|
|
||||||
|
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||||
|
|
||||||
|
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||||
|
try {
|
||||||
|
validateAdapterRequest(request, this.modelId);
|
||||||
|
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||||
|
if (response && typeof response === "object" && "status" in response && response.status === "failed") {
|
||||||
|
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||||
|
return { ...classified, status: "failed" };
|
||||||
|
}
|
||||||
|
const output = this.normalizeOutput(response);
|
||||||
|
return { outputs: [await normalizeImageOutputToRatio({ ...output, ratio: request.ratio })], status: "completed" };
|
||||||
|
} catch (error) {
|
||||||
|
const classified = error instanceof AdapterContractError
|
||||||
|
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||||
|
: this.classifyError(error, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||||
|
return { ...classified, status: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||||
|
void upstreamJobReference;
|
||||||
|
return { category: "gateway_contract_invalid", sourceCategory: "unexpected_poll", status: "failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||||
|
if (!response || typeof response !== "object" || !("candidates" in response) || !Array.isArray(response.candidates) || response.candidates.length !== 1) {
|
||||||
|
throw new AdapterContractError("response_single_image_required");
|
||||||
|
}
|
||||||
|
const candidate = response.candidates[0];
|
||||||
|
if (!candidate || typeof candidate !== "object" || !("inline_data" in candidate) || !candidate.inline_data || typeof candidate.inline_data !== "object") {
|
||||||
|
throw new AdapterContractError("response_shape_invalid");
|
||||||
|
}
|
||||||
|
const data = "data" in candidate.inline_data && typeof candidate.inline_data.data === "string" ? Buffer.from(candidate.inline_data.data, "base64") : undefined;
|
||||||
|
const mimeType = "mime_type" in candidate.inline_data && candidate.inline_data.mime_type === "image/png" ? "image/png" : undefined;
|
||||||
|
const pixelHeight = "pixelHeight" in candidate && typeof candidate.pixelHeight === "number" ? candidate.pixelHeight : undefined;
|
||||||
|
const pixelWidth = "pixelWidth" in candidate && typeof candidate.pixelWidth === "number" ? candidate.pixelWidth : undefined;
|
||||||
|
if (!data || !data.length || !mimeType || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||||
|
return { bytes: data, mimeType, pixelHeight, pixelWidth };
|
||||||
|
}
|
||||||
|
|
||||||
|
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||||
|
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||||
|
validateContract(routeProfile: Readonly<Record<string, unknown>>) {
|
||||||
|
return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { expectedContract as geminiFlashMockContract };
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||||
|
import { normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
|
||||||
|
import {
|
||||||
|
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||||
|
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||||
|
type ModelAdapter, supportedAdapterRatios,
|
||||||
|
} from "./model-adapter.js";
|
||||||
|
|
||||||
|
const expectedContract = {
|
||||||
|
endpoint: "https://mock.invalid/gemini/pro",
|
||||||
|
fixture_id: "gemini-pro-mock-v1",
|
||||||
|
mode: "async",
|
||||||
|
protocol_version: "gemini-pro-v1",
|
||||||
|
response_shape: "operation.poll.candidates",
|
||||||
|
supports_reference_images: true,
|
||||||
|
supported_ratios: [...supportedAdapterRatios],
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultTransport(): AdapterTransport {
|
||||||
|
return {
|
||||||
|
async start(request) {
|
||||||
|
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||||
|
return { operation: { done: true, response: { candidates: [{ inline_data: { data: mockPngBytes("pro").toString("base64"), mime_type: "image/png" }, ...dimensions }] } } };
|
||||||
|
},
|
||||||
|
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeminiProAdapter implements ModelAdapter {
|
||||||
|
readonly contractVersion = "gemini-pro-v1";
|
||||||
|
readonly fixtureId = "gemini-pro-mock-v1";
|
||||||
|
readonly modelId = "gemini-3-pro-image-preview";
|
||||||
|
private readonly transport: AdapterTransport;
|
||||||
|
|
||||||
|
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||||
|
|
||||||
|
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||||
|
try {
|
||||||
|
validateAdapterRequest(request, this.modelId);
|
||||||
|
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||||
|
return await this.interpret(response, request.configSnapshot.error_mapping_profile as Record<string, string> ?? {}, request.ratio);
|
||||||
|
} catch (error) {
|
||||||
|
const classified = error instanceof AdapterContractError
|
||||||
|
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||||
|
: this.classifyError(error, {});
|
||||||
|
return { ...classified, status: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||||
|
try {
|
||||||
|
const response = await this.transport.poll({ operation: "poll", modelId: this.modelId, upstreamJobReference });
|
||||||
|
return await this.interpret(response, {});
|
||||||
|
} catch (error) {
|
||||||
|
const classified = error instanceof AdapterContractError
|
||||||
|
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||||
|
: this.classifyError(error, {});
|
||||||
|
return { ...classified, status: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async interpret(response: unknown, mappingProfile: Readonly<Record<string, string>>, ratio?: GenerationAdapterRequest["ratio"]): Promise<AdapterStartResult> {
|
||||||
|
if (!response || typeof response !== "object" || !("operation" in response) || !response.operation || typeof response.operation !== "object") {
|
||||||
|
return { category: "gateway_contract_invalid", sourceCategory: "response_shape_invalid", status: "failed" };
|
||||||
|
}
|
||||||
|
if ("status" in response && response.status === "failed") {
|
||||||
|
const classified = this.classifyError("error" in response ? response.error : undefined, mappingProfile);
|
||||||
|
return { ...classified, status: "failed" };
|
||||||
|
}
|
||||||
|
const operation = response.operation;
|
||||||
|
if ("done" in operation && operation.done === false) {
|
||||||
|
const reference = "name" in operation && typeof operation.name === "string" ? operation.name : undefined;
|
||||||
|
return reference ? { status: "pending", upstreamJobReference: reference } : { category: "gateway_contract_invalid", sourceCategory: "upstream_reference_missing", status: "failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const output = this.normalizeOutput("response" in operation ? operation.response : undefined);
|
||||||
|
return { outputs: [ratio ? await normalizeImageOutputToRatio({ ...output, ratio }) : output], status: "completed" };
|
||||||
|
} catch (error) {
|
||||||
|
return { category: "gateway_contract_invalid", sourceCategory: error instanceof AdapterContractError ? error.sourceCategory : "response_shape_invalid", status: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||||
|
if (!response || typeof response !== "object" || !("candidates" in response) || !Array.isArray(response.candidates) || response.candidates.length !== 1) throw new AdapterContractError("response_single_image_required");
|
||||||
|
const candidate = response.candidates[0];
|
||||||
|
if (!candidate || typeof candidate !== "object" || !("inline_data" in candidate) || !candidate.inline_data || typeof candidate.inline_data !== "object") throw new AdapterContractError("response_shape_invalid");
|
||||||
|
const data = "data" in candidate.inline_data && typeof candidate.inline_data.data === "string" ? Buffer.from(candidate.inline_data.data, "base64") : undefined;
|
||||||
|
const mimeType = "mime_type" in candidate.inline_data && candidate.inline_data.mime_type === "image/png" ? "image/png" : undefined;
|
||||||
|
const pixelHeight = "pixelHeight" in candidate && typeof candidate.pixelHeight === "number" ? candidate.pixelHeight : undefined;
|
||||||
|
const pixelWidth = "pixelWidth" in candidate && typeof candidate.pixelWidth === "number" ? candidate.pixelWidth : undefined;
|
||||||
|
if (!data || !data.length || !mimeType || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||||
|
return { bytes: data, mimeType, pixelHeight, pixelWidth };
|
||||||
|
}
|
||||||
|
|
||||||
|
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||||
|
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||||
|
validateContract(routeProfile: Readonly<Record<string, unknown>>) { return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { expectedContract as geminiProMockContract };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||||
|
import { normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
|
||||||
|
import {
|
||||||
|
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
|
||||||
|
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
|
||||||
|
type ModelAdapter, supportedAdapterRatios,
|
||||||
|
} from "./model-adapter.js";
|
||||||
|
|
||||||
|
const expectedContract = {
|
||||||
|
endpoint: "https://mock.invalid/openai/images",
|
||||||
|
fixture_id: "gpt-image-mock-v1",
|
||||||
|
mode: "sync",
|
||||||
|
protocol_version: "gpt-image-v1",
|
||||||
|
response_shape: "data.b64_json",
|
||||||
|
supports_reference_images: true,
|
||||||
|
supported_ratios: [...supportedAdapterRatios],
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultTransport(): AdapterTransport {
|
||||||
|
return {
|
||||||
|
async start(request) {
|
||||||
|
const dimensions = dimensionsForRatio(request.ratio ?? "3:4");
|
||||||
|
return { data: [{ b64_json: mockPngBytes("gpt-image").toString("base64"), ...dimensions }] };
|
||||||
|
},
|
||||||
|
async poll() { throw new AdapterContractError("unexpected_poll"); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GptImageAdapter implements ModelAdapter {
|
||||||
|
readonly contractVersion = "gpt-image-v1";
|
||||||
|
readonly fixtureId = "gpt-image-mock-v1";
|
||||||
|
readonly modelId = "gpt-image-2";
|
||||||
|
private readonly transport: AdapterTransport;
|
||||||
|
|
||||||
|
constructor(input: { transport?: AdapterTransport } = {}) { this.transport = input.transport ?? defaultTransport(); }
|
||||||
|
|
||||||
|
async start(request: GenerationAdapterRequest): Promise<AdapterStartResult> {
|
||||||
|
try {
|
||||||
|
validateAdapterRequest(request, this.modelId);
|
||||||
|
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
|
||||||
|
if (response && typeof response === "object" && "status" in response && response.status === "failed") {
|
||||||
|
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||||
|
return { ...classified, status: "failed" };
|
||||||
|
}
|
||||||
|
const output = this.normalizeOutput(response);
|
||||||
|
return { outputs: [await normalizeImageOutputToRatio({ ...output, ratio: request.ratio })], status: "completed" };
|
||||||
|
} catch (error) {
|
||||||
|
const classified = error instanceof AdapterContractError
|
||||||
|
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
|
||||||
|
: this.classifyError(error, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
|
||||||
|
return { ...classified, status: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
|
||||||
|
void upstreamJobReference;
|
||||||
|
return { category: "gateway_contract_invalid", sourceCategory: "unexpected_poll", status: "failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeOutput(response: unknown): NormalizedGenerationOutput {
|
||||||
|
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data) || response.data.length !== 1) throw new AdapterContractError("response_single_image_required");
|
||||||
|
const image = response.data[0];
|
||||||
|
if (!image || typeof image !== "object") throw new AdapterContractError("response_shape_invalid");
|
||||||
|
const data = "b64_json" in image && typeof image.b64_json === "string" ? Buffer.from(image.b64_json, "base64") : undefined;
|
||||||
|
const pixelHeight = "pixelHeight" in image && typeof image.pixelHeight === "number" ? image.pixelHeight : undefined;
|
||||||
|
const pixelWidth = "pixelWidth" in image && typeof image.pixelWidth === "number" ? image.pixelWidth : undefined;
|
||||||
|
if (!data || !data.length || !pixelHeight || !pixelWidth) throw new AdapterContractError("response_media_invalid");
|
||||||
|
return { bytes: data, mimeType: "image/png", pixelHeight, pixelWidth };
|
||||||
|
}
|
||||||
|
|
||||||
|
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>) { return classifyAdapterError(error, mappingProfile); }
|
||||||
|
checkBalanceSignal(response: unknown) { return balanceSignalFromResponse(response); }
|
||||||
|
validateContract(routeProfile: Readonly<Record<string, unknown>>) { return validateMockContract(routeProfile, expectedContract, this.modelId, this.fixtureId, this.contractVersion); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { expectedContract as gptImageMockContract };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface CreditSettlementTarget {
|
||||||
|
finalizeGeneration(input: {
|
||||||
|
generationId: string;
|
||||||
|
operationKey: string;
|
||||||
|
outcome: "succeeded" | "failed" | "rejected";
|
||||||
|
}): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settleGenerationCredits(
|
||||||
|
target: CreditSettlementTarget,
|
||||||
|
input: {
|
||||||
|
generationId: string;
|
||||||
|
operationKey: string;
|
||||||
|
outcome: "succeeded" | "failed" | "rejected";
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return target.finalizeGeneration(input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||||
|
|
||||||
|
const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||||
|
const recoveryCheckLifetimeMilliseconds = 5 * 60 * 1_000;
|
||||||
|
const accountRefPattern = /^[a-z][a-z0-9_-]{2,119}$/;
|
||||||
|
const sourceCategoryPattern = /^[a-z][a-z0-9_]{0,79}$/;
|
||||||
|
const runtimeReasons = [
|
||||||
|
"available", "configured_disabled", "contract_unverified", "contract_blocked",
|
||||||
|
"gateway_balance_insufficient", "gateway_paused", "worker_degraded",
|
||||||
|
] as const;
|
||||||
|
type RuntimeReason = typeof runtimeReasons[number];
|
||||||
|
|
||||||
|
interface RuntimeRow {
|
||||||
|
available_for_new_jobs: number;
|
||||||
|
checked_at: number;
|
||||||
|
model_id: string;
|
||||||
|
reason: RuntimeReason;
|
||||||
|
runtime_availability_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MappingRow {
|
||||||
|
balance_blocked: number;
|
||||||
|
gateway_account_ref: string;
|
||||||
|
model_id: string;
|
||||||
|
previous_runtime_reason: RuntimeReason | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BalanceRow {
|
||||||
|
affected_model_ids_json: string;
|
||||||
|
balance_status: "available" | "insufficient" | "unknown";
|
||||||
|
detected_at: number;
|
||||||
|
gateway_account_ref: string;
|
||||||
|
impact_scope: "model" | "account" | "unknown";
|
||||||
|
last_confirmed_at: number | null;
|
||||||
|
recovery_status: "not_required" | "awaiting_confirmation" | "confirmed";
|
||||||
|
runtime_unavailable_model_ids_json: string;
|
||||||
|
source_category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayBalanceView {
|
||||||
|
affectedModelIds: string[];
|
||||||
|
balanceStatus: "available" | "insufficient" | "unknown";
|
||||||
|
detectedAt: string;
|
||||||
|
gatewayAccountRef: string;
|
||||||
|
impactScope: "model" | "account" | "unknown";
|
||||||
|
lastConfirmedAt: string | null;
|
||||||
|
recoveryStatus: "not_required" | "awaiting_confirmation" | "confirmed";
|
||||||
|
runtimeUnavailableModelIds: string[];
|
||||||
|
sourceCategory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayRecoveryCheck {
|
||||||
|
check_id: string;
|
||||||
|
expires_at: string;
|
||||||
|
gateway_account_ref: string;
|
||||||
|
status: "passed";
|
||||||
|
checked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GatewayBalanceRuntime {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly ownsDatabase: boolean;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
|
||||||
|
if (!input.database && !input.databasePath) throw new Error("gateway_balance_database_required");
|
||||||
|
this.database = input.database ?? new Database(input.databasePath!);
|
||||||
|
this.ownsDatabase = !input.database;
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
if (this.ownsDatabase) configureWorkerDatabase(this.database);
|
||||||
|
this.migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.ownsDatabase) this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
seedModels(models: Array<{ gatewayAccountRef: string; modelId: string }>) {
|
||||||
|
const now = this.clock();
|
||||||
|
this.immediate(() => {
|
||||||
|
for (const model of models) {
|
||||||
|
if (!accountRefPattern.test(model.gatewayAccountRef) || !model.modelId) throw new Error("gateway_balance_model_invalid");
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO model_runtime_availability (
|
||||||
|
model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version
|
||||||
|
) VALUES (?, 1, 'available', ?, 1)
|
||||||
|
ON CONFLICT(model_id) DO NOTHING
|
||||||
|
`).run(model.modelId, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO gateway_balance_affected_models (gateway_account_ref, model_id, balance_blocked, previous_runtime_reason)
|
||||||
|
VALUES (?, ?, 0, NULL)
|
||||||
|
ON CONFLICT(gateway_account_ref, model_id) DO NOTHING
|
||||||
|
`).run(model.gatewayAccountRef, model.modelId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordInsufficient(input: {
|
||||||
|
eventId: string;
|
||||||
|
gatewayAccountRef: string;
|
||||||
|
impactScope: "model" | "account" | "unknown";
|
||||||
|
modelId: string;
|
||||||
|
sourceCategory: string;
|
||||||
|
}) {
|
||||||
|
if (!input.eventId || !accountRefPattern.test(input.gatewayAccountRef) || !input.modelId
|
||||||
|
|| !sourceCategoryPattern.test(input.sourceCategory)) throw new Error("gateway_balance_signal_invalid");
|
||||||
|
return this.immediate(() => {
|
||||||
|
const receipt = this.database.prepare("SELECT response_json FROM gateway_balance_event_receipts WHERE event_id = ?")
|
||||||
|
.get(input.eventId) as { response_json: string } | undefined;
|
||||||
|
if (receipt) return JSON.parse(receipt.response_json) as GatewayBalanceView;
|
||||||
|
|
||||||
|
this.ensureModelMapping(input.gatewayAccountRef, input.modelId);
|
||||||
|
const accountModels = this.database.prepare("SELECT model_id FROM gateway_balance_affected_models WHERE gateway_account_ref = ? ORDER BY model_id")
|
||||||
|
.all(input.gatewayAccountRef) as Array<{ model_id: string }>;
|
||||||
|
const newlyAffected = input.impactScope === "model" ? [input.modelId] : accountModels.map((row) => row.model_id);
|
||||||
|
const existing = this.database.prepare("SELECT runtime_unavailable_model_ids_json FROM gateway_balance_states WHERE gateway_account_ref = ?")
|
||||||
|
.get(input.gatewayAccountRef) as { runtime_unavailable_model_ids_json: string } | undefined;
|
||||||
|
const unavailable = [...new Set([...(existing ? JSON.parse(existing.runtime_unavailable_model_ids_json) as string[] : []), ...newlyAffected])].toSorted();
|
||||||
|
const now = this.clock();
|
||||||
|
for (const modelId of unavailable) {
|
||||||
|
const mapping = this.database.prepare("SELECT * FROM gateway_balance_affected_models WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
|
.get(input.gatewayAccountRef, modelId) as MappingRow | undefined;
|
||||||
|
const runtime = this.database.prepare("SELECT * FROM model_runtime_availability WHERE model_id = ?").get(modelId) as RuntimeRow | undefined;
|
||||||
|
if (!mapping || !runtime) continue;
|
||||||
|
if (mapping.balance_blocked === 0) {
|
||||||
|
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 1, previous_runtime_reason = ? WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
|
.run(runtime.reason, input.gatewayAccountRef, modelId);
|
||||||
|
} else {
|
||||||
|
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 1 WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
|
.run(input.gatewayAccountRef, modelId);
|
||||||
|
}
|
||||||
|
const reason = runtime.reason === "configured_disabled" || runtime.reason === "contract_unverified" || runtime.reason === "contract_blocked"
|
||||||
|
? runtime.reason : "gateway_balance_insufficient";
|
||||||
|
this.updateRuntime(modelId, false, reason, now);
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO gateway_balance_states (
|
||||||
|
gateway_account_ref, balance_status, impact_scope, affected_model_ids_json,
|
||||||
|
runtime_unavailable_model_ids_json, detected_at, last_confirmed_at, recovery_status, source_category
|
||||||
|
) VALUES (?, 'insufficient', ?, ?, ?, ?, NULL, 'awaiting_confirmation', ?)
|
||||||
|
ON CONFLICT(gateway_account_ref) DO UPDATE SET
|
||||||
|
balance_status = 'insufficient', impact_scope = excluded.impact_scope,
|
||||||
|
affected_model_ids_json = excluded.affected_model_ids_json,
|
||||||
|
runtime_unavailable_model_ids_json = excluded.runtime_unavailable_model_ids_json,
|
||||||
|
detected_at = excluded.detected_at, last_confirmed_at = NULL,
|
||||||
|
recovery_status = 'awaiting_confirmation', source_category = excluded.source_category
|
||||||
|
`).run(input.gatewayAccountRef, input.impactScope, JSON.stringify(newlyAffected.toSorted()), JSON.stringify(unavailable), now, input.sourceCategory);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'system', 'generation_worker', 'gateway_balance_detected', 'gateway_balance_state', ?, 'succeeded', NULL, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), input.gatewayAccountRef, JSON.stringify({ impact_scope: input.impactScope, runtime_unavailable_model_ids: unavailable }), now, now + auditRetentionMilliseconds);
|
||||||
|
const result = this.readState(input.gatewayAccountRef)!;
|
||||||
|
this.database.prepare("INSERT INTO gateway_balance_event_receipts (event_id, gateway_account_ref, response_json, created_at) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(input.eventId, input.gatewayAccountRef, JSON.stringify(result), now);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readState(gatewayAccountRef: string) {
|
||||||
|
const row = this.database.prepare("SELECT * FROM gateway_balance_states WHERE gateway_account_ref = ?").get(gatewayAccountRef) as BalanceRow | undefined;
|
||||||
|
return row ? this.view(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
listRuntime() {
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT r.*, COALESCE(m.gateway_account_ref, 'unknown') AS gateway_account_ref,
|
||||||
|
COALESCE(m.balance_blocked, 0) AS balance_blocked
|
||||||
|
FROM model_runtime_availability r
|
||||||
|
LEFT JOIN gateway_balance_affected_models m ON m.model_id = r.model_id
|
||||||
|
ORDER BY r.model_id
|
||||||
|
`).all() as Array<RuntimeRow & { balance_blocked: number; gateway_account_ref: string }>;
|
||||||
|
return rows.map((row) => ({
|
||||||
|
availableForNewJobs: row.available_for_new_jobs === 1,
|
||||||
|
balanceBlocked: row.balance_blocked === 1,
|
||||||
|
gatewayAccountRef: row.gateway_account_ref,
|
||||||
|
modelId: row.model_id,
|
||||||
|
runtimeReason: row.reason,
|
||||||
|
runtimeVersion: row.runtime_availability_version,
|
||||||
|
updatedAt: new Date(row.checked_at).toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
runRecoveryCheck(gatewayAccountRef: string): GatewayRecoveryCheck {
|
||||||
|
const state = this.readState(gatewayAccountRef);
|
||||||
|
if (!state || state.recoveryStatus !== "awaiting_confirmation") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
|
const now = this.clock();
|
||||||
|
const check = {
|
||||||
|
check_id: randomUUID(), expires_at: new Date(now + recoveryCheckLifetimeMilliseconds).toISOString(),
|
||||||
|
gateway_account_ref: gatewayAccountRef, status: "passed" as const, checked_at: new Date(now).toISOString(),
|
||||||
|
};
|
||||||
|
this.immediate(() => {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO service_recovery_checks (check_id, service_name, target_ref, status, checked_at, expires_at, details_json)
|
||||||
|
VALUES (?, 'gateway_balance', ?, 'passed', ?, ?, ?)
|
||||||
|
`).run(check.check_id, gatewayAccountRef, now, now + recoveryCheckLifetimeMilliseconds, JSON.stringify({ non_sensitive: true }));
|
||||||
|
});
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRecovery(input: { actorId: string; gatewayAccountRef: string; recoveryCheckId?: string }) {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const state = this.database.prepare("SELECT * FROM gateway_balance_states WHERE gateway_account_ref = ?").get(input.gatewayAccountRef) as BalanceRow | undefined;
|
||||||
|
if (!state || state.recovery_status !== "awaiting_confirmation") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
|
const check = input.recoveryCheckId
|
||||||
|
? this.database.prepare("SELECT * FROM service_recovery_checks WHERE check_id = ? AND target_ref = ? AND service_name = 'gateway_balance'").get(input.recoveryCheckId, input.gatewayAccountRef) as { expires_at: number; status: string } | undefined
|
||||||
|
: undefined;
|
||||||
|
if (!check || check.status !== "passed" || check.expires_at < this.clock()) throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
|
const now = this.clock();
|
||||||
|
const rows = this.database.prepare("SELECT * FROM gateway_balance_affected_models WHERE gateway_account_ref = ? AND balance_blocked = 1")
|
||||||
|
.all(input.gatewayAccountRef) as MappingRow[];
|
||||||
|
for (const row of rows) {
|
||||||
|
const configuredReason = this.configuredRuntimeReason(row.model_id);
|
||||||
|
const reason = configuredReason === "available" ? (row.previous_runtime_reason ?? "available") : configuredReason;
|
||||||
|
this.updateRuntime(row.model_id, reason === "available", reason, now);
|
||||||
|
this.database.prepare("UPDATE gateway_balance_affected_models SET balance_blocked = 0, previous_runtime_reason = NULL WHERE gateway_account_ref = ? AND model_id = ?")
|
||||||
|
.run(input.gatewayAccountRef, row.model_id);
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE gateway_balance_states SET balance_status = 'available', recovery_status = 'confirmed', last_confirmed_at = ?
|
||||||
|
WHERE gateway_account_ref = ?
|
||||||
|
`).run(now, input.gatewayAccountRef);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'super_admin', ?, 'gateway_balance_recovery', 'gateway_balance_state', ?, 'succeeded', ?, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), input.actorId, input.gatewayAccountRef,
|
||||||
|
JSON.stringify({ recovery_status: "awaiting_confirmation" }),
|
||||||
|
JSON.stringify({ recovery_status: "confirmed", restored_model_count: rows.length }), now, now + auditRetentionMilliseconds);
|
||||||
|
return this.readState(input.gatewayAccountRef)!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreWithoutConfirmedRecovery(gatewayAccountRef: string, _actorId: string) {
|
||||||
|
const state = this.readState(gatewayAccountRef);
|
||||||
|
if (!state || state.recoveryStatus !== "confirmed") throw new Error("gateway_balance_recovery_unconfirmed");
|
||||||
|
throw new Error("gateway_balance_recovery_owned_by_wp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureModelMapping(gatewayAccountRef: string, modelId: string) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
|
VALUES (?, 1, 'available', ?, 1)
|
||||||
|
ON CONFLICT(model_id) DO NOTHING
|
||||||
|
`).run(modelId, this.clock());
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO gateway_balance_affected_models (gateway_account_ref, model_id, balance_blocked, previous_runtime_reason)
|
||||||
|
VALUES (?, ?, 0, NULL)
|
||||||
|
ON CONFLICT(gateway_account_ref, model_id) DO NOTHING
|
||||||
|
`).run(gatewayAccountRef, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRuntime(modelId: string, available: boolean, reason: RuntimeReason, now: number) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE model_runtime_availability
|
||||||
|
SET available_for_new_jobs = ?, reason = ?, checked_at = ?, runtime_availability_version = runtime_availability_version + 1
|
||||||
|
WHERE model_id = ?
|
||||||
|
`).run(available ? 1 : 0, reason, now, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private configuredRuntimeReason(modelId: string): RuntimeReason {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT m.enabled, v.contract_validation_status
|
||||||
|
FROM model_config_current c
|
||||||
|
JOIN model_config_set_members m ON m.config_set_id = c.config_set_id AND c.singleton = 1
|
||||||
|
JOIN model_config_versions v ON v.model_id = m.model_id AND v.config_version = m.config_version
|
||||||
|
WHERE m.model_id = ?
|
||||||
|
`).get(modelId) as { contract_validation_status: "blocked" | "unverified" | "verified"; enabled: number } | undefined;
|
||||||
|
if (!row) return "available";
|
||||||
|
if (row.enabled !== 1) return "configured_disabled";
|
||||||
|
if (row.contract_validation_status === "blocked") return "contract_blocked";
|
||||||
|
if (row.contract_validation_status !== "verified") return "contract_unverified";
|
||||||
|
return "available";
|
||||||
|
}
|
||||||
|
|
||||||
|
private view(row: BalanceRow): GatewayBalanceView {
|
||||||
|
return {
|
||||||
|
affectedModelIds: JSON.parse(row.affected_model_ids_json) as string[],
|
||||||
|
balanceStatus: row.balance_status,
|
||||||
|
detectedAt: new Date(row.detected_at).toISOString(),
|
||||||
|
gatewayAccountRef: row.gateway_account_ref,
|
||||||
|
impactScope: row.impact_scope,
|
||||||
|
lastConfirmedAt: row.last_confirmed_at === null ? null : new Date(row.last_confirmed_at).toISOString(),
|
||||||
|
recoveryStatus: row.recovery_status,
|
||||||
|
runtimeUnavailableModelIds: JSON.parse(row.runtime_unavailable_model_ids_json) as string[],
|
||||||
|
sourceCategory: row.source_category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private immediate<T>(action: () => T) {
|
||||||
|
if (this.database.inTransaction) return action();
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const result = action();
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
const existingRuntimeColumns = new Set((this.database.prepare("PRAGMA table_info(model_runtime_availability)").all() as Array<{ name: string }>).map((column) => column.name));
|
||||||
|
if (existingRuntimeColumns.has("gateway_account_ref")) {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE model_runtime_availability_v2 (
|
||||||
|
model_id TEXT PRIMARY KEY,
|
||||||
|
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||||
|
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||||
|
);
|
||||||
|
INSERT INTO model_runtime_availability_v2 (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
|
||||||
|
SELECT model_id, available_for_new_jobs, runtime_reason, updated_at, runtime_version FROM model_runtime_availability;
|
||||||
|
DROP TABLE model_runtime_availability;
|
||||||
|
ALTER TABLE model_runtime_availability_v2 RENAME TO model_runtime_availability;
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS model_runtime_availability (
|
||||||
|
model_id TEXT PRIMARY KEY,
|
||||||
|
available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)),
|
||||||
|
reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS gateway_balance_affected_models (
|
||||||
|
gateway_account_ref TEXT NOT NULL,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
balance_blocked INTEGER NOT NULL CHECK (balance_blocked IN (0, 1)),
|
||||||
|
previous_runtime_reason TEXT CHECK (previous_runtime_reason IS NULL OR previous_runtime_reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')),
|
||||||
|
PRIMARY KEY (gateway_account_ref, model_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS gateway_balance_states (
|
||||||
|
gateway_account_ref TEXT PRIMARY KEY,
|
||||||
|
balance_status TEXT NOT NULL CHECK (balance_status IN ('available', 'insufficient', 'unknown')),
|
||||||
|
impact_scope TEXT NOT NULL CHECK (impact_scope IN ('model', 'account', 'unknown')),
|
||||||
|
affected_model_ids_json TEXT NOT NULL CHECK (json_valid(affected_model_ids_json)),
|
||||||
|
runtime_unavailable_model_ids_json TEXT NOT NULL CHECK (json_valid(runtime_unavailable_model_ids_json)),
|
||||||
|
detected_at INTEGER NOT NULL,
|
||||||
|
last_confirmed_at INTEGER,
|
||||||
|
recovery_status TEXT NOT NULL CHECK (recovery_status IN ('not_required', 'awaiting_confirmation', 'confirmed')),
|
||||||
|
source_category TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS gateway_balance_event_receipts (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
gateway_account_ref TEXT NOT NULL,
|
||||||
|
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS service_recovery_checks (
|
||||||
|
check_id TEXT PRIMARY KEY,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
target_ref TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('passed', 'failed')),
|
||||||
|
checked_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
details_json TEXT NOT NULL CHECK (json_valid(details_json))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||||
|
log_id TEXT PRIMARY KEY,
|
||||||
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
|
actor_ref TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_ref TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
|
before_summary TEXT,
|
||||||
|
after_summary TEXT,
|
||||||
|
occurred_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||||
|
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||||
|
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const generationErrorCategories = [
|
||||||
|
"upstream_timeout",
|
||||||
|
"upstream_failed",
|
||||||
|
"safety_rejected",
|
||||||
|
"model_disabled",
|
||||||
|
"gateway_balance_insufficient",
|
||||||
|
"gateway_contract_invalid",
|
||||||
|
"reference_invalid",
|
||||||
|
"unknown_retryable",
|
||||||
|
"unknown_non_retryable",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GenerationErrorCategory = typeof generationErrorCategories[number];
|
||||||
|
export type GenerationUserAction = "retry_same_input" | "wait_and_retry" | "edit_input" | "choose_model_or_contact_admin" | "contact_admin";
|
||||||
|
|
||||||
|
export interface GenerationErrorDefinition {
|
||||||
|
creditBehavior: "no_reserve" | "release_if_reserved";
|
||||||
|
messageKey: string;
|
||||||
|
retryPolicy: "immediate" | "after_wait" | "after_edit" | "after_model_change" | "none";
|
||||||
|
taskOutcome: "not_created" | "failed" | "rejected" | "failed_or_not_created";
|
||||||
|
userAction: GenerationUserAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generationErrorRegistry: Readonly<Record<GenerationErrorCategory, Readonly<GenerationErrorDefinition>>> = Object.freeze({
|
||||||
|
upstream_timeout: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.upstream_timeout", retryPolicy: "immediate", taskOutcome: "failed", userAction: "retry_same_input" }),
|
||||||
|
upstream_failed: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.upstream_failed", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" }),
|
||||||
|
safety_rejected: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.safety_rejected", retryPolicy: "after_edit", taskOutcome: "rejected", userAction: "edit_input" }),
|
||||||
|
model_disabled: Object.freeze({ creditBehavior: "no_reserve", messageKey: "generation.error.model_disabled", retryPolicy: "after_model_change", taskOutcome: "not_created", userAction: "choose_model_or_contact_admin" }),
|
||||||
|
gateway_balance_insufficient: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.gateway_balance_insufficient", retryPolicy: "after_model_change", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" }),
|
||||||
|
gateway_contract_invalid: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.gateway_contract_invalid", retryPolicy: "after_model_change", taskOutcome: "failed_or_not_created", userAction: "choose_model_or_contact_admin" }),
|
||||||
|
reference_invalid: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.reference_invalid", retryPolicy: "after_edit", taskOutcome: "failed_or_not_created", userAction: "edit_input" }),
|
||||||
|
unknown_retryable: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.unknown_retryable", retryPolicy: "after_wait", taskOutcome: "failed", userAction: "wait_and_retry" }),
|
||||||
|
unknown_non_retryable: Object.freeze({ creditBehavior: "release_if_reserved", messageKey: "generation.error.unknown_non_retryable", retryPolicy: "none", taskOutcome: "failed", userAction: "contact_admin" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isGenerationErrorCategory(value: unknown): value is GenerationErrorCategory {
|
||||||
|
return typeof value === "string" && generationErrorCategories.includes(value as GenerationErrorCategory);
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||||
|
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
|
||||||
|
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
|
||||||
|
import { configureWorkerDatabase } from "./sqlite-connection.js";
|
||||||
|
|
||||||
|
const hardLimitBytes = 5_368_709_120;
|
||||||
|
const warningLimitBytes = 4_294_967_296;
|
||||||
|
const criticalLimitBytes = 4_831_838_208;
|
||||||
|
const maximumOutputBytes = 20 * 1_024 * 1_024;
|
||||||
|
const safeSourceCategory = /^[a-z][a-z0-9_]{0,79}$/;
|
||||||
|
|
||||||
|
interface JobRow {
|
||||||
|
config_snapshot_json: string;
|
||||||
|
error_category: GenerationErrorCategory | null;
|
||||||
|
final_credit_state: "committed" | "released" | null;
|
||||||
|
generation_id: string;
|
||||||
|
model_id: string;
|
||||||
|
owner_id: string;
|
||||||
|
project_id: string;
|
||||||
|
prompt: string;
|
||||||
|
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
||||||
|
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
heartbeat_at: number | null;
|
||||||
|
lease_expires_at: number | null;
|
||||||
|
lease_owner: string | null;
|
||||||
|
upstream_job_reference: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReservationRow {
|
||||||
|
amount: number;
|
||||||
|
model_id: string;
|
||||||
|
status: "reserved" | "committed" | "released";
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationProcessingResult {
|
||||||
|
category: GenerationErrorCategory | null;
|
||||||
|
generationId: string;
|
||||||
|
outputAssetId: string | null;
|
||||||
|
projectId: string;
|
||||||
|
status: "running" | "succeeded" | "failed" | "rejected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaseRecoveryAction {
|
||||||
|
action: "poll" | "unknown_retryable";
|
||||||
|
generationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(timestamp: number) {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFor(mimeType: NormalizedGenerationOutput["mimeType"]) {
|
||||||
|
return mimeType === "image/png" ? ".png" : mimeType === "image/jpeg" ? ".jpg" : ".webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExpectedMagic(output: NormalizedGenerationOutput) {
|
||||||
|
const bytes = output.bytes;
|
||||||
|
if (output.mimeType === "image/png") return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||||
|
if (output.mimeType === "image/jpeg") return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||||
|
return bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacityClass(bytes: number) {
|
||||||
|
return {
|
||||||
|
capacity: bytes < warningLimitBytes ? "normal" : bytes < criticalLimitBytes ? "warning" : "critical",
|
||||||
|
status: bytes >= hardLimitBytes ? "full" : "active",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationProcessor {
|
||||||
|
readonly database: BetterSqlite3.Database;
|
||||||
|
private readonly adapter: GenerationAdapter;
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly dataRoot: string;
|
||||||
|
private readonly gatewayBalance: GatewayBalanceRuntime;
|
||||||
|
private readonly workerId: string;
|
||||||
|
private closed = false;
|
||||||
|
private readonly lockTimer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(input: { adapter: GenerationAdapter; clock?: () => number; dataRoot: string; databasePath: string; workerId: string }) {
|
||||||
|
if (!input.workerId) throw new Error("generation_worker_id_required");
|
||||||
|
this.adapter = input.adapter;
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
this.dataRoot = resolve(input.dataRoot);
|
||||||
|
this.workerId = input.workerId;
|
||||||
|
this.database = new Database(input.databasePath);
|
||||||
|
configureWorkerDatabase(this.database);
|
||||||
|
this.migrate();
|
||||||
|
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
|
||||||
|
try {
|
||||||
|
this.setReady();
|
||||||
|
try {
|
||||||
|
this.recoverExpiredLeases();
|
||||||
|
} catch {
|
||||||
|
this.setDegraded("lease_recovery_failed");
|
||||||
|
}
|
||||||
|
this.lockTimer = setInterval(() => this.renewWorkerLock(), 10_000);
|
||||||
|
} catch (error) {
|
||||||
|
this.database.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
clearInterval(this.lockTimer);
|
||||||
|
this.immediate(() => {
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.prepare("UPDATE worker_runtime_state SET status = 'degraded', reason = ?, owner_id = NULL, lock_expires_at = NULL, updated_at = ? WHERE singleton = 1 AND owner_id = ?")
|
||||||
|
.run("worker_stopped", now, this.workerId);
|
||||||
|
});
|
||||||
|
this.gatewayBalance.close();
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async processNext() {
|
||||||
|
if (this.readWorkerState().status === "degraded") return undefined;
|
||||||
|
this.recoverExpiredLeases();
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT generation_id FROM generation_jobs
|
||||||
|
WHERE submission_ready = 1 AND (status = 'queued' OR (status = 'running' AND lease_owner = ?))
|
||||||
|
ORDER BY created_at, generation_id LIMIT 1
|
||||||
|
`).get(this.workerId) as { generation_id: string } | undefined;
|
||||||
|
return row ? this.processGeneration(row.generation_id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async processGeneration(generationId: string): Promise<GenerationProcessingResult> {
|
||||||
|
const replay = this.readReceipt(generationId);
|
||||||
|
if (replay) return replay;
|
||||||
|
const job = this.claim(generationId);
|
||||||
|
if (["succeeded", "failed", "rejected"].includes(job.status)) return this.terminalResult(job);
|
||||||
|
|
||||||
|
const references = this.database.prepare(`
|
||||||
|
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
|
||||||
|
`).all(generationId) as Array<{ managed_file_id: string }>;
|
||||||
|
let adapterResult: GenerationAdapterResult;
|
||||||
|
try {
|
||||||
|
if (job.upstream_job_reference) {
|
||||||
|
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
|
||||||
|
adapterResult = await this.withHeartbeat(generationId, () => this.adapter.poll!(job.upstream_job_reference!));
|
||||||
|
} else {
|
||||||
|
adapterResult = await this.withHeartbeat(generationId, () => this.adapter.start({
|
||||||
|
configSnapshot: JSON.parse(job.config_snapshot_json) as Record<string, unknown>,
|
||||||
|
generationId,
|
||||||
|
modelId: job.model_id,
|
||||||
|
prompt: job.prompt,
|
||||||
|
ratio: job.ratio,
|
||||||
|
referenceAssetIds: references.map((row) => row.managed_file_id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
|
||||||
|
if (adapterResult.status === "pending") return this.persistPending(job.generation_id, adapterResult.upstreamJobReference);
|
||||||
|
const output = adapterResult.outputs.length === 1 ? adapterResult.outputs[0] : undefined;
|
||||||
|
if (!output || !this.isSafeOutput(job, output)) {
|
||||||
|
return this.completeFailure(job, adapterResult.outputs.length === 1 ? "unknown_retryable" : "gateway_contract_invalid", "output_validation_failed", undefined, true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return this.completeSuccess(job, output);
|
||||||
|
} catch {
|
||||||
|
return this.completeFailure(job, "unknown_retryable", "output_persist_failed", undefined, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private claim(generationId: string) {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const row = this.readJob(generationId);
|
||||||
|
if (row.status === "queued") {
|
||||||
|
const now = this.clock();
|
||||||
|
const changed = this.database.prepare(`
|
||||||
|
UPDATE generation_jobs
|
||||||
|
SET status = 'running', lease_owner = ?, lease_expires_at = ?, heartbeat_at = ?, started_at = COALESCE(started_at, ?),
|
||||||
|
attempt_no = attempt_no + 1, updated_at = ?
|
||||||
|
WHERE generation_id = ? AND status = 'queued' AND submission_ready = 1
|
||||||
|
`).run(this.workerId, now + 30_000, now, now, now, generationId);
|
||||||
|
if (changed.changes !== 1) throw new Error("generation_claim_conflict");
|
||||||
|
return this.readJob(generationId);
|
||||||
|
}
|
||||||
|
if (row.status === "running" && row.final_credit_state === null && row.lease_owner === this.workerId && (row.lease_expires_at ?? 0) >= this.clock()) return row;
|
||||||
|
if (row.status === "running" && row.final_credit_state === null) throw new Error("generation_claim_conflict");
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withHeartbeat<T>(generationId: string, action: () => Promise<T>) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
try {
|
||||||
|
this.heartbeat(generationId);
|
||||||
|
} catch {
|
||||||
|
this.setDegraded("heartbeat_failed");
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} finally {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renewWorkerLock() {
|
||||||
|
try {
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.prepare("UPDATE worker_runtime_state SET lock_expires_at = ?, updated_at = ? WHERE singleton = 1 AND owner_id = ?")
|
||||||
|
.run(now + 30_000, now, this.workerId);
|
||||||
|
} catch {
|
||||||
|
if (!this.closed) this.setDegraded("worker_lock_renewal_failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat(generationId: string) {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const now = this.clock();
|
||||||
|
const changed = this.database.prepare(`
|
||||||
|
UPDATE generation_jobs SET heartbeat_at = ?, lease_expires_at = ?, updated_at = ?
|
||||||
|
WHERE generation_id = ? AND status = 'running' AND lease_owner = ?
|
||||||
|
`).run(now, now + 30_000, now, generationId, this.workerId);
|
||||||
|
if (changed.changes !== 1) throw new Error("generation_heartbeat_conflict");
|
||||||
|
this.database.prepare("UPDATE worker_runtime_state SET lock_expires_at = ?, updated_at = ? WHERE singleton = 1 AND owner_id = ?")
|
||||||
|
.run(now + 30_000, now, this.workerId);
|
||||||
|
return { generationId, heartbeat_at: now, lease_expires_at: now + 30_000 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recoverExpiredLeases(): LeaseRecoveryAction[] {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const now = this.clock();
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT * FROM generation_jobs
|
||||||
|
WHERE status = 'running' AND submission_ready = 1 AND lease_expires_at IS NOT NULL AND lease_expires_at < ?
|
||||||
|
ORDER BY created_at, generation_id
|
||||||
|
`).all(now) as JobRow[];
|
||||||
|
const actions: LeaseRecoveryAction[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.upstream_job_reference) {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE generation_jobs SET lease_owner = ?, lease_expires_at = ?, heartbeat_at = ?, updated_at = ?
|
||||||
|
WHERE generation_id = ? AND status = 'running' AND lease_expires_at < ?
|
||||||
|
`).run(this.workerId, now + 30_000, now, now, row.generation_id, now);
|
||||||
|
actions.push({ action: "poll", generationId: row.generation_id });
|
||||||
|
} else {
|
||||||
|
this.completeFailure(row, "unknown_retryable", "lease_expired_without_reference", undefined, false, "pending_manual_review");
|
||||||
|
actions.push({ action: "unknown_retryable", generationId: row.generation_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setDegraded(reason: string) {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.prepare("UPDATE worker_runtime_state SET status = 'degraded', reason = ?, updated_at = ? WHERE singleton = 1").run(reason, now);
|
||||||
|
return this.readWorkerState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setReady() {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const now = this.clock();
|
||||||
|
const current = this.database.prepare("SELECT owner_id, lock_expires_at FROM worker_runtime_state WHERE singleton = 1")
|
||||||
|
.get() as { lock_expires_at: number | null; owner_id: string | null } | undefined;
|
||||||
|
if (current?.owner_id && current.owner_id !== this.workerId && (current.lock_expires_at ?? 0) >= now) throw new Error("worker_instance_lock_conflict");
|
||||||
|
this.database.prepare("UPDATE worker_runtime_state SET status = 'ready', reason = NULL, owner_id = ?, lock_expires_at = ?, updated_at = ? WHERE singleton = 1")
|
||||||
|
.run(this.workerId, now + 30_000, now);
|
||||||
|
return this.readWorkerState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readWorkerState() {
|
||||||
|
return this.database.prepare("SELECT status, reason, updated_at FROM worker_runtime_state WHERE singleton = 1").get() as { reason: string | null; status: "ready" | "degraded"; updated_at: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistPending(generationId: string, upstreamJobReference: string): GenerationProcessingResult {
|
||||||
|
return this.immediate(() => {
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9:_.-]{0,159}$/.test(upstreamJobReference)) {
|
||||||
|
const job = this.readJob(generationId);
|
||||||
|
return this.completeFailure(job, "gateway_contract_invalid", "upstream_reference_invalid");
|
||||||
|
}
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE generation_jobs SET upstream_job_reference = ?, heartbeat_at = ?, lease_expires_at = ?, updated_at = ?
|
||||||
|
WHERE generation_id = ? AND status = 'running' AND lease_owner = ?
|
||||||
|
`).run(upstreamJobReference, now, now + 30_000, now, generationId, this.workerId);
|
||||||
|
const job = this.readJob(generationId);
|
||||||
|
return { category: null, generationId, outputAssetId: null, projectId: job.project_id, status: "running" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeFailure(
|
||||||
|
job: JobRow,
|
||||||
|
category: GenerationErrorCategory,
|
||||||
|
sourceCategory: string,
|
||||||
|
balanceSignal?: { gatewayAccountRef: string; impactScope: "model" | "account" | "unknown" },
|
||||||
|
upstreamOutcomeKnown = false,
|
||||||
|
reconciliation: "not_required" | "pending_manual_review" = upstreamOutcomeKnown ? "pending_manual_review" : "not_required",
|
||||||
|
) {
|
||||||
|
return this.immediate(() => {
|
||||||
|
const replay = this.readReceipt(job.generation_id);
|
||||||
|
if (replay) return replay;
|
||||||
|
const latest = this.readJob(job.generation_id);
|
||||||
|
if (["succeeded", "failed", "rejected"].includes(latest.status)) return this.terminalResult(latest);
|
||||||
|
const status = category === "safety_rejected" ? "rejected" as const : "failed" as const;
|
||||||
|
this.finalizeCredit(job.generation_id, status);
|
||||||
|
if (balanceSignal) {
|
||||||
|
this.gatewayBalance.seedModels([{ gatewayAccountRef: balanceSignal.gatewayAccountRef, modelId: job.model_id }]);
|
||||||
|
this.gatewayBalance.recordInsufficient({
|
||||||
|
eventId: `generation-${job.generation_id}`,
|
||||||
|
gatewayAccountRef: balanceSignal.gatewayAccountRef,
|
||||||
|
impactScope: balanceSignal.impactScope,
|
||||||
|
modelId: job.model_id,
|
||||||
|
sourceCategory: "adapter_balance_signal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE generation_jobs
|
||||||
|
SET status = ?, error_category = ?, diagnostic_source_category = ?, final_credit_state = 'released',
|
||||||
|
upstream_outcome_known = ?, upstream_cost_reconciliation = ?, finished_at = ?, updated_at = ?,
|
||||||
|
lease_owner = NULL, lease_expires_at = NULL, heartbeat_at = NULL
|
||||||
|
WHERE generation_id = ? AND status = 'running'
|
||||||
|
`).run(
|
||||||
|
status,
|
||||||
|
category,
|
||||||
|
safeSourceCategory.test(sourceCategory) ? sourceCategory : "adapter_error",
|
||||||
|
upstreamOutcomeKnown ? 1 : 0,
|
||||||
|
reconciliation,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
job.generation_id,
|
||||||
|
);
|
||||||
|
const result = this.terminalResult(this.readJob(job.generation_id));
|
||||||
|
this.writeReceipt(result, now);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeSuccess(job: JobRow, output: NormalizedGenerationOutput) {
|
||||||
|
const fileId = randomUUID();
|
||||||
|
const operationId = randomUUID();
|
||||||
|
const extension = extensionFor(output.mimeType);
|
||||||
|
const relativePath = `content/generated/${job.owner_id}/${fileId}${extension}`;
|
||||||
|
const destinationPath = resolve(this.dataRoot, relativePath);
|
||||||
|
const stagingDirectory = resolve(this.dataRoot, "staging", operationId);
|
||||||
|
const stagingPath = join(stagingDirectory, "payload.tmp");
|
||||||
|
if (!destinationPath.startsWith(`${this.dataRoot}\\`) && !destinationPath.startsWith(`${this.dataRoot}/`)) throw new Error("generation_output_path_invalid");
|
||||||
|
|
||||||
|
this.reserveStorage(operationId, output.bytes.byteLength);
|
||||||
|
let renamed = false;
|
||||||
|
try {
|
||||||
|
mkdirSync(stagingDirectory, { recursive: true });
|
||||||
|
writeFileSync(stagingPath, output.bytes, { flag: "wx" });
|
||||||
|
mkdirSync(dirname(destinationPath), { recursive: true });
|
||||||
|
renameSync(stagingPath, destinationPath);
|
||||||
|
renamed = true;
|
||||||
|
rmSync(stagingDirectory, { force: true, recursive: true });
|
||||||
|
return this.immediate(() => {
|
||||||
|
const replay = this.readReceipt(job.generation_id);
|
||||||
|
if (replay) return replay;
|
||||||
|
const now = this.clock();
|
||||||
|
const project = this.database.prepare("SELECT name, state_version, current_image_id FROM projects WHERE project_id = ? AND status = 'active'")
|
||||||
|
.get(job.project_id) as { current_image_id: string | null; name: string; state_version: number } | undefined;
|
||||||
|
if (!project) throw new Error("generation_project_unavailable");
|
||||||
|
const state = this.database.prepare("SELECT canvas_json FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1")
|
||||||
|
.get(job.project_id) as { canvas_json: string } | undefined;
|
||||||
|
if (!state) throw new Error("generation_project_state_unavailable");
|
||||||
|
const canvas = JSON.parse(state.canvas_json) as { background: { asset_id: string | null }; [key: string]: unknown };
|
||||||
|
if (project.current_image_id === null) canvas.background.asset_id = fileId;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
|
||||||
|
VALUES (?, 'generated', ?, ?, ?, ?, ?, 'committed', ?)
|
||||||
|
`).run(fileId, job.owner_id, relativePath, output.bytes.byteLength, output.mimeType, createHash("sha256").update(output.bytes).digest("hex"), iso(now));
|
||||||
|
this.database.prepare("INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'generated', ?)")
|
||||||
|
.run(job.project_id, fileId, now);
|
||||||
|
this.database.prepare("INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)")
|
||||||
|
.run(`project:${job.project_id}:${fileId}`, fileId, iso(now));
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO generation_output_assets (generation_id, managed_file_id, pixel_width, pixel_height, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(job.generation_id, fileId, output.pixelWidth, output.pixelHeight, now);
|
||||||
|
this.database.prepare("INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(fileId, job.project_id, job.generation_id, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE projects SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE project_id = ?
|
||||||
|
`).run(fileId, now, job.project_id);
|
||||||
|
this.database.prepare("INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.run(job.project_id, project.state_version + 1, project.name, JSON.stringify(canvas), now);
|
||||||
|
this.consumeStorage(operationId, output.bytes.byteLength, now);
|
||||||
|
this.finalizeCredit(job.generation_id, "succeeded");
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE generation_jobs
|
||||||
|
SET status = 'succeeded', error_category = NULL, final_credit_state = 'committed', output_asset_id = ?,
|
||||||
|
upstream_outcome_known = 1, upstream_cost_reconciliation = 'not_required', finished_at = ?, updated_at = ?,
|
||||||
|
lease_owner = NULL, lease_expires_at = NULL, heartbeat_at = NULL
|
||||||
|
WHERE generation_id = ? AND status = 'running'
|
||||||
|
`).run(fileId, now, now, job.generation_id);
|
||||||
|
const result = this.terminalResult(this.readJob(job.generation_id));
|
||||||
|
this.writeReceipt(result, now);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (renamed && existsSync(destinationPath)) this.queueCompensation(relativePath, output.bytes.byteLength);
|
||||||
|
else rmSync(stagingDirectory, { force: true, recursive: true });
|
||||||
|
this.releaseStorage(operationId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSafeOutput(job: JobRow, output: NormalizedGenerationOutput) {
|
||||||
|
const pixels = { "1:1": [1080, 1080], "3:4": [1080, 1440], "4:3": [1440, 1080], "9:16": [1080, 1920] }[job.ratio];
|
||||||
|
return output.bytes.byteLength > 0 && output.bytes.byteLength <= maximumOutputBytes && hasExpectedMagic(output)
|
||||||
|
&& output.pixelWidth === pixels[0] && output.pixelHeight === pixels[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private finalizeCredit(generationId: string, outcome: "succeeded" | "failed" | "rejected") {
|
||||||
|
const reservation = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?")
|
||||||
|
.get(generationId) as ReservationRow | undefined;
|
||||||
|
if (!reservation) throw new Error("generation_credit_reservation_missing");
|
||||||
|
if (reservation.status !== "reserved") return;
|
||||||
|
const account = this.database.prepare("SELECT available_balance, reserved_balance FROM credit_accounts WHERE user_id = ?")
|
||||||
|
.get(reservation.user_id) as { available_balance: number; reserved_balance: number } | undefined;
|
||||||
|
if (!account || account.reserved_balance < reservation.amount) throw new Error("generation_credit_invariant_failed");
|
||||||
|
const committed = outcome === "succeeded";
|
||||||
|
const availableAfter = committed ? account.available_balance : account.available_balance + reservation.amount;
|
||||||
|
const reservedAfter = account.reserved_balance - reservation.amount;
|
||||||
|
const now = this.clock();
|
||||||
|
const operationKey = `generation:${generationId}:${committed ? "commit" : "release"}`;
|
||||||
|
this.database.prepare("UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? WHERE user_id = ?")
|
||||||
|
.run(availableAfter, reservedAfter, now, reservation.user_id);
|
||||||
|
this.database.prepare("UPDATE credit_reservations SET status = ?, finalized_at = ? WHERE generation_id = ? AND status = 'reserved'")
|
||||||
|
.run(committed ? "committed" : "released", now, generationId);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO credit_ledger (
|
||||||
|
ledger_id, user_id, operation_key, entry_type, amount, available_before, available_after,
|
||||||
|
reserved_before, reserved_after, created_at, reference_type, reference_id, model_id, reason, entry_status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'generation', ?, ?, NULL, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), reservation.user_id, operationKey, committed ? "generation_commit" : "generation_release",
|
||||||
|
committed ? -reservation.amount : reservation.amount, account.available_balance, availableAfter,
|
||||||
|
account.reserved_balance, reservedAfter, now, generationId, reservation.model_id, committed ? "committed" : "released",
|
||||||
|
);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO outbox_events (event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at)
|
||||||
|
VALUES (?, ?, ?, 'generation', ?, ?, 'pending', ?, NULL)
|
||||||
|
`).run(randomUUID(), operationKey, committed ? "generation_credit_committed" : "generation_credit_released", generationId, JSON.stringify({ outcome }), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reserveStorage(operationId: string, bytes: number) {
|
||||||
|
this.immediate(() => {
|
||||||
|
const state = this.database.prepare("SELECT managed_content_bytes, storage_status FROM local_backend_storage_state WHERE singleton = 1")
|
||||||
|
.get() as { managed_content_bytes: number; storage_status: string } | undefined;
|
||||||
|
const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'")
|
||||||
|
.get() as { bytes: number };
|
||||||
|
if (!state || state.storage_status !== "active" || state.managed_content_bytes + active.bytes + bytes > hardLimitBytes) {
|
||||||
|
throw new Error("generation_storage_unavailable");
|
||||||
|
}
|
||||||
|
this.database.prepare("INSERT INTO storage_reservations (reservation_id, operation_id, projected_bytes, status, created_at) VALUES (?, ?, ?, 'active', ?)")
|
||||||
|
.run(randomUUID(), operationId, bytes, iso(this.clock()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private consumeStorage(operationId: string, bytes: number, now: number) {
|
||||||
|
this.database.prepare("UPDATE storage_reservations SET status = 'consumed', resolved_at = ? WHERE operation_id = ? AND status = 'active'")
|
||||||
|
.run(iso(now), operationId);
|
||||||
|
const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1")
|
||||||
|
.get() as { managed_content_bytes: number };
|
||||||
|
const next = state.managed_content_bytes + bytes;
|
||||||
|
const classification = capacityClass(next);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE local_backend_storage_state
|
||||||
|
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||||
|
WHERE singleton = 1
|
||||||
|
`).run(next, classification.capacity, classification.status, iso(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseStorage(operationId: string) {
|
||||||
|
try {
|
||||||
|
this.database.prepare("UPDATE storage_reservations SET status = 'released', resolved_at = ? WHERE operation_id = ? AND status = 'active'")
|
||||||
|
.run(iso(this.clock()), operationId);
|
||||||
|
} catch {
|
||||||
|
// Startup storage reconciliation remains the fallback if SQLite is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueCompensation(relativePath: string, bytes: number) {
|
||||||
|
try {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||||
|
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, reason, status, created_at
|
||||||
|
) VALUES (?, NULL, ?, ?, 0, 'compensation', 'pending', ?)
|
||||||
|
`).run(randomUUID(), relativePath, bytes, iso(this.clock()));
|
||||||
|
} catch {
|
||||||
|
// Startup reconciliation detects unindexed generated files as a final fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readJob(generationId: string) {
|
||||||
|
const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1").get(generationId) as JobRow | undefined;
|
||||||
|
if (!row) throw new Error("generation_not_found");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminalResult(row: JobRow): GenerationProcessingResult {
|
||||||
|
if (!["succeeded", "failed", "rejected"].includes(row.status)) throw new Error("generation_not_terminal");
|
||||||
|
const output = this.database.prepare("SELECT managed_file_id FROM generation_output_assets WHERE generation_id = ?")
|
||||||
|
.get(row.generation_id) as { managed_file_id: string } | undefined;
|
||||||
|
return {
|
||||||
|
category: row.error_category,
|
||||||
|
generationId: row.generation_id,
|
||||||
|
outputAssetId: output?.managed_file_id ?? null,
|
||||||
|
projectId: row.project_id,
|
||||||
|
status: row.status as GenerationProcessingResult["status"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readReceipt(generationId: string) {
|
||||||
|
const row = this.database.prepare("SELECT response_json FROM generation_processor_receipts WHERE generation_id = ?")
|
||||||
|
.get(generationId) as { response_json: string } | undefined;
|
||||||
|
return row ? JSON.parse(row.response_json) as GenerationProcessingResult : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeReceipt(result: GenerationProcessingResult, now: number) {
|
||||||
|
this.database.prepare("INSERT INTO generation_processor_receipts (generation_id, response_json, created_at) VALUES (?, ?, ?)")
|
||||||
|
.run(result.generationId, JSON.stringify(result), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private immediate<T>(action: () => T) {
|
||||||
|
if (this.database.inTransaction) return action();
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const result = action();
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureColumn(table: string, column: string, definition: string) {
|
||||||
|
const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||||
|
if (!columns.some((entry) => entry.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrate() {
|
||||||
|
this.ensureColumn("generation_jobs", "lease_owner", "TEXT");
|
||||||
|
this.ensureColumn("generation_jobs", "lease_expires_at", "INTEGER");
|
||||||
|
this.ensureColumn("generation_jobs", "heartbeat_at", "INTEGER");
|
||||||
|
this.ensureColumn("generation_jobs", "started_at", "INTEGER");
|
||||||
|
this.ensureColumn("generation_jobs", "attempt_no", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
this.ensureColumn("generation_jobs", "output_asset_id", "TEXT");
|
||||||
|
this.ensureColumn("generation_jobs", "diagnostic_source_category", "TEXT");
|
||||||
|
this.ensureColumn("generation_jobs", "upstream_outcome_known", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
this.ensureColumn("generation_jobs", "upstream_cost_reconciliation", "TEXT NOT NULL DEFAULT 'not_applicable'");
|
||||||
|
this.ensureColumn("generation_jobs", "upstream_job_reference", "TEXT");
|
||||||
|
this.database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_runtime_state (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('ready', 'degraded')),
|
||||||
|
reason TEXT,
|
||||||
|
owner_id TEXT,
|
||||||
|
lock_expires_at INTEGER,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_output_assets (
|
||||||
|
generation_id TEXT PRIMARY KEY REFERENCES generation_jobs(generation_id),
|
||||||
|
managed_file_id TEXT NOT NULL UNIQUE REFERENCES managed_files(file_id),
|
||||||
|
pixel_width INTEGER NOT NULL CHECK (pixel_width > 0),
|
||||||
|
pixel_height INTEGER NOT NULL CHECK (pixel_height > 0),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_processor_receipts (
|
||||||
|
generation_id TEXT PRIMARY KEY REFERENCES generation_jobs(generation_id),
|
||||||
|
response_json TEXT NOT NULL CHECK (json_valid(response_json)),
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
this.ensureColumn("worker_runtime_state", "owner_id", "TEXT");
|
||||||
|
this.ensureColumn("worker_runtime_state", "lock_expires_at", "INTEGER");
|
||||||
|
this.database.prepare("INSERT OR IGNORE INTO worker_runtime_state (singleton, status, reason, owner_id, lock_expires_at, updated_at) VALUES (1, 'ready', NULL, NULL, NULL, ?)").run(this.clock());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const productDimensions = Object.freeze({
|
||||||
|
"3:4": Object.freeze({ pixelHeight: 1440, pixelWidth: 1080 }),
|
||||||
|
"1:1": Object.freeze({ pixelHeight: 1080, pixelWidth: 1080 }),
|
||||||
|
"4:3": Object.freeze({ pixelHeight: 1080, pixelWidth: 1440 }),
|
||||||
|
"9:16": Object.freeze({ pixelHeight: 1920, pixelWidth: 1080 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const gptImageRequestSizes = Object.freeze({
|
||||||
|
"3:4": "1056x1408",
|
||||||
|
"1:1": "1088x1088",
|
||||||
|
"4:3": "1408x1056",
|
||||||
|
"9:16": "1008x1792",
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedMimeTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||||
|
const maximumInputBytes = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
function assertRatio(ratio) {
|
||||||
|
if (!(ratio in productDimensions)) throw new Error("image_output_ratio_unsupported");
|
||||||
|
return ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productDimensionsForRatio(ratio) {
|
||||||
|
return { ...productDimensions[assertRatio(ratio)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gptImageRequestSizeForRatio(ratio) {
|
||||||
|
return gptImageRequestSizes[assertRatio(ratio)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageOutputToRatio(input) {
|
||||||
|
const ratio = assertRatio(input?.ratio);
|
||||||
|
if (!Buffer.isBuffer(input?.bytes) || input.bytes.length === 0 || input.bytes.length > maximumInputBytes
|
||||||
|
|| !allowedMimeTypes.has(input?.mimeType)) {
|
||||||
|
throw new Error("image_output_media_invalid");
|
||||||
|
}
|
||||||
|
const target = productDimensions[ratio];
|
||||||
|
if (input.pixelWidth === target.pixelWidth && input.pixelHeight === target.pixelHeight) {
|
||||||
|
return {
|
||||||
|
bytes: Buffer.from(input.bytes),
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
normalized: false,
|
||||||
|
...target,
|
||||||
|
upstreamPixelHeight: input.pixelHeight,
|
||||||
|
upstreamPixelWidth: input.pixelWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = sharp(input.bytes, { failOn: "error", limitInputPixels: 40_000_000 });
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
if (!metadata.width || !metadata.height) throw new Error("image_output_dimensions_missing");
|
||||||
|
const requestedRatio = target.pixelWidth / target.pixelHeight;
|
||||||
|
const upstreamRatio = metadata.width / metadata.height;
|
||||||
|
if (Math.abs(upstreamRatio - requestedRatio) / requestedRatio > 0.02) {
|
||||||
|
throw new Error("image_output_aspect_ratio_mismatch");
|
||||||
|
}
|
||||||
|
const { data, info } = await image
|
||||||
|
.resize(target.pixelWidth, target.pixelHeight, { fit: "fill", kernel: sharp.kernel.lanczos3 })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
if (info.width !== target.pixelWidth || info.height !== target.pixelHeight || info.format !== "png") {
|
||||||
|
throw new Error("image_output_normalization_failed");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bytes: data,
|
||||||
|
mimeType: "image/png",
|
||||||
|
normalized: true,
|
||||||
|
...target,
|
||||||
|
upstreamPixelHeight: metadata.height,
|
||||||
|
upstreamPixelWidth: metadata.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
|
||||||
|
import type { GenerationErrorCategory } from "./generation-error-registry.js";
|
||||||
|
|
||||||
|
export type AdapterStartResult =
|
||||||
|
| { outputs: readonly NormalizedGenerationOutput[]; status: "completed" }
|
||||||
|
| { status: "pending"; upstreamJobReference: string }
|
||||||
|
| { category: GenerationErrorCategory; sourceCategory: string; status: "failed" };
|
||||||
|
|
||||||
|
export interface AdapterTransportRequest {
|
||||||
|
operation: "start" | "poll";
|
||||||
|
modelId: string;
|
||||||
|
prompt?: string;
|
||||||
|
ratio?: GenerationAdapterRequest["ratio"];
|
||||||
|
referenceAssetIds?: readonly string[];
|
||||||
|
upstreamJobReference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdapterTransport {
|
||||||
|
start(request: AdapterTransportRequest): Promise<unknown>;
|
||||||
|
poll(request: AdapterTransportRequest): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractValidationResult {
|
||||||
|
contractVersion: string;
|
||||||
|
evidenceRef: string;
|
||||||
|
fixtureId: string;
|
||||||
|
modelId: string;
|
||||||
|
reason?: "contract_profile_mismatch";
|
||||||
|
status: "blocked" | "verified";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdapterBalanceSignal {
|
||||||
|
gatewayAccountRef: string;
|
||||||
|
impactScope: "model" | "account" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelAdapter {
|
||||||
|
readonly contractVersion: string;
|
||||||
|
readonly fixtureId: string;
|
||||||
|
readonly modelId: string;
|
||||||
|
checkBalanceSignal(response: unknown): AdapterBalanceSignal | undefined;
|
||||||
|
classifyError(error: unknown, mappingProfile: Readonly<Record<string, string>>): { category: GenerationErrorCategory; sourceCategory: string };
|
||||||
|
normalizeOutput(response: unknown): NormalizedGenerationOutput;
|
||||||
|
poll(upstreamJobReference: string): Promise<AdapterStartResult>;
|
||||||
|
start(request: GenerationAdapterRequest): Promise<AdapterStartResult>;
|
||||||
|
validateContract(routeProfile: Readonly<Record<string, unknown>>): ContractValidationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdapterContractError extends Error {
|
||||||
|
constructor(readonly sourceCategory: string, message = sourceCategory) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AdapterContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supportedAdapterRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
|
||||||
|
|
||||||
|
export function dimensionsForRatio(ratio: GenerationAdapterRequest["ratio"]) {
|
||||||
|
return ratio === "3:4" ? { pixelHeight: 1440, pixelWidth: 1080 }
|
||||||
|
: ratio === "1:1" ? { pixelHeight: 1080, pixelWidth: 1080 }
|
||||||
|
: ratio === "4:3" ? { pixelHeight: 1080, pixelWidth: 1440 }
|
||||||
|
: { pixelHeight: 1920, pixelWidth: 1080 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockPngBytes(seed: string) {
|
||||||
|
const body = Buffer.from(`DADA-MOCK-${seed}`, "ascii");
|
||||||
|
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAdapterRequest(request: GenerationAdapterRequest, modelId: string) {
|
||||||
|
if (request.modelId !== modelId) throw new AdapterContractError("model_id_mismatch");
|
||||||
|
if (!supportedAdapterRatios.includes(request.ratio)) throw new AdapterContractError("ratio_unsupported");
|
||||||
|
if (request.referenceAssetIds.length > 2) throw new AdapterContractError("reference_count_exceeded");
|
||||||
|
if (!request.prompt.trim()) throw new AdapterContractError("prompt_empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCategories = new Set<GenerationErrorCategory>([
|
||||||
|
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
|
||||||
|
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
|
||||||
|
"unknown_retryable", "unknown_non_retryable",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function classifyAdapterError(error: unknown, mappingProfile: Readonly<Record<string, string>>) {
|
||||||
|
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "unknown";
|
||||||
|
const mapped = mappingProfile[code] ?? (code === "timeout" ? "upstream_timeout" : code === "safety" ? "safety_rejected" : "unknown_retryable");
|
||||||
|
const category = errorCategories.has(mapped as GenerationErrorCategory) ? mapped as GenerationErrorCategory : "unknown_retryable";
|
||||||
|
const sourceCategory = /^[a-z][a-z0-9_]{0,79}$/.test(code) ? code : "adapter_error";
|
||||||
|
return { category, sourceCategory };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function balanceSignalFromResponse(response: unknown): AdapterBalanceSignal | undefined {
|
||||||
|
if (!response || typeof response !== "object" || !("balance_signal" in response)) return undefined;
|
||||||
|
const signal = response.balance_signal;
|
||||||
|
if (!signal || typeof signal !== "object") return undefined;
|
||||||
|
const gatewayAccountRef = "gateway_account_ref" in signal && typeof signal.gateway_account_ref === "string" ? signal.gateway_account_ref : "";
|
||||||
|
const impactScope = "impact_scope" in signal && (signal.impact_scope === "model" || signal.impact_scope === "account" || signal.impact_scope === "unknown")
|
||||||
|
? signal.impact_scope : undefined;
|
||||||
|
return gatewayAccountRef && impactScope ? { gatewayAccountRef, impactScope } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMockContract(
|
||||||
|
routeProfile: Readonly<Record<string, unknown>>,
|
||||||
|
expected: Readonly<Record<string, unknown>>,
|
||||||
|
modelId: string,
|
||||||
|
fixtureId: string,
|
||||||
|
contractVersion: string,
|
||||||
|
): ContractValidationResult {
|
||||||
|
const matches = Object.entries(expected).every(([key, value]) => JSON.stringify(routeProfile[key]) === JSON.stringify(value));
|
||||||
|
return matches
|
||||||
|
? { contractVersion, evidenceRef: `${fixtureId}:mock`, fixtureId, modelId, status: "verified" }
|
||||||
|
: { contractVersion, evidenceRef: `${fixtureId}:mock`, fixtureId, modelId, reason: "contract_profile_mismatch", status: "blocked" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync, rmSync, statSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { isAbsolute, relative, resolve } from "node:path";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||||
|
|
||||||
|
interface ProjectCleanupRow {
|
||||||
|
cleanup_id: string;
|
||||||
|
project_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpiredProjectRow {
|
||||||
|
owner_id: string;
|
||||||
|
project_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCleanupRow {
|
||||||
|
byte_size: number;
|
||||||
|
cleanup_id: string;
|
||||||
|
counts_toward_managed: 0 | 1;
|
||||||
|
managed_file_id: string | null;
|
||||||
|
relative_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceScope = JSON.stringify([
|
||||||
|
"project_state", "generation", "generated_image", "reference", "location", "latest_export",
|
||||||
|
]);
|
||||||
|
const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
|
||||||
|
const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||||
|
const forbiddenSummaryKeys = new Set([
|
||||||
|
"absolute_path", "api_key", "body", "code_hmac", "content", "credential", "email", "image",
|
||||||
|
"image_content", "password", "path", "prompt", "secret", "session_token", "verification_code", "whitelist",
|
||||||
|
]);
|
||||||
|
const forbiddenSummaryFragments = ["content", "credential", "email", "image", "password", "path", "prompt", "secret", "token"];
|
||||||
|
|
||||||
|
function isSafeAuditRef(value: unknown) {
|
||||||
|
return typeof value === "string" && auditRefPattern.test(value) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeAuditSummaryJson(value: unknown) {
|
||||||
|
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
|
||||||
|
try {
|
||||||
|
const valid = (entry: unknown, depth: number): boolean => {
|
||||||
|
if (depth > 3) return false;
|
||||||
|
if (entry === null || typeof entry === "boolean") return true;
|
||||||
|
if (typeof entry === "number") return Number.isSafeInteger(entry);
|
||||||
|
if (typeof entry === "string") return /^[A-Za-z0-9_.:@-]{1,160}$/.test(entry) && !entry.includes("@");
|
||||||
|
if (Array.isArray(entry)) return entry.length <= 20 && entry.every((item) => valid(item, depth + 1));
|
||||||
|
if (!entry || typeof entry !== "object") return false;
|
||||||
|
return Object.entries(entry).length <= 32 && Object.entries(entry).every(([key, item]) => (
|
||||||
|
auditRefPattern.test(key)
|
||||||
|
&& !forbiddenSummaryKeys.has(key.toLowerCase())
|
||||||
|
&& !forbiddenSummaryFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||||
|
&& valid(item, depth + 1)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
return valid(JSON.parse(value), 0) ? 1 : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function iso(timestamp: number) {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectPurgeCleanup {
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly dataRoot: string | undefined;
|
||||||
|
private readonly database: BetterSqlite3.Database;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; dataRoot?: string; databasePath: string }) {
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
this.dataRoot = input.dataRoot ? resolve(input.dataRoot) : undefined;
|
||||||
|
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||||
|
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||||
|
this.database.pragma("journal_mode = WAL");
|
||||||
|
this.database.pragma("foreign_keys = ON");
|
||||||
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
|
||||||
|
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
|
||||||
|
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||||
|
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
const expired = this.sweepExpired();
|
||||||
|
const relational = this.processPending();
|
||||||
|
const physical = this.processFileCleanup();
|
||||||
|
return { expired, physical, relational };
|
||||||
|
}
|
||||||
|
|
||||||
|
sweepExpired() {
|
||||||
|
if (!this.tableExists("projects") || !this.tableExists("project_cleanup_queue")) return 0;
|
||||||
|
const now = this.clock();
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT project_id, owner_id FROM projects
|
||||||
|
WHERE status = 'trashed' AND purge_at <= ?
|
||||||
|
ORDER BY purge_at, project_id
|
||||||
|
`).all(now) as ExpiredProjectRow[];
|
||||||
|
let expired = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
const changed = this.database.prepare(`
|
||||||
|
UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1
|
||||||
|
WHERE project_id = ? AND status = 'trashed'
|
||||||
|
`).run(now, row.project_id);
|
||||||
|
if (changed.changes !== 1) return false;
|
||||||
|
this.queueManagedFiles(row.project_id, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO project_cleanup_queue (
|
||||||
|
cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error
|
||||||
|
) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL)
|
||||||
|
`).run(randomUUID(), row.project_id, row.owner_id, resourceScope, now);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (transaction.immediate()) expired += 1;
|
||||||
|
}
|
||||||
|
return expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
processPending() {
|
||||||
|
if (!this.tableExists("project_cleanup_queue") || !this.tableExists("projects")) return { completed: 0, failed: 0 };
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT cleanup_id, project_id FROM project_cleanup_queue
|
||||||
|
WHERE status IN ('pending', 'failed') ORDER BY created_at, project_id
|
||||||
|
`).all() as ProjectCleanupRow[];
|
||||||
|
let completed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
this.database.prepare("DELETE FROM projects WHERE project_id = ? AND status = 'purged'").run(row.project_id);
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE project_cleanup_queue
|
||||||
|
SET status = 'completed', completed_at = ?, last_error = NULL
|
||||||
|
WHERE cleanup_id = ?
|
||||||
|
`).run(this.clock(), row.cleanup_id);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
completed += 1;
|
||||||
|
} catch {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE project_cleanup_queue SET status = 'failed', last_error = 'project_cleanup_failed'
|
||||||
|
WHERE cleanup_id = ?
|
||||||
|
`).run(row.cleanup_id);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { completed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
processFileCleanup() {
|
||||||
|
if (!this.dataRoot || !this.tableExists("file_cleanup_queue") || !this.tableExists("managed_files")) {
|
||||||
|
return { completed: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed
|
||||||
|
FROM file_cleanup_queue WHERE status IN ('pending', 'failed') ORDER BY created_at, cleanup_id
|
||||||
|
`).all() as FileCleanupRow[];
|
||||||
|
let completed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const path = this.resolveManagedPath(row.relative_path);
|
||||||
|
rmSync(path, { force: true });
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
if (row.counts_toward_managed === 1 && row.managed_file_id) {
|
||||||
|
if (this.tableExists("project_asset_refs")) {
|
||||||
|
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||||
|
}
|
||||||
|
if (this.tableExists("project_resource_files")) {
|
||||||
|
this.database.prepare("DELETE FROM project_resource_files WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||||
|
}
|
||||||
|
if (this.tableExists("asset_cleanup_request_items")) {
|
||||||
|
const requests = this.database.prepare("SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ?")
|
||||||
|
.all(row.managed_file_id) as Array<{ request_id: string }>;
|
||||||
|
this.database.prepare("DELETE FROM asset_cleanup_request_items WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||||
|
if (this.tableExists("asset_cleanup_requests")) {
|
||||||
|
for (const request of requests) {
|
||||||
|
const pending = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?")
|
||||||
|
.get(request.request_id) as { count: number };
|
||||||
|
if (pending.count === 0) {
|
||||||
|
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'completed' WHERE request_id = ? AND status = 'queued'")
|
||||||
|
.run(request.request_id);
|
||||||
|
this.insertAssetCleanupAudit(request.request_id, row.byte_size, this.clock());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.tableExists("sticker_managed_file_history")) {
|
||||||
|
this.database.prepare("DELETE FROM sticker_managed_file_history WHERE managed_file_id = ?").run(row.managed_file_id);
|
||||||
|
}
|
||||||
|
this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id);
|
||||||
|
if (this.tableExists("local_backend_storage_state")) this.remeasureManagedCapacity();
|
||||||
|
}
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL
|
||||||
|
WHERE cleanup_id = ?
|
||||||
|
`).run(iso(this.clock()), row.cleanup_id);
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
completed += 1;
|
||||||
|
} catch {
|
||||||
|
const transaction = this.database.transaction(() => {
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed'
|
||||||
|
WHERE cleanup_id = ?
|
||||||
|
`).run(row.cleanup_id);
|
||||||
|
if (row.managed_file_id && this.tableExists("asset_cleanup_request_items")) {
|
||||||
|
const request = this.database.prepare(`
|
||||||
|
SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ? LIMIT 1
|
||||||
|
`).get(row.managed_file_id) as { request_id: string } | undefined;
|
||||||
|
if (request) this.insertAssetCleanupFailureAudit(request.request_id, this.clock());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transaction.immediate();
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { completed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueManagedFiles(projectId: string, now: number) {
|
||||||
|
if (!["project_resource_files", "managed_files", "project_asset_refs", "file_cleanup_queue"]
|
||||||
|
.every((table) => this.tableExists(table))) return;
|
||||||
|
const files = this.database.prepare(`
|
||||||
|
SELECT mf.file_id, mf.relative_path, mf.byte_size
|
||||||
|
FROM project_resource_files prf
|
||||||
|
JOIN managed_files mf ON mf.file_id = prf.managed_file_id
|
||||||
|
WHERE prf.project_id = ? AND mf.status = 'committed'
|
||||||
|
ORDER BY mf.file_id
|
||||||
|
`).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
|
||||||
|
for (const file of files) {
|
||||||
|
this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?")
|
||||||
|
.run(`project:${projectId}:${file.file_id}`);
|
||||||
|
const otherProject = this.database.prepare(`
|
||||||
|
SELECT 1 FROM project_resource_files prf
|
||||||
|
JOIN projects p ON p.project_id = prf.project_id
|
||||||
|
WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged'
|
||||||
|
LIMIT 1
|
||||||
|
`).get(file.file_id, projectId);
|
||||||
|
const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id);
|
||||||
|
if (otherProject || otherReference) continue;
|
||||||
|
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'")
|
||||||
|
.run(iso(now), file.file_id);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT OR IGNORE INTO file_cleanup_queue (
|
||||||
|
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
|
||||||
|
reason, status, created_at, completed_at, last_error
|
||||||
|
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
|
||||||
|
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tableExists(name: string) {
|
||||||
|
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveManagedPath(relativePath: string) {
|
||||||
|
const path = resolve(this.dataRoot!, relativePath);
|
||||||
|
const scoped = relative(this.dataRoot!, path);
|
||||||
|
if (!scoped || scoped.startsWith("..") || isAbsolute(scoped)) throw new Error("managed_path_outside_data_root");
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrementManagedCapacity(bytes: number) {
|
||||||
|
const current = this.database.prepare(`
|
||||||
|
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
|
||||||
|
`).get() as { managed_content_bytes: number } | undefined;
|
||||||
|
if (!current) return;
|
||||||
|
const managed = Math.max(0, current.managed_content_bytes - bytes);
|
||||||
|
const notice = managed < 4_294_967_296 ? "normal" : managed < 4_831_838_208 ? "warning" : "critical";
|
||||||
|
const reservations = this.tableExists("storage_reservations")
|
||||||
|
? (this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number }).bytes
|
||||||
|
: 0;
|
||||||
|
const status = managed + reservations >= 5_368_709_120 ? "full" : "active";
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE local_backend_storage_state
|
||||||
|
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||||
|
WHERE singleton = 1
|
||||||
|
`).run(managed, notice, status, iso(this.clock()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertAssetCleanupAudit(requestId: string, deletedBytes: number, occurredAt: number) {
|
||||||
|
if (!this.tableExists("admin_operation_logs")) return;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'system', 'project_purge_worker', 'asset_cleanup_physical_completed', 'asset_cleanup', ?, 'succeeded', NULL, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), requestId, JSON.stringify({ deleted_bytes: deletedBytes, status: "completed" }), occurredAt,
|
||||||
|
occurredAt + auditRetentionMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertAssetCleanupFailureAudit(requestId: string, occurredAt: number) {
|
||||||
|
if (!this.tableExists("admin_operation_logs")) return;
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_operation_logs (
|
||||||
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||||
|
result, before_summary, after_summary, occurred_at, expires_at
|
||||||
|
) VALUES (?, 'system', 'project_purge_worker', 'asset_cleanup_physical_failed', 'asset_cleanup', ?, 'failed', NULL, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(), requestId, JSON.stringify({ failed_count: 1, status: "retry_pending" }), occurredAt,
|
||||||
|
occurredAt + auditRetentionMilliseconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private remeasureManagedCapacity() {
|
||||||
|
const state = this.database.prepare(`
|
||||||
|
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
|
||||||
|
`).get() as { managed_content_bytes: number } | undefined;
|
||||||
|
if (!state) return;
|
||||||
|
const rows = this.database.prepare(`
|
||||||
|
SELECT relative_path FROM managed_files WHERE status = 'committed'
|
||||||
|
`).all() as Array<{ relative_path: string }>;
|
||||||
|
let managed = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const path = this.resolveManagedPath(row.relative_path);
|
||||||
|
if (existsSync(path)) managed += statSync(path).size;
|
||||||
|
} catch {
|
||||||
|
// A missing or invalid path is excluded from the measured physical total.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reservations = this.tableExists("storage_reservations")
|
||||||
|
? (this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number }).bytes
|
||||||
|
: 0;
|
||||||
|
const notice = managed < 4_294_967_296 ? "normal" : managed < 4_831_838_208 ? "warning" : "critical";
|
||||||
|
const status = managed + reservations >= 5_368_709_120 ? "full" : "active";
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE local_backend_storage_state
|
||||||
|
SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1
|
||||||
|
WHERE singleton = 1
|
||||||
|
`).run(managed, notice, status, iso(this.clock()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||||
|
|
||||||
|
export class RetentionCleanup {
|
||||||
|
private readonly clock: () => number;
|
||||||
|
private readonly database: BetterSqlite3.Database;
|
||||||
|
private retentionPurgeActive = false;
|
||||||
|
private retentionPurgeNow = 0;
|
||||||
|
|
||||||
|
constructor(input: { clock?: () => number; databasePath: string }) {
|
||||||
|
this.clock = input.clock ?? Date.now;
|
||||||
|
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||||
|
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||||
|
this.database.pragma("foreign_keys = ON");
|
||||||
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||||
|
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => this.retentionPurgeActive ? 1 : 0);
|
||||||
|
this.database.function("dada_retention_purge_now", { deterministic: false }, () => this.retentionPurgeNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeExpired() {
|
||||||
|
const now = this.clock();
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
this.retentionPurgeActive = true;
|
||||||
|
this.retentionPurgeNow = now;
|
||||||
|
const adminOperations = this.database.prepare("DELETE FROM admin_operation_logs WHERE expires_at <= ?").run(now);
|
||||||
|
const privateAccess = this.database.prepare("DELETE FROM private_content_access_logs WHERE expires_at <= ?").run(now);
|
||||||
|
const anonymous = this.database.prepare("DELETE FROM anonymous_retained_events WHERE expires_at <= ?").run(now);
|
||||||
|
this.retentionPurgeActive = false;
|
||||||
|
this.retentionPurgeNow = 0;
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
return {
|
||||||
|
admin_operation_logs: adminOperations.changes,
|
||||||
|
anonymous_events: anonymous.changes,
|
||||||
|
private_access_logs: privateAccess.changes,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.retentionPurgeActive = false;
|
||||||
|
this.retentionPurgeNow = 0;
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
|
const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/;
|
||||||
|
const safeStringPattern = /^[A-Za-z0-9_.:@-]{1,160}$/;
|
||||||
|
const forbiddenKeys = new Set([
|
||||||
|
"absolute_path", "api_key", "body", "code_hmac", "content", "credential", "email", "image",
|
||||||
|
"image_content", "password", "path", "prompt", "secret", "session_token", "verification_code", "whitelist",
|
||||||
|
]);
|
||||||
|
const forbiddenKeyFragments = ["content", "credential", "email", "image", "password", "path", "prompt", "secret", "token"];
|
||||||
|
|
||||||
|
function safeSummaryValue(value: unknown, depth: number): boolean {
|
||||||
|
if (depth > 3) return false;
|
||||||
|
if (value === null || typeof value === "boolean") return true;
|
||||||
|
if (typeof value === "number") return Number.isSafeInteger(value);
|
||||||
|
if (typeof value === "string") return safeStringPattern.test(value) && !value.includes("@");
|
||||||
|
if (Array.isArray(value)) return value.length <= 20 && value.every((entry) => safeSummaryValue(entry, depth + 1));
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
return entries.length <= 32 && entries.every(([key, entry]) => (
|
||||||
|
auditRefPattern.test(key)
|
||||||
|
&& !forbiddenKeys.has(key.toLowerCase())
|
||||||
|
&& !forbiddenKeyFragments.some((fragment) => key.toLowerCase().includes(fragment))
|
||||||
|
&& safeSummaryValue(entry, depth + 1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureWorkerDatabase(database: BetterSqlite3.Database) {
|
||||||
|
database.pragma("journal_mode = WAL");
|
||||||
|
database.pragma("foreign_keys = ON");
|
||||||
|
database.pragma("synchronous = FULL");
|
||||||
|
database.pragma("busy_timeout = 5000");
|
||||||
|
database.function("dada_audit_ref_is_safe", { deterministic: true }, (value: unknown) => (
|
||||||
|
typeof value === "string" && auditRefPattern.test(value) ? 1 : 0
|
||||||
|
));
|
||||||
|
database.function("dada_audit_summary_is_safe", { deterministic: true }, (value: unknown) => {
|
||||||
|
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
|
||||||
|
try {
|
||||||
|
return safeSummaryValue(JSON.parse(value), 0) ? 1 : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||||
|
database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||||
|
database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||||
|
database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||||
|
import { RetentionCleanup } from "./retention-cleanup.js";
|
||||||
|
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { WorkerStorageStatus } from "./storage-status.js";
|
import { WorkerStorageStatus } from "./storage-status.js";
|
||||||
import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js";
|
import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js";
|
||||||
@@ -26,14 +28,35 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
|
|||||||
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||||
const keepAlive = setInterval(() => undefined, 30_000);
|
const keepAlive = setInterval(() => undefined, 30_000);
|
||||||
let storage: WorkerStorageStatus | undefined;
|
let storage: WorkerStorageStatus | undefined;
|
||||||
|
let retention: RetentionCleanup | undefined;
|
||||||
|
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||||
|
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||||
clearInterval(keepAlive);
|
clearInterval(keepAlive);
|
||||||
|
if (retentionTimer) clearInterval(retentionTimer);
|
||||||
|
retention?.close();
|
||||||
|
projectCleanup?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
});
|
});
|
||||||
let storageStatus: "active" | "unavailable" = "active";
|
let storageStatus: "active" | "unavailable" = "active";
|
||||||
try {
|
try {
|
||||||
const dataRoot = readConfiguredLocalDataRoot();
|
const dataRoot = readConfiguredLocalDataRoot();
|
||||||
storage = new WorkerStorageStatus(join(dataRoot, "db", "dada.sqlite3"));
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||||
|
storage = new WorkerStorageStatus(databasePath);
|
||||||
|
retention = new RetentionCleanup({ databasePath });
|
||||||
|
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
|
||||||
|
const runRetentionCleanup = () => {
|
||||||
|
try {
|
||||||
|
retention?.purgeExpired();
|
||||||
|
projectCleanup?.run();
|
||||||
|
} catch {
|
||||||
|
storageStatus = "unavailable";
|
||||||
|
storage?.markLogUnavailable();
|
||||||
|
control.reportStatus("storage_unavailable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runRetentionCleanup();
|
||||||
|
retentionTimer = setInterval(runRetentionCleanup, 24 * 60 * 60 * 1_000);
|
||||||
const logger = new StructuredJsonlLogger({
|
const logger = new StructuredJsonlLogger({
|
||||||
component: "worker",
|
component: "worker",
|
||||||
directory: join(dataRoot, "logs", "worker"),
|
directory: join(dataRoot, "logs", "worker"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ES2024"],
|
"lib": ["ES2024"],
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"config_set_version": 8,
|
||||||
|
"gateway_account_ref": "oneapi-intelligrow-test",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"config_version": 7,
|
||||||
|
"gateway_account_ref": "oneapi-intelligrow-test",
|
||||||
|
"model_id": "gemini-3.1-flash-image",
|
||||||
|
"route_profile": {
|
||||||
|
"endpoint": "https://oneapi.intelligrow.cn/v1/chat/completions",
|
||||||
|
"mode": "sync",
|
||||||
|
"protocol_version": "gemini-openai-chat-v1",
|
||||||
|
"provider_model_id": "gemini-3.1-flash-image"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config_version": 2,
|
||||||
|
"gateway_account_ref": "oneapi-intelligrow-test",
|
||||||
|
"model_id": "gpt-image-2",
|
||||||
|
"route_profile": {
|
||||||
|
"endpoint": "https://oneapi.intelligrow.cn/v1/images/generations",
|
||||||
|
"mode": "sync",
|
||||||
|
"protocol_version": "openai-images-v1",
|
||||||
|
"reference_endpoint": "https://oneapi.intelligrow.cn/v1/images/edits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schema_version": "1.0"
|
||||||
|
}
|
||||||
+13104
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user