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 { AttachmentModule } from "./attachment/attachment.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
|
import { SyncModule } from "./sync/sync.module";
|
||||||
import { TaskModule } from "./task/task.module";
|
import { TaskModule } from "./task/task.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -14,7 +15,8 @@ import { TaskModule } from "./task/task.module";
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
TaskModule,
|
TaskModule,
|
||||||
AttachmentModule
|
AttachmentModule,
|
||||||
|
SyncModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import request from "supertest";
|
||||||
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { PrismaService } from "../src/prisma/prisma.service";
|
||||||
|
import { SyncController } from "../src/sync/sync.controller";
|
||||||
|
import { SyncService } from "../src/sync/sync.service";
|
||||||
|
|
||||||
|
type SyncOperationRecord = {
|
||||||
|
id: string;
|
||||||
|
opId: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
payload?: string;
|
||||||
|
clientTs: Date;
|
||||||
|
serverTs: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
class InMemoryPrismaService {
|
||||||
|
private syncOperationIdSequence = 1;
|
||||||
|
private syncOperations: SyncOperationRecord[] = [];
|
||||||
|
|
||||||
|
readonly syncOperation = {
|
||||||
|
findMany: async (args: {
|
||||||
|
where: {
|
||||||
|
userId: string;
|
||||||
|
opId: {
|
||||||
|
in: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
opId: true;
|
||||||
|
serverTs: true;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
return this.syncOperations
|
||||||
|
.filter(
|
||||||
|
(item) => item.userId === args.where.userId && args.where.opId.in.includes(item.opId)
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
opId: item.opId,
|
||||||
|
serverTs: item.serverTs
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (args: {
|
||||||
|
data: {
|
||||||
|
opId: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
payload?: string;
|
||||||
|
clientTs: Date;
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
opId: true;
|
||||||
|
serverTs: true;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const createdOperation: SyncOperationRecord = {
|
||||||
|
id: `sync_${this.syncOperationIdSequence++}`,
|
||||||
|
opId: args.data.opId,
|
||||||
|
userId: args.data.userId,
|
||||||
|
deviceId: args.data.deviceId,
|
||||||
|
entityType: args.data.entityType,
|
||||||
|
entityId: args.data.entityId,
|
||||||
|
action: args.data.action,
|
||||||
|
payload: args.data.payload,
|
||||||
|
clientTs: args.data.clientTs,
|
||||||
|
serverTs: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.syncOperations.push(createdOperation);
|
||||||
|
|
||||||
|
return {
|
||||||
|
opId: createdOperation.opId,
|
||||||
|
serverTs: createdOperation.serverTs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getOperationCount(): number {
|
||||||
|
return this.syncOperations.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SyncController (integration)", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prismaService: InMemoryPrismaService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prismaService = new InMemoryPrismaService();
|
||||||
|
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [SyncController],
|
||||||
|
providers: [SyncService, { provide: PrismaService, useValue: prismaService }]
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept operations once and mark repeated push as duplicate", async () => {
|
||||||
|
const payload = {
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
opId: "op-create-1",
|
||||||
|
entityType: "TASK",
|
||||||
|
entityId: "task-1",
|
||||||
|
action: "CREATE",
|
||||||
|
payload: '{"title":"任务一"}',
|
||||||
|
clientTs: 1712419200000,
|
||||||
|
deviceId: "device-a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opId: "op-update-1",
|
||||||
|
entityType: "TASK",
|
||||||
|
entityId: "task-1",
|
||||||
|
action: "UPDATE",
|
||||||
|
payload: '{"title":"任务一-更新"}',
|
||||||
|
clientTs: 1712419201000,
|
||||||
|
deviceId: "device-a"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstResponse = await request(app.getHttpServer())
|
||||||
|
.post("/sync/push")
|
||||||
|
.set("x-user-id", "user-1")
|
||||||
|
.send(payload)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(firstResponse.body.acceptedCount).toBe(2);
|
||||||
|
expect(firstResponse.body.duplicateCount).toBe(0);
|
||||||
|
expect(firstResponse.body.failedCount).toBe(0);
|
||||||
|
expect(firstResponse.body.results).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
opId: "op-create-1",
|
||||||
|
status: "accepted"
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
opId: "op-update-1",
|
||||||
|
status: "accepted"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(prismaService.getOperationCount()).toBe(2);
|
||||||
|
|
||||||
|
const secondResponse = await request(app.getHttpServer())
|
||||||
|
.post("/sync/push")
|
||||||
|
.set("x-user-id", "user-1")
|
||||||
|
.send(payload)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(secondResponse.body.acceptedCount).toBe(0);
|
||||||
|
expect(secondResponse.body.duplicateCount).toBe(2);
|
||||||
|
expect(secondResponse.body.failedCount).toBe(0);
|
||||||
|
expect(secondResponse.body.results).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
opId: "op-create-1",
|
||||||
|
status: "duplicate",
|
||||||
|
reason: "already_synced"
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
opId: "op-update-1",
|
||||||
|
status: "duplicate",
|
||||||
|
reason: "already_synced"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(prismaService.getOperationCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark duplicated op ids in the same batch as duplicate", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/sync/push")
|
||||||
|
.set("x-user-id", "user-2")
|
||||||
|
.send({
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
opId: "op-dup-1",
|
||||||
|
entityType: "TASK",
|
||||||
|
entityId: "task-2",
|
||||||
|
action: "CREATE",
|
||||||
|
payload: '{"title":"任务二"}',
|
||||||
|
clientTs: 1712419300000,
|
||||||
|
deviceId: "device-b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opId: "op-dup-1",
|
||||||
|
entityType: "TASK",
|
||||||
|
entityId: "task-2",
|
||||||
|
action: "UPDATE",
|
||||||
|
payload: '{"title":"任务二-重复"}',
|
||||||
|
clientTs: 1712419301000,
|
||||||
|
deviceId: "device-b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body.acceptedCount).toBe(1);
|
||||||
|
expect(response.body.duplicateCount).toBe(1);
|
||||||
|
expect(response.body.failedCount).toBe(0);
|
||||||
|
expect(response.body.results[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
opId: "op-dup-1",
|
||||||
|
status: "accepted"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(response.body.results[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
opId: "op-dup-1",
|
||||||
|
status: "duplicate",
|
||||||
|
reason: "same_batch_duplicate"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(prismaService.getOperationCount()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user