diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.module.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.module.ts new file mode 100644 index 0000000..1881a1b --- /dev/null +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AppDataRetirementService } from './app-data-retirement.service'; + +@Module({ + providers: [AppDataRetirementService], + exports: [AppDataRetirementService], +}) +export class AppDataRetirementModule {} diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts new file mode 100644 index 0000000..328a88d --- /dev/null +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts @@ -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', + }); + } + }); +}); diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts new file mode 100644 index 0000000..02e19be --- /dev/null +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts @@ -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; + +@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, + }); + } +} diff --git a/apps/server/src/modules/data/data.module.ts b/apps/server/src/modules/data/data.module.ts index 897227d..90d099c 100644 --- a/apps/server/src/modules/data/data.module.ts +++ b/apps/server/src/modules/data/data.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AppDataRetirementModule } from '../app-data-retirement/app-data-retirement.module'; import { MigrationModule } from '../migration/migration.module'; import { DataController } from './data.controller'; import { DataService } from './data.service'; @Module({ - imports: [MigrationModule], + imports: [MigrationModule, AppDataRetirementModule], controllers: [DataController], providers: [DataService], }) diff --git a/apps/server/src/modules/data/data.service.spec.ts b/apps/server/src/modules/data/data.service.spec.ts index ac65290..1a667a0 100644 --- a/apps/server/src/modules/data/data.service.spec.ts +++ b/apps/server/src/modules/data/data.service.spec.ts @@ -14,10 +14,14 @@ describe('DataService', () => { const syncService = { syncAfterAppDataPut: jest.fn(), }; + const retirementService = { + assertWritable: jest.fn(), + }; return { prisma, 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 () => { - const { prisma, service, syncService } = makeService(); + const { prisma, service, syncService, retirementService } = makeService(); const value = [{ id: 'p1', name: 'Product 1' }]; const updatedAt = new Date('2026-07-02T08:01:00.000Z'); prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt }); @@ -70,9 +74,34 @@ describe('DataService', () => { update: { value }, create: { key: 'products-overview', value }, }); + expect(retirementService.assertWritable).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 () => { const { prisma, service } = makeService(); const value: unknown[] = []; diff --git a/apps/server/src/modules/data/data.service.ts b/apps/server/src/modules/data/data.service.ts index 9b67cbd..48830db 100644 --- a/apps/server/src/modules/data/data.service.ts +++ b/apps/server/src/modules/data/data.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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 { isAppDataKey } from './data-keys'; +import { type AppDataKey, isAppDataKey } from './data-keys'; type AppDataRow = { key: string; @@ -17,6 +18,7 @@ export class DataService { constructor( private prisma: PrismaService, private readonly appDataSync?: AppDataV23SyncService, + private readonly appDataRetirement = new AppDataRetirementService(), ) {} async get(key: string) { @@ -27,6 +29,7 @@ export class DataService { async put(key: string, value: unknown, version?: string | null) { this.ensureAllowedKey(key); + this.appDataRetirement.assertWritable(key); const jsonValue = value as Prisma.InputJsonValue; if (version === null) { @@ -66,7 +69,7 @@ export class DataService { return this.toResponseAfterSync(key, row); } - private ensureAllowedKey(key: string) { + private ensureAllowedKey(key: string): asserts key is AppDataKey { if (!isAppDataKey(key)) { throw new BadRequestException(`Unsupported data key: ${key}`); } diff --git a/apps/web/lib/server-data.test.ts b/apps/web/lib/server-data.test.ts index f23ab49..3778315 100644 --- a/apps/web/lib/server-data.test.ts +++ b/apps/web/lib/server-data.test.ts @@ -6,6 +6,7 @@ import { loadServerData, saveServerData, ServerDataConflictError, + ServerDataWriteFrozenError, SERVER_DATA_SAVE_ERROR_EVENT, type ServerDataSaveErrorDetail, } 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> = []; + __resetApiAvailabilityForTests(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + dispatchEvent: (event: Event) => { + events.push(event as CustomEvent); + 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'); + } + } +}); diff --git a/apps/web/lib/server-data.ts b/apps/web/lib/server-data.ts index 80b7cdf..0d87d84 100644 --- a/apps/web/lib/server-data.ts +++ b/apps/web/lib/server-data.ts @@ -29,6 +29,14 @@ type ServerDataConflictBody = { 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(); const serverDataCache = new Map 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) { if (error instanceof ServerDataConflictError) return '数据已被其他用户更新,请刷新后重试'; + if (error instanceof ServerDataWriteFrozenError) return error.message; if (error instanceof Error && error.message) return error.message; return '保存失败,请检查网络或 API 服务'; } @@ -153,6 +174,20 @@ async function putServerData(key: ServerDataKey, value: T): Promise { notifyServerDataSaveFailure(key, 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); throw error; } @@ -173,3 +208,18 @@ function isServerDataConflictBody( : 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' + ); +}