feat: complete TASK-WP2-02 project autosave

This commit is contained in:
suyx
2026-08-02 18:28:07 +08:00
parent 4d38530361
commit da6fa25e60
17 changed files with 2268 additions and 35 deletions
+77
View File
@@ -15,6 +15,9 @@ import {
AdminLoginSendRequestSchema,
AdminSessionResponseSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
@@ -33,6 +36,7 @@ import {
FailedEmptyTrashResponseSchema,
GenerationProjectItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectIdSchema,
ProjectImageItemSchema,
ProjectListQuerySchema,
@@ -41,6 +45,9 @@ import {
ProjectRatioSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectStateConflictResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectSummarySchema,
ProjectViewStatusSchema,
RegistrationCompleteHeadersSchema,
@@ -66,6 +73,8 @@ import {
type ProjectListQuery,
type ProjectParams,
type ProjectRenameRequest,
type ProjectEditableState,
type ProjectStateSaveHeaders,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -196,6 +205,9 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
project_not_found: 404,
project_ratio_fixed: 409,
project_retry_not_allowed: 409,
project_state_conflict: 412,
project_state_idempotency_conflict: 409,
project_state_invalid: 400,
} as const;
return reply.code(mapping[error.code]).send(null);
}
@@ -221,6 +233,7 @@ function projectSummaryResponse(project: ProjectSummaryView) {
function projectDetailResponse(project: ProjectDetailView) {
return {
...projectSummaryResponse(project),
canvas_state: project.canvasState,
created_at: project.createdAt,
draft_prompt: project.draftPrompt,
generations: project.generations.map((generation) => ({
@@ -239,6 +252,7 @@ function projectDetailResponse(project: ProjectDetailView) {
})),
pixel_height: project.pixelHeight,
pixel_width: project.pixelWidth,
save_status: project.saveStatus,
};
}
@@ -336,6 +350,9 @@ export async function createApp(options: CreateAppOptions = {}) {
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
StateSseEventSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
@@ -350,8 +367,12 @@ export async function createApp(options: CreateAppOptions = {}) {
GenerationProjectItemSchema,
ProjectImageItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
]) {
@@ -1084,6 +1105,62 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.put(
"/api/v1/projects/:projectId/state",
{
attachValidation: true,
schema: {
body: Type.Ref(ProjectEditableStateSchema),
headers: Type.Ref(ProjectStateSaveHeadersSchema),
operationId: "saveProjectState",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectStateSaveResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Ref(ErrorEnvelopeSchema),
412: Type.Ref(ProjectStateConflictResponseSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const headers = request.headers as ProjectStateSaveHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const saved = options.projects.saveProjectState({
expectedStateVersion: Number(headers["if-match"]),
idempotencyKey: headers["idempotency-key"],
ownerId: owner.userId,
projectId: (request.params as ProjectParams).projectId,
state: request.body as ProjectEditableState,
});
return { save_status: "saved" as const, state_version: saved.stateVersion };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
if (error instanceof ProjectError && error.code === "project_state_conflict") {
return reply.code(412).send({
latest_state_version: error.latestStateVersion ?? 1,
save_status: "conflicted" as const,
});
}
if (error instanceof ProjectError && error.code === "project_state_idempotency_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId: request.id }));
}
return projectFailure(reply, request.id, error);
}
},
);
app.patch(
"/api/v1/projects/:projectId",
{