feat(appdata): 冻结兼容写入入口
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppDataRetirementService } from './app-data-retirement.service';
|
||||
|
||||
@Module({
|
||||
providers: [AppDataRetirementService],
|
||||
exports: [AppDataRetirementService],
|
||||
})
|
||||
export class AppDataRetirementModule {}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user