feat(v2.3): 完成关系表写入闭环

This commit is contained in:
Script Generator
2026-07-03 13:28:59 +08:00
parent e9ff986bac
commit bba02775dc
17 changed files with 732 additions and 18 deletions

View File

@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { ProductModule } from './modules/product/product.module'; import { ProductModule } from './modules/product/product.module';
import { RequirementModule } from './modules/requirement/requirement.module'; import { RequirementModule } from './modules/requirement/requirement.module';
@@ -11,6 +13,11 @@ import { V22QueryModule } from './modules/v22-query/v22-query.module';
@Module({ @Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, AiModule], imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, AiModule],
controllers: [], controllers: [],
providers: [], providers: [
{
provide: APP_INTERCEPTOR,
useClass: ApiTimingInterceptor,
},
],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -0,0 +1,69 @@
import { lastValueFrom, of, throwError } from 'rxjs';
import { ApiTimingInterceptor, resolveApiSlowRequestThreshold } from './api-timing.interceptor';
describe('ApiTimingInterceptor', () => {
const originalEnv = process.env.API_SLOW_REQUEST_MS;
afterEach(() => {
process.env.API_SLOW_REQUEST_MS = originalEnv;
jest.restoreAllMocks();
});
it('uses a safe default threshold when env is missing or invalid', () => {
delete process.env.API_SLOW_REQUEST_MS;
expect(resolveApiSlowRequestThreshold()).toBe(1000);
process.env.API_SLOW_REQUEST_MS = 'not-a-number';
expect(resolveApiSlowRequestThreshold()).toBe(1000);
});
it('uses a positive configured threshold', () => {
process.env.API_SLOW_REQUEST_MS = '250';
expect(resolveApiSlowRequestThreshold()).toBe(250);
});
it('logs slow HTTP requests with method and url', async () => {
process.env.API_SLOW_REQUEST_MS = '10';
const interceptor = new ApiTimingInterceptor();
const warn = jest.fn();
(interceptor as any).logger.warn = warn;
jest.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(1025);
const context = {
switchToHttp: () => ({
getRequest: () => ({
method: 'PUT',
originalUrl: '/api/v1/data/dev-tasks',
}),
}),
};
const next = { handle: () => of({ ok: true }) };
await lastValueFrom(interceptor.intercept(context as any, next as any));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('PUT /api/v1/data/dev-tasks'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('25ms'));
});
it('logs slow HTTP requests even when the handler fails', async () => {
process.env.API_SLOW_REQUEST_MS = '10';
const interceptor = new ApiTimingInterceptor();
const warn = jest.fn();
(interceptor as any).logger.warn = warn;
jest.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(1030);
const context = {
switchToHttp: () => ({
getRequest: () => ({
method: 'PATCH',
url: '/api/v1/data/bugs',
}),
}),
};
const next = { handle: () => throwError(() => new Error('boom')) };
await expect(lastValueFrom(interceptor.intercept(context as any, next as any))).rejects.toThrow('boom');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('PATCH /api/v1/data/bugs'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('30ms'));
});
});

View File

@@ -0,0 +1,31 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { finalize, Observable } from 'rxjs';
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
export function resolveApiSlowRequestThreshold(): number {
const parsed = Number(process.env.API_SLOW_REQUEST_MS);
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_API_SLOW_REQUEST_MS;
return Math.floor(parsed);
}
@Injectable()
export class ApiTimingInterceptor implements NestInterceptor {
private readonly logger = new Logger(ApiTimingInterceptor.name);
private readonly thresholdMs = resolveApiSlowRequestThreshold();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const startedAt = Date.now();
return next.handle().pipe(
finalize(() => {
const durationMs = Date.now() - startedAt;
if (durationMs < this.thresholdMs) return;
const request = context.switchToHttp().getRequest<{ method?: string; originalUrl?: string; url?: string }>();
const method = request.method ?? 'UNKNOWN';
const url = request.originalUrl ?? request.url ?? 'unknown-url';
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
}),
);
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
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],
controllers: [DataController], controllers: [DataController],
providers: [DataService], providers: [DataService],
}) })

View File

