feat(appdata): 冻结兼容写入入口

This commit is contained in:
2026-07-08 16:36:16 +08:00
parent 18380edda8
commit 36202028d2
8 changed files with 294 additions and 5 deletions

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AppDataRetirementService } from './app-data-retirement.service';
@Module({
providers: [AppDataRetirementService],
exports: [AppDataRetirementService],
})
export class AppDataRetirementModule {}

View File

@@ -0,0 +1,41 @@
import { ConflictException } from '@nestjs/common';
import { APP_DATA_KEYS } from '../data/data-keys';
import {
APP_DATA_RETIREMENT_CONFIG,
AppDataRetirementService,
} from './app-data-retirement.service';
describe('AppDataRetirementService', () => {
const service = new AppDataRetirementService();
it('declares a retirement state for every allowed AppData key', () => {
expect(Object.keys(APP_DATA_RETIREMENT_CONFIG).sort()).toEqual([...APP_DATA_KEYS].sort());
});
it('rejects writes to frozen business documents with replacement guidance', () => {
expect(() => service.assertWritable('dev-tasks')).toThrow(ConflictException);
try {
service.assertWritable('dev-tasks');
} catch (error: any) {
expect(error.getResponse()).toMatchObject({
code: 'APP_DATA_WRITE_FROZEN',
key: 'dev-tasks',
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/dev-tasks',
});
}
});
it('treats archived documents as read-only', () => {
try {
service.assertWritable('xiaobao-risk-snapshots');
} catch (error: any) {
expect(error.getResponse()).toMatchObject({
code: 'APP_DATA_WRITE_FROZEN',
key: 'xiaobao-risk-snapshots',
state: 'read_only_archive',
});
}
});
});

View File

@@ -0,0 +1,95 @@
import { ConflictException, Injectable } from '@nestjs/common';
import type { AppDataKey } from '../data/data-keys';
export type AppDataRetirementState = 'active' | 'write_frozen' | 'read_only_archive';
export interface AppDataRetirementEntry {
state: AppDataRetirementState;
replacement: string;
note?: string;
}
export const APP_DATA_RETIREMENT_CONFIG = {
'products-overview': {
state: 'write_frozen',
replacement: '/api/v1/products, /api/v1/products/:productId/projects, /api/v1/products/:productId/versions',
},
requirements: {
state: 'write_frozen',
replacement: '/api/v1/products/:productId/requirements',
},
'version-plans': {
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/plans',
},
'dev-tasks': {
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/dev-tasks',
},
'test-cases': {
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/test-cases',
},
bugs: {
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/bugs',
},
members: {
state: 'write_frozen',
replacement: '/api/v1/members',
note: '成员身份已迁移;部门、角色和密码策略仍需 V2.7 配置表承接。',
},
'task-categories': {
state: 'write_frozen',
replacement: '/api/v1/task-categories',
},
'task-worklogs': {
state: 'write_frozen',
replacement: '/api/v1/task-worklogs',
},
'work-activities': {
state: 'write_frozen',
replacement: '/api/v1/work-activities',
},
'xiaobao-risk-insights': {
state: 'read_only_archive',
replacement: 'V2.6 Xiaobao relation writer backed by xiaobao_risk_insights',
note: '风险解读缓存不再扩大 AppData 主写路径,后台化由 V2.6 承接。',
},
'xiaobao-risk-snapshots': {
state: 'read_only_archive',
replacement: 'V2.6 Xiaobao relation writer backed by xiaobao_risk_snapshots',
note: '风险快照不再扩大 AppData 主写路径,后台化由 V2.6 承接。',
},
'xiaobao-warning-views': {
state: 'read_only_archive',
replacement: 'V2.7 per-user warning read-state API',
note: '个人已读状态等待企业协作/通知治理阶段承接。',
},
overtime: {
state: 'write_frozen',
replacement: '/api/v1/overtime',
note: '加班记录已迁移;加班原因配置仍需 V2.7 配置表承接。',
},
} satisfies Record<AppDataKey, AppDataRetirementEntry>;
@Injectable()
export class AppDataRetirementService {
getEntry(key: AppDataKey): AppDataRetirementEntry {
return APP_DATA_RETIREMENT_CONFIG[key];
}
assertWritable(key: AppDataKey) {
const entry = this.getEntry(key);
if (entry.state === 'active') return;
throw new ConflictException({
code: 'APP_DATA_WRITE_FROZEN',
message: `AppData key "${key}" is ${entry.state}; use ${entry.replacement} instead.`,
key,
state: entry.state,
replacement: entry.replacement,
note: entry.note,
});
}
}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AppDataRetirementModule } from '../app-data-retirement/app-data-retirement.module';
import { MigrationModule } from '../migration/migration.module'; import { MigrationModule } from '../migration/migration.module';
import { DataController } from './data.controller'; import { DataController } from './data.controller';
import { DataService } from './data.service'; import { DataService } from './data.service';
@Module({ @Module({
imports: [MigrationModule], imports: [MigrationModule, AppDataRetirementModule],
controllers: [DataController], controllers: [DataController],
providers: [DataService], providers: [DataService],
}) })

