feat(P0-A): 整合第一版并冻结最终发布 #1
+202
-1
@@ -1,5 +1,5 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createReadStream, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
@@ -51,11 +51,17 @@ import {
|
||||
ModelRuntimeSseEventSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ExportFormatSchema,
|
||||
GenerationProjectItemSchema,
|
||||
LatestExportItemSchema,
|
||||
LatestExportMultipartBodySchema,
|
||||
LatestExportParamsSchema,
|
||||
LatestExportSaveResponseSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectEditableStateSchema,
|
||||
ProjectIdSchema,
|
||||
ProjectImageItemSchema,
|
||||
ProjectImageParamsSchema,
|
||||
ProjectListQuerySchema,
|
||||
ProjectListResponseSchema,
|
||||
ProjectParamsSchema,
|
||||
@@ -94,6 +100,8 @@ import {
|
||||
type LoginCompleteRequest,
|
||||
type LoginSendRequest,
|
||||
type FailedEmptyTrashRequest,
|
||||
type LatestExportParams,
|
||||
type ProjectImageParams,
|
||||
type GenerationCreateHeaders,
|
||||
type GenerationParams,
|
||||
type ProjectListQuery,
|
||||
@@ -136,6 +144,8 @@ import type {
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
import type { ProjectService } from "./projects.js";
|
||||
import { LatestExportError } from "./latest-export-errors.js";
|
||||
import type { LatestExportService } from "./latest-exports.js";
|
||||
import {
|
||||
RegistrationError,
|
||||
registrationFieldError,
|
||||
@@ -162,6 +172,7 @@ export interface CreateAppOptions {
|
||||
credits?: CreditService;
|
||||
eventHub?: EventHub;
|
||||
generations?: GenerationSubmissionService;
|
||||
latestExports?: LatestExportService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
projects?: ProjectService;
|
||||
@@ -251,6 +262,45 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
|
||||
return reply.code(mapping[error.code]).send(null);
|
||||
}
|
||||
|
||||
function latestExportResponse(item: ReturnType<LatestExportService["getLatest"]> | Awaited<ReturnType<LatestExportService["saveLatest"]>>) {
|
||||
return {
|
||||
byte_size: item.byteSize,
|
||||
created_at: item.createdAt,
|
||||
download_url: item.downloadUrl,
|
||||
export_id: item.exportId,
|
||||
format: item.format,
|
||||
pixel_height: item.pixelHeight,
|
||||
pixel_width: item.pixelWidth,
|
||||
sha256: item.sha256,
|
||||
state_version: item.stateVersion,
|
||||
status: "saved" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function latestExportFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (error instanceof LatestExportError) {
|
||||
return reply.code(error.code === "not_found" ? 404 : error.code === "conflict" ? 409 : 400).send(null);
|
||||
}
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "STORAGE_CAPACITY_EXCEEDED") {
|
||||
const details = "details" in error && error.details && typeof error.details === "object"
|
||||
? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number }
|
||||
: undefined;
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: { capacity_status: details?.capacity_status ?? "full", remaining_bytes: details?.remaining_bytes ?? 0 },
|
||||
}));
|
||||
}
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "storage_unavailable") {
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: { capacity_status: "unavailable", remaining_bytes: 0 },
|
||||
}));
|
||||
}
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
|
||||
function creditFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (!(error instanceof CreditError)) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
@@ -475,6 +525,17 @@ function projectDetailResponse(project: ProjectDetailView) {
|
||||
generation_id: image.generationId,
|
||||
image_id: image.imageId,
|
||||
})),
|
||||
latest_exports: project.latestExports.map((item) => ({
|
||||
byte_size: item.byteSize,
|
||||
created_at: item.createdAt,
|
||||
download_url: item.downloadUrl,
|
||||
export_id: item.exportId,
|
||||
format: item.format,
|
||||
pixel_height: item.pixelHeight,
|
||||
pixel_width: item.pixelWidth,
|
||||
sha256: item.sha256,
|
||||
state_version: item.stateVersion,
|
||||
})),
|
||||
pixel_height: project.pixelHeight,
|
||||
pixel_width: project.pixelWidth,
|
||||
save_status: project.saveStatus,
|
||||
@@ -619,6 +680,12 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
ProjectParamsSchema,
|
||||
GenerationProjectItemSchema,
|
||||
ProjectImageItemSchema,
|
||||
ExportFormatSchema,
|
||||
LatestExportItemSchema,
|
||||
LatestExportMultipartBodySchema,
|
||||
LatestExportParamsSchema,
|
||||
LatestExportSaveResponseSchema,
|
||||
ProjectImageParamsSchema,
|
||||
ProjectDetailResponseSchema,
|
||||
ProjectEditableStateSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
@@ -1708,6 +1775,140 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/v1/projects/:projectId/latest-exports/:format",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Optional(Type.Ref(LatestExportMultipartBodySchema)),
|
||||
consumes: ["multipart/form-data"],
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "saveLatestExport",
|
||||
params: Type.Ref(LatestExportParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(LatestExportSaveResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
409: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
507: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Projects"],
|
||||
},
|
||||
validatorCompiler: () => (data) => ({ value: data }),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError || !request.isMultipart()) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.latestExports) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const csrfToken = headerValue(request.headers["x-csrf-token"]);
|
||||
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
|
||||
const params = request.params as LatestExportParams;
|
||||
const fields = new Map<string, string>();
|
||||
let saved: Awaited<ReturnType<LatestExportService["saveLatest"]>> | undefined;
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
if (saved || fields.has(part.fieldname) || typeof part.value !== "string") throw new LatestExportError("invalid");
|
||||
fields.set(part.fieldname, part.value);
|
||||
continue;
|
||||
}
|
||||
if (saved || part.fieldname !== "export_file" || !part.filename) throw new LatestExportError("invalid");
|
||||
const format = fields.get("format");
|
||||
const expectedMime = params.format === "png" ? "image/png" : "image/jpeg";
|
||||
if (format !== params.format || part.mimetype !== expectedMime) throw new LatestExportError("invalid");
|
||||
saved = await options.latestExports.saveLatest({
|
||||
byteSize: Number(fields.get("byte_size")),
|
||||
content: part.file,
|
||||
exportId: fields.get("export_id") ?? "",
|
||||
format: params.format,
|
||||
ownerId: owner.userId,
|
||||
pixelHeight: Number(fields.get("pixel_height")),
|
||||
pixelWidth: Number(fields.get("pixel_width")),
|
||||
projectId: params.projectId,
|
||||
sha256: fields.get("sha256") ?? "",
|
||||
stateVersion: Number(fields.get("state_version")),
|
||||
});
|
||||
if (part.file.truncated) throw new LatestExportError("invalid");
|
||||
}
|
||||
if (!saved) throw new LatestExportError("invalid");
|
||||
return latestExportResponse(saved);
|
||||
} catch (error) {
|
||||
return error instanceof RegistrationError
|
||||
? registrationFailure(reply, request.id, error)
|
||||
: latestExportFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/projects/:projectId/latest-exports/:format",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "downloadLatestExport",
|
||||
params: Type.Ref(LatestExportParamsSchema),
|
||||
produces: ["application/octet-stream"],
|
||||
response: { 200: Type.String({ format: "binary" }), 400: Type.Null(), 401: Type.Ref(ErrorEnvelopeSchema), 404: Type.Null(), 503: Type.Ref(ErrorEnvelopeSchema) },
|
||||
tags: ["Projects"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const params = request.params as LatestExportParams;
|
||||
const item = options.latestExports.getLatest(session.userId, params.projectId, params.format);
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
reply.header("Content-Disposition", `attachment; filename="dada-latest.${params.format === "jpg" ? "jpg" : "png"}"`);
|
||||
reply.type(item.mimeType);
|
||||
return reply.send(createReadStream(item.path));
|
||||
} catch (error) {
|
||||
return latestExportFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/private-assets/projects/:projectId/images/:imageId",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "downloadOriginalGeneration",
|
||||
params: Type.Ref(ProjectImageParamsSchema),
|
||||
produces: ["application/octet-stream"],
|
||||
response: { 200: Type.String({ format: "binary" }), 400: Type.Null(), 401: Type.Ref(ErrorEnvelopeSchema), 404: Type.Null(), 503: Type.Ref(ErrorEnvelopeSchema) },
|
||||
tags: ["Projects"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const params = request.params as ProjectImageParams;
|
||||
const item = options.latestExports.getOriginal(session.userId, params.projectId, params.imageId);
|
||||
const extension = item.mime_type === "image/jpeg" ? "jpg" : item.mime_type === "image/webp" ? "webp" : "png";
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
reply.header("Content-Disposition", `attachment; filename="dada-original.${extension}"`);
|
||||
reply.type(item.mime_type);
|
||||
return reply.send(createReadStream(item.path));
|
||||
} catch (error) {
|
||||
return latestExportFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/api/v1/projects/:projectId/state",
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -7,6 +7,7 @@ import { createApp } from "./app.js";
|
||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.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";
|
||||
@@ -19,6 +20,8 @@ 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;
|
||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||
if (credentialChannelEnabled) {
|
||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||
@@ -39,8 +42,14 @@ if (credentialChannelEnabled) {
|
||||
});
|
||||
projects = new ProjectService({ databasePath });
|
||||
credits = new CreditService({ databasePath });
|
||||
storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
latestExports = new LatestExportService({ databasePath, storage });
|
||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||
} catch (error) {
|
||||
latestExports?.close();
|
||||
latestExports = undefined;
|
||||
storage?.close();
|
||||
storage = undefined;
|
||||
credits?.close();
|
||||
credits = undefined;
|
||||
projects?.close();
|
||||
@@ -57,6 +66,7 @@ const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"))
|
||||
const app = await createApp({
|
||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||
...(credits ? { credits } : {}),
|
||||
...(latestExports ? { latestExports } : {}),
|
||||
...(projects ? { projects } : {}),
|
||||
...(registration ? { registration } : {}),
|
||||
});
|
||||
@@ -70,9 +80,9 @@ const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
|
||||
if (controlPipeIndex >= 0) {
|
||||
const controlPipe = process.argv[controlPipeIndex + 1];
|
||||
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||
let storage: ManagedStorage | undefined;
|
||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||
await app.close();
|
||||
latestExports?.close();
|
||||
credits?.close();
|
||||
projects?.close();
|
||||
registration?.close();
|
||||
@@ -80,7 +90,7 @@ if (controlPipeIndex >= 0) {
|
||||
});
|
||||
try {
|
||||
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({
|
||||
component: "api",
|
||||
directory: join(dataRoot, "logs", "api"),
|
||||
|
||||
@@ -645,6 +645,26 @@ export class ManagedStorage {
|
||||
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") {
|
||||
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 (?, ?, ?, ?)`)
|
||||
|
||||
@@ -72,6 +72,17 @@ interface ProjectStateRow {
|
||||
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);
|
||||
}
|
||||
@@ -510,6 +521,10 @@ export class ProjectService {
|
||||
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 {
|
||||
@@ -519,6 +534,17 @@ export class ProjectService {
|
||||
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,
|
||||
@@ -748,6 +774,20 @@ export class ProjectService {
|
||||
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 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,
|
||||
|
||||
@@ -1210,6 +1210,10 @@ export class RegistrationService {
|
||||
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects'
|
||||
`).get();
|
||||
if (projectsTable) this.database.prepare("DELETE FROM projects WHERE owner_id = ?").run(session.user_id);
|
||||
const exportReceiptsTable = this.database.prepare(`
|
||||
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'latest_export_receipts'
|
||||
`).get();
|
||||
if (exportReceiptsTable) this.database.prepare("DELETE FROM latest_export_receipts WHERE owner_id = ?").run(session.user_id);
|
||||
this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id);
|
||||
this.database.prepare("DELETE FROM email_challenges WHERE email = ?").run(session.normalized_email);
|
||||
this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?")
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
@@ -90,13 +90,28 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
|
||||
return response.json() as Promise<RegistrationCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function createGeneration(options: ClientOptions = {}): Promise<GenerationCreateResponse> {
|
||||
export async function createGeneration(body: FormData, options: ClientOptions = {}): Promise<GenerationCreateResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { method: "POST", headers: options.headers ?? {} });
|
||||
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 ?? {} });
|
||||
@@ -240,6 +255,14 @@ export async function restoreProject(options: ClientOptions = {}): Promise<Proje
|
||||
return response.json() as Promise<ProjectRestoreResponse>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -311,6 +311,8 @@ export type ErrorEnvelope = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportFormat = "jpg" | "png";
|
||||
|
||||
export type FailedEmptyTrashRequest = {
|
||||
"project_ids": Array<ProjectId>;
|
||||
};
|
||||
@@ -379,6 +381,47 @@ export type GenerationTaskResponse = {
|
||||
|
||||
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;
|
||||
@@ -429,6 +472,7 @@ export type ProjectDetailResponse = {
|
||||
"draft_prompt": string;
|
||||
"generations": Array<GenerationProjectItem>;
|
||||
"images": Array<ProjectImageItem>;
|
||||
"latest_exports": Array<LatestExportItem>;
|
||||
"name": string;
|
||||
"pixel_height": number;
|
||||
"pixel_width": number;
|
||||
@@ -455,6 +499,11 @@ export type ProjectImageItem = {
|
||||
"image_id": ProjectId;
|
||||
};
|
||||
|
||||
export type ProjectImageParams = {
|
||||
"imageId": ProjectId;
|
||||
"projectId": ProjectId;
|
||||
};
|
||||
|
||||
export type ProjectListQuery = {
|
||||
"status"?: "active" | "trashed";
|
||||
};
|
||||
|
||||
@@ -1049,6 +1049,106 @@
|
||||
color: #65655f;
|
||||
}
|
||||
|
||||
.project-latest-exports {
|
||||
margin-top: 22px;
|
||||
border: 1px solid #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.project-latest-exports > header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid #111111;
|
||||
}
|
||||
|
||||
.project-latest-exports h2,
|
||||
.project-latest-exports p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-latest-exports p {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-latest-exports > header > span {
|
||||
color: #65655f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.latest-exports-empty {
|
||||
display: grid;
|
||||
min-height: 150px;
|
||||
place-items: center;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.latest-exports-empty > strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.latest-exports-empty > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.latest-exports-empty a,
|
||||
.project-latest-exports li > a {
|
||||
display: inline-grid;
|
||||
min-height: 40px;
|
||||
place-items: center;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #111111;
|
||||
color: #111111;
|
||||
background: #ffffff;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.latest-exports-empty a:first-child {
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.project-latest-exports ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-latest-exports li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #b9b9b1;
|
||||
}
|
||||
|
||||
.project-latest-exports li > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-latest-exports li > time {
|
||||
color: #65655f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-latest-exports li > a {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.project-leave-overlay {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
@@ -1330,6 +1430,15 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-latest-exports ul {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.latest-exports-empty > div {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-current > .project-placeholder {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,17 @@ interface ProjectDetailPayload extends ProjectSummary {
|
||||
updated_at: string;
|
||||
}>;
|
||||
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
|
||||
latest_exports: Array<{
|
||||
byte_size: number;
|
||||
created_at: string;
|
||||
download_url: string;
|
||||
export_id: string;
|
||||
format: "jpg" | "png";
|
||||
pixel_height: number;
|
||||
pixel_width: number;
|
||||
sha256: string;
|
||||
state_version: number;
|
||||
}>;
|
||||
pixel_height?: number;
|
||||
pixel_width?: number;
|
||||
save_status: "saved";
|
||||
@@ -767,6 +778,7 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
const normalizedProject = {
|
||||
...nextProject,
|
||||
canvas_state: nextProject.canvas_state ?? initialCanvasState(nextProject),
|
||||
latest_exports: nextProject.latest_exports ?? [],
|
||||
save_status: nextProject.save_status ?? "saved",
|
||||
};
|
||||
setProject(normalizedProject);
|
||||
@@ -944,8 +956,8 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
||||
<div className="project-actions">
|
||||
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
||||
<button disabled={conflicted || !project.current_image_id} type="button">进入编辑器</button>
|
||||
<button disabled={conflicted || !project.current_image_id} type="button">下载原始图</button>
|
||||
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
||||
{conflicted || !project.current_image_id ? <button disabled type="button">下载原始图</button> : <a href={`/api/v1/private-assets/projects/${project.project_id}/images/${project.current_image_id}`}>下载原始图</a>}
|
||||
</div>
|
||||
{atHistoryLimit ? <p className="project-blocker">请先删除一张非当前底图的历史图</p> : null}
|
||||
{project.status === "failed_empty" && !conflicted ? <a className="project-retry" href={`/app?retry=${project.project_id}`}>修改并重试</a> : null}
|
||||
@@ -959,13 +971,35 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
||||
{project.images.toReversed().map((image, index) => (
|
||||
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
||||
<ProjectPlaceholder ratio={project.ratio} status="active" />
|
||||
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time></div>
|
||||
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<section aria-labelledby="latest-exports-title" className="project-latest-exports">
|
||||
<header><div><p>LOCAL LATEST</p><h2 id="latest-exports-title">最新成品</h2></div><span>同一电脑可重新下载</span></header>
|
||||
{project.latest_exports.length === 0 ? (
|
||||
<div className="latest-exports-empty">
|
||||
<strong>暂无导出成品</strong>
|
||||
<div>
|
||||
<a href={`/app/projects/${project.project_id}/editor`}>进入编辑并导出</a>
|
||||
{project.current_image_id ? <a href={`/api/v1/private-assets/projects/${project.project_id}/images/${project.current_image_id}`}>下载原始生成图</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{project.latest_exports.map((item) => (
|
||||
<li key={item.export_id}>
|
||||
<div><strong>{item.format.toUpperCase()}</strong><span>{item.pixel_width} × {item.pixel_height}</span></div>
|
||||
<time dateTime={item.created_at}>{formatUpdatedAt(item.created_at)}</time>
|
||||
<a href={item.download_url}>重新下载</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
{pendingNavigation ? (
|
||||
<div className="project-leave-overlay" role="presentation">
|
||||
|
||||
@@ -2083,6 +2083,22 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ExportFormat": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"jpg"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"png"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FailedEmptyTrashRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -2512,6 +2528,180 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"LatestExportItem": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"byte_size": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||
"type": "string"
|
||||
},
|
||||
"download_url": {
|
||||
"pattern": "^/api/v1/projects/[0-9a-fA-F-]{36}/latest-exports/(jpg|png)$",
|
||||
"type": "string"
|
||||
},
|
||||
"export_id": {
|
||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/ExportFormat"
|
||||
},
|
||||
"pixel_height": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pixel_width": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"sha256": {
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"type": "string"
|
||||
},
|
||||
"state_version": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"byte_size",
|
||||
"created_at",
|
||||
"download_url",
|
||||
"export_id",
|
||||
"format",
|
||||
"pixel_height",
|
||||
"pixel_width",
|
||||
"sha256",
|
||||
"state_version"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LatestExportMultipartBody": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"byte_size": {
|
||||
"pattern": "^[1-9][0-9]*$",
|
||||
"type": "string"
|
||||
},
|
||||
"export_file": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
},
|
||||
"export_id": {
|
||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/ExportFormat"
|
||||
},
|
||||
"pixel_height": {
|
||||
"pattern": "^[1-9][0-9]*$",
|
||||
"type": "string"
|
||||
},
|
||||
"pixel_width": {
|
||||
"pattern": "^[1-9][0-9]*$",
|
||||
"type": "string"
|
||||
},
|
||||
"sha256": {
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"type": "string"
|
||||
},
|
||||
"state_version": {
|
||||
"pattern": "^[1-9][0-9]*$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"byte_size",
|
||||
"export_file",
|
||||
"export_id",
|
||||
"format",
|
||||
"pixel_height",
|
||||
"pixel_width",
|
||||
"sha256",
|
||||
"state_version"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LatestExportParams": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/ExportFormat"
|
||||
},
|
||||
"projectId": {
|
||||
"$ref": "#/components/schemas/ProjectId"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"format",
|
||||
"projectId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LatestExportSaveResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"byte_size": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||
"type": "string"
|
||||
},
|
||||
"download_url": {
|
||||
"pattern": "^/api/v1/projects/[0-9a-fA-F-]{36}/latest-exports/(jpg|png)$",
|
||||
"type": "string"
|
||||
},
|
||||
"export_id": {
|
||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/ExportFormat"
|
||||
},
|
||||
"pixel_height": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"pixel_width": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"sha256": {
|
||||
"pattern": "^[0-9a-f]{64}$",
|
||||
"type": "string"
|
||||
},
|
||||
"state_version": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"saved"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"byte_size",
|
||||
"created_at",
|
||||
"download_url",
|
||||
"export_id",
|
||||
"format",
|
||||
"pixel_height",
|
||||
"pixel_width",
|
||||
"sha256",
|
||||
"state_version",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LoginCompleteRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -2737,6 +2927,13 @@
|
||||
"maxItems": 10,
|
||||
"type": "array"
|
||||
},
|
||||
"latest_exports": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/LatestExportItem"
|
||||
},
|
||||
"maxItems": 2,
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"maxLength": 160,
|
||||
"minLength": 1,
|
||||
@@ -2806,6 +3003,7 @@
|
||||
"draft_prompt",
|
||||
"generations",
|
||||
"images",
|
||||
"latest_exports",
|
||||
"pixel_height",
|
||||
"pixel_width",
|
||||
"save_status"
|
||||
@@ -2856,6 +3054,22 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProjectImageParams": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"imageId": {
|
||||
"$ref": "#/components/schemas/ProjectId"
|
||||
},
|
||||
"projectId": {
|
||||
"$ref": "#/components/schemas/ProjectId"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"imageId",
|
||||
"projectId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProjectListQuery": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -6143,6 +6357,71 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/private-assets/projects/{projectId}/images/{imageId}": {
|
||||
"get": {
|
||||
"operationId": "downloadOriginalGeneration",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "imageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectId"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectId"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"400": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"404": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/projects": {
|
||||
"get": {
|
||||
"operationId": "listProjects",
|
||||
@@ -6350,6 +6629,177 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/projects/{projectId}/latest-exports/{format}": {
|
||||
"get": {
|
||||
"operationId": "downloadLatestExport",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "format",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExportFormat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectId"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"400": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"404": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "saveLatestExport",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "format",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExportFormat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectId"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "x-csrf-token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 64,
|
||||
"minLength": 43,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LatestExportMultipartBody"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LatestExportSaveResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"400": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"404": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"409": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"507": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/projects/{projectId}/purge": {
|
||||
"post": {
|
||||
"operationId": "purgeProject",
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts --config playwright.config.ts",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts --config playwright.config.ts",
|
||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||
@@ -63,7 +63,9 @@
|
||||
"test:wp2-05": "node scripts/run-wp2-05-validation.mjs",
|
||||
"test:wp2-05:red": "node scripts/run-wp2-05-validation.mjs --phase red",
|
||||
"test:wp2-06": "node scripts/run-wp2-06-validation.mjs",
|
||||
"test:wp2-06:red": "node scripts/run-wp2-06-validation.mjs --phase red"
|
||||
"test:wp2-06:red": "node scripts/run-wp2-06-validation.mjs --phase red",
|
||||
"test:wp2-07": "node scripts/run-wp2-07-validation.mjs",
|
||||
"test:wp2-07:red": "node scripts/run-wp2-07-validation.mjs --phase red"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
|
||||
@@ -69,6 +69,21 @@ export const ProjectImageItemSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectImageItem" },
|
||||
);
|
||||
export const ExportFormatSchema = Type.Union([Type.Literal("jpg"), Type.Literal("png")], { $id: "ExportFormat" });
|
||||
export const LatestExportItemSchema = Type.Object(
|
||||
{
|
||||
byte_size: Type.Integer({ minimum: 1 }),
|
||||
created_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
download_url: Type.String({ pattern: "^/api/v1/projects/[0-9a-fA-F-]{36}/latest-exports/(jpg|png)$" }),
|
||||
export_id: Type.String({ pattern: uuidPattern }),
|
||||
format: Type.Ref(ExportFormatSchema),
|
||||
pixel_height: Type.Integer({ minimum: 1 }),
|
||||
pixel_width: Type.Integer({ minimum: 1 }),
|
||||
sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }),
|
||||
state_version: Type.Integer({ minimum: 1 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LatestExportItem" },
|
||||
);
|
||||
export const ProjectDetailResponseSchema = Type.Object(
|
||||
{
|
||||
...ProjectSummarySchema.properties,
|
||||
@@ -77,12 +92,41 @@ export const ProjectDetailResponseSchema = Type.Object(
|
||||
draft_prompt: Type.String({ maxLength: 4_000, minLength: 1 }),
|
||||
generations: Type.Array(Type.Ref(GenerationProjectItemSchema)),
|
||||
images: Type.Array(Type.Ref(ProjectImageItemSchema), { maxItems: 10 }),
|
||||
latest_exports: Type.Array(Type.Ref(LatestExportItemSchema), { maxItems: 2 }),
|
||||
pixel_height: Type.Integer({ minimum: 1 }),
|
||||
pixel_width: Type.Integer({ minimum: 1 }),
|
||||
save_status: Type.Literal("saved"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "ProjectDetailResponse" },
|
||||
);
|
||||
export const LatestExportParamsSchema = Type.Object(
|
||||
{ format: Type.Ref(ExportFormatSchema), projectId: Type.Ref(ProjectIdSchema) },
|
||||
{ additionalProperties: false, $id: "LatestExportParams" },
|
||||
);
|
||||
export const ProjectImageParamsSchema = Type.Object(
|
||||
{ imageId: Type.Ref(ProjectIdSchema), projectId: Type.Ref(ProjectIdSchema) },
|
||||
{ additionalProperties: false, $id: "ProjectImageParams" },
|
||||
);
|
||||
export const LatestExportMultipartBodySchema = Type.Object(
|
||||
{
|
||||
byte_size: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
export_file: Type.String({ format: "binary" }),
|
||||
export_id: Type.String({ pattern: uuidPattern }),
|
||||
format: Type.Ref(ExportFormatSchema),
|
||||
pixel_height: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
pixel_width: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }),
|
||||
state_version: Type.String({ pattern: "^[1-9][0-9]*$" }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LatestExportMultipartBody" },
|
||||
);
|
||||
export const LatestExportSaveResponseSchema = Type.Object(
|
||||
{
|
||||
...LatestExportItemSchema.properties,
|
||||
status: Type.Literal("saved"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "LatestExportSaveResponse" },
|
||||
);
|
||||
export const ProjectRenameRequestSchema = Type.Object(
|
||||
{ name: Type.String({ maxLength: 80, minLength: 1 }) },
|
||||
{ additionalProperties: false, $id: "ProjectRenameRequest" },
|
||||
@@ -134,5 +178,7 @@ export const ProjectPurgeResponseSchema = Type.Object(
|
||||
|
||||
export type ProjectListQuery = Static<typeof ProjectListQuerySchema>;
|
||||
export type ProjectParams = Static<typeof ProjectParamsSchema>;
|
||||
export type LatestExportParams = Static<typeof LatestExportParamsSchema>;
|
||||
export type ProjectImageParams = Static<typeof ProjectImageParamsSchema>;
|
||||
export type ProjectRenameRequest = Static<typeof ProjectRenameRequestSchema>;
|
||||
export type FailedEmptyTrashRequest = Static<typeof FailedEmptyTrashRequestSchema>;
|
||||
|
||||
@@ -34,15 +34,30 @@ function operationResult(operation) {
|
||||
const response = operation.responses?.["200"];
|
||||
if (!response) return "unknown";
|
||||
if (response.content) {
|
||||
const media = response.content["application/json"] ?? response.content["text/event-stream"];
|
||||
if (media?.schema) return schemaType(media.schema);
|
||||
const media = response.content["application/json"] ?? response.content["text/event-stream"] ?? Object.values(response.content)[0];
|
||||
if (media?.schema) return media.schema.format === "binary" ? "Blob" : schemaType(media.schema);
|
||||
}
|
||||
return response.schema ? schemaType(response.schema) : "unknown";
|
||||
}
|
||||
|
||||
function operationBodyType(operation) {
|
||||
const schema = operation.requestBody?.content?.["application/json"]?.schema;
|
||||
return schema ? schemaType(schema) : undefined;
|
||||
const content = operation.requestBody?.content;
|
||||
const jsonSchema = content?.["application/json"]?.schema;
|
||||
if (jsonSchema) return schemaType(jsonSchema);
|
||||
if (content?.["multipart/form-data"]?.schema) return "FormData";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function operationRequestMediaType(operation) {
|
||||
const content = operation.requestBody?.content;
|
||||
if (content?.["application/json"]) return "application/json";
|
||||
if (content?.["multipart/form-data"]) return "multipart/form-data";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function operationReturnsBinary(operation) {
|
||||
return Object.values(operation.responses?.["200"]?.content ?? {})
|
||||
.some((media) => media?.schema?.format === "binary");
|
||||
}
|
||||
|
||||
function operationMediaType(operation) {
|
||||
@@ -73,10 +88,11 @@ export function generateClient(input, output) {
|
||||
].join("\n\n");
|
||||
|
||||
const operationList = operations(document);
|
||||
const builtInTypes = new Set(["Blob", "FormData", "boolean", "number", "string", "unknown"]);
|
||||
const importedTypes = [...new Set(operationList.flatMap(({ operation }) => [
|
||||
operationResult(operation),
|
||||
operationBodyType(operation),
|
||||
]).filter((type) => type && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))];
|
||||
]).filter((type) => type && !builtInTypes.has(type) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))];
|
||||
const sdk = [
|
||||
"// Generated from openapi/openapi.json. Do not edit by hand.",
|
||||
importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "",
|
||||
@@ -84,13 +100,17 @@ export function generateClient(input, output) {
|
||||
...operationList.map(({ method, operation, path }) => {
|
||||
const resultType = operationResult(operation);
|
||||
const bodyType = operationBodyType(operation);
|
||||
const requestMediaType = operationRequestMediaType(operation);
|
||||
if (operationMediaType(operation) === "text/event-stream") {
|
||||
return `export function ${identifier(operation.operationId)}(options: Pick<ClientOptions, "baseUrl"> = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`;
|
||||
}
|
||||
if (bodyType) {
|
||||
return `export async function ${identifier(operation.operationId)}(body: ${bodyType}, options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const headers = new Headers(options.headers);\n headers.set("Content-Type", "application/json");\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { body: JSON.stringify(body), method: "${method.toUpperCase()}", headers });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`;
|
||||
const bodyExpression = requestMediaType === "application/json" ? "JSON.stringify(body)" : "body";
|
||||
const contentType = requestMediaType === "application/json" ? '\n headers.set("Content-Type", "application/json");' : "";
|
||||
return `export async function ${identifier(operation.operationId)}(body: ${bodyType}, options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const headers = new Headers(options.headers);${contentType}\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { body: ${bodyExpression}, method: "${method.toUpperCase()}", headers });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`;
|
||||
}
|
||||
return `export async function ${identifier(operation.operationId)}(options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { method: "${method.toUpperCase()}", headers: options.headers ?? {} });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`;
|
||||
const responseReader = operationReturnsBinary(operation) ? "response.blob()" : "response.json()";
|
||||
return `export async function ${identifier(operation.operationId)}(options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { method: "${method.toUpperCase()}", headers: options.headers ?? {} });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return ${responseReader} as Promise<${resultType}>;\n}`;
|
||||
}),
|
||||
"",
|
||||
].filter(Boolean).join("\n\n");
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const phaseIndex = process.argv.indexOf("--phase");
|
||||
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||
const runId = process.env.DADA_TDD_RUN_ID ?? `wp2-07-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||
const casesDirectory = resolve(runDirectory, "cases");
|
||||
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||
mkdirSync(casesDirectory, { recursive: true });
|
||||
|
||||
const cases = [
|
||||
{ acceptance: ["AC-23", "AC-35"], evidence: ["download.json", "request.json", "response.json", "db-diff.json", "fs-before.json", "fs-after.json"], id: "TDD-WP2-EXP-001-same-blob-success", requirements: ["EXPORT-04"] },
|
||||
{ acceptance: ["AC-35", "AC-55"], evidence: ["network-timeline.json", "response.json", "db-diff.json", "trace.zip", "screenshots/result.png"], id: "TDD-WP2-EXP-001-result-matrix", requirements: ["EXPORT-05", "EXPORT-06"] },
|
||||
{ acceptance: ["AC-23", "AC-38"], evidence: ["response.json", "db-diff.json", "trace.zip", "screenshots/no-export.png"], id: "TDD-WP2-EXP-002-empty-latest", requirements: ["EXPORT-04"] },
|
||||
];
|
||||
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
|
||||
|
||||
const commands = phase === "red"
|
||||
? [
|
||||
["unit", ["exec", "vitest", "run", "tests/unit/wp2-07-export-flow.test.ts"]],
|
||||
["api", ["exec", "vitest", "run", "tests/api/wp2-07-latest-exports.test.ts"]],
|
||||
["e2e", ["exec", "playwright", "test", "tests/e2e/project-latest-exports.spec.ts", "--config", "playwright.config.ts"]],
|
||||
]
|
||||
: [["unit", ["test:unit"]], ["integration", ["test:integration"]], ["api", ["test:api"]], ["e2e", ["test:e2e"]], ["tdd-trace", ["validate:tdd-trace"]]];
|
||||
const environment = { ...process.env, DADA_EVIDENCE_DIR_LATEST_EXPORTS: casesDirectory };
|
||||
const commandResults = [];
|
||||
for (const [name, args] of commands) {
|
||||
const command = `pnpm ${args.join(" ")}`;
|
||||
const started_at = new Date().toISOString();
|
||||
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||
}
|
||||
if (phase === "green") {
|
||||
const traces = [];
|
||||
const visit = (directory) => {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(path);
|
||||
else if (entry.name === "trace.zip" && path.includes("project-latest-exports")) traces.push(path);
|
||||
}
|
||||
};
|
||||
visit(resolve("test-results", "e2e"));
|
||||
traces.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs);
|
||||
const emptyTrace = traces.find((path) => path.includes("two-allowed-actions"));
|
||||
const matrixTrace = traces.find((path) => path.includes("download-and-persistence"));
|
||||
if (emptyTrace) copyFileSync(emptyTrace, resolve(casesDirectory, "TDD-WP2-EXP-002-empty-latest", "trace.zip"));
|
||||
if (matrixTrace) copyFileSync(matrixTrace, resolve(casesDirectory, "TDD-WP2-EXP-001-result-matrix", "trace.zip"));
|
||||
}
|
||||
const commandState = phase === "red" ? commandResults.every((result) => result.exit_code !== 0) : commandResults.every((result) => result.exit_code === 0);
|
||||
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
|
||||
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||
const summaries = [];
|
||||
for (const item of cases) {
|
||||
const directory = resolve(casesDirectory, item.id);
|
||||
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||
if (phase === "red") writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({ expected_failure: "Latest export persistence, private downloads, result matrix, and no-export UI are absent", status: commandState ? "red_confirmed" : "failed" }, null, 2)}\n`);
|
||||
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
|
||||
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
|
||||
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||
const status = commandState && missingEvidence.length === 0 ? targetStatus : "failed";
|
||||
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
|
||||
acceptance_criteria: item.acceptance, automation: ["automated"], commit, evidence_refs: evidenceRefs,
|
||||
layer: ["DB", "API", "UNIT", "E2E"], manifest, missing_evidence: missingEvidence, phase,
|
||||
requirements: item.requirements, run_id: runId, status, task_id: "TASK-WP2-07", test_id: item.id,
|
||||
work_package: "WP-2", worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
||||
}, null, 2)}\n`);
|
||||
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
|
||||
}
|
||||
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||
const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed";
|
||||
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
|
||||
if (status !== targetStatus) process.exit(1);
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { LatestExportService } from "../../apps/api/src/latest-exports.js";
|
||||
import { HARD_LIMIT_BYTES, ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
||||
import { ProjectService } from "../../apps/api/src/projects.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const now = Date.parse("2026-08-03T08:00:00.000Z");
|
||||
const roots: string[] = [];
|
||||
const closeables: Array<{ close(): void }> = [];
|
||||
const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), Buffer.from("dada-export")]);
|
||||
const jpeg = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.from("dada-export-jpg")]);
|
||||
|
||||
function writeEvidence(caseId: string, file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function multipart(fields: Record<string, string>, bytes: Buffer, type = "image/png") {
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) form.append(key, value);
|
||||
form.append("export_file", new Blob([bytes], { type }), type === "image/png" ? "latest.png" : "latest.jpg");
|
||||
const serialized = new Response(form);
|
||||
return { contentType: serialized.headers.get("content-type")!, payload: Buffer.from(await serialized.arrayBuffer()) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const value of closeables.splice(0).reverse()) value.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
async function fixture() {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-07-api-"));
|
||||
roots.push(dataRoot);
|
||||
mkdirSync(join(dataRoot, "db"), { recursive: true });
|
||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
|
||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath,
|
||||
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
|
||||
});
|
||||
const projects = new ProjectService({ clock: () => now, databasePath });
|
||||
const storage = new ManagedStorage({ dataRoot, databasePath });
|
||||
const latestExports = new LatestExportService({ clock: () => now, databasePath, storage });
|
||||
closeables.push(latestExports, storage, projects, registration);
|
||||
const ownerId = randomUUID();
|
||||
registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'exports@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now);
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Export User', '@export')").run(ownerId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(ownerId, now);
|
||||
const session = registration.issueAuthenticatedSession(ownerId, "user");
|
||||
const csrf = registration.issueUserCsrfToken(session.sessionToken);
|
||||
const created = projects.createProjectForGeneration({ ownerId, prompt: "导出服务", ratio: "3:4", status: "queued" });
|
||||
const original = await storage.commitStream({
|
||||
content: (await import("node:stream")).Readable.from(png), expectedMimeType: "image/png", fileKind: "generated",
|
||||
fileName: "original.png", operationId: randomUUID(), ownerRef: ownerId, projectedWriteBytes: png.byteLength,
|
||||
});
|
||||
projects.linkManagedResource(ownerId, created.project.projectId, original.file_id, "generated");
|
||||
projects.recordSuccessfulImage({ generationId: created.generation.generationId, imageId: original.file_id });
|
||||
const app = await createApp({
|
||||
browserGate: false, latestExports, networkBoundary: { allowTestPort: true }, projects, registration,
|
||||
});
|
||||
const headers = { ...baseHeaders, cookie: `dada_session=${session.sessionToken}`, "x-csrf-token": csrf };
|
||||
return { app, created, headers, latestExports, ownerId, projects, registration, storage };
|
||||
}
|
||||
|
||||
describe("TDD-WP2-EXP-001 latest export API", () => {
|
||||
it("saves the uploaded bytes as the only latest export for that format and replays export_id without re-metering", async () => {
|
||||
const test = await fixture();
|
||||
const projectId = test.created.project.projectId;
|
||||
const stateVersion = test.projects.getProject(test.ownerId, projectId).stateVersion;
|
||||
const exportId = randomUUID();
|
||||
const sha256 = createHash("sha256").update(png).digest("hex");
|
||||
const body = await multipart({ byte_size: String(png.byteLength), export_id: exportId, format: "png", pixel_height: "1440", pixel_width: "1080", sha256, state_version: String(stateVersion) }, png);
|
||||
const request = () => test.app.inject({ headers: { ...test.headers, "content-type": body.contentType }, method: "PUT", payload: body.payload, url: `/api/v1/projects/${projectId}/latest-exports/png` });
|
||||
|
||||
const saved = await request();
|
||||
const metered = test.storage.getState().managed_content_bytes;
|
||||
const replay = await request();
|
||||
expect(saved.statusCode).toBe(200);
|
||||
expect(saved.json()).toMatchObject({ export_id: exportId, format: "png", sha256, state_version: stateVersion });
|
||||
expect(replay.json()).toEqual(saved.json());
|
||||
expect(test.storage.getState().managed_content_bytes).toBe(metered);
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ count: 1 });
|
||||
|
||||
const downloaded = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}/latest-exports/png` });
|
||||
expect(downloaded.statusCode).toBe(200);
|
||||
expect(downloaded.headers["cache-control"]).toContain("no-store");
|
||||
expect(downloaded.rawPayload).toEqual(png);
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "download.json", { byte_size: downloaded.rawPayload.byteLength, sha256: createHash("sha256").update(downloaded.rawPayload).digest("hex") });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "request.json", { byte_size: png.byteLength, export_id: exportId, format: "png", sha256, state_version: stateVersion });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "response.json", saved.json());
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "db-diff.json", { latest_rows: 1, replay_metered_delta: 0 });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "fs-before.json", { managed_export_files: 0 });
|
||||
writeEvidence("TDD-WP2-EXP-001-same-blob-success", "fs-after.json", { managed_export_files: 1, sha256 });
|
||||
await test.app.close();
|
||||
});
|
||||
|
||||
it("replaces only the selected format and preserves old latest across conflict, persistence, full, and unavailable failures", async () => {
|
||||
const test = await fixture();
|
||||
const projectId = test.created.project.projectId;
|
||||
const stateVersion = test.projects.getProject(test.ownerId, projectId).stateVersion;
|
||||
const save = async (format: "jpg" | "png", bytes: Buffer, overrides: Partial<Record<"export_id" | "sha256" | "state_version", string>> = {}) => {
|
||||
const fields = {
|
||||
byte_size: String(bytes.byteLength),
|
||||
export_id: overrides.export_id ?? randomUUID(),
|
||||
format,
|
||||
pixel_height: "1440",
|
||||
pixel_width: "1080",
|
||||
sha256: overrides.sha256 ?? createHash("sha256").update(bytes).digest("hex"),
|
||||
state_version: overrides.state_version ?? String(stateVersion),
|
||||
};
|
||||
const body = await multipart(fields, bytes, format === "png" ? "image/png" : "image/jpeg");
|
||||
return test.app.inject({
|
||||
headers: { ...test.headers, "content-type": body.contentType }, method: "PUT", payload: body.payload,
|
||||
url: `/api/v1/projects/${projectId}/latest-exports/${format}`,
|
||||
});
|
||||
};
|
||||
|
||||
const oldPng = await save("png", png);
|
||||
const jpg = await save("jpg", jpeg);
|
||||
const replacementBytes = Buffer.concat([png, Buffer.from("-replacement")]);
|
||||
const replacement = await save("png", replacementBytes);
|
||||
expect([oldPng.statusCode, jpg.statusCode, replacement.statusCode]).toEqual([200, 200, 200]);
|
||||
const latestRows = test.registration.database.prepare("SELECT format, export_id FROM latest_exports WHERE project_id = ? ORDER BY format").all(projectId) as Array<{ export_id: string; format: string }>;
|
||||
expect(latestRows).toEqual([
|
||||
{ export_id: jpg.json().export_id, format: "jpg" },
|
||||
{ export_id: replacement.json().export_id, format: "png" },
|
||||
]);
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM managed_files WHERE file_kind = 'export' AND status = 'purged'").get()).toEqual({ count: 1 });
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE reason = 'purge' AND status = 'pending'").get()).toEqual({ count: 1 });
|
||||
|
||||
const expectedLatestId = replacement.json().export_id as string;
|
||||
const beforeOldReplay = test.storage.getState().managed_content_bytes;
|
||||
const oldReplay = await save("png", png, { export_id: oldPng.json().export_id });
|
||||
expect(oldReplay.statusCode).toBe(200);
|
||||
expect(oldReplay.json()).toEqual(oldPng.json());
|
||||
expect(test.storage.getState().managed_content_bytes).toBe(beforeOldReplay);
|
||||
const stale = await save("png", png, { state_version: String(stateVersion + 1) });
|
||||
const badHash = await save("png", png, { sha256: "0".repeat(64) });
|
||||
expect(stale.statusCode).toBe(409);
|
||||
expect(badHash.statusCode).toBe(503);
|
||||
expect(test.registration.database.prepare("SELECT export_id FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ export_id: expectedLatestId });
|
||||
test.storage.applyControlledMeasurement(HARD_LIMIT_BYTES);
|
||||
const full = await save("png", png);
|
||||
expect(full.statusCode).toBe(507);
|
||||
expect(full.json()).toMatchObject({ error: { code: "STORAGE_CAPACITY_EXCEEDED", details: { capacity_status: "full" } } });
|
||||
test.storage.applyControlledMeasurement(0);
|
||||
test.storage.setAvailability({ dataRootWritable: false, diskSpaceAvailable: true, sqliteWritable: true });
|
||||
const unavailable = await save("png", png);
|
||||
expect(unavailable.statusCode).toBe(507);
|
||||
expect(unavailable.json()).toMatchObject({ error: { details: { capacity_status: "unavailable" } } });
|
||||
expect(test.registration.database.prepare("SELECT export_id FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ export_id: expectedLatestId });
|
||||
writeEvidence("TDD-WP2-EXP-001-result-matrix", "response.json", {
|
||||
content_hash_failure: badHash.statusCode,
|
||||
storage_full: full.statusCode,
|
||||
storage_unavailable: unavailable.statusCode,
|
||||
state_conflict: stale.statusCode,
|
||||
});
|
||||
writeEvidence("TDD-WP2-EXP-001-result-matrix", "db-diff.json", { latest_export_id_after_failures: expectedLatestId, old_latest_preserved: true });
|
||||
await test.app.close();
|
||||
}, 15_000);
|
||||
|
||||
it("returns stable empty latest state without creating a row and serves only owned originals", async () => {
|
||||
const test = await fixture();
|
||||
const projectId = test.created.project.projectId;
|
||||
const before = test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports").get();
|
||||
const missing = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}/latest-exports/jpg` });
|
||||
const detail = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}` });
|
||||
const original = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/private-assets/projects/${projectId}/images/${test.projects.getProject(test.ownerId, projectId).currentImageId}` });
|
||||
const intruderId = randomUUID();
|
||||
test.registration.database.prepare(`INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at
|
||||
) VALUES (?, 'exports-other@example.invalid', 'user', 'active', 1, ?, ?)`).run(intruderId, randomUUID(), now);
|
||||
test.registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Other User', '@other_export')").run(intruderId);
|
||||
test.registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(intruderId, now);
|
||||
const intruder = test.registration.issueAuthenticatedSession(intruderId, "user");
|
||||
const denied = await test.app.inject({
|
||||
headers: { ...baseHeaders, cookie: `dada_session=${intruder.sessionToken}` }, method: "GET",
|
||||
url: `/api/v1/private-assets/projects/${projectId}/images/${test.projects.getProject(test.ownerId, projectId).currentImageId}`,
|
||||
});
|
||||
|
||||
expect(missing.statusCode).toBe(404);
|
||||
expect(detail.json().latest_exports).toEqual([]);
|
||||
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports").get()).toEqual(before);
|
||||
expect(original.statusCode).toBe(200);
|
||||
expect(original.rawPayload).toEqual(png);
|
||||
expect(denied.statusCode).toBe(404);
|
||||
writeEvidence("TDD-WP2-EXP-002-empty-latest", "response.json", { detail_latest_exports: detail.json().latest_exports, intruder_status: denied.statusCode, jpg_status: missing.statusCode, original_status: original.statusCode });
|
||||
writeEvidence("TDD-WP2-EXP-002-empty-latest", "db-diff.json", { after: before, before, created_by_read: 0 });
|
||||
await test.app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
const projectId = "00000000-0000-4000-8000-000000000701";
|
||||
const imageId = "00000000-0000-4000-8000-000000000702";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
test("TDD-WP2-EXP-002 shows the no-export state and only its two allowed actions", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify({ csrf_token: "csrf-latest-export-fixture-000000000000000000000000000000", user: { creator_name: "Export User" } }), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
canvas_state: { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: imageId }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1 },
|
||||
created_at: "2026-08-03T08:00:00.000Z", current_image_id: imageId, deleted_at: null, draft_prompt: "导出空状态",
|
||||
generations: [], images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000703", image_id: imageId }],
|
||||
latest_exports: [], name: "导出空状态", pixel_height: 1440, pixel_width: 1080, project_id: projectId, purge_at: null,
|
||||
ratio: "3:4", save_status: "saved", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z",
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||
const region = page.getByRole("region", { name: "最新成品" });
|
||||
await expect(region.getByText("暂无导出成品", { exact: true })).toBeVisible();
|
||||
await expect(region.getByRole("link", { name: "进入编辑并导出" })).toHaveAttribute("href", `/app/projects/${projectId}/editor`);
|
||||
await expect(region.getByRole("link", { name: "下载原始生成图" })).toHaveAttribute("href", `/api/v1/private-assets/projects/${projectId}/images/${imageId}`);
|
||||
await expect(region.getByRole("link")).toHaveCount(2);
|
||||
expect(await region.getByRole("button").count()).toBe(0);
|
||||
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (evidenceRoot) {
|
||||
const directory = resolve(evidenceRoot, "TDD-WP2-EXP-002-empty-latest", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "no-export.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP2-EXP-001 exposes the frozen browser result matrix without conflating download and persistence", async ({ page }) => {
|
||||
await page.goto(webUrl);
|
||||
const matrix = await page.evaluate(async () => {
|
||||
const { exportResultCopy, runExportFlow } = await import("/src/export-flow.ts");
|
||||
const run = async (downloadFails: boolean, persistFails: boolean) => runExportFlow({
|
||||
compose: async () => new Blob(["browser-export"], { type: "image/png" }),
|
||||
download: async () => { if (downloadFails) throw new Error("download_failed"); },
|
||||
persist: async () => { if (persistFails) throw new Error("persist_failed"); },
|
||||
});
|
||||
const results = [await run(false, false), await run(false, true), await run(true, false)];
|
||||
return results.map((result) => ({ copy: exportResultCopy[result.status], status: result.status }));
|
||||
});
|
||||
expect(matrix).toEqual([
|
||||
{ copy: "已下载并保存为最新成品", status: "downloaded_and_saved" },
|
||||
{ copy: "文件已下载,但未保存为最新成品", status: "downloaded_not_saved" },
|
||||
{ copy: "浏览器下载失败,可重新下载同一次合成结果", status: "download_failed" },
|
||||
]);
|
||||
await page.evaluate((items) => {
|
||||
document.body.innerHTML = `<main style="font-family:system-ui;padding:32px"><h1>导出结果矩阵</h1>${items.map((item) => `<p data-status="${item.status}" style="padding:16px;border:1px solid #111">${item.copy}</p>`).join("")}</main>`;
|
||||
}, matrix);
|
||||
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (evidenceRoot) {
|
||||
const directory = resolve(evidenceRoot, "TDD-WP2-EXP-001-result-matrix", "screenshots");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
await page.screenshot({ fullPage: true, path: resolve(directory, "result.png") });
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP2-EXP-001 lists only the newest saved artifact for each format", async ({ page }) => {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify({ csrf_token: "csrf-latest-list-fixture-00000000000000000000000000000000", user: { creator_name: "Export User" } }), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
canvas_state: { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: imageId }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1 },
|
||||
created_at: "2026-08-03T08:00:00.000Z", current_image_id: imageId, deleted_at: null, draft_prompt: "最新成品",
|
||||
generations: [], images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000703", image_id: imageId }],
|
||||
latest_exports: ["jpg", "png"].map((format, index) => ({
|
||||
byte_size: 100 + index, created_at: "2026-08-03T08:00:00.000Z",
|
||||
download_url: `/api/v1/projects/${projectId}/latest-exports/${format}`,
|
||||
export_id: `00000000-0000-4000-8000-00000000071${index}`, format, pixel_height: 1440, pixel_width: 1080,
|
||||
sha256: String(index).repeat(64), state_version: 2,
|
||||
})),
|
||||
name: "最新成品", pixel_height: 1440, pixel_width: 1080, project_id: projectId, purge_at: null,
|
||||
ratio: "3:4", save_status: "saved", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z",
|
||||
}), contentType: "application/json", status: 200,
|
||||
}));
|
||||
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||
const region = page.getByRole("region", { name: "最新成品" });
|
||||
await expect(region.getByRole("link", { name: "重新下载" })).toHaveCount(2);
|
||||
await expect(region.getByText("JPG", { exact: true })).toBeVisible();
|
||||
await expect(region.getByText("PNG", { exact: true })).toBeVisible();
|
||||
await expect(region.getByText("暂无导出成品", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { exportResultCopy, runExportFlow } from "../../apps/web/src/export-flow.js";
|
||||
|
||||
function writeEvidence(file: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, "TDD-WP2-EXP-001-result-matrix");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
describe("TDD-WP2-EXP-001-result-matrix", () => {
|
||||
it("uses the exact same Blob for download and latest persistence", async () => {
|
||||
const blob = new Blob(["same-export"], { type: "image/png" });
|
||||
const download = vi.fn(async (_value: Blob) => undefined);
|
||||
const persist = vi.fn(async (_value: Blob) => undefined);
|
||||
const result = await runExportFlow({ compose: async () => blob, download, persist });
|
||||
|
||||
expect(result.status).toBe("downloaded_and_saved");
|
||||
expect(download).toHaveBeenCalledWith(blob);
|
||||
expect(persist).toHaveBeenCalledWith(blob);
|
||||
expect(download.mock.calls[0][0]).toBe(persist.mock.calls[0][0]);
|
||||
expect(exportResultCopy[result.status]).toBe("已下载并保存为最新成品");
|
||||
});
|
||||
|
||||
it("preserves the composed Blob for retry when browser download fails", async () => {
|
||||
const blob = new Blob(["retry-export"], { type: "image/jpeg" });
|
||||
const persist = vi.fn();
|
||||
const result = await runExportFlow({
|
||||
compose: async () => blob,
|
||||
download: async () => { throw new Error("download_failed"); },
|
||||
persist,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("download_failed");
|
||||
expect(result.blob).toBe(blob);
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
expect(exportResultCopy[result.status]).toBe("浏览器下载失败,可重新下载同一次合成结果");
|
||||
});
|
||||
|
||||
it("does not turn a successful local download into a false double success", async () => {
|
||||
const result = await runExportFlow({
|
||||
compose: async () => new Blob(["local-only"], { type: "image/png" }),
|
||||
download: async () => undefined,
|
||||
persist: async () => { throw new Error("latest_failed"); },
|
||||
});
|
||||
|
||||
expect(result.status).toBe("downloaded_not_saved");
|
||||
expect(exportResultCopy[result.status]).toBe("文件已下载,但未保存为最新成品");
|
||||
});
|
||||
|
||||
it("does not download or persist after composition failure", async () => {
|
||||
const download = vi.fn();
|
||||
const persist = vi.fn();
|
||||
const result = await runExportFlow({
|
||||
compose: async () => { throw new Error("composition_failed"); },
|
||||
download,
|
||||
persist,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ blob: null, status: "composition_failed" });
|
||||
expect(download).not.toHaveBeenCalled();
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
expect(exportResultCopy[result.status]).toBe("合成失败,请返回编辑器检查");
|
||||
writeEvidence("network-timeline.json", {
|
||||
composition_failed: ["compose:failed", "download:not_started", "persist:not_started"],
|
||||
download_failed: ["compose:succeeded", "download:failed", "persist:not_started"],
|
||||
downloaded_and_saved: ["compose:succeeded", "download:succeeded", "persist:succeeded"],
|
||||
downloaded_not_saved: ["compose:succeeded", "download:succeeded", "persist:failed"],
|
||||
});
|
||||
writeEvidence("response.json", { copy: exportResultCopy, statuses: Object.keys(exportResultCopy) });
|
||||
writeEvidence("db-diff.json", { browser_persistent_blob_writes: 0, latest_updates_after_backend_failure: 0 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user