@@ -11,9 +11,13 @@ describe('DataService', () => {
upsert: jest.fn(), upsert: jest.fn(),
}, },
}; };
const syncService = {
syncAfterAppDataPut: jest.fn(),
};
return { return {
prisma, prisma,
service: new DataService(prisma as any), syncService,
service: new DataService(prisma as any, syncService as any),
}; };
}; };
@@ -51,7 +55,7 @@ describe('DataService', () => {
}); });
it('upserts JSON values for allowed keys', async () => { it('upserts JSON values for allowed keys', async () => {
const { prisma, service } = makeService(); const { prisma, service, syncService } = 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 });
@@ -66,6 +70,7 @@ describe('DataService', () => {
update: { value }, update: { value },
create: { key: 'products-overview', value }, create: { key: 'products-overview', value },
}); });
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
}); });
it('allows supporting business data keys migrated from browser storage', async () => { it('allows supporting business data keys migrated from browser storage', async () => {
@@ -84,7 +89,7 @@ describe('DataService', () => {
}); });
it('updates only when the supplied version matches the stored row version', async () => { it('updates only when the supplied version matches the stored row version', async () => {
const { prisma, service } = makeService(); const { prisma, service, syncService } = makeService();
const previousVersion = '2026-07-02T08:03:00.000Z'; const previousVersion = '2026-07-02T08:03:00.000Z';
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z'); const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
const nextValue = [{ id: 'p2', name: 'Product 2' }]; const nextValue = [{ id: 'p2', name: 'Product 2' }];
@@ -107,10 +112,11 @@ describe('DataService', () => {
}, },
data: { value: nextValue }, data: { value: nextValue },
}); });
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
}); });
it('rejects stale AppData versions without overwriting the current value', async () => { it('rejects stale AppData versions without overwriting the current value', async () => {
const { prisma, service } = makeService(); const { prisma, service, syncService } = makeService();
prisma.appData.updateMany.mockResolvedValue({ count: 0 }); prisma.appData.updateMany.mockResolvedValue({ count: 0 });
prisma.appData.findUnique.mockResolvedValue({ prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview', key: 'products-overview',
@@ -122,10 +128,11 @@ describe('DataService', () => {
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'), service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
expect(prisma.appData.upsert).not.toHaveBeenCalled(); expect(prisma.appData.upsert).not.toHaveBeenCalled();
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
}); });
it('creates a missing row only when the client loaded a null version', async () => { it('creates a missing row only when the client loaded a null version', async () => {
const { prisma, service } = makeService(); const { prisma, service, syncService } = makeService();
const value = [{ id: 'p1' }]; const value = [{ id: 'p1' }];
const updatedAt = new Date('2026-07-02T08:06:00.000Z'); const updatedAt = new Date('2026-07-02T08:06:00.000Z');
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt }); prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
@@ -139,10 +146,11 @@ describe('DataService', () => {
data: { key: 'products-overview', value }, data: { key: 'products-overview', value },
}); });
expect(prisma.appData.upsert).not.toHaveBeenCalled(); expect(prisma.appData.upsert).not.toHaveBeenCalled();
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
}); });
it('rejects create-only writes when another client created the row first', async () => { it('rejects create-only writes when another client created the row first', async () => {
const { prisma, service } = makeService(); const { prisma, service, syncService } = makeService();
prisma.appData.create.mockRejectedValue({ code: 'P2002' }); prisma.appData.create.mockRejectedValue({ code: 'P2002' });
prisma.appData.findUnique.mockResolvedValue({ prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview', key: 'products-overview',
@@ -153,6 +161,7 @@ describe('DataService', () => {
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf( await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
ConflictException, ConflictException,
); );
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
}); });
it('rejects invalid AppData versions', async () => { it('rejects invalid AppData versions', async () => {
@@ -162,4 +171,20 @@ describe('DataService', () => {
BadRequestException, BadRequestException,
); );
}); });
it('keeps the AppData response successful when relation sync fails', async () => {
const { prisma, service, syncService } = makeService();
const value = [{ id: 'p1' }];
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
await expect(service.put('products-overview', value)).resolves.toEqual({
key: 'products-overview',
value,
version: updatedAt.toISOString(),
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('products-overview'));
});
}); });

View File