View File

@@ -14,10 +14,14 @@ describe('DataService', () => {
const syncService = { const syncService = {
syncAfterAppDataPut: jest.fn(), syncAfterAppDataPut: jest.fn(),
}; };
const retirementService = {
assertWritable: jest.fn(),
};
return { return {
prisma, prisma,
syncService, syncService,
service: new DataService(prisma as any, syncService as any), retirementService,
service: new (DataService as any)(prisma, syncService, retirementService) as DataService,
}; };
}; };
@@ -55,7 +59,7 @@ describe('DataService', () => {
}); });
it('upserts JSON values for allowed keys', async () => { it('upserts JSON values for allowed keys', async () => {
const { prisma, service, syncService } = makeService(); const { prisma, service, syncService, retirementService } = makeService();
const value = [{ id: 'p1', name: 'Product 1' }]; const value = [{ id: 'p1', name: 'Product 1' }];
const updatedAt = new Date('2026-07-02T08:01:00.000Z'); const updatedAt = new Date('2026-07-02T08:01:00.000Z');
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt }); prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
@@ -70,9 +74,34 @@ describe('DataService', () => {
update: { value }, update: { value },
create: { key: 'products-overview', value }, create: { key: 'products-overview', value },
}); });
expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview');
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview'); expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
}); });
it('rejects frozen AppData writes before touching storage or relation sync', async () => {
const { prisma, service, syncService, retirementService } = makeService();
retirementService.assertWritable.mockImplementation(() => {
throw new ConflictException({
code: 'APP_DATA_WRITE_FROZEN',
key: 'dev-tasks',
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/dev-tasks',
});
});
await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({
response: expect.objectContaining({
code: 'APP_DATA_WRITE_FROZEN',
key: 'dev-tasks',
state: 'write_frozen',
}),
});
expect(prisma.appData.create).not.toHaveBeenCalled();
expect(prisma.appData.updateMany).not.toHaveBeenCalled();
expect(prisma.appData.upsert).not.toHaveBeenCalled();
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
});
it('allows supporting business data keys migrated from browser storage', async () => { it('allows supporting business data keys migrated from browser storage', async () => {
const { prisma, service } = makeService(); const { prisma, service } = makeService();
const value: unknown[] = []; const value: unknown[] = [];

View File

@@ -1,8 +1,9 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
import { AppDataV23SyncService } from '../migration/app-data-v23-sync.service'; import { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
import { isAppDataKey } from './data-keys'; import { type AppDataKey, isAppDataKey } from './data-keys';
type AppDataRow = { type AppDataRow = {
key: string; key: string;
@@ -17,6 +18,7 @@ export class DataService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private readonly appDataSync?: AppDataV23SyncService, private readonly appDataSync?: AppDataV23SyncService,
private readonly appDataRetirement = new AppDataRetirementService(),
) {} ) {}
async get(key: string) { async get(key: string) {
@@ -27,6 +29,7 @@ export class DataService {
async put(key: string, value: unknown, version?: string | null) { async put(key: string, value: unknown, version?: string | null) {
this.ensureAllowedKey(key); this.ensureAllowedKey(key);
this.appDataRetirement.assertWritable(key);
const jsonValue = value as Prisma.InputJsonValue; const jsonValue = value as Prisma.InputJsonValue;
if (version === null) { if (version === null) {
@@ -66,7 +69,7 @@ export class DataService {
return this.toResponseAfterSync(key, row); return this.toResponseAfterSync(key, row);
} }
private ensureAllowedKey(key: string) { private ensureAllowedKey(key: string): asserts key is AppDataKey {
if (!isAppDataKey(key)) { if (!isAppDataKey(key)) {
throw new BadRequestException(`Unsupported data key: ${key}`); throw new BadRequestException(`Unsupported data key: ${key}`);
} }

View File

@@ -6,6 +6,7 @@ import {
loadServerData, loadServerData,
saveServerData, saveServerData,
ServerDataConflictError, ServerDataConflictError,
ServerDataWriteFrozenError,
SERVER_DATA_SAVE_ERROR_EVENT, SERVER_DATA_SAVE_ERROR_EVENT,
type ServerDataSaveErrorDetail, type ServerDataSaveErrorDetail,
} from './server-data'; } from './server-data';
@@ -312,3 +313,64 @@ test('server data save failures dispatch a browser-visible failure event', async
} }
} }
}); });
test('server data frozen writes surface the replacement domain API path', async () => {
const originalFetch = globalThis.fetch;
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const events: Array<CustomEvent<ServerDataSaveErrorDetail>> = [];
__resetApiAvailabilityForTests();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
dispatchEvent: (event: Event) => {
events.push(event as CustomEvent<ServerDataSaveErrorDetail>);
return true;
},
},
});
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/config/ai')) {
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.endsWith('/data/dev-tasks') && init?.method === 'PUT') {
return new Response(JSON.stringify({
code: 'APP_DATA_WRITE_FROZEN',
key: 'dev-tasks',
state: 'write_frozen',
replacement: '/api/v1/versions/:versionId/dev-tasks',
}), {
status: 409,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`);
}) as typeof fetch;
try {
await assert.rejects(
() => saveServerData('dev-tasks', []),
(error: unknown) => error instanceof ServerDataWriteFrozenError &&
error.key === 'dev-tasks' &&
error.replacement === '/api/v1/versions/:versionId/dev-tasks',
);
assert.equal(events.length, 1);
assert.deepEqual(events[0].detail, {
key: 'dev-tasks',
message: 'AppData 写入已冻结,请改用 /api/v1/versions/:versionId/dev-tasks',
});
} finally {
globalThis.fetch = originalFetch;
__resetApiAvailabilityForTests();
if (originalWindowDescriptor) {
Object.defineProperty(globalThis, 'window', originalWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});

View File

@@ -29,6 +29,14 @@ type ServerDataConflictBody<T> = {
currentVersion: string | null; currentVersion: string | null;
}; };
type ServerDataWriteFrozenBody = {
code: 'APP_DATA_WRITE_FROZEN';
key: ServerDataKey;
state: 'write_frozen' | 'read_only_archive';
replacement: string;
note?: string;
};
const serverDataVersions = new Map<ServerDataKey, string | null>(); const serverDataVersions = new Map<ServerDataKey, string | null>();
const serverDataCache = new Map<ServerDataKey, { const serverDataCache = new Map<ServerDataKey, {
value: unknown | null; value: unknown | null;
@@ -61,8 +69,21 @@ export class ServerDataConflictError<T = unknown> extends Error {
} }
} }
export class ServerDataWriteFrozenError extends Error {
constructor(
public readonly key: ServerDataKey,
public readonly state: 'write_frozen' | 'read_only_archive',
public readonly replacement: string,
public readonly note?: string,
) {
super(`AppData 写入已冻结,请改用 ${replacement}`);
this.name = 'ServerDataWriteFrozenError';
}
}
function getServerDataSaveErrorMessage(error: unknown) { function getServerDataSaveErrorMessage(error: unknown) {
if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试'; if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试';
if (error instanceof ServerDataWriteFrozenError) return error.message;
if (error instanceof Error && error.message) return error.message; if (error instanceof Error && error.message) return error.message;
return '保存失败,请检查网络或 API 服务'; return '保存失败,请检查网络或 API 服务';
} }
@@ -153,6 +174,20 @@ async function putServerData<T>(key: ServerDataKey, value: T): Promise<void> {
notifyServerDataSaveFailure(key, conflict); notifyServerDataSaveFailure(key, conflict);
throw conflict; throw conflict;
} }
if (
error instanceof ApiRequestError &&
error.status === 409 &&
isServerDataWriteFrozenBody(error.body, key)
) {
const frozen = new ServerDataWriteFrozenError(
error.body.key,
error.body.state,
error.body.replacement,
error.body.note,
);
notifyServerDataSaveFailure(key, frozen);
throw frozen;
}
notifyServerDataSaveFailure(key, error); notifyServerDataSaveFailure(key, error);
throw error; throw error;
} }
@@ -173,3 +208,18 @@ function isServerDataConflictBody<T>(
: true) : true)
); );
} }
function isServerDataWriteFrozenBody(
body: unknown,
key: ServerDataKey,
): body is ServerDataWriteFrozenBody {
return (
typeof body === 'object' &&
body !== null &&
(body as { code?: unknown }).code === 'APP_DATA_WRITE_FROZEN' &&
(body as { key?: unknown }).key === key &&
((body as { state?: unknown }).state === 'write_frozen' ||
(body as { state?: unknown }).state === 'read_only_archive') &&
typeof (body as { replacement?: unknown }).replacement === 'string'
);
}