fix(data): 清理旧数据并保护服务端写入
This commit is contained in:
@@ -11,7 +11,11 @@ export class DataController {
|
||||
}
|
||||
|
||||
@Put(':key')
|
||||
put(@Param('key') key: string, @Body('value') value: unknown) {
|
||||
return this.dataService.put(key, value);
|
||||
put(
|
||||
@Param('key') key: string,
|
||||
@Body('value') value: unknown,
|
||||
@Body('version') version?: string | null,
|
||||
) {
|
||||
return this.dataService.put(key, value, version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { DataService } from './data.service';
|
||||
|
||||
describe('DataService', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
appData: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
};
|
||||
@@ -22,6 +24,23 @@ describe('DataService', () => {
|
||||
await expect(service.get('products-overview')).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
value: null,
|
||||
version: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the AppData version for a stored value', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const updatedAt = new Date('2026-07-02T08:00:00.000Z');
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'p1' }],
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
await expect(service.get('products-overview')).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'p1' }],
|
||||
version: updatedAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,11 +53,13 @@ describe('DataService', () => {
|
||||
it('upserts JSON values for allowed keys', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const value = [{ id: 'p1', name: 'Product 1' }];
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value });
|
||||
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||
|
||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
value,
|
||||
version: updatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
||||
where: { key: 'products-overview' },
|
||||
@@ -51,20 +72,94 @@ describe('DataService', () => {
|
||||
const { prisma, service } = makeService();
|
||||
const value: unknown[] = [];
|
||||
prisma.appData.upsert.mockImplementation(({ where }) =>
|
||||
Promise.resolve({ key: where.key, value }),
|
||||
Promise.resolve({ key: where.key, value, updatedAt: new Date('2026-07-02T08:02:00.000Z') }),
|
||||
);
|
||||
|
||||
await expect(service.put('task-worklogs', value)).resolves.toEqual({
|
||||
key: 'task-worklogs',
|
||||
value,
|
||||
});
|
||||
await expect(service.put('work-activities', value)).resolves.toEqual({
|
||||
key: 'work-activities',
|
||||
value,
|
||||
});
|
||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({
|
||||
await expect(service.put('task-worklogs', value)).resolves.toMatchObject({ key: 'task-worklogs', value });
|
||||
await expect(service.put('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value });
|
||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({
|
||||
key: 'overtime',
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates only when the supplied version matches the stored row version', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const previousVersion = '2026-07-02T08:03:00.000Z';
|
||||
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
|
||||
const nextValue = [{ id: 'p2', name: 'Product 2' }];
|
||||
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: nextValue,
|
||||
updatedAt: nextUpdatedAt,
|
||||
});
|
||||
|
||||
await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
value: nextValue,
|
||||
version: nextUpdatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
key: 'products-overview',
|
||||
updatedAt: new Date(previousVersion),
|
||||
},
|
||||
data: { value: nextValue },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale AppData versions without overwriting the current value', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'current' }],
|
||||
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a missing row only when the client loaded a null version', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const value = [{ id: 'p1' }];
|
||||
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
|
||||
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||
|
||||
await expect(service.put('products-overview', value, null)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
value,
|
||||
version: updatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.appData.create).toHaveBeenCalledWith({
|
||||
data: { key: 'products-overview', value },
|
||||
});
|
||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects create-only writes when another client created the row first', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'current' }],
|
||||
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid AppData versions', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.put('products-overview', [], 'not-a-date')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { isAppDataKey } from './data-keys';
|
||||
|
||||
type AppDataRow = {
|
||||
key: string;
|
||||
value: Prisma.JsonValue;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DataService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -10,18 +16,48 @@ export class DataService {
|
||||
async get(key: string) {
|
||||
this.ensureAllowedKey(key);
|
||||
const row = await this.prisma.appData.findUnique({ where: { key } });
|
||||
return { key, value: row?.value ?? null };
|
||||
return this.toResponse(key, row);
|
||||
}
|
||||
|
||||
async put(key: string, value: unknown) {
|
||||
async put(key: string, value: unknown, version?: string | null) {
|
||||
this.ensureAllowedKey(key);
|
||||
const jsonValue = value as Prisma.InputJsonValue;
|
||||
|
||||
if (version === null) {
|
||||
try {
|
||||
const row = await this.prisma.appData.create({
|
||||
data: { key, value: jsonValue },
|
||||
});
|
||||
return this.toResponse(key, row);
|
||||
} catch (error) {
|
||||
if (this.isUniqueConflict(error)) {
|
||||
await this.throwConflict(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (version !== undefined) {
|
||||
const updatedAt = this.parseVersion(version);
|
||||
const result = await this.prisma.appData.updateMany({
|
||||
where: { key, updatedAt },
|
||||
data: { value: jsonValue },
|
||||
});
|
||||
|
||||
if (result.count !== 1) {
|
||||
await this.throwConflict(key);
|
||||
}
|
||||
|
||||
const row = await this.prisma.appData.findUnique({ where: { key } });
|
||||
return this.toResponse(key, row);
|
||||
}
|
||||
|
||||
const row = await this.prisma.appData.upsert({
|
||||
where: { key },
|
||||
update: { value: jsonValue },
|
||||
create: { key, value: jsonValue },
|
||||
});
|
||||
return { key: row.key, value: row.value };
|
||||
return this.toResponse(key, row);
|
||||
}
|
||||
|
||||
private ensureAllowedKey(key: string) {
|
||||
@@ -29,4 +65,41 @@ export class DataService {
|
||||
throw new BadRequestException(`Unsupported data key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseVersion(version: string) {
|
||||
const updatedAt = new Date(version);
|
||||
if (!Number.isFinite(updatedAt.getTime())) {
|
||||
throw new BadRequestException('Invalid AppData version');
|
||||
}
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
private toResponse(key: string, row: AppDataRow | null) {
|
||||
return {
|
||||
key,
|
||||
value: row?.value ?? null,
|
||||
version: row?.updatedAt.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async throwConflict(key: string): Promise<never> {
|
||||
const row = await this.prisma.appData.findUnique({ where: { key } });
|
||||
const current = this.toResponse(key, row);
|
||||
throw new ConflictException({
|
||||
code: 'APP_DATA_CONFLICT',
|
||||
message: 'AppData was changed by another client. Reload before saving again.',
|
||||
key,
|
||||
currentValue: current.value,
|
||||
currentVersion: current.version,
|
||||
});
|
||||
}
|
||||
|
||||
private isUniqueConflict(error: unknown) {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user