feat(api-sync): implement sync push endpoint with idempotency
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user