@@ -1,6 +1,7 @@
import { BadRequestException, ConflictException, Injectable } 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 { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
import { isAppDataKey } from './data-keys'; import { isAppDataKey } from './data-keys';
type AppDataRow = { type AppDataRow = {
@@ -11,7 +12,12 @@ type AppDataRow = {
@Injectable() @Injectable()
export class DataService { export class DataService {
constructor(private prisma: PrismaService) {} private readonly logger = new Logger(DataService.name);
constructor(
private prisma: PrismaService,
private readonly appDataSync?: AppDataV23SyncService,
) {}
async get(key: string) { async get(key: string) {
this.ensureAllowedKey(key); this.ensureAllowedKey(key);
@@ -28,7 +34,7 @@ export class DataService {
const row = await this.prisma.appData.create({ const row = await this.prisma.appData.create({
data: { key, value: jsonValue }, data: { key, value: jsonValue },
}); });
return this.toResponse(key, row); return this.toResponseAfterSync(key, row);
} catch (error) { } catch (error) {
if (this.isUniqueConflict(error)) { if (this.isUniqueConflict(error)) {
await this.throwConflict(key); await this.throwConflict(key);
@@ -49,7 +55,7 @@ export class DataService {
} }
const row = await this.prisma.appData.findUnique({ where: { key } }); const row = await this.prisma.appData.findUnique({ where: { key } });
return this.toResponse(key, row); return this.toResponseAfterSync(key, row);
} }
const row = await this.prisma.appData.upsert({ const row = await this.prisma.appData.upsert({
@@ -57,7 +63,7 @@ export class DataService {
update: { value: jsonValue }, update: { value: jsonValue },
create: { key, value: jsonValue }, create: { key, value: jsonValue },
}); });
return this.toResponse(key, row); return this.toResponseAfterSync(key, row);
} }
private ensureAllowedKey(key: string) { private ensureAllowedKey(key: string) {
@@ -82,6 +88,20 @@ export class DataService {
}; };
} }
private async toResponseAfterSync(key: string, row: AppDataRow | null) {
await this.syncRelations(key);
return this.toResponse(key, row);
}
private async syncRelations(key: string) {
if (!this.appDataSync) return;
try {
await this.appDataSync.syncAfterAppDataPut(key);
} catch (error: any) {
this.logger.warn(`AppData relation sync failed for ${key}: ${error?.message ?? error}`);
}
}
private async throwConflict(key: string): Promise<never> { private async throwConflict(key: string): Promise<never> {
const row = await this.prisma.appData.findUnique({ where: { key } }); const row = await this.prisma.appData.findUnique({ where: { key } });
const current = this.toResponse(key, row); const current = this.toResponse(key, row);

View File

@@ -1,7 +1,21 @@
import { APP_DATA_KEYS } from '../data/data-keys'; import { APP_DATA_KEYS } from '../data/data-keys';
import { AppDataV22MigrationService } from './app-data-v22.migration.service'; import { AppDataV22MigrationService, normalizeDateTimeFields } from './app-data-v22.migration.service';
describe('AppDataV22MigrationService', () => { describe('AppDataV22MigrationService', () => {
it('normalizes date-only fields used by relation-table writes', () => {
expect(
normalizeDateTimeFields({
workDate: '2026-07-03',
snapshotDate: '2026-07-03',
createdAt: '2026-07-03',
}),
).toEqual({
workDate: '2026-07-03T00:00:00.000Z',
snapshotDate: '2026-07-03T00:00:00.000Z',
createdAt: '2026-07-03T00:00:00.000Z',
});
});
it('loads allowed AppData keys and returns a migration preview without writing domain tables', async () => { it('loads allowed AppData keys and returns a migration preview without writing domain tables', async () => {
const findMany = jest.fn().mockResolvedValue([ const findMany = jest.fn().mockResolvedValue([
{ {

View File

@@ -89,6 +89,8 @@ const DATE_TIME_KEYS = new Set([
'aiDraftAt', 'aiDraftAt',
'recomputedAt', 'recomputedAt',
'forecastReleaseDate', 'forecastReleaseDate',
'snapshotDate',
'workDate',
]); ]);
export interface AppDataV22MigrationPreview { export interface AppDataV22MigrationPreview {
@@ -172,7 +174,7 @@ async function createMany(delegate: CreateManyDelegate, data: unknown[]): Promis
return result.count; return result.count;
} }
function normalizeDateTimeFields(row: unknown): unknown { export function normalizeDateTimeFields(row: unknown): unknown {
if (typeof row !== 'object' || row === null || Array.isArray(row)) return row; if (typeof row !== 'object' || row === null || Array.isArray(row)) return row;
return Object.fromEntries( return Object.fromEntries(
Object.entries(row).map(([key, value]) => [ Object.entries(row).map(([key, value]) => [

View File

@@ -0,0 +1,186 @@
import { AppDataV23SyncService } from './app-data-v23-sync.service';
describe('AppDataV23SyncService', () => {
const makeDelegate = () => ({
createMany: jest.fn().mockResolvedValue({ count: 0 }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
upsert: jest.fn().mockResolvedValue({}),
});
const makePrisma = (snapshot: Record<string, unknown>) => {
const prisma: any = {
appData: {
findMany: jest.fn().mockResolvedValue(
Object.entries(snapshot).map(([key, value]) => ({
key,
value,
})),
),
},
user: makeDelegate(),
product: makeDelegate(),
project: makeDelegate(),
version: makeDelegate(),
taskCategory: makeDelegate(),
requirement: makeDelegate(),
versionPlan: makeDelegate(),
devTask: makeDelegate(),
testCase: makeDelegate(),
bug: makeDelegate(),
workActivity: makeDelegate(),
taskWorklog: makeDelegate(),
overtimeRecord: makeDelegate(),
xiaobaoRiskSnapshot: makeDelegate(),
xiaobaoRiskInsight: makeDelegate(),
xiaobaoRiskSummary: makeDelegate(),
$transaction: jest.fn(async (callback: (tx: any) => Promise<unknown>): Promise<unknown> => callback(prisma)),
};
return prisma;
};
const productTree = [
{
id: 'product-1',
name: 'Product 1',
projects: [{ id: 'project-1', name: 'Project 1' }],
versions: [{ id: 'version-1', projectId: 'project-1', name: 'V1' }],
},
];
it('replaces requirement rows by product partition scope', async () => {
const prisma = makePrisma({
'products-overview': productTree,
requirements: [
{
id: 'req-1',
productId: 'product-1',
versionId: 'version-1',
code: 'REQ-001',
title: 'Login',
status: 'adopted',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('requirements');
expect(prisma.requirement.deleteMany).toHaveBeenCalledWith({
where: { productId: { in: ['product-1'] } },
});
expect(prisma.requirement.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
id: 'req-1',
productId: 'product-1',
versionId: 'version-1',
code: 'REQ-001',
}),
],
skipDuplicates: true,
});
});
it('replaces version detail rows by version partition scope', async () => {
const prisma = makePrisma({
'products-overview': productTree,
'version-plans': [
{
id: 'plan-1',
versionId: 'version-1',
type: 'product',
title: 'PRD',
status: 'in_progress',
},
],
'dev-tasks': [
{
id: 'dev-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'DEV-001',
title: 'API',
status: 'in_progress',
},
],
'test-cases': [
{
id: 'tc-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'TC-001',
title: 'Login success',
status: 'running',
},
],
bugs: [
{
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'BUG-001',
title: 'Login failure',
status: 'open',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('dev-tasks');
expect(prisma.devTask.deleteMany).toHaveBeenCalledWith({
where: { versionId: { in: ['version-1'] } },
});
expect(prisma.devTask.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: 'dev-1', versionId: 'version-1', code: 'DEV-001' })],
skipDuplicates: true,
});
expect(prisma.testCase.deleteMany).not.toHaveBeenCalled();
expect(prisma.bug.deleteMany).not.toHaveBeenCalled();
expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { versionId: 'version-1' },
update: { dirty: true },
}),
);
});
it('refreshes Xiaobao summaries from the latest risk snapshot', async () => {
const prisma = makePrisma({
'products-overview': productTree,
'xiaobao-risk-snapshots': [
{
id: 'snapshot-1',
versionId: 'version-1',
snapshotDate: '2026-07-03',
riskLevel: 'likely_delayed',
riskScore: 78,
confidence: 85,
forecastReleaseDate: '2026-07-10',
createdAt: '2026-07-03T10:00:00.000Z',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('xiaobao-risk-snapshots');
expect(prisma.xiaobaoRiskSnapshot.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: 'snapshot-1', versionId: 'version-1', riskScore: 78 })],
skipDuplicates: true,
});
expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { versionId: 'version-1' },
update: expect.objectContaining({
riskLevel: 'likely_delayed',
riskScore: 78,
dirty: false,
}),
}),
);
});
});

View File

@@ -0,0 +1,270 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { APP_DATA_KEYS, isAppDataKey } from '../data/data-keys';
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
import { normalizeDateTimeFields } from './app-data-v22.migration.service';
type DelegateName =
| 'user'
| 'product'
| 'project'
| 'version'
| 'taskCategory'
| 'requirement'
| 'versionPlan'
| 'devTask'
| 'testCase'
| 'bug'
| 'workActivity'
| 'taskWorklog'
| 'overtimeRecord'
| 'xiaobaoRiskSnapshot'
| 'xiaobaoRiskInsight'
| 'xiaobaoRiskSummary';
type CreateManyDelegate = {
createMany(args: { data: unknown[]; skipDuplicates: boolean }): Promise<{ count: number }>;
};
type DeleteManyDelegate = {
deleteMany(args: { where: unknown }): Promise<{ count: number }>;
};
type UpsertDelegate = {
upsert(args: unknown): Promise<unknown>;
};
type SyncDelegate = Partial<CreateManyDelegate & DeleteManyDelegate & UpsertDelegate>;
type SyncTransaction = Record<DelegateName, SyncDelegate>;
const RISK_INPUT_KEYS = new Set([
'version-plans',
'dev-tasks',
'test-cases',
'bugs',
'work-activities',
'task-worklogs',
'overtime',
]);
@Injectable()
export class AppDataV23SyncService {
constructor(private readonly prisma: PrismaService) {}
async syncAfterAppDataPut(key: string): Promise<void> {
if (!isAppDataKey(key)) return;
const mapped = mapAppDataToV22Rows(await this.loadSnapshot());
await this.prisma.$transaction(async (tx) => {
const client = tx as unknown as SyncTransaction;
await this.syncBaseTables(client, key, mapped);
await this.syncCurrentStateTables(client, key, mapped);
await this.syncAppendOnlyTables(client, key, mapped);
await this.syncXiaobaoSummaries(client, key, mapped);
});
}
private async loadSnapshot(): Promise<Record<string, unknown>> {
const rows = await this.prisma.appData.findMany({
where: { key: { in: [...APP_DATA_KEYS] } },
select: { key: true, value: true },
});
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
private async syncBaseTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'products-overview') {
await upsertById(client.product, mapped.products);
await upsertById(client.project, mapped.projects);
await upsertById(client.version, mapped.versions);
}
if (key === 'members') {
await upsertById(client.user, mapped.users);
}
if (key === 'task-categories') {
await upsertById(client.taskCategory, mapped.taskCategories);
}
}
private async syncCurrentStateTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'requirements') {
const productIds = getProductScope(mapped);
await replaceRows(
client.requirement,
{ productId: { in: productIds } },
mapped.requirements.filter((row) => productIds.includes(row.productId)),
);
}
if (key === 'version-plans') {
const versionIds = getVersionScope(mapped, mapped.versionPlans.map((row) => row.versionId));
await replaceRows(
client.versionPlan,
{ versionId: { in: versionIds } },
mapped.versionPlans.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'dev-tasks') {
const versionIds = getVersionScope(mapped, mapped.devTasks.map((row) => row.versionId));
await replaceRows(
client.devTask,
{ versionId: { in: versionIds } },
mapped.devTasks.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'test-cases') {
const versionIds = getVersionScope(mapped, mapped.testCases.map((row) => row.versionId));
await replaceRows(
client.testCase,
{ versionId: { in: versionIds } },
mapped.testCases.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'bugs') {
const versionIds = getVersionScope(mapped, mapped.bugs.map((row) => row.versionId));
await replaceRows(
client.bug,
{ versionId: { in: versionIds } },
mapped.bugs.filter((row) => versionIds.includes(row.versionId)),
);
}
}
private async syncAppendOnlyTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'work-activities') {
await createMany(client.workActivity, mapped.workActivities);
}
if (key === 'task-worklogs') {
await createMany(client.taskWorklog, mapped.taskWorklogs);
}
if (key === 'overtime') {
await createMany(client.overtimeRecord, mapped.overtimeRecords);
}
if (key === 'xiaobao-risk-snapshots') {
await createMany(client.xiaobaoRiskSnapshot, mapped.xiaobaoRiskSnapshots);
}
if (key === 'xiaobao-risk-insights') {
await createMany(client.xiaobaoRiskInsight, mapped.xiaobaoRiskInsights);
}
}
private async syncXiaobaoSummaries(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'xiaobao-risk-snapshots') {
await upsertRiskSummaries(client.xiaobaoRiskSummary, mapped.xiaobaoRiskSummaries);
return;
}
if (!RISK_INPUT_KEYS.has(key)) return;
await markSummariesDirty(client.xiaobaoRiskSummary, getAffectedRiskVersionIds(key, mapped));
}
}
async function upsertById(delegate: SyncDelegate, rows: Array<{ id: string }>) {
if (!delegate.upsert) return;
for (const row of rows) {
const data = normalizeDateTimeFields(row);
await delegate.upsert({
where: { id: row.id },
update: omitKeys(data, ['id']),
create: data,
});
}
}
async function replaceRows(delegate: SyncDelegate, where: unknown, rows: unknown[]) {
if (!delegate.deleteMany || !delegate.createMany) return;
await delegate.deleteMany({ where });
await createMany(delegate, rows);
}
async function createMany(delegate: SyncDelegate, rows: unknown[]) {
if (!delegate.createMany || rows.length === 0) return;
await delegate.createMany({
data: rows.map(normalizeDateTimeFields),
skipDuplicates: true,
});
}
async function upsertRiskSummaries(delegate: SyncDelegate, rows: V22MappedRows['xiaobaoRiskSummaries']) {
if (!delegate.upsert) return;
for (const row of rows) {
const data = normalizeDateTimeFields(row);
await delegate.upsert({
where: { versionId: row.versionId },
update: omitKeys(data, ['versionId', 'updatedAt']),
create: data,
});
}
}
async function markSummariesDirty(delegate: SyncDelegate, versionIds: string[]) {
if (!delegate.upsert) return;
for (const versionId of unique(versionIds)) {
const riskSignature = `dirty:${versionId}`;
await delegate.upsert({
where: { versionId },
update: { dirty: true },
create: {
versionId,
riskLevel: 'attention',
riskScore: 1,
confidence: 0,
riskSignature,
summary: {
versionId,
riskLevel: 'attention',
riskScore: 1,
confidence: 0,
riskSignature,
dirty: true,
},
dirty: true,
},
});
}
}
function getProductScope(mapped: V22MappedRows): string[] {
return unique([
...mapped.products.map((row) => row.id),
...mapped.requirements.map((row) => row.productId),
]);
}
function getVersionScope(mapped: V22MappedRows, fallback: string[]): string[] {
return unique([...mapped.versions.map((row) => row.id), ...fallback]);
}
function getAffectedRiskVersionIds(key: string, mapped: V22MappedRows): string[] {
if (key === 'version-plans') return mapped.versionPlans.map((row) => row.versionId);
if (key === 'dev-tasks') return mapped.devTasks.map((row) => row.versionId);
if (key === 'test-cases') return mapped.testCases.map((row) => row.versionId);
if (key === 'bugs') return mapped.bugs.map((row) => row.versionId);
if (key === 'work-activities') return mapped.workActivities.map((row) => row.versionId).filter(isPresent);
if (key === 'task-worklogs') return mapped.taskWorklogs.map((row) => row.versionId).filter(isPresent);
if (key === 'overtime') return mapped.overtimeRecords.map((row) => row.versionId).filter(isPresent);
return [];
}
function omitKeys(value: unknown, keys: string[]): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
const blocked = new Set(keys);
return Object.fromEntries(Object.entries(value).filter(([key]) => !blocked.has(key)));
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(isPresent)));
}
function isPresent(value: string | undefined): value is string {
return Boolean(value);
}

View File

@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AppDataV22MigrationService } from './app-data-v22.migration.service'; import { AppDataV22MigrationService } from './app-data-v22.migration.service';
import { AppDataV23SyncService } from './app-data-v23-sync.service';
@Module({ @Module({
providers: [AppDataV22MigrationService], providers: [AppDataV22MigrationService, AppDataV23SyncService],
exports: [AppDataV22MigrationService], exports: [AppDataV22MigrationService, AppDataV23SyncService],
}) })
export class MigrationModule {} export class MigrationModule {}

View File

@@ -0,0 +1,29 @@
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
describe('prisma monitoring helpers', () => {
const originalEnv = process.env.PRISMA_SLOW_QUERY_MS;
afterEach(() => {
process.env.PRISMA_SLOW_QUERY_MS = originalEnv;
});
it('uses a safe default threshold when env is missing or invalid', () => {
delete process.env.PRISMA_SLOW_QUERY_MS;
expect(resolvePrismaSlowQueryThreshold()).toBe(300);
process.env.PRISMA_SLOW_QUERY_MS = '-1';
expect(resolvePrismaSlowQueryThreshold()).toBe(300);
});
it('uses a positive configured threshold', () => {
process.env.PRISMA_SLOW_QUERY_MS = '750';
expect(resolvePrismaSlowQueryThreshold()).toBe(750);
});
it('detects slow query events at or above the threshold', () => {
expect(shouldLogPrismaQuery(299, 300)).toBe(false);
expect(shouldLogPrismaQuery(300, 300)).toBe(true);
expect(shouldLogPrismaQuery(450, 300)).toBe(true);
});
});

View File

@@ -0,0 +1,11 @@
const DEFAULT_PRISMA_SLOW_QUERY_MS = 300;
export function resolvePrismaSlowQueryThreshold(): number {
const parsed = Number(process.env.PRISMA_SLOW_QUERY_MS);
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PRISMA_SLOW_QUERY_MS;
return Math.floor(parsed);
}
export function shouldLogPrismaQuery(durationMs: number, thresholdMs: number): boolean {
return durationMs >= thresholdMs;
}

View File

@@ -1,11 +1,24 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name); private readonly logger = new Logger(PrismaService.name);
private readonly slowQueryThresholdMs = resolvePrismaSlowQueryThreshold();
private connected = false; private connected = false;
constructor() {
super({
log: [{ emit: 'event', level: 'query' }],
});
(this as any).$on('query', (event: { duration: number; query: string }) => {
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
});
}
async onModuleInit() { async onModuleInit() {
try { try {
await this.$connect(); await this.$connect();

View File

@@ -252,3 +252,18 @@ The first V2.2 read layer is query-first and AppData-compatible. `V22QueryModule
- Xiaobao warning reads `xiaobao_risk_summaries` first, then maps the precomputed summary into the existing warning UI shape. - Xiaobao warning reads `xiaobao_risk_summaries` first, then maps the precomputed summary into the existing warning UI shape.
During the V2.2 compatibility window, writes still go through the existing AppData stores. The frontend consumes V2.2 relation-table results for render-heavy pages and falls back to AppData only when the V2.2 read is unavailable or empty. During the V2.2 compatibility window, writes still go through the existing AppData stores. The frontend consumes V2.2 relation-table results for render-heavy pages and falls back to AppData only when the V2.2 read is unavailable or empty.
## V2.3 AppData-to-Relational Write Sync Layer (2026-07-03)
V2.3 closes the first compatibility gap after V2.2: AppData remains the frontend write source, but successful `PUT /api/v1/data/:key` calls now trigger a backend relation-table sync.
`DataService` writes AppData with the existing optimistic-lock rules first. After the AppData write succeeds, it calls `AppDataV23SyncService.syncAfterAppDataPut(key)`. Sync failures are logged and do not fail the user save, because AppData is still the source of truth during this compatibility window and V2.2 read paths keep their AppData fallback.
`AppDataV23SyncService` reuses the V2.2 pure mapper, then writes only the table family affected by the changed AppData key:
- `requirements` is replaced by `product_id` scope.
- `version_plans`, `dev_tasks`, `test_cases`, and `bugs` are replaced by `version_id` scope.
- `work_activities`, `task_worklogs`, `overtime_records`, `xiaobao_risk_snapshots`, and `xiaobao_risk_insights` remain append-oriented with duplicate skipping.
- `xiaobao_risk_summaries` is refreshed from risk snapshots and marked `dirty=true` when plans, tasks, test cases, bugs, activities, worklogs, or overtime change.
The server also has lightweight observability for this phase: a global API timing interceptor logs slow HTTP requests, and `PrismaService` logs slow query events. Thresholds are controlled by `API_SLOW_REQUEST_MS` and `PRISMA_SLOW_QUERY_MS`.

View File

@@ -529,3 +529,17 @@
- `xiaobao_risk_summaries` 不分区,保持 `version_id` 单行汇总;历史趋势进入分区快照表。 - `xiaobao_risk_summaries` 不分区,保持 `version_id` 单行汇总;历史趋势进入分区快照表。
**理由**:把分区键纳入主键和唯一约束是 PostgreSQL 分区表的结构性要求。提前做这件事,可以避免客户数据变大后再重塑主键和外键。固定 HASH 分区避免每个项目/版本单独建分区的维护负担RANGE 分区只用于天然追加、按时间维护的历史数据。 **理由**:把分区键纳入主键和唯一约束是 PostgreSQL 分区表的结构性要求。提前做这件事,可以避免客户数据变大后再重塑主键和外键。固定 HASH 分区避免每个项目/版本单独建分区的维护负担RANGE 分区只用于天然追加、按时间维护的历史数据。
## 43. V2.3 AppData 写入后非阻塞同步关系表
**问题**V2.2 已经让版本详情、需求池、与我相关和小宝预警优先读取关系表,但前端保存仍然写 AppData。如果关系表不跟随 AppData 更新,快读路径会逐渐变旧,最后又回落到加载大 JSON 文档,无法解决几十万条需求/任务/用例后的卡顿风险。
**决策**
- AppData 继续作为兼容窗口内的写入事实源,`DataService.put()` 先完成乐观锁写入,再触发关系表同步。
- 同步服务独立为 `AppDataV23SyncService`,复用 V2.2 mapper不把一次性迁移服务改造成在线写入服务。
- 当前态表按分区键作用域替换:需求按 `product_id`,版本计划/开发任务/测试用例/BUG 按 `version_id`
- 追加型证据表继续 `createMany(skipDuplicates)`,不因当前 AppData 文档缺失而删除历史。
- 小宝摘要由快照刷新;计划/任务/用例/BUG/活动/工时/加班变更只标记摘要 `dirty=true`,等待下一次规则计算刷新完整内容。
- 同步失败只记录日志,不阻塞 AppData 保存。慢 API 和慢 Prisma 查询先通过日志监控,后续再接 Prometheus/Grafana。
**理由**:这是从 AppData 兼容写入平滑过渡到领域 CRUD 的中间层。用户保存不能因为派生关系表暂时失败而丢失业务数据同时关系表保持跟随更新后V2.2 快读路径才能真正承受大数据量。把同步服务独立出来,也能让后续领域 CRUD 逐步替换 AppData 时复用同一套映射和小宝 dirty 策略。

View File

@@ -1,12 +1,17 @@
# 开发路线图 # 开发路线图
## 当前阶段V2.2分区关系表与高频读取热路径 ## 当前阶段V2.3 — 关系表写入与预计算闭环
V2.2 已完成第一批高频读取热路径分区关系表基础、AppData 迁移预演、V2.2 scoped read API以及版本详情、需求池、与我相关小宝预警的前端快读接入。业务写入仍保留现有 AppData store 兼容窗口,后续再逐步打开关系表写入和领域 CRUD V2.3 在 V2.2 快读路径之后补上写入闭环:前端仍保留现有 AppData Store 写入形状,但 AppData 保存成功后会同步关系表、刷新/标脏小宝风险摘要,并记录慢 API 与慢 Prisma 查询。领域 CRUD 仍是后续阶段,当前重点是让版本详情、需求池、与我相关小宝预警在大数据量下持续命中关系表快读
### 已完成(按时间倒序) ### 已完成(按时间倒序)
**2026-07-03** **2026-07-03**
- V2.3 AppData write-side bridge added: successful `PUT /api/v1/data/:key` calls now trigger `AppDataV23SyncService` relation-table sync after optimistic-lock AppData writes.
- Relation sync reuses the V2.2 mapper and replaces current-state rows by partition scope: requirements by `product_id`, version plans/dev tasks/test cases/bugs by `version_id`.
- Append-only evidence tables continue to use duplicate-skipping inserts for work activities, worklogs, overtime, Xiaobao snapshots, and Xiaobao insights.
- Xiaobao summaries now refresh from risk snapshots and are marked `dirty=true` when version risk inputs change.
- Added lightweight observability: slow API request logging through a global Nest interceptor and slow Prisma query logging through query events.
- V2.2 partitioned domain schema foundation added: `requirements` uses HASH partitioning by `product_id`; `dev_tasks`, `test_cases`, and `bugs` use HASH partitioning by `version_id`. - V2.2 partitioned domain schema foundation added: `requirements` uses HASH partitioning by `product_id`; `dev_tasks`, `test_cases`, and `bugs` use HASH partitioning by `version_id`.
- Partitioned table primary keys and business unique constraints now include partition keys, for example `(id, version_id)` and `(version_id, code)`. - Partitioned table primary keys and business unique constraints now include partition keys, for example `(id, version_id)` and `(version_id, code)`.
- Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots. - Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots.