feat(api-sync): implement sync push endpoint with idempotency

This commit is contained in:
2026-04-06 00:53:36 +08:00
parent de1db459c2
commit ecf0d9ff03
6 changed files with 483 additions and 1 deletions
+3 -1
View File
@@ -3,6 +3,7 @@ import { ConfigModule } from "@nestjs/config";
import { AttachmentModule } from "./attachment/attachment.module";
import { AuthModule } from "./auth/auth.module";
import { PrismaModule } from "./prisma/prisma.module";
import { SyncModule } from "./sync/sync.module";
import { TaskModule } from "./task/task.module";
@Module({
@@ -14,7 +15,8 @@ import { TaskModule } from "./task/task.module";
PrismaModule,
AuthModule,
TaskModule,
AttachmentModule
AttachmentModule,
SyncModule
]
})
export class AppModule {}
+62
View File
@@ -0,0 +1,62 @@
import { Type } from "class-transformer";
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested
} from "class-validator";
export enum SyncEntityTypeDto {
TASK = "TASK"
}
export enum SyncActionTypeDto {
CREATE = "CREATE",
UPDATE = "UPDATE",
DELETE = "DELETE"
}
export class SyncPushOperationDto {
@IsString()
@MaxLength(64)
opId!: string;
@IsString()
@MaxLength(64)
entityId!: string;
@IsEnum(SyncEntityTypeDto)
entityType!: SyncEntityTypeDto;
@IsEnum(SyncActionTypeDto)
action!: SyncActionTypeDto;
@IsOptional()
@IsString()
@MaxLength(50000)
payload?: string;
@Type(() => Number)
@IsInt()
@Min(0)
clientTs!: number;
@IsString()
@MaxLength(128)
deviceId!: string;
}
export class SyncPushDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@ValidateNested({ each: true })
@Type(() => SyncPushOperationDto)
operations!: SyncPushOperationDto[];
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Headers, Post, UnauthorizedException } from "@nestjs/common";
import { SyncPushDto } from "./dto/sync-push.dto";
import { SyncPushResponse, SyncService } from "./sync.service";
@Controller("sync")
export class SyncController {
constructor(private readonly syncService: SyncService) {}
@Post("push")
async pushOperations(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Body() body: SyncPushDto
): Promise<SyncPushResponse> {
return this.syncService.pushOperations(this.resolveUserId(userIdHeader), body);
}
private resolveUserId(userIdHeader: string | string[] | undefined): string {
const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader;
if (!userId) {
throw new UnauthorizedException("缺少用户上下文");
}
return userId;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { SyncController } from "./sync.controller";
import { SyncService } from "./sync.service";
@Module({
imports: [PrismaModule],
controllers: [SyncController],
providers: [SyncService]
})
export class SyncModule {}
+149
View File
@@ -0,0 +1,149 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "../../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { SyncPushDto, SyncPushOperationDto } from "./dto/sync-push.dto";
export type SyncPushItemStatus = "accepted" | "duplicate" | "failed";
export type SyncPushItemResult = {
opId: string;
status: SyncPushItemStatus;
serverTs: string | null;
reason: string | null;
};
export type SyncPushResponse = {
acceptedCount: number;
duplicateCount: number;
failedCount: number;
results: SyncPushItemResult[];
};
type ExistingOperationRecord = {
opId: string;
serverTs: Date;
};
@Injectable()
export class SyncService {
constructor(private readonly prismaService: PrismaService) {}
async pushOperations(userId: string, body: SyncPushDto): Promise<SyncPushResponse> {
const existingOperations = await this.loadExistingOperations(userId, body.operations);
const results: SyncPushItemResult[] = [];
const seenOperationIds = new Set<string>();
const acceptedOperationServerTs = new Map<string, string>();
for (const operation of body.operations) {
if (seenOperationIds.has(operation.opId)) {
results.push({
opId: operation.opId,
status: "duplicate",
serverTs: acceptedOperationServerTs.get(operation.opId) ?? null,
reason: "same_batch_duplicate"
});
continue;
}
seenOperationIds.add(operation.opId);
const existingOperation = existingOperations.get(operation.opId);
if (existingOperation) {
results.push({
opId: operation.opId,
status: "duplicate",
serverTs: existingOperation.serverTs.toISOString(),
reason: "already_synced"
});
continue;
}
try {
const createdOperation = await this.prismaService.syncOperation.create({
data: {
opId: operation.opId,
userId,
deviceId: operation.deviceId,
entityType: operation.entityType,
entityId: operation.entityId,
action: operation.action,
payload: operation.payload,
clientTs: new Date(operation.clientTs)
},
select: {
opId: true,
serverTs: true
}
});
const serverTs = createdOperation.serverTs.toISOString();
acceptedOperationServerTs.set(createdOperation.opId, serverTs);
results.push({
opId: createdOperation.opId,
status: "accepted",
serverTs,
reason: null
});
} catch (error) {
if (this.isDuplicateOpIdError(error)) {
results.push({
opId: operation.opId,
status: "duplicate",
serverTs: null,
reason: "already_synced"
});
continue;
}
results.push({
opId: operation.opId,
status: "failed",
serverTs: null,
reason: "persist_failed"
});
}
}
return {
acceptedCount: results.filter((item) => item.status === "accepted").length,
duplicateCount: results.filter((item) => item.status === "duplicate").length,
failedCount: results.filter((item) => item.status === "failed").length,
results
};
}
private async loadExistingOperations(
userId: string,
operations: SyncPushOperationDto[]
): Promise<Map<string, ExistingOperationRecord>> {
const opIds = Array.from(new Set(operations.map((operation) => operation.opId)));
const existingOperations = (await this.prismaService.syncOperation.findMany({
where: {
userId,
opId: {
in: opIds
}
},
select: {
opId: true,
serverTs: true
}
})) as ExistingOperationRecord[];
return new Map(
existingOperations.map((operation): [string, ExistingOperationRecord] => [
operation.opId,
operation
])
);
}
private isDuplicateOpIdError(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
return false;
}
return error.code === "P2002";
}
}