merge: 集成V2.5 AppData退场与权限审计

This commit is contained in:
2026-07-08 17:58:38 +08:00
156 changed files with 9487 additions and 285 deletions

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ next-env.d.ts
*.tsbuildinfo
apps/server/data/
.worktrees/
appdata-archive-*.json

View File

@@ -0,0 +1 @@
ALTER TABLE "requirements" ALTER COLUMN "status" SET DEFAULT 'pending_review';

View File

@@ -0,0 +1,8 @@
ALTER TABLE "versions" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'planned';
ALTER TABLE "versions" ADD COLUMN "current_stage" TEXT;
ALTER TABLE "versions" ADD COLUMN "start_date" TIMESTAMP(3);
ALTER TABLE "versions" ADD COLUMN "expected_release_date" TIMESTAMP(3);
ALTER TABLE "versions" ADD COLUMN "members" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "versions" ADD COLUMN "progress" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "versions" ADD COLUMN "priority" INTEGER;
ALTER TABLE "versions" ADD COLUMN "links" JSONB NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,9 @@
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "username" TEXT,
ADD COLUMN IF NOT EXISTS "department_id" TEXT,
ADD COLUMN IF NOT EXISTS "role_id" TEXT NOT NULL DEFAULT 'member',
ADD COLUMN IF NOT EXISTS "phone" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "password" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "is_system" BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX IF NOT EXISTS "users_username_key" ON "users"("username");

View File

@@ -0,0 +1,25 @@
CREATE TABLE "audit_events" (
"id" TEXT NOT NULL,
"actor_id" TEXT,
"actor_name" TEXT NOT NULL DEFAULT '',
"action" TEXT NOT NULL,
"entity_type" TEXT NOT NULL,
"entity_id" TEXT NOT NULL,
"product_id" TEXT,
"project_id" TEXT,
"version_id" TEXT,
"scope" JSONB NOT NULL DEFAULT '{}',
"before" JSONB,
"after" JSONB,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id", "created_at")
) PARTITION BY RANGE ("created_at");
CREATE TABLE "audit_events_default" PARTITION OF "audit_events" DEFAULT;
CREATE INDEX "audit_events_actor_created_at_idx" ON "audit_events"("actor_id", "created_at" DESC);
CREATE INDEX "audit_events_entity_created_at_idx" ON "audit_events"("entity_type", "entity_id", "created_at" DESC);
CREATE INDEX "audit_events_product_created_at_idx" ON "audit_events"("product_id", "created_at" DESC);
CREATE INDEX "audit_events_project_created_at_idx" ON "audit_events"("project_id", "created_at" DESC);
CREATE INDEX "audit_events_version_created_at_idx" ON "audit_events"("version_id", "created_at" DESC);

View File

@@ -12,6 +12,12 @@ model User {
email String @unique
name String
avatar String?
username String? @unique
departmentId String? @map("department_id")
roleId String @default("member") @map("role_id")
phone String @default("")
password String @default("")
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -76,7 +82,15 @@ model Version {
projectId String? @map("project_id")
name String
description String @default("")
status String @default("planned")
currentStage String? @map("current_stage")
startDate DateTime? @map("start_date")
expectedReleaseDate DateTime? @map("expected_release_date")
releaseDate DateTime? @map("release_date")
members Json @default("[]")
progress Json @default("[]")
priority Int?
links Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -95,7 +109,7 @@ model Requirement {
code String
title String
description String @default("")
status String @default("draft")
status String @default("pending_review")
priority Int @default(0)
type String?
sourceType String? @map("source_type")
@@ -409,6 +423,26 @@ model AiLog {
@@map("ai_logs")
}
model AuditEvent {
id String @default(cuid())
actorId String? @map("actor_id")
actorName String @default("") @map("actor_name")
action String
entityType String @map("entity_type")
entityId String @map("entity_id")
productId String? @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
scope Json @default("{}")
before Json?
after Json?
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("audit_events")
}
model XiaobaoRiskSummary {
versionId String @id @map("version_id")
riskLevel String @map("risk_level")

View File

@@ -1,18 +1,54 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
import { AuthModule } from './common/auth/auth.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuditModule } from './modules/audit/audit.module';
import { ProductModule } from './modules/product/product.module';
import { ProjectModule } from './modules/project/project.module';
import { RequirementModule } from './modules/requirement/requirement.module';
import { VersionModule } from './modules/version/version.module';
import { VersionPlanModule } from './modules/version-plan/version-plan.module';
import { DevTaskModule } from './modules/dev-task/dev-task.module';
import { TestCaseModule } from './modules/test-case/test-case.module';
import { BugModule } from './modules/bug/bug.module';
import { MemberModule } from './modules/member/member.module';
import { TaskCategoryModule } from './modules/task-category/task-category.module';
import { TaskWorklogModule } from './modules/task-worklog/task-worklog.module';
import { OvertimeModule } from './modules/overtime/overtime.module';
import { AiModule } from './modules/ai/ai.module';
import { ConfigModule } from './modules/config/config.module';
import { DataModule } from './modules/data/data.module';
import { MigrationModule } from './modules/migration/migration.module';
import { V22QueryModule } from './modules/v22-query/v22-query.module';
import { HealthModule } from './modules/health/health.module';
import { ConsistencyModule } from './modules/consistency/consistency.module';
@Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule],
imports: [
PrismaModule,
AuthModule,
AuditModule,
ProductModule,
ProjectModule,
VersionModule,
VersionPlanModule,
DevTaskModule,
TestCaseModule,
BugModule,
MemberModule,
TaskCategoryModule,
TaskWorklogModule,
OvertimeModule,
RequirementModule,
ConfigModule,
DataModule,
MigrationModule,
V22QueryModule,
ConsistencyModule,
HealthModule,
AiModule,
],
controllers: [],
providers: [
{

View File

@@ -0,0 +1,14 @@
import { SetMetadata } from '@nestjs/common';
import type { PermissionScopeOptions } from '../auth/permission.decorator';
export const AUDIT_MUTATION_METADATA_KEY = 'ftb:audit-mutation';
export interface AuditMutationMetadata extends PermissionScopeOptions {
action: string;
entityType: string;
entityIdParam?: string;
}
export function AuditMutation(metadata: AuditMutationMetadata) {
return SetMetadata(AUDIT_MUTATION_METADATA_KEY, metadata);
}

View File

@@ -0,0 +1,59 @@
import { lastValueFrom, of } from 'rxjs';
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
import { AuditMutation } from './audit-mutation.decorator';
describe('AuditMutationInterceptor', () => {
it('writes an audit event after a successful mutation response', async () => {
const record = jest.fn().mockResolvedValue({ id: 'audit-1' });
const resolveCurrentUser = jest.fn().mockResolvedValue({ id: 'm-8', name: '超级管理员', roleId: 'role-admin' });
const interceptor = new AuditMutationInterceptor(
new (jest.requireActual('@nestjs/core').Reflector)(),
{ record } as any,
{ resolveCurrentUser } as any,
);
const handler = decorate(() => undefined);
const result = await lastValueFrom(interceptor.intercept(contextFor(handler), {
handle: () => of({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }),
} as any));
expect(result).toEqual({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } });
expect(record).toHaveBeenCalledWith({
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
action: 'dev_task.update',
entityType: 'dev_task',
entityId: 'task-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
scope: { productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
after: { item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } },
metadata: { route: 'PATCH /versions/version-1/dev-tasks/task-1' },
});
});
});
function decorate(handler: Function) {
AuditMutation({
action: 'dev_task.update',
entityType: 'dev_task',
entityIdParam: 'id',
versionIdParam: 'versionId',
})(handler as any, undefined as any, undefined as any);
return handler;
}
function contextFor(handler: Function) {
const request = {
method: 'PATCH',
originalUrl: '/versions/version-1/dev-tasks/task-1',
params: { id: 'task-1', versionId: 'version-1' },
body: {},
headers: {},
};
return {
getHandler: () => handler,
getClass: () => class TestController {},
switchToHttp: () => ({ getRequest: () => request }),
} as any;
}

View File

@@ -0,0 +1,90 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { mergeMap, Observable } from 'rxjs';
import { AuthContextService, type AuthenticatedRequest } from '../auth/auth-context.service';
import { AuditService } from '../../modules/audit/audit.service';
import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from './audit-mutation.decorator';
type MutationRequest = AuthenticatedRequest & {
method?: string;
originalUrl?: string;
url?: string;
params?: Record<string, string>;
body?: Record<string, unknown>;
};
@Injectable()
export class AuditMutationInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly audit: AuditService,
private readonly authContext: AuthContextService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const metadata = this.reflector.getAllAndOverride<AuditMutationMetadata>(AUDIT_MUTATION_METADATA_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!metadata) return next.handle();
const request = context.switchToHttp().getRequest<MutationRequest>();
return next.handle().pipe(mergeMap(async (result) => {
const actor = await this.authContext.resolveCurrentUser(request);
const entity = extractEntity(result);
const scope = resolveScope(metadata, request, entity);
await this.audit.record({
actor,
action: metadata.action,
entityType: metadata.entityType,
entityId: resolveEntityId(metadata, request, entity),
productId: scope.productId,
projectId: scope.projectId,
versionId: scope.versionId,
scope,
after: result,
metadata: { route: `${request.method ?? 'UNKNOWN'} ${request.originalUrl ?? request.url ?? ''}`.trim() },
});
return result;
}));
}
}
function extractEntity(result: unknown): Record<string, unknown> | undefined {
if (!result || typeof result !== 'object') return undefined;
const record = result as Record<string, unknown>;
if (record.item && typeof record.item === 'object') return record.item as Record<string, unknown>;
if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record<string, unknown>;
return record;
}
function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
const fromParam = metadata.entityIdParam ? request.params?.[metadata.entityIdParam] : undefined;
const fromEntity = entity?.id;
return fromParam ?? (typeof fromEntity === 'string' ? fromEntity : 'unknown');
}
function resolveScope(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
return compact({
productId: scopedValue(metadata.productIdParam, metadata.productIdBody, 'productId', request, entity),
projectId: scopedValue(metadata.projectIdParam, metadata.projectIdBody, 'projectId', request, entity),
versionId: scopedValue(metadata.versionIdParam, metadata.versionIdBody, 'versionId', request, entity),
});
}
function scopedValue(
paramKey: string | undefined,
bodyKey: string | undefined,
resultKey: string,
request: MutationRequest,
entity: Record<string, unknown> | undefined,
): string | undefined {
const value = (paramKey ? request.params?.[paramKey] : undefined)
?? (bodyKey ? request.body?.[bodyKey] : undefined)
?? entity?.[resultKey];
return typeof value === 'string' && value ? value : undefined;
}
function compact<T extends Record<string, string | undefined>>(value: T): { [K in keyof T]?: string } {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string };
}

View File

@@ -0,0 +1,18 @@
import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common';
import { PermissionGuard } from '../auth/permission.guard';
import { RequirePermission, type PermissionScopeOptions } from '../auth/permission.decorator';
import { AuditMutation, type AuditMutationMetadata } from './audit-mutation.decorator';
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
export function ProtectedMutation(
permission: string,
scope: PermissionScopeOptions,
audit: AuditMutationMetadata,
) {
return applyDecorators(
UseGuards(PermissionGuard),
RequirePermission(permission, scope),
UseInterceptors(AuditMutationInterceptor),
AuditMutation(audit),
);
}

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface CurrentUser {
id: string;
name?: string;
username?: string;
roleId: string;
email?: string;
}
export interface AuthenticatedRequest {
headers?: Record<string, string | string[] | undefined>;
currentUser?: CurrentUser | null;
}
@Injectable()
export class AuthContextService {
constructor(private readonly prisma: PrismaService) {}
async resolveCurrentUser(request: AuthenticatedRequest): Promise<CurrentUser | null> {
if (request.currentUser !== undefined) return request.currentUser;
const userId = headerValue(request, 'x-ftb-user-id') || headerValue(request, 'x-user-id');
if (!userId) {
request.currentUser = null;
return null;
}
const headerUser: CurrentUser = {
id: userId,
name: decodeHeader(headerValue(request, 'x-ftb-user-name')),
username: headerValue(request, 'x-ftb-user-username'),
roleId: headerValue(request, 'x-ftb-user-role-id') || '',
email: headerValue(request, 'x-ftb-user-email'),
};
if (headerUser.roleId) {
request.currentUser = headerUser;
return headerUser;
}
const row = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, username: true, roleId: true, email: true },
});
request.currentUser = row ? {
id: row.id,
name: row.name,
username: row.username ?? undefined,
roleId: row.roleId || 'member',
email: row.email,
} : null;
return request.currentUser;
}
}
function headerValue(request: AuthenticatedRequest, name: string): string {
const value = request.headers?.[name] ?? request.headers?.[name.toLowerCase()];
if (Array.isArray(value)) return value[0] ?? '';
return value ?? '';
}
function decodeHeader(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

View File

@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuthContextService } from './auth-context.service';
import { PermissionGuard } from './permission.guard';
import { PermissionService } from './permission.service';
@Global()
@Module({
providers: [AuthContextService, PermissionGuard, PermissionService],
exports: [AuthContextService, PermissionGuard, PermissionService],
})
export class AuthModule {}

View File

@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthenticatedRequest, CurrentUser as ResolvedCurrentUser } from './auth-context.service';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): ResolvedCurrentUser | null => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
return request.currentUser ?? null;
},
);

View File

@@ -0,0 +1,20 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_METADATA_KEY = 'ftb:required-permission';
export interface PermissionScopeOptions {
productIdParam?: string;
projectIdParam?: string;
versionIdParam?: string;
productIdBody?: string;
projectIdBody?: string;
versionIdBody?: string;
}
export interface RequiredPermissionMetadata extends PermissionScopeOptions {
permission: string;
}
export function RequirePermission(permission: string, scope: PermissionScopeOptions = {}) {
return SetMetadata(PERMISSION_METADATA_KEY, { permission, ...scope });
}

View File

@@ -0,0 +1,74 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthContextService } from './auth-context.service';
import { PermissionGuard } from './permission.guard';
import { RequirePermission } from './permission.decorator';
describe('PermissionGuard', () => {
it('rejects protected routes when no current user can be resolved', async () => {
const guard = new PermissionGuard(
new Reflector(),
{ resolveCurrentUser: jest.fn().mockResolvedValue(null) } as unknown as AuthContextService,
{ can: jest.fn() } as any,
);
const handler = decorate(() => undefined, 'product:create');
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects users without the required permission', async () => {
const guard = new PermissionGuard(
new Reflector(),
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
{ can: jest.fn().mockResolvedValue(false) } as any,
);
const handler = decorate(() => undefined, 'product:delete');
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(ForbiddenException);
});
it('passes params-derived resource scope to the permission service', async () => {
const can = jest.fn().mockResolvedValue(true);
const guard = new PermissionGuard(
new Reflector(),
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
{ can } as any,
);
const handler = decorate(() => undefined, 'version.devtask:manage', {
versionIdParam: 'versionId',
projectIdParam: 'projectId',
productIdParam: 'productId',
});
await expect(guard.canActivate(contextFor(handler, {
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
}))).resolves.toBe(true);
expect(can).toHaveBeenCalledWith(
{ id: 'user-1', roleId: 'role-dev' },
'version.devtask:manage',
{ productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
);
});
});
function decorate(handler: Function, permission: string, scope?: {
productIdParam?: string;
projectIdParam?: string;
versionIdParam?: string;
}) {
RequirePermission(permission, scope)(handler as any, undefined as any, undefined as any);
return handler;
}
function contextFor(handler: Function, params: Record<string, string> = {}) {
return {
getHandler: () => handler,
getClass: () => class TestController {},
switchToHttp: () => ({
getRequest: () => ({ params, headers: {} }),
}),
} as any;
}

View File

@@ -0,0 +1,46 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthContextService, type AuthenticatedRequest } from './auth-context.service';
import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from './permission.decorator';
import { PermissionService } from './permission.service';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly authContext: AuthContextService,
private readonly permissions: PermissionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const metadata = this.reflector.getAllAndOverride<RequiredPermissionMetadata>(PERMISSION_METADATA_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!metadata) return true;
const request = context.switchToHttp().getRequest<AuthenticatedRequest & {
params?: Record<string, string>;
body?: Record<string, unknown>;
}>();
const user = await this.authContext.resolveCurrentUser(request);
if (!user) throw new UnauthorizedException('Authentication required');
const allowed = await this.permissions.can(user, metadata.permission, {
productId: scopedValue(request, metadata.productIdParam, metadata.productIdBody),
projectId: scopedValue(request, metadata.projectIdParam, metadata.projectIdBody),
versionId: scopedValue(request, metadata.versionIdParam, metadata.versionIdBody),
});
if (!allowed) throw new ForbiddenException(`Missing permission: ${metadata.permission}`);
return true;
}
}
function scopedValue(
request: { params?: Record<string, string>; body?: Record<string, unknown> },
paramKey?: string,
bodyKey?: string,
): string | undefined {
const value = (paramKey ? request.params?.[paramKey] : undefined) ?? (bodyKey ? request.body?.[bodyKey] : undefined);
return typeof value === 'string' && value ? value : undefined;
}

View File

@@ -0,0 +1,92 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { PermissionService } from './permission.service';
import type { CurrentUser } from './auth-context.service';
describe('PermissionService', () => {
const versionFindUnique = jest.fn();
const projectMemberFindUnique = jest.fn();
const prisma = {
version: { findUnique: versionFindUnique },
projectMember: { findUnique: projectMemberFindUnique },
} as any;
const service = new PermissionService(prisma);
beforeEach(() => {
jest.clearAllMocks();
});
it('allows the built-in super admin wildcard for any permission without a scope lookup', async () => {
const user = currentUser({ id: 'm-8', roleId: 'role-admin' });
await expect(service.can(user, 'audit:view', { versionId: 'version-1' })).resolves.toBe(true);
expect(versionFindUnique).not.toHaveBeenCalled();
expect(projectMemberFindUnique).not.toHaveBeenCalled();
});
it('requires scoped users to belong to the project resolved from a version', async () => {
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
versionId: 'version-1',
})).resolves.toBe(true);
expect(versionFindUnique).toHaveBeenCalledWith({
where: { id: 'version-1' },
select: { id: true, projectId: true, members: true },
});
expect(projectMemberFindUnique).toHaveBeenCalledWith({
where: { projectId_userId: { projectId: 'project-1', userId: 'dev-1' } },
});
});
it('allows a version member when no project membership exists', async () => {
versionFindUnique.mockResolvedValue({
id: 'version-1',
projectId: 'project-1',
members: [{ id: 'tester-1', name: 'Tester One' }],
});
projectMemberFindUnique.mockResolvedValue(null);
await expect(service.can(currentUser({ id: 'tester-1', name: 'Tester One', roleId: 'role-test' }), 'version.testcase:manage', {
versionId: 'version-1',
})).resolves.toBe(true);
});
it('allows a project member to write version-scoped work activity evidence', async () => {
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'work-activity:manage', {
versionId: 'version-1',
})).resolves.toBe(true);
});
it('denies users who have a global role permission but are outside the resource scope', async () => {
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
projectMemberFindUnique.mockResolvedValue(null);
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
versionId: 'version-1',
})).resolves.toBe(false);
});
it('throws explicit auth exceptions for assertion callers', async () => {
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
});
});
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
return {
id: 'user-1',
name: 'User One',
username: 'user.one',
roleId: 'role-dev',
email: '',
...overrides,
};
}

View File

@@ -0,0 +1,167 @@
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { CurrentUser } from './auth-context.service';
export interface ResourceScope {
productId?: string;
projectId?: string;
versionId?: string;
}
interface ResolvedScope extends ResourceScope {
versionMembers?: unknown;
}
const VIEW_ONLY = ['product:view', 'project:view', 'version:view', 'requirement:view', 'version.req:view'];
const SYSTEM_ROLE_PERMISSIONS: Record<string, string[]> = {
'role-admin': ['*'],
'role-pm': [
...std4('product'),
...std4('project'),
...std4('version'),
...std4('requirement'),
'version.req:view', 'version.req:manage',
'version.product_plan:view', 'version.product_plan:manage',
'xiaobao.warning:view', 'xiaobao.warning:manage',
'overtime:view', 'member:view', 'role:view',
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
'version.testcase:view', 'version.bug:view',
'work-activity:manage',
],
'role-dev': [
...VIEW_ONLY,
'version.devtask:view', 'version.devtask:manage',
'version.bug:view', 'version.bug:edit',
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view',
'xiaobao.warning:view',
'overtime:view', 'overtime:create',
'work-activity:manage',
],
'role-test': [
...VIEW_ONLY,
'version.testcase:view', 'version.testcase:manage',
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view',
'xiaobao.warning:view',
'overtime:view', 'overtime:create',
'work-activity:manage',
],
'role-design': [
...VIEW_ONLY,
'version.ui_plan:view', 'version.ui_plan:manage',
'version.research:view', 'version.product_plan:view', 'version.devtask:view',
'version.testcase:view', 'version.bug:view',
'xiaobao.warning:view',
'overtime:view', 'overtime:create',
'work-activity:manage',
],
'role-lead': [
...VIEW_ONLY,
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view',
'version.devtask:view', 'version.devtask:manage',
'version.testcase:view', 'version.testcase:manage',
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
'xiaobao.warning:view',
'overtime:view', 'overtime:create', 'overtime:export',
'work-activity:manage',
],
};
const PROJECT_ROLE_PERMISSIONS: Record<string, string[]> = {
owner: ['*'],
admin: ['*'],
member: [
'project:view',
'version:view',
'requirement:view',
'version.req:view',
'version.research:view', 'version.research:manage',
'version.product_plan:view', 'version.product_plan:manage',
'version.ui_plan:view', 'version.ui_plan:manage',
'version.devtask:view', 'version.devtask:manage',
'version.testcase:view', 'version.testcase:manage',
'version.bug:view', 'version.bug:create', 'version.bug:edit',
'overtime:view', 'overtime:create',
'xiaobao.warning:view',
'work-activity:manage',
],
viewer: [
'project:view',
'version:view',
'requirement:view',
'version.req:view',
'version.research:view',
'version.product_plan:view',
'version.ui_plan:view',
'version.devtask:view',
'version.testcase:view',
'version.bug:view',
'xiaobao.warning:view',
],
};
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
if (!user) return false;
const systemPermissions = SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? [];
if (systemPermissions.includes('*')) return true;
const hasSystemPermission = systemPermissions.includes(permission);
const hasScopedResource = Boolean(scope.projectId || scope.versionId);
if (!hasScopedResource) return hasSystemPermission;
const resolvedScope = await this.resolveScope(scope);
const projectMember = resolvedScope.projectId ? await this.prisma.projectMember.findUnique({
where: { projectId_userId: { projectId: resolvedScope.projectId, userId: user.id } },
}) : null;
if (projectMember && roleAllows(PROJECT_ROLE_PERMISSIONS[projectMember.role] ?? [], permission)) {
return true;
}
if (hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers)) return true;
return false;
}
async assertCan(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<void> {
if (!user) throw new UnauthorizedException('Authentication required');
if (!(await this.can(user, permission, scope))) {
throw new ForbiddenException(`Missing permission: ${permission}`);
}
}
private async resolveScope(scope: ResourceScope): Promise<ResolvedScope> {
if (!scope.versionId || scope.projectId) return scope;
const version = await this.prisma.version.findUnique({
where: { id: scope.versionId },
select: { id: true, projectId: true, members: true },
});
return {
...scope,
projectId: version?.projectId ?? scope.projectId,
versionMembers: version?.members,
};
}
}
function std4(module: string): string[] {
return [`${module}:view`, `${module}:create`, `${module}:edit`, `${module}:delete`];
}
function roleAllows(permissions: string[], permission: string): boolean {
return permissions.includes('*') || permissions.includes(permission);
}
function isVersionMember(user: CurrentUser, members: unknown): boolean {
if (!Array.isArray(members)) return false;
return members.some((member) => {
if (typeof member === 'string') return member === user.id || member === user.name;
if (!member || typeof member !== 'object') return false;
const record = member as { id?: unknown; userId?: unknown; name?: unknown };
return record.id === user.id || record.userId === user.id || record.name === user.name;
});
}

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

@@ -0,0 +1,20 @@
import { Reflector } from '@nestjs/core';
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
import { AuditController } from './audit.controller';
describe('AuditController', () => {
it('requires audit:view for audit queries', () => {
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, AuditController.prototype.findAll);
expect(metadata).toEqual({ permission: 'audit:view' });
});
it('delegates list query parameters to the audit service', async () => {
const service = { query: jest.fn().mockResolvedValue([{ id: 'audit-1' }]) };
const controller = new AuditController(service as any);
await expect(controller.findAll({ actorId: 'm-8' })).resolves.toEqual([{ id: 'audit-1' }]);
expect(service.query).toHaveBeenCalledWith({ actorId: 'm-8' });
});
});

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { PermissionGuard } from '../../common/auth/permission.guard';
import { RequirePermission } from '../../common/auth/permission.decorator';
import { AuditService } from './audit.service';
import { QueryAuditEventsDto } from './dto/query-audit-events.dto';
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get()
@UseGuards(PermissionGuard)
@RequirePermission('audit:view')
findAll(@Query() query: QueryAuditEventsDto) {
return this.auditService.query(query);
}
}

View File

@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { AuditMutationInterceptor } from '../../common/audit/audit-mutation.interceptor';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
@Global()
@Module({
controllers: [AuditController],
providers: [AuditService, AuditMutationInterceptor],
exports: [AuditService, AuditMutationInterceptor],
})
export class AuditModule {}

View File

@@ -0,0 +1,74 @@
import { AuditService } from './audit.service';
describe('AuditService', () => {
const create = jest.fn();
const findMany = jest.fn();
const prisma = { auditEvent: { create, findMany } } as any;
const service = new AuditService(prisma);
beforeEach(() => {
jest.clearAllMocks();
});
it('writes append-only audit events with sensitive fields redacted', async () => {
create.mockResolvedValue({ id: 'audit-1' });
await service.record({
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
action: 'product.update',
entityType: 'product',
entityId: 'product-1',
productId: 'product-1',
before: { name: 'Old', password: '123456' },
after: { name: 'New', nested: { apiKey: 'sk-test', keep: 'visible' } },
metadata: { authorization: 'Bearer token', reason: 'manual edit' },
});
expect(create).toHaveBeenCalledWith({
data: expect.objectContaining({
actorId: 'm-8',
actorName: '超级管理员',
action: 'product.update',
entityType: 'product',
entityId: 'product-1',
productId: 'product-1',
before: { name: 'Old', password: '[REDACTED]' },
after: { name: 'New', nested: { apiKey: '[REDACTED]', keep: 'visible' } },
metadata: { authorization: '[REDACTED]', reason: 'manual edit' },
}),
});
});
it('queries by actor, entity, scope, and date range with bounded page size', async () => {
findMany.mockResolvedValue([]);
await service.query({
actorId: 'm-8',
entityType: 'bug',
entityId: 'bug-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
dateFrom: '2026-07-01T00:00:00.000Z',
dateTo: '2026-07-08T23:59:59.000Z',
take: '500',
});
expect(findMany).toHaveBeenCalledWith({
where: {
actorId: 'm-8',
entityType: 'bug',
entityId: 'bug-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
createdAt: {
gte: new Date('2026-07-01T00:00:00.000Z'),
lte: new Date('2026-07-08T23:59:59.000Z'),
},
},
orderBy: { createdAt: 'desc' },
take: 100,
});
});
});

View File

@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { CurrentUser } from '../../common/auth/auth-context.service';
import type { QueryAuditEventsDto } from './dto/query-audit-events.dto';
export interface AuditRecordInput {
actor?: CurrentUser | null;
action: string;
entityType: string;
entityId: string;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
scope?: unknown;
before?: unknown;
after?: unknown;
metadata?: unknown;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
record(input: AuditRecordInput) {
return this.prisma.auditEvent.create({
data: {
actorId: input.actor?.id ?? null,
actorName: input.actor?.name ?? input.actor?.username ?? '',
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
scope: toJson(input.scope ?? {}),
before: input.before === undefined ? undefined : toJson(redactSensitive(input.before)),
after: input.after === undefined ? undefined : toJson(redactSensitive(input.after)),
metadata: toJson(redactSensitive(input.metadata ?? {})),
},
});
}
query(query: QueryAuditEventsDto) {
return this.prisma.auditEvent.findMany({
where: buildWhere(query),
orderBy: { createdAt: 'desc' },
take: clampTake(query.take),
});
}
}
function buildWhere(query: QueryAuditEventsDto) {
const where: Record<string, unknown> = {};
for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) {
if (query[key]) where[key] = query[key];
}
const dateRange: Record<string, Date> = {};
if (query.dateFrom) dateRange.gte = new Date(query.dateFrom);
if (query.dateTo) dateRange.lte = new Date(query.dateTo);
if (Object.keys(dateRange).length > 0) where.createdAt = dateRange;
return where;
}
function clampTake(value: string | undefined): number {
const parsed = Number(value ?? 50);
if (!Number.isFinite(parsed) || parsed <= 0) return 50;
return Math.min(100, Math.floor(parsed));
}
function redactSensitive(value: unknown): unknown {
if (Array.isArray(value)) return value.map((item) => redactSensitive(item));
if (value instanceof Date) return value.toISOString();
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(item),
]));
}
function isSensitiveKey(key: string): boolean {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
return ['password', 'token', 'secret', 'apikey', 'authorization'].some((sensitive) => normalized.includes(sensitive));
}
function toJson(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}

View File

@@ -0,0 +1,39 @@
import { IsOptional, IsString } from 'class-validator';
export class QueryAuditEventsDto {
@IsOptional()
@IsString()
actorId?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@IsString()
entityId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
versionId?: string;
@IsOptional()
@IsString()
dateFrom?: string;
@IsOptional()
@IsString()
dateTo?: string;
@IsOptional()
@IsString()
take?: string;
}

View File

@@ -0,0 +1,79 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateBugDto } from './dto/create-bug.dto';
import { UpdateBugDto } from './dto/update-bug.dto';
import { BugService } from './bug.service';
@Controller('versions/:versionId/bugs')
export class BugController {
constructor(private readonly bugService: BugService) {}
@Post()
@ProtectedMutation('version.bug:create', { versionIdParam: 'versionId' }, {
action: 'bug.create',
entityType: 'bug',
versionIdParam: 'versionId',
})
create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) {
return this.bugService.create(versionId, dto);
}
@Get()
findAll(@Param('versionId') versionId: string) {
return this.bugService.findAll(versionId);
}
@Patch(':id')
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
action: 'bug.update',
entityType: 'bug',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateBugDto) {
return this.bugService.update(versionId, id, dto);
}
@Patch(':id/status')
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
action: 'bug.status',
entityType: 'bug',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
updateStatus(
@Param('versionId') versionId: string,
@Param('id') id: string,
@Body('status') status: string,
@Body() body: { operator?: string; resolution?: string },
) {
return this.bugService.updateStatus(versionId, id, status, body);
}
@Patch(':id/transfer')
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
action: 'bug.transfer',
entityType: 'bug',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
transfer(
@Param('versionId') versionId: string,
@Param('id') id: string,
@Body('assigneeId') assigneeId: string,
@Body('operator') operator?: string,
) {
return this.bugService.transfer(versionId, id, assigneeId, operator);
}
@Delete(':id')
@ProtectedMutation('version.bug:delete', { versionIdParam: 'versionId' }, {
action: 'bug.delete',
entityType: 'bug',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
return this.bugService.remove(versionId, id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { BugController } from './bug.controller';
import { BugService } from './bug.service';
@Module({
imports: [WorkActivityModule],
controllers: [BugController],
providers: [BugService],
exports: [BugService],
})
export class BugModule {}

View File

@@ -0,0 +1,153 @@
import { NotFoundException } from '@nestjs/common';
import { BugService } from './bug.service';
describe('BugService domain writes', () => {
const makeService = () => {
const workActivity = {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
};
const prisma = {
version: {
findUnique: jest.fn(),
},
bug: {
create: jest.fn(),
delete: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return {
prisma,
workActivity,
service: new BugService(prisma as any, workActivity as any),
};
};
it('creates bugs directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.bug.create.mockResolvedValue({ id: 'bug-1', versionId: 'version-1', title: '登录报错' });
await service.create('version-1', {
bugNo: 'BUG-001',
testCaseId: 'tc-1',
title: '登录报错',
description: '登录按钮无响应',
severity: 'critical',
priority: 'P0',
assigneeId: 'dev-1',
reportedBy: 'tester-1',
plannedFixAt: '2026-07-08T18:00:00.000Z',
} as any);
expect(prisma.bug.create).toHaveBeenCalledWith({
data: expect.objectContaining({
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
testCaseId: 'tc-1',
testCaseVersionId: 'version-1',
code: 'BUG-001',
title: '登录报错',
severity: 'critical',
priority: 0,
assigneeId: 'dev-1',
reporterId: 'tester-1',
plannedFixAt: new Date('2026-07-08T18:00:00.000Z'),
status: 'open',
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'bug',
sourceId: 'bug-1',
action: 'bug_created',
}));
});
it('changes status and records closing evidence inside the version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.bug.findFirst.mockResolvedValue({
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'verifying',
title: '登录报错',
assigneeId: 'dev-1',
reporterId: 'tester-1',
});
prisma.bug.update.mockResolvedValue({
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'closed',
title: '登录报错',
assigneeId: 'dev-1',
reporterId: 'tester-1',
});
await service.updateStatus('version-1', 'bug-1', 'closed', { operator: 'tester-1', resolution: '复测通过' });
expect(prisma.bug.update).toHaveBeenCalledWith({
where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } },
data: expect.objectContaining({
status: 'closed',
closedAt: expect.any(Date),
resolution: '复测通过',
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'bug',
sourceId: 'bug-1',
action: 'bug_closed',
}));
});
it('transfers bugs by id plus version id and records activity evidence', async () => {
const { prisma, workActivity, service } = makeService();
prisma.bug.findFirst.mockResolvedValue({
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'open',
title: '登录报错',
assigneeId: 'dev-1',
reporterId: 'tester-1',
});
prisma.bug.update.mockResolvedValue({
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
title: '登录报错',
assigneeId: 'dev-2',
reporterId: 'tester-1',
});
await service.transfer('version-1', 'bug-1', 'dev-2', 'tester-1');
expect(prisma.bug.update).toHaveBeenCalledWith({
where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } },
data: { assigneeId: 'dev-2' },
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
action: 'bug_transferred',
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
}));
});
it('rejects updates outside the version partition', async () => {
const { prisma, service } = makeService();
prisma.bug.findFirst.mockResolvedValue(null);
await expect(service.update('version-1', 'missing-bug', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.bug.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,176 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateBugDto } from './dto/create-bug.dto';
import { UpdateBugDto } from './dto/update-bug.dto';
@Injectable()
export class BugService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity: WorkActivityService,
) {}
async create(versionId: string, dto: CreateBugDto) {
const version = await this.ensureVersion(versionId);
const item = await this.prisma.bug.create({
data: {
...this.toBugData(dto),
versionId,
productId: version.productId,
projectId: version.projectId,
testCaseVersionId: dto.testCaseVersionId ?? (dto.testCaseId ? versionId : null),
code: dto.code?.trim() || dto.bugNo?.trim() || createFallbackCode('BUG'),
title: dto.title,
status: dto.status ?? 'open',
},
});
const activity = await this.recordBugActivity(item, 'bug_created', 'creation', `新建 Bug${item.title}`, {
operator: dto.reporterId ?? dto.reportedBy,
});
return { item, activities: [activity] };
}
findAll(versionId: string) {
return this.prisma.bug.findMany({
where: { versionId },
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
});
}
async update(versionId: string, id: string, dto: UpdateBugDto) {
await this.ensureBugInVersion(versionId, id);
const item = await this.prisma.bug.update({
where: { id_versionId: { id, versionId } },
data: this.toBugData(dto),
});
return { item, activities: [] };
}
async updateStatus(
versionId: string,
id: string,
status: string,
options: { operator?: string; resolution?: string } = {},
) {
const current = await this.ensureBugInVersion(versionId, id);
const data: Record<string, unknown> = { status };
if (status === 'fixed' && !current.resolvedAt) data.resolvedAt = new Date();
if (status === 'closed' && !current.closedAt) data.closedAt = new Date();
if (options.resolution?.trim()) data.resolution = options.resolution.trim();
const item = await this.prisma.bug.update({
where: { id_versionId: { id, versionId } },
data,
});
const activity = await this.recordStatusActivity(item, current.status, status, options.operator);
return { item, activities: activity ? [activity] : [] };
}
async transfer(versionId: string, id: string, assigneeId: string, operator?: string) {
const current = await this.ensureBugInVersion(versionId, id);
const item = await this.prisma.bug.update({
where: { id_versionId: { id, versionId } },
data: { assigneeId },
});
const activity = await this.recordBugActivity(
item,
'bug_transferred',
'progress',
`转交 Bug${item.title}${assigneeId}`,
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId, operator },
);
return { item, activities: [activity] };
}
async remove(versionId: string, id: string) {
await this.ensureBugInVersion(versionId, id);
return this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } });
}
private toBugData(dto: Partial<CreateBugDto>) {
return {
...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }),
...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }),
...(dto.code !== undefined || dto.bugNo !== undefined ? { code: dto.code?.trim() || dto.bugNo?.trim() } : {}),
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description ?? '' }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }),
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
...(dto.reporterId !== undefined || dto.reportedBy !== undefined ? { reporterId: emptyToNull(dto.reporterId ?? dto.reportedBy) } : {}),
...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }),
...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }),
...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }),
...(dto.resolution !== undefined && { resolution: emptyToNull(dto.resolution) }),
};
}
private async ensureVersion(versionId: string) {
const version = await this.prisma.version.findUnique({ where: { id: versionId } });
if (!version) throw new NotFoundException('版本不存在');
if (!version.projectId) throw new BadRequestException('Bug 所属版本必须归属于项目');
return { ...version, projectId: version.projectId };
}
private async ensureBugInVersion(versionId: string, id: string) {
const bug = await this.prisma.bug.findFirst({ where: { id, versionId } });
if (!bug) throw new NotFoundException('Bug 不存在');
return bug;
}
private recordStatusActivity(bug: any, fromStatus: string, toStatus: string, operator?: string) {
if (toStatus === 'fixing') {
return this.recordBugActivity(bug, 'bug_fixing', 'progress', `开始修复 Bug${bug.title}`, { fromStatus, toStatus, operator });
}
if (toStatus === 'fixed') {
return this.recordBugActivity(bug, 'bug_fixed', 'delivery', `已修复 Bug${bug.title}`, { fromStatus, toStatus, operator });
}
if (toStatus === 'closed') {
return this.recordBugActivity(bug, 'bug_closed', 'delivery', `已关闭 Bug${bug.title}`, { fromStatus, toStatus, operator });
}
return undefined;
}
private recordBugActivity(bug: any, action: string, category: string, summary: string, metadata: Record<string, unknown> = {}) {
return this.workActivity.record({
versionId: bug.versionId,
productId: bug.productId,
projectId: bug.projectId,
actorId: metadata.operator as string | undefined ?? bug.assigneeId ?? bug.reporterId,
sourceType: 'bug',
sourceId: bug.id,
action,
category,
title: bug.title,
summary,
metadata,
});
}
}
function createFallbackCode(prefix: string) {
return `${prefix}-${Date.now().toString(36).toUpperCase()}`;
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function parseOptionalDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : null;
}
function parsePriority(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined;
const match = /^P([0-4])$/i.exec(value.trim());
if (match) return Number(match[1]);
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
}

View File

@@ -0,0 +1,65 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateBugDto {
@IsString()
@IsOptional()
testCaseId?: string;
@IsString()
@IsOptional()
testCaseVersionId?: string;
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
bugNo?: string;
@IsString()
title!: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
status?: string;
@IsString()
@IsOptional()
severity?: string;
@IsOptional()
priority?: string | number;
@IsString()
@IsOptional()
assigneeId?: string;
@IsString()
@IsOptional()
reporterId?: string;
@IsString()
@IsOptional()
reportedBy?: string;
@IsString()
@IsOptional()
plannedFixAt?: string;
@IsString()
@IsOptional()
resolvedAt?: string;
@IsString()
@IsOptional()
closedAt?: string;
@IsString()
@IsOptional()
resolution?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateBugDto } from './create-bug.dto';
export class UpdateBugDto extends PartialType(CreateBugDto) {}

View File

@@ -0,0 +1,19 @@
import { Reflector } from '@nestjs/core';
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
import { ConsistencyController } from './consistency.controller';
describe('ConsistencyController', () => {
it('requires consistency:view for consistency checks', () => {
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, ConsistencyController.prototype.run);
expect(metadata).toEqual({ permission: 'consistency:view' });
});
it('delegates consistency checks to the service', async () => {
const service = { run: jest.fn().mockResolvedValue({ status: 'pass' }) };
const controller = new ConsistencyController(service as any);
await expect(controller.run()).resolves.toEqual({ status: 'pass' });
expect(service.run).toHaveBeenCalledWith();
});
});

View File

@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { PermissionGuard } from '../../common/auth/permission.guard';
import { RequirePermission } from '../../common/auth/permission.decorator';
import { ConsistencyService } from './consistency.service';
@Controller('consistency')
export class ConsistencyController {
constructor(private readonly consistencyService: ConsistencyService) {}
@Get()
@UseGuards(PermissionGuard)
@RequirePermission('consistency:view')
run() {
return this.consistencyService.run();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ConsistencyController } from './consistency.controller';
import { ConsistencyService } from './consistency.service';
@Module({
controllers: [ConsistencyController],
providers: [ConsistencyService],
})
export class ConsistencyModule {}

View File

@@ -0,0 +1,73 @@
import { ConsistencyService } from './consistency.service';
describe('ConsistencyService', () => {
const makePrisma = () => {
const counts = {
product: 1,
project: 2,
version: 3,
requirement: 4,
versionPlan: 5,
devTask: 6,
testCase: 7,
bug: 8,
user: 9,
taskCategory: 10,
taskWorklog: 11,
overtimeRecord: 12,
workActivity: 13,
auditEvent: 14,
};
const prisma: any = {
$queryRawUnsafe: jest.fn((sql: string) => {
if (sql.includes('dev_tasks') && sql.includes("version_id = ''")) return Promise.resolve([{ count: 1n }]);
if (sql.includes('requirements') && sql.includes('missing_version')) return Promise.resolve([{ count: 2n }]);
if (sql.includes('audit_events') && sql.includes("entity_type = 'bug'")) return Promise.resolve([{ count: 0n }]);
return Promise.resolve([{ count: 0n }]);
}),
};
for (const [model, count] of Object.entries(counts)) {
prisma[model] = { count: jest.fn().mockResolvedValue(count) };
}
return prisma;
};
it('returns counts plus error/warn consistency groups', async () => {
const prisma = makePrisma();
const service = new ConsistencyService(prisma);
const result = await service.run();
expect(result.status).toBe('fail');
expect(result.counts.devTasks).toBe(6);
expect(result.summary.errors).toBeGreaterThan(0);
expect(result.summary.warnings).toBeGreaterThan(0);
expect(result.checks.partitionKeys).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'dev_tasks.version_id.present',
severity: 'error',
count: 1,
}),
]),
);
expect(result.checks.orphanReferences).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'requirements.version_id.exists',
severity: 'error',
count: 2,
}),
]),
);
expect(result.checks.auditCoverage).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'audit.coverage.bug',
severity: 'warn',
count: 0,
}),
]),
);
});
});

View File

@@ -0,0 +1,186 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export type ConsistencySeverity = 'ok' | 'warn' | 'error';
export type ConsistencyStatus = 'pass' | 'fail';
export interface ConsistencyCheckResult {
id: string;
label: string;
severity: ConsistencySeverity;
count: number;
message: string;
}
export interface ConsistencyResult {
generatedAt: string;
status: ConsistencyStatus;
counts: Record<string, number>;
checks: {
partitionKeys: ConsistencyCheckResult[];
orphanReferences: ConsistencyCheckResult[];
auditCoverage: ConsistencyCheckResult[];
};
summary: {
errors: number;
warnings: number;
human: string;
};
}
const COUNT_MODELS: Array<[string, string]> = [
['products', 'product'],
['projects', 'project'],
['versions', 'version'],
['requirements', 'requirement'],
['versionPlans', 'versionPlan'],
['devTasks', 'devTask'],
['testCases', 'testCase'],
['bugs', 'bug'],
['members', 'user'],
['taskCategories', 'taskCategory'],
['taskWorklogs', 'taskWorklog'],
['overtimeRecords', 'overtimeRecord'],
['workActivities', 'workActivity'],
['auditEvents', 'auditEvent'],
];
const PARTITION_KEY_CHECKS = [
check('requirements.product_id.present', 'requirements must keep product_id partition key', 'error', "SELECT COUNT(*) AS count FROM requirements WHERE product_id IS NULL OR product_id = ''"),
check('dev_tasks.version_id.present', 'dev_tasks must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM dev_tasks WHERE version_id IS NULL OR version_id = ''"),
check('test_cases.version_id.present', 'test_cases must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM test_cases WHERE version_id IS NULL OR version_id = ''"),
check('bugs.version_id.present', 'bugs must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM bugs WHERE version_id IS NULL OR version_id = ''"),
check('work_activities.created_at.present', 'work_activities must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM work_activities WHERE created_at IS NULL'),
check('task_worklogs.created_at.present', 'task_worklogs must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM task_worklogs WHERE created_at IS NULL'),
check('overtime_records.created_at.present', 'overtime_records must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM overtime_records WHERE created_at IS NULL'),
check('audit_events.created_at.present', 'audit_events must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM audit_events WHERE created_at IS NULL'),
];
const ORPHAN_REFERENCE_CHECKS = [
check('projects.product_id.exists', 'projects.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM projects p LEFT JOIN products pr ON pr.id = p.product_id WHERE pr.id IS NULL'),
check('versions.product_id.exists', 'versions.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN products p ON p.id = v.product_id WHERE p.id IS NULL'),
check('versions.project_id.exists', 'versions.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN projects p ON p.id = v.project_id WHERE v.project_id IS NOT NULL AND p.id IS NULL'),
check('requirements.product_id.exists', 'requirements.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN products p ON p.id = r.product_id WHERE p.id IS NULL'),
check('requirements.project_id.exists', 'requirements.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN projects p ON p.id = r.project_id WHERE r.project_id IS NOT NULL AND p.id IS NULL'),
check('requirements.version_id.exists', 'requirements.version_id must reference versions.id when present', 'error', 'SELECT COUNT(*) AS count /* missing_version */ FROM requirements r LEFT JOIN versions v ON v.id = r.version_id WHERE r.version_id IS NOT NULL AND v.id IS NULL'),
check('version_plans.version_id.exists', 'version_plans.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM version_plans vp LEFT JOIN versions v ON v.id = vp.version_id WHERE v.id IS NULL'),
check('dev_tasks.version_id.exists', 'dev_tasks.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN versions v ON v.id = dt.version_id WHERE v.id IS NULL'),
check('dev_tasks.requirement.exists', 'dev_tasks requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN requirements r ON r.id = dt.requirement_id AND r.product_id = dt.requirement_product_id WHERE dt.requirement_id IS NOT NULL AND r.id IS NULL'),
check('test_cases.version_id.exists', 'test_cases.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN versions v ON v.id = tc.version_id WHERE v.id IS NULL'),
check('test_cases.requirement.exists', 'test_cases requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN requirements r ON r.id = tc.requirement_id AND r.product_id = tc.requirement_product_id WHERE tc.requirement_id IS NOT NULL AND r.id IS NULL'),
check('bugs.version_id.exists', 'bugs.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN versions v ON v.id = b.version_id WHERE v.id IS NULL'),
check('bugs.test_case.exists', 'bugs test_case composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN test_cases tc ON tc.id = b.test_case_id AND tc.version_id = b.test_case_version_id WHERE b.test_case_id IS NOT NULL AND tc.id IS NULL'),
];
const AUDIT_ENTITY_TYPES = [
'product',
'project',
'version',
'requirement',
'version_plan',
'dev_task',
'test_case',
'bug',
'member',
'task_category',
'task_worklog',
'overtime',
'work_activity',
];
@Injectable()
export class ConsistencyService {
constructor(private readonly prisma: PrismaService) {}
async run(): Promise<ConsistencyResult> {
const [counts, partitionKeys, orphanReferences, auditCoverage] = await Promise.all([
this.collectCounts(),
this.runChecks(PARTITION_KEY_CHECKS),
this.runChecks(ORPHAN_REFERENCE_CHECKS),
this.runAuditCoverageChecks(),
]);
const allChecks = [...partitionKeys, ...orphanReferences, ...auditCoverage];
const errors = allChecks.filter((item) => item.severity === 'error').length;
const warnings = allChecks.filter((item) => item.severity === 'warn').length;
const status: ConsistencyStatus = errors > 0 ? 'fail' : 'pass';
return {
generatedAt: new Date().toISOString(),
status,
counts,
checks: { partitionKeys, orphanReferences, auditCoverage },
summary: {
errors,
warnings,
human: buildHumanSummary(status, errors, warnings, counts),
},
};
}
private async collectCounts() {
const entries = await Promise.all(
COUNT_MODELS.map(async ([label, model]) => [label, await (this.prisma as any)[model].count()] as const),
);
return Object.fromEntries(entries);
}
private async runChecks(checks: ConsistencyCheck[]) {
return Promise.all(checks.map((item) => this.runSqlCheck(item)));
}
private async runAuditCoverageChecks() {
return Promise.all(AUDIT_ENTITY_TYPES.map(async (entityType) => {
const count = await this.rawCount(`SELECT COUNT(*) AS count FROM audit_events WHERE entity_type = '${entityType}'`);
const label = `audit_events should contain mutation events for ${entityType}`;
return {
id: `audit.coverage.${entityType}`,
label,
severity: count === 0 ? 'warn' : 'ok',
count,
message: count === 0 ? `${label}: no events yet` : `${label}: ${count}`,
} satisfies ConsistencyCheckResult;
}));
}
private async runSqlCheck(item: ConsistencyCheck): Promise<ConsistencyCheckResult> {
const count = await this.rawCount(item.sql);
const severity = count > 0 ? item.severityWhenNonZero : 'ok';
return {
id: item.id,
label: item.label,
severity,
count,
message: count > 0 ? `${item.label}: ${count}` : `${item.label}: ok`,
};
}
private async rawCount(sql: string): Promise<number> {
const rows = await this.prisma.$queryRawUnsafe<Array<{ count: bigint | number | string }>>(sql);
return Number(rows[0]?.count ?? 0);
}
}
interface ConsistencyCheck {
id: string;
label: string;
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>;
sql: string;
}
function check(
id: string,
label: string,
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>,
sql: string,
): ConsistencyCheck {
return { id, label, severityWhenNonZero, sql };
}
function buildHumanSummary(
status: ConsistencyStatus,
errors: number,
warnings: number,
counts: Record<string, number>,
) {
return `V2.5 consistency ${status}: ${errors} error(s), ${warnings} warning(s), ${counts.auditEvents ?? 0} audit event(s).`;
}

View File

@@ -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],
})

View File

@@ -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[] = [];

View File

@@ -1,8 +1,9 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, Logger, Optional } 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,8 @@ export class DataService {
constructor(
private prisma: PrismaService,
private readonly appDataSync?: AppDataV23SyncService,
@Optional()
private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(),
) {}
async get(key: string) {
@@ -27,6 +30,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 +70,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}`);
}

View File

@@ -0,0 +1,93 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
import { DevTaskService } from './dev-task.service';
@Controller('versions/:versionId/dev-tasks')
export class DevTaskController {
constructor(private readonly devTaskService: DevTaskService) {}
@Post()
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
action: 'dev_task.create',
entityType: 'dev_task',
versionIdParam: 'versionId',
})
create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) {
return this.devTaskService.create(versionId, dto);
}
@Get()
findAll(@Param('versionId') versionId: string) {
return this.devTaskService.findAll(versionId);
}
@Patch(':id')
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
action: 'dev_task.update',
entityType: 'dev_task',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) {
return this.devTaskService.update(versionId, id, dto);
}
@Patch(':id/status')
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
action: 'dev_task.status',
entityType: 'dev_task',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
updateStatus(
@Param('versionId') versionId: string,
@Param('id') id: string,
@Body('status') status: string,
) {
return this.devTaskService.updateStatus(versionId, id, status);
}
@Patch(':id/block')
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
action: 'dev_task.block',
entityType: 'dev_task',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
setBlocked(
@Param('versionId') versionId: string,
@Param('id') id: string,
@Body('blocked') blocked: boolean,
@Body('reason') reason?: string,
) {
return this.devTaskService.setBlocked(versionId, id, blocked, reason);
}
@Patch(':id/transfer')
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
action: 'dev_task.transfer',
entityType: 'dev_task',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
transfer(
@Param('versionId') versionId: string,
@Param('id') id: string,
@Body('assigneeId') assigneeId: string,
) {
return this.devTaskService.transfer(versionId, id, assigneeId);
}
@Delete(':id')
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
action: 'dev_task.delete',
entityType: 'dev_task',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
return this.devTaskService.remove(versionId, id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { DevTaskController } from './dev-task.controller';
import { DevTaskService } from './dev-task.service';
@Module({
imports: [WorkActivityModule],
controllers: [DevTaskController],
providers: [DevTaskService],
exports: [DevTaskService],
})
export class DevTaskModule {}

View File

@@ -0,0 +1,153 @@
import { NotFoundException } from '@nestjs/common';
import { DevTaskService } from './dev-task.service';
describe('DevTaskService domain writes', () => {
const makeService = () => {
const workActivity = {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
};
const prisma = {
version: {
findUnique: jest.fn(),
},
devTask: {
create: jest.fn(),
delete: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return {
prisma,
workActivity,
service: new DevTaskService(prisma as any, workActivity as any),
};
};
it('creates dev tasks directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' });
await service.create('version-1', {
requirementId: 'req-1',
requirementProductId: 'product-1',
taskNo: 'DEV-001',
title: '开发登录',
categoryId: 'cat-fe',
assigneeId: 'member-1',
priority: 'P1',
expectedStartAt: '2026-07-08T09:00:00.000Z',
expectedEndAt: '2026-07-08T18:00:00.000Z',
estimateHours: 8,
references: [{ type: 'requirement', id: 'REQ-001', label: 'REQ-001 登录' }],
createdBy: 'member-pm',
} as any);
expect(prisma.devTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
requirementId: 'req-1',
requirementProductId: 'product-1',
code: 'DEV-001',
title: '开发登录',
categoryId: 'cat-fe',
assigneeId: 'member-1',
priority: 1,
expectedStartAt: new Date('2026-07-08T09:00:00.000Z'),
expectedEndAt: new Date('2026-07-08T18:00:00.000Z'),
estimateHours: 8,
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'dev_task',
sourceId: 'task-1',
action: 'dev_task_created',
}));
});
it('changes status by id plus version id and records activity evidence', async () => {
const { prisma, workActivity, service } = makeService();
prisma.devTask.findFirst.mockResolvedValue({
id: 'task-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'todo',
title: '开发登录',
assigneeId: 'member-1',
});
prisma.devTask.update.mockResolvedValue({
id: 'task-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'in_progress',
title: '开发登录',
assigneeId: 'member-1',
});
await service.updateStatus('version-1', 'task-1', 'in_progress');
expect(prisma.devTask.update).toHaveBeenCalledWith({
where: { id_versionId: { id: 'task-1', versionId: 'version-1' } },
data: expect.objectContaining({
status: 'in_progress',
startDate: expect.any(Date),
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'dev_task',
sourceId: 'task-1',
action: 'dev_task_started',
}));
});
it('blocks and unblocks tasks inside the version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.devTask.findFirst.mockResolvedValue({
id: 'task-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'in_progress',
title: '开发登录',
assigneeId: 'member-1',
});
prisma.devTask.update.mockResolvedValue({
id: 'task-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
isBlocked: true,
blockReason: '接口未就绪',
title: '开发登录',
assigneeId: 'member-1',
});
await service.setBlocked('version-1', 'task-1', true, '接口未就绪');
expect(prisma.devTask.update).toHaveBeenCalledWith({
where: { id_versionId: { id: 'task-1', versionId: 'version-1' } },
data: { isBlocked: true, blockReason: '接口未就绪' },
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
action: 'dev_task_blocked',
metadata: expect.objectContaining({ blocker: '接口未就绪' }),
}));
});
it('rejects updates outside the version partition', async () => {
const { prisma, service } = makeService();
prisma.devTask.findFirst.mockResolvedValue(null);
await expect(service.update('version-1', 'missing-task', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.devTask.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,205 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
@Injectable()
export class DevTaskService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity: WorkActivityService,
) {}
async create(versionId: string, dto: CreateDevTaskDto) {
const version = await this.ensureVersion(versionId);
if (!version.projectId) {
throw new BadRequestException('开发任务所属版本必须归属于项目');
}
const item = await this.prisma.devTask.create({
data: {
...this.toTaskData(dto),
versionId,
productId: version.productId,
projectId: version.projectId,
code: dto.code?.trim() || dto.taskNo?.trim() || createFallbackDevCode(),
title: dto.title,
status: dto.status ?? 'todo',
isBlocked: dto.isBlocked ?? false,
},
});
const activity = await this.recordTaskActivity(item, 'dev_task_created', 'creation', `新建开发任务:${item.title}`);
return { item, activities: [activity] };
}
findAll(versionId: string) {
return this.prisma.devTask.findMany({
where: { versionId },
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
});
}
async update(versionId: string, id: string, dto: UpdateDevTaskDto) {
await this.ensureTaskInVersion(versionId, id);
const item = await this.prisma.devTask.update({
where: { id_versionId: { id, versionId } },
data: this.toTaskData(dto),
});
return { item, activities: [] };
}
async updateStatus(versionId: string, id: string, status: string) {
const current = await this.ensureTaskInVersion(versionId, id);
const data: Record<string, unknown> = { status };
if (status === 'in_progress' && !current.startDate) data.startDate = new Date();
if (status === 'submitted' && !current.completedAt) data.completedAt = new Date();
const item = await this.prisma.devTask.update({
where: { id_versionId: { id, versionId } },
data,
});
const activity = await this.recordStatusActivity(item, current.status, status);
return { item, activities: activity ? [activity] : [] };
}
async setBlocked(versionId: string, id: string, blocked: boolean, reason?: string) {
await this.ensureTaskInVersion(versionId, id);
const item = await this.prisma.devTask.update({
where: { id_versionId: { id, versionId } },
data: {
isBlocked: blocked,
blockReason: blocked ? reason : null,
},
});
const activity = await this.recordTaskActivity(
item,
blocked ? 'dev_task_blocked' : 'dev_task_unblocked',
blocked ? 'risk' : 'progress',
blocked ? `标记阻塞:${reason?.trim() || item.title}` : `解除阻塞:${item.title}`,
{ blocker: blocked ? reason : undefined },
);
return { item, activities: [activity] };
}
async transfer(versionId: string, id: string, assigneeId: string) {
const current = await this.ensureTaskInVersion(versionId, id);
const item = await this.prisma.devTask.update({
where: { id_versionId: { id, versionId } },
data: { assigneeId },
});
const activity = await this.recordTaskActivity(
item,
'dev_task_transferred',
'progress',
`转派开发任务:${item.title}`,
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId },
);
return { item, activities: [activity] };
}
async remove(versionId: string, id: string) {
await this.ensureTaskInVersion(versionId, id);
return this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } });
}
private toTaskData(dto: Partial<CreateDevTaskDto>) {
return {
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
...(dto.categoryId !== undefined && { categoryId: emptyToNull(dto.categoryId) }),
...(dto.code !== undefined || dto.taskNo !== undefined ? { code: dto.code?.trim() || dto.taskNo?.trim() } : {}),
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description ?? '' }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }),
...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }),
...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }),
...(dto.expectedEndAt !== undefined && { expectedEndAt: parseOptionalDate(dto.expectedEndAt) }),
...(dto.actualStartAt !== undefined || dto.startDate !== undefined
? { startDate: parseOptionalDate(dto.actualStartAt ?? dto.startDate) }
: {}),
...(dto.actualEndAt !== undefined || dto.completedAt !== undefined
? { completedAt: parseOptionalDate(dto.actualEndAt ?? dto.completedAt) }
: {}),
...(dto.estimateHours !== undefined && { estimateHours: dto.estimateHours }),
...(dto.aiEstimateHours !== undefined && { aiEstimateHours: dto.aiEstimateHours }),
...(dto.references !== undefined && { references: toJsonInput(dto.references) }),
...(dto.aiDraft !== undefined && { aiDraft: dto.aiDraft }),
...(dto.aiDraftAt !== undefined && { aiDraftAt: parseOptionalDate(dto.aiDraftAt) }),
};
}
private async ensureVersion(versionId: string) {
const version = await this.prisma.version.findUnique({ where: { id: versionId } });
if (!version) throw new NotFoundException('版本不存在');
return version;
}
private async ensureTaskInVersion(versionId: string, id: string) {
const task = await this.prisma.devTask.findFirst({ where: { id, versionId } });
if (!task) throw new NotFoundException('开发任务不存在');
return task;
}
private recordStatusActivity(task: any, fromStatus: string, toStatus: string) {
if (toStatus === 'in_progress') {
return this.recordTaskActivity(task, 'dev_task_started', 'progress', `开始开发:${task.title}`, { fromStatus, toStatus });
}
if (toStatus === 'testing') {
return this.recordTaskActivity(task, 'dev_task_self_testing', 'progress', `进入自测:${task.title}`, { fromStatus, toStatus });
}
if (toStatus === 'submitted') {
return this.recordTaskActivity(task, 'dev_task_submitted', 'delivery', `已提测开发任务:${task.title}`, { fromStatus, toStatus });
}
return undefined;
}
private recordTaskActivity(task: any, action: string, category: string, summary: string, metadata: Record<string, unknown> = {}) {
return this.workActivity.record({
versionId: task.versionId,
productId: task.productId,
projectId: task.projectId,
actorId: task.assigneeId ?? task.creatorId,
sourceType: 'dev_task',
sourceId: task.id,
action,
category,
title: task.title,
summary,
metadata,
});
}
}
function createFallbackDevCode() {
return `DEV-${Date.now().toString(36).toUpperCase()}`;
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function parseOptionalDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : null;
}
function parsePriority(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined;
const match = /^P([0-4])$/i.exec(value.trim());
if (match) return Number(match[1]);
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
}
function toJsonInput(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}

View File

@@ -0,0 +1,99 @@
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
export class CreateDevTaskDto {
@IsString()
@IsOptional()
requirementId?: string;
@IsString()
@IsOptional()
requirementProductId?: string;
@IsString()
@IsOptional()
categoryId?: string;
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
taskNo?: string;
@IsString()
title!: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
status?: string;
@IsOptional()
priority?: string | number;
@IsString()
@IsOptional()
assigneeId?: string;
@IsString()
@IsOptional()
creatorId?: string;
@IsString()
@IsOptional()
createdBy?: string;
@IsBoolean()
@IsOptional()
isBlocked?: boolean;
@IsString()
@IsOptional()
blockReason?: string;
@IsString()
@IsOptional()
expectedStartAt?: string;
@IsString()
@IsOptional()
expectedEndAt?: string;
@IsString()
@IsOptional()
actualStartAt?: string;
@IsString()
@IsOptional()
actualEndAt?: string;
@IsString()
@IsOptional()
startDate?: string;
@IsString()
@IsOptional()
completedAt?: string;
@IsOptional()
estimateHours?: number;
@IsOptional()
aiEstimateHours?: number;
@IsArray()
@IsOptional()
references?: unknown[];
@IsBoolean()
@IsOptional()
aiDraft?: boolean;
@IsString()
@IsOptional()
aiDraftAt?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDevTaskDto } from './create-dev-task.dto';
export class UpdateDevTaskDto extends PartialType(CreateDevTaskDto) {}

View File

@@ -0,0 +1,34 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class CreateMemberDto {
@IsString()
name!: string;
@IsString()
@IsOptional()
username?: string;
@IsString()
@IsOptional()
departmentId?: string;
@IsString()
@IsOptional()
roleId?: string;
@IsString()
@IsOptional()
phone?: string;
@IsString()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
password?: string;
@IsBoolean()
@IsOptional()
isSystem?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateMemberDto } from './create-member.dto';
export class UpdateMemberDto extends PartialType(CreateMemberDto) {}

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateMemberDto } from './dto/create-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
import { MemberService } from './member.service';
@Controller('members')
export class MemberController {
constructor(private readonly memberService: MemberService) {}
@Get()
findAll() {
return this.memberService.findAll();
}
@Post()
@ProtectedMutation('member:create', {}, { action: 'member.create', entityType: 'member' })
create(@Body() dto: CreateMemberDto) {
return this.memberService.create(dto);
}
@Patch(':id')
@ProtectedMutation('member:edit', {}, {
action: 'member.update',
entityType: 'member',
entityIdParam: 'id',
})
update(@Param('id') id: string, @Body() dto: UpdateMemberDto) {
return this.memberService.update(id, dto);
}
@Delete(':id')
@ProtectedMutation('member:delete', {}, {
action: 'member.delete',
entityType: 'member',
entityIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.memberService.remove(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MemberController } from './member.controller';
import { MemberService } from './member.service';
@Module({
controllers: [MemberController],
providers: [MemberService],
exports: [MemberService],
})
export class MemberModule {}

View File

@@ -0,0 +1,94 @@
import { BadRequestException } from '@nestjs/common';
import { MemberService } from './member.service';
describe('MemberService domain writes', () => {
const makeService = () => {
const prisma = {
user: {
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return { prisma, service: new MemberService(prisma as any) };
};
it('creates members with UI role and department fields in users', async () => {
const { prisma, service } = makeService();
prisma.user.create.mockResolvedValue({
id: 'member-1',
name: '张三',
username: 'zhangsan',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13000000000',
email: 'zhangsan@example.com',
password: 'Ftb12345',
isSystem: false,
createdAt: new Date('2026-07-08T00:00:00.000Z'),
});
await service.create({
name: '张三',
username: 'zhangsan',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13000000000',
email: 'zhangsan@example.com',
password: 'Ftb12345',
});
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: '张三',
username: 'zhangsan',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13000000000',
email: 'zhangsan@example.com',
password: 'Ftb12345',
}),
});
});
it('protects the built-in admin from deletion and privileged field edits', async () => {
const { prisma, service } = makeService();
prisma.user.update.mockResolvedValue({
id: 'm-8',
name: '超级管理员',
username: 'admin',
roleId: 'role-admin',
departmentId: '',
phone: '13200132009',
email: 'admin@example.com',
password: 'Ftb12345',
isSystem: true,
createdAt: new Date('2026-07-08T00:00:00.000Z'),
});
await expect(service.remove('m-8')).rejects.toBeInstanceOf(BadRequestException);
await service.update('m-8', {
name: '误改',
username: 'other',
roleId: 'role-viewer',
departmentId: 'dept-1',
phone: '13200132009',
});
expect(prisma.user.delete).not.toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'm-8' },
data: expect.not.objectContaining({
name: '误改',
username: 'other',
roleId: 'role-viewer',
departmentId: 'dept-1',
}),
});
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ phone: '13200132009', isSystem: true }),
}));
});
});

View File

@@ -0,0 +1,95 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateMemberDto } from './dto/create-member.dto';
import { UpdateMemberDto } from './dto/update-member.dto';
const SYSTEM_ADMIN_MEMBER_ID = 'm-8';
const SYSTEM_ADMIN_NAME = '超级管理员';
const SYSTEM_ADMIN_USERNAME = 'admin';
const SYSTEM_ADMIN_ROLE_ID = 'role-admin';
@Injectable()
export class MemberService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
const rows = await (this.prisma.user as any).findMany({ orderBy: [{ isSystem: 'desc' }, { name: 'asc' }] });
return rows.map(toMember);
}
async create(dto: CreateMemberDto) {
const data = toUserData(dto);
const row = await (this.prisma.user as any).create({ data });
return toMember(row);
}
async update(id: string, dto: UpdateMemberDto) {
const data = id === SYSTEM_ADMIN_MEMBER_ID
? { ...sanitizeSystemAdminPatch(dto), isSystem: true }
: toUserData(dto, { partial: true });
const row = await (this.prisma.user as any).update({ where: { id }, data });
return toMember(row);
}
async remove(id: string) {
if (id === SYSTEM_ADMIN_MEMBER_ID) {
throw new BadRequestException('系统内置超级管理员不可删除');
}
await (this.prisma.user as any).delete({ where: { id } });
return { deleted: true };
}
}
function sanitizeSystemAdminPatch(dto: UpdateMemberDto) {
const data = toUserData(dto, { partial: true });
delete (data as any).name;
delete (data as any).username;
delete (data as any).departmentId;
delete (data as any).roleId;
delete (data as any).email;
return data;
}
function toUserData(dto: Partial<CreateMemberDto>, options: { partial?: boolean } = {}) {
const id = (dto as any).id as string | undefined;
const email = dto.email?.trim() || (options.partial ? undefined : `${id || dto.username || Date.now()}@local.ftb`);
return {
...(id !== undefined && { id }),
...(dto.name !== undefined && { name: dto.name }),
...(dto.username !== undefined && { username: emptyToNull(dto.username) }),
...(dto.departmentId !== undefined && { departmentId: emptyToNull(dto.departmentId) }),
...(dto.roleId !== undefined && { roleId: dto.roleId || 'member' }),
...(dto.phone !== undefined && { phone: dto.phone ?? '' }),
...(email !== undefined && { email }),
...(dto.password !== undefined && { password: dto.password ?? '' }),
...(dto.isSystem !== undefined && { isSystem: dto.isSystem }),
};
}
function toMember(row: any) {
return {
id: row.id,
name: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_NAME : row.name,
username: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_USERNAME : row.username ?? undefined,
departmentId: row.id === SYSTEM_ADMIN_MEMBER_ID ? '' : row.departmentId ?? '',
roleId: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_ROLE_ID : row.roleId ?? 'member',
phone: row.phone ?? '',
email: row.email?.endsWith('@local.ftb') ? '' : row.email ?? '',
password: row.password ?? '',
createdAt: toIsoDate(row.createdAt),
isSystem: Boolean(row.isSystem) || row.id === SYSTEM_ADMIN_MEMBER_ID,
};
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function toIsoDate(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (typeof value === 'string' && value) return value.slice(0, 10);
return new Date().toISOString().slice(0, 10);
}

View File

@@ -29,7 +29,15 @@ interface VersionRow {
projectId?: string;
name: string;
description: string;
status: string;
currentStage?: string;
startDate?: string;
expectedReleaseDate?: string;
releaseDate?: string;
members: unknown[];
progress: unknown[];
priority?: number;
links: unknown;
createdAt?: string;
updatedAt?: string;
}
@@ -348,7 +356,15 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
projectId,
name: stringField(version, 'name') ?? versionId,
description: stringField(version, 'description') ?? '',
status: stringField(version, 'status') ?? 'planned',
currentStage: stringField(version, 'currentStage'),
startDate: stringField(version, 'startDate'),
expectedReleaseDate: stringField(version, 'expectedReleaseDate'),
releaseDate: stringField(version, 'releaseDate') ?? stringField(version, 'expectedReleaseDate'),
members: asArray(version.members),
progress: asArray(version.progress),
priority: priorityRank(version.priority),
links: asRecord(version.links) ?? {},
createdAt: stringField(version, 'createdAt') ?? productRow.createdAt,
updatedAt: stringField(version, 'updatedAt') ?? stringField(version, 'createdAt') ?? productRow.updatedAt,
};

View File

@@ -0,0 +1,34 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateOvertimeDto {
@IsString()
@IsOptional()
productId?: string;
@IsString()
@IsOptional()
projectId?: string;
@IsString()
@IsOptional()
versionId?: string;
@IsString()
person!: string;
@IsString()
startTime!: string;
@IsString()
endTime!: string;
@IsOptional()
duration?: number;
@IsString()
reasonId!: string;
@IsString()
@IsOptional()
remark?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateOvertimeDto } from './create-overtime.dto';
export class UpdateOvertimeDto extends PartialType(CreateOvertimeDto) {}

View File

@@ -0,0 +1,50 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateOvertimeDto } from './dto/create-overtime.dto';
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
import { OvertimeService } from './overtime.service';
@Controller('overtime')
export class OvertimeController {
constructor(private readonly overtimeService: OvertimeService) {}
@Get()
findAll() {
return this.overtimeService.findAll();
}
@Post()
@ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, {
action: 'overtime.create',
entityType: 'overtime',
productIdBody: 'productId',
projectIdBody: 'projectId',
versionIdBody: 'versionId',
})
create(@Body() dto: CreateOvertimeDto) {
return this.overtimeService.create(dto);
}
@Patch(':id')
@ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, {
action: 'overtime.update',
entityType: 'overtime',
entityIdParam: 'id',
productIdBody: 'productId',
projectIdBody: 'projectId',
versionIdBody: 'versionId',
})
update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) {
return this.overtimeService.update(id, dto);
}
@Delete(':id')
@ProtectedMutation('overtime:delete', {}, {
action: 'overtime.delete',
entityType: 'overtime',
entityIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.overtimeService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { OvertimeController } from './overtime.controller';
import { OvertimeService } from './overtime.service';
@Module({
imports: [WorkActivityModule],
controllers: [OvertimeController],
providers: [OvertimeService],
exports: [OvertimeService],
})
export class OvertimeModule {}

View File

@@ -0,0 +1,37 @@
import { OvertimeService } from './overtime.service';
describe('OvertimeService domain writes', () => {
it('writes overtime records to relation table', async () => {
const prisma = {
overtimeRecord: {
create: jest.fn().mockResolvedValue({ id: 'ot-1' }),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
const service = new OvertimeService(prisma as any);
await service.create({
projectId: 'project-1',
versionId: 'version-1',
person: 'member-1',
startTime: '2026-07-08T19:00:00.000Z',
endTime: '2026-07-08T21:00:00.000Z',
duration: 2,
reasonId: 'reason-4',
});
expect(prisma.overtimeRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({
projectId: 'project-1',
versionId: 'version-1',
userId: 'member-1',
reason: 'reason-4',
startAt: new Date('2026-07-08T19:00:00.000Z'),
endAt: new Date('2026-07-08T21:00:00.000Z'),
hours: 2,
}),
});
});
});

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateOvertimeDto } from './dto/create-overtime.dto';
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
@Injectable()
export class OvertimeService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity?: WorkActivityService,
) {}
async create(dto: CreateOvertimeDto) {
const item = await this.prisma.overtimeRecord.create({
data: {
productId: dto.productId || null,
projectId: dto.projectId || null,
versionId: dto.versionId || null,
userId: dto.person,
reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId,
startAt: new Date(dto.startTime),
endAt: new Date(dto.endTime),
hours: dto.duration ?? 0,
},
});
if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId);
return item;
}
findAll() {
return this.prisma.overtimeRecord.findMany({ orderBy: { createdAt: 'desc' } });
}
update(id: string, dto: UpdateOvertimeDto) {
return this.prisma.overtimeRecord.updateMany({ where: { id }, data: toData(dto) });
}
async remove(id: string) {
await this.prisma.overtimeRecord.deleteMany({ where: { id } });
return { deleted: true };
}
}
function toData(dto: UpdateOvertimeDto) {
return {
...(dto.productId !== undefined && { productId: dto.productId || null }),
...(dto.projectId !== undefined && { projectId: dto.projectId || null }),
...(dto.versionId !== undefined && { versionId: dto.versionId || null }),
...(dto.person !== undefined && { userId: dto.person }),
...(dto.reasonId !== undefined && { reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId }),
...(dto.startTime !== undefined && { startAt: new Date(dto.startTime) }),
...(dto.endTime !== undefined && { endAt: new Date(dto.endTime) }),
...(dto.duration !== undefined && { hours: dto.duration ?? 0 }),
};
}

View File

@@ -1,4 +1,5 @@
import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { ProductService } from './product.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
@@ -8,6 +9,7 @@ export class ProductController {
constructor(private readonly productService: ProductService) {}
@Post()
@ProtectedMutation('product:create', {}, { action: 'product.create', entityType: 'product' })
create(@Body() dto: CreateProductDto) {
return this.productService.create(dto);
}
@@ -28,11 +30,23 @@ export class ProductController {
}
@Patch(':id')
@ProtectedMutation('product:edit', { productIdParam: 'id' }, {
action: 'product.update',
entityType: 'product',
entityIdParam: 'id',
productIdParam: 'id',
})
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
return this.productService.update(id, dto);
}
@Delete(':id')
@ProtectedMutation('product:delete', { productIdParam: 'id' }, {
action: 'product.delete',
entityType: 'product',
entityIdParam: 'id',
productIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.productService.remove(id);
}

View File

@@ -10,7 +10,22 @@ type ProductOverviewItem = {
createdAt?: string;
updatedAt?: string;
projects?: { id: string; name: string; description?: string; createdAt?: string }[];
versions?: { id: string; name: string; releaseDate?: string | null; createdAt?: string }[];
versions?: {
id: string;
productId?: string;
projectId?: string | null;
name: string;
status?: string;
currentStage?: string | null;
startDate?: string | null;
expectedReleaseDate?: string | null;
releaseDate?: string | null;
members?: unknown[];
progress?: unknown[];
priority?: string | number | null;
links?: unknown;
createdAt?: string;
}[];
_count?: { requirements?: number; projects?: number; versions?: number };
};
@@ -23,8 +38,16 @@ export class ProductService {
}
async findAll() {
const products = await this.prisma.product.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { requirements: true, projects: true, versions: true } },
},
});
if (products.length > 0) return products;
const overview = await this.getAppDataOverview();
if (overview) {
if (!overview) return [];
return overview.map((product) => {
const normalized = this.normalizeOverviewProduct(product);
const { projects: _projects, versions: _versions, ...rest } = normalized;
@@ -32,21 +55,8 @@ export class ProductService {
});
}
return this.prisma.product.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { requirements: true, projects: true } },
},
});
}
async findAllWithChildren() {
const overview = await this.getAppDataOverview();
if (overview) {
return overview.map((product) => this.normalizeOverviewProduct(product));
}
return this.prisma.product.findMany({
const products = await this.prisma.product.findMany({
orderBy: { createdAt: 'desc' },
include: {
projects: {
@@ -55,34 +65,57 @@ export class ProductService {
},
versions: {
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, releaseDate: true, createdAt: true },
select: {
id: true,
productId: true,
projectId: true,
name: true,
description: true,
status: true,
currentStage: true,
startDate: true,
expectedReleaseDate: true,
releaseDate: true,
members: true,
progress: true,
priority: true,
links: true,
createdAt: true,
updatedAt: true,
},
},
_count: { select: { requirements: true, projects: true, versions: true } },
},
});
if (products.length > 0) return products.map((product) => this.normalizeRelationProduct(product));
const overview = await this.getAppDataOverview();
if (!overview) return [];
return overview.map((product) => this.normalizeOverviewProduct(product));
}
async findOne(id: string) {
const overview = await this.getAppDataOverview();
if (overview) {
const product = overview.find((item) => item.id === id);
if (!product) throw new NotFoundException('产品不存在');
return {
...this.normalizeOverviewProduct(product),
requirements: [],
};
}
const product = await this.prisma.product.findUnique({
where: { id },
include: {
requirements: { orderBy: { createdAt: 'desc' } },
projects: {
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, description: true, createdAt: true },
},
versions: { orderBy: { createdAt: 'desc' } },
_count: { select: { projects: true } },
},
});
if (!product) throw new NotFoundException('产品不存在');
return product;
if (product) return product;
const overview = await this.getAppDataOverview();
const fallback = overview?.find((item) => item.id === id);
if (!fallback) throw new NotFoundException('产品不存在');
return {
...this.normalizeOverviewProduct(fallback),
requirements: [],
};
}
async update(id: string, dto: UpdateProductDto) {
@@ -123,4 +156,53 @@ export class ProductService {
},
};
}
private normalizeRelationProduct(product: any) {
const projects = product.projects ?? [];
const versions = (product.versions ?? []).map((version: any) => ({
...version,
createdAt: toIso(version.createdAt),
updatedAt: toIso(version.updatedAt),
startDate: toIsoOrNull(version.startDate),
expectedReleaseDate: toIsoOrNull(version.expectedReleaseDate),
releaseDate: toIsoOrNull(version.releaseDate),
members: Array.isArray(version.members) ? version.members : [],
progress: Array.isArray(version.progress) ? version.progress : [],
priority: toPriorityLabel(version.priority),
links: isPlainObject(version.links) ? version.links : {},
}));
return {
...product,
createdAt: toIso(product.createdAt),
updatedAt: toIso(product.updatedAt),
projects: projects.map((project: any) => ({ ...project, createdAt: toIso(project.createdAt) })),
versions,
_count: {
requirements: product._count?.requirements ?? 0,
projects: projects.length,
versions: versions.length,
},
};
}
}
function toIso(value: Date | string | undefined): string {
if (value instanceof Date) return value.toISOString();
return value ?? new Date(0).toISOString();
}
function toIsoOrNull(value: Date | string | null | undefined): string | null {
if (!value) return null;
if (value instanceof Date) return value.toISOString();
return value;
}
function toPriorityLabel(value: number | null | undefined): string | undefined {
if (typeof value !== 'number') return undefined;
return `P${Math.max(0, Math.min(4, Math.floor(value)))}`;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateProjectDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateProjectDto } from './create-project.dto';
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}

View File

@@ -0,0 +1,53 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { ProjectService } from './project.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
@Controller('products/:productId/projects')
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Post()
@ProtectedMutation('project:create', { productIdParam: 'productId' }, {
action: 'project.create',
entityType: 'project',
productIdParam: 'productId',
})
create(@Param('productId') productId: string, @Body() dto: CreateProjectDto) {
return this.projectService.create(productId, dto);
}
@Get()
findAll(@Param('productId') productId: string) {
return this.projectService.findAll(productId);
}
@Patch(':projectId')
@ProtectedMutation('project:edit', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
action: 'project.update',
entityType: 'project',
entityIdParam: 'projectId',
productIdParam: 'productId',
projectIdParam: 'projectId',
})
update(
@Param('productId') productId: string,
@Param('projectId') projectId: string,
@Body() dto: UpdateProjectDto,
) {
return this.projectService.update(productId, projectId, dto);
}
@Delete(':projectId')
@ProtectedMutation('project:delete', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
action: 'project.delete',
entityType: 'project',
entityIdParam: 'projectId',
productIdParam: 'productId',
projectIdParam: 'projectId',
})
remove(@Param('productId') productId: string, @Param('projectId') projectId: string) {
return this.projectService.remove(productId, projectId);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProjectController } from './project.controller';
import { ProjectService } from './project.service';
@Module({
controllers: [ProjectController],
providers: [ProjectService],
})
export class ProjectModule {}

View File

@@ -0,0 +1,83 @@
import { NotFoundException } from '@nestjs/common';
import { ProjectService } from './project.service';
describe('ProjectService domain writes', () => {
const makeService = () => {
const prisma = {
product: {
findUnique: jest.fn(),
},
project: {
create: jest.fn(),
delete: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return {
prisma,
service: new ProjectService(prisma as any),
};
};
it('creates projects directly under a product relation row', async () => {
const { prisma, service } = makeService();
prisma.product.findUnique.mockResolvedValue({ id: 'product-1' });
prisma.project.create.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
await service.create('product-1', { name: 'CRM', description: 'Customer system' });
expect(prisma.project.create).toHaveBeenCalledWith({
data: {
productId: 'product-1',
name: 'CRM',
description: 'Customer system',
},
});
});
it('lists projects by product id', async () => {
const { prisma, service } = makeService();
prisma.project.findMany.mockResolvedValue([]);
await service.findAll('product-1');
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { productId: 'product-1' },
orderBy: { createdAt: 'desc' },
});
});
it('updates projects only inside their product scope', async () => {
const { prisma, service } = makeService();
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
prisma.project.update.mockResolvedValue({ id: 'project-1', name: 'CRM v2' });
await service.update('product-1', 'project-1', { name: 'CRM v2' });
expect(prisma.project.update).toHaveBeenCalledWith({
where: { id: 'project-1' },
data: { name: 'CRM v2' },
});
});
it('throws when updating a project outside the product scope', async () => {
const { prisma, service } = makeService();
prisma.project.findFirst.mockResolvedValue(null);
await expect(service.update('product-1', 'project-404', { name: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.project.update).not.toHaveBeenCalled();
});
it('deletes projects only inside their product scope', async () => {
const { prisma, service } = makeService();
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
prisma.project.delete.mockResolvedValue({ id: 'project-1' });
await service.remove('product-1', 'project-1');
expect(prisma.project.delete).toHaveBeenCalledWith({ where: { id: 'project-1' } });
});
});

View File

@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
@Injectable()
export class ProjectService {
constructor(private readonly prisma: PrismaService) {}
async create(productId: string, dto: CreateProjectDto) {
await this.ensureProductExists(productId);
return this.prisma.project.create({
data: {
productId,
name: dto.name,
description: dto.description ?? '',
},
});
}
findAll(productId: string) {
return this.prisma.project.findMany({
where: { productId },
orderBy: { createdAt: 'desc' },
});
}
async update(productId: string, projectId: string, dto: UpdateProjectDto) {
await this.ensureProjectInProduct(productId, projectId);
return this.prisma.project.update({
where: { id: projectId },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
},
});
}
async remove(productId: string, projectId: string) {
await this.ensureProjectInProduct(productId, projectId);
return this.prisma.project.delete({ where: { id: projectId } });
}
private async ensureProductExists(productId: string) {
const product = await this.prisma.product.findUnique({ where: { id: productId } });
if (!product) throw new NotFoundException('产品不存在');
}
private async ensureProjectInProduct(productId: string, projectId: string) {
const project = await this.prisma.project.findFirst({ where: { id: projectId, productId } });
if (!project) throw new NotFoundException('项目不存在');
}
}

View File

@@ -1,4 +1,5 @@
import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator';
import { RequirementStatus } from '@ftb/shared';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateRequirementDto {
@IsString()
@@ -13,13 +14,46 @@ export class CreateRequirementDto {
@IsOptional()
description?: string;
@IsInt()
@Min(0)
@Max(4)
@IsOptional()
priority?: number;
priority?: string | number;
@IsString()
@IsNotEmpty()
creatorId!: string;
@IsOptional()
projectId?: string;
@IsString()
@IsOptional()
versionId?: string;
@IsString()
@IsOptional()
type?: string;
@IsString()
@IsOptional()
typeId?: string;
@IsString()
@IsOptional()
sourceType?: string;
@IsString()
@IsOptional()
sourceTarget?: string;
@IsString()
@IsOptional()
platform?: string;
@IsArray()
@IsOptional()
platforms?: string[];
@IsIn(Object.values(RequirementStatus))
@IsOptional()
status?: RequirementStatus;
@IsString()
@IsOptional()
creatorId?: string;
}

View File

@@ -1,4 +1,5 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { RequirementService } from './requirement.service';
import { CreateRequirementDto } from './dto/create-requirement.dto';
import { UpdateRequirementDto } from './dto/update-requirement.dto';
@@ -9,6 +10,11 @@ export class RequirementController {
constructor(private readonly requirementService: RequirementService) {}
@Post()
@ProtectedMutation('requirement:create', { productIdParam: 'productId' }, {
action: 'requirement.create',
entityType: 'requirement',
productIdParam: 'productId',
})
create(
@Param('productId') productId: string,
@Body() dto: CreateRequirementDto,
@@ -20,8 +26,26 @@ export class RequirementController {
findAll(
@Param('productId') productId: string,
@Query('status') status?: string,
@Query('projectId') projectId?: string,
@Query('versionId') versionId?: string,
@Query('priority') priority?: string,
@Query('type') type?: string,
@Query('q') q?: string,
@Query('sort') sort?: string,
@Query('cursor') cursor?: string,
@Query('limit') limit?: string,
) {
return this.requirementService.findAll(productId, status);
return this.requirementService.findAll(productId, {
projectId,
versionId,
status,
priority,
type,
q,
sort,
cursor,
limit,
});
}
@Get(':id')
@@ -33,6 +57,12 @@ export class RequirementController {
}
@Patch(':id')
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
action: 'requirement.update',
entityType: 'requirement',
entityIdParam: 'id',
productIdParam: 'productId',
})
update(
@Param('productId') productId: string,
@Param('id') id: string,
@@ -42,6 +72,12 @@ export class RequirementController {
}
@Patch(':id/status')
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
action: 'requirement.status',
entityType: 'requirement',
entityIdParam: 'id',
productIdParam: 'productId',
})
updateStatus(
@Param('productId') productId: string,
@Param('id') id: string,
@@ -51,6 +87,12 @@ export class RequirementController {
}
@Delete(':id')
@ProtectedMutation('requirement:delete', { productIdParam: 'productId' }, {
action: 'requirement.delete',
entityType: 'requirement',
entityIdParam: 'id',
productIdParam: 'productId',
})
remove(
@Param('productId') productId: string,
@Param('id') id: string,

View File

@@ -37,12 +37,90 @@ describe('RequirementService with V2.2 composite requirement key', () => {
});
});
it('creates requirements with frontend pool fields in relation columns', async () => {
const { prisma, service } = makeService();
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
await service.create('product-1', {
code: 'REQ-001',
title: 'Payment',
projectId: 'project-1',
versionId: 'version-1',
type: 'feature',
sourceType: 'customer',
sourceTarget: 'ACME',
platform: 'web,ios',
priority: 1,
} as any);
expect(prisma.requirement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
type: 'feature',
sourceType: 'customer',
sourceTarget: 'ACME',
platform: 'web,ios',
}),
});
});
it('paginates requirement pool queries by product partition key and composite cursor', async () => {
const { prisma, service } = makeService();
prisma.requirement.findMany.mockResolvedValue([{ id: 'req-2' }, { id: 'req-1' }]);
const result = await service.findAll('product-1', {
projectId: 'project-1',
versionId: 'version-1',
status: 'adopted',
priority: 'P1',
type: 'feature',
q: 'login',
sort: 'created_at_asc',
cursor: 'req-3',
limit: '1',
} as any);
expect(result).toEqual({ items: [{ id: 'req-2' }], nextCursor: 'req-1' });
expect(prisma.requirement.findMany).toHaveBeenCalledWith({
where: {
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
status: 'adopted',
priority: 1,
type: 'feature',
OR: [
{ code: { contains: 'login', mode: 'insensitive' } },
{ title: { contains: 'login', mode: 'insensitive' } },
],
},
include: { creator: { select: { id: true, name: true } } },
orderBy: { createdAt: 'asc' },
take: 2,
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
skip: 1,
});
});
it('uses only approved requirement sort keys', async () => {
const { prisma, service } = makeService();
prisma.requirement.findMany.mockResolvedValue([]);
await service.findAll('product-1', { sort: 'unsafe_sql_asc', limit: '20' } as any);
expect(prisma.requirement.findMany).toHaveBeenCalledWith(expect.objectContaining({
orderBy: { createdAt: 'desc' },
}));
});
it('updates requirements by id plus product id', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'draft',
status: 'pending_review',
});
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
@@ -54,12 +132,43 @@ describe('RequirementService with V2.2 composite requirement key', () => {
});
});
it('updates frontend pool fields by id plus product id', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'pending_review',
});
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
await service.update('product-1', 'req-1', {
projectId: 'project-2',
versionId: 'version-2',
type: 'optimization',
sourceType: 'internal',
sourceTarget: 'Product',
platform: 'web,h5',
} as any);
expect(prisma.requirement.update).toHaveBeenCalledWith({
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
data: {
projectId: 'project-2',
versionId: 'version-2',
type: 'optimization',
sourceType: 'internal',
sourceTarget: 'Product',
platform: 'web,h5',
},
});
});
it('deletes requirements by id plus product id', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'draft',
status: 'pending_review',
});
prisma.requirement.delete.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
@@ -69,4 +178,50 @@ describe('RequirementService with V2.2 composite requirement key', () => {
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
});
});
it.each([
['pending_review', 'adopted'],
['pending_review', 'rejected'],
['rejected', 'pending_review'],
['adopted', 'planned'],
['adopted', 'closed'],
['planned', 'developing'],
['planned', 'closed'],
['developing', 'testing'],
['testing', 'released'],
['released', 'closed'],
])('allows current requirement workflow transition %s -> %s', async (fromStatus, toStatus) => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: fromStatus,
});
prisma.requirement.update.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: toStatus,
});
await service.updateStatus('product-1', 'req-1', toStatus);
expect(prisma.requirement.update).toHaveBeenCalledWith({
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
data: { status: toStatus },
});
});
it('rejects skipping adopted requirements directly to development', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'adopted',
});
await expect(service.updateStatus('product-1', 'req-1', 'developing')).rejects.toThrow(
'无法从 "adopted" 转换到 "developing"',
);
expect(prisma.requirement.update).not.toHaveBeenCalled();
});
});

View File

@@ -5,13 +5,28 @@ import { CreateRequirementDto } from './dto/create-requirement.dto';
import { UpdateRequirementDto } from './dto/update-requirement.dto';
const VALID_TRANSITIONS: Record<string, string[]> = {
[RequirementStatus.DRAFT]: [RequirementStatus.REVIEWING],
[RequirementStatus.REVIEWING]: [RequirementStatus.APPROVED, RequirementStatus.REJECTED],
[RequirementStatus.APPROVED]: [RequirementStatus.DELIVERED],
[RequirementStatus.REJECTED]: [RequirementStatus.DRAFT],
[RequirementStatus.DELIVERED]: [],
[RequirementStatus.PENDING_REVIEW]: [RequirementStatus.ADOPTED, RequirementStatus.REJECTED],
[RequirementStatus.REJECTED]: [RequirementStatus.PENDING_REVIEW],
[RequirementStatus.ADOPTED]: [RequirementStatus.PLANNED, RequirementStatus.CLOSED],
[RequirementStatus.PLANNED]: [RequirementStatus.DEVELOPING, RequirementStatus.CLOSED],
[RequirementStatus.DEVELOPING]: [RequirementStatus.TESTING],
[RequirementStatus.TESTING]: [RequirementStatus.RELEASED],
[RequirementStatus.RELEASED]: [RequirementStatus.CLOSED],
[RequirementStatus.CLOSED]: [],
};
export interface RequirementListQuery {
projectId?: string;
versionId?: string;
status?: string;
priority?: string;
type?: string;
q?: string;
sort?: string;
cursor?: string;
limit?: string | number;
}
function createFallbackRequirementCode() {
return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random()
.toString(36)
@@ -24,27 +39,62 @@ export class RequirementService {
constructor(private prisma: PrismaService) {}
create(productId: string, dto: CreateRequirementDto) {
const data = this.toRequirementData(dto);
return this.prisma.requirement.create({
data: {
...data,
productId,
code: dto.code?.trim() || createFallbackRequirementCode(),
title: dto.title,
description: dto.description || '',
priority: dto.priority ?? 0,
creatorId: dto.creatorId,
description: dto.description ?? '',
status: dto.status ?? RequirementStatus.PENDING_REVIEW,
priority: parsePriorityValue(dto.priority) ?? 0,
},
});
}
findAll(productId: string, status?: string) {
return this.prisma.requirement.findMany({
async findAll(productId: string, query: RequirementListQuery = {}) {
const normalizedProductId = productId?.trim();
if (!normalizedProductId) {
throw new BadRequestException('productId is required for requirement pool queries');
}
const limit = parseLimit(query.limit);
const priority = parsePriorityValue(query.priority);
const search = query.q?.trim();
const rows = await this.prisma.requirement.findMany({
where: {
productId,
...(status ? { status } : {}),
productId: normalizedProductId,
...(query.projectId?.trim() ? { projectId: query.projectId.trim() } : {}),
...(query.versionId?.trim() ? { versionId: query.versionId.trim() } : {}),
...(query.status?.trim() ? { status: query.status.trim() } : {}),
...(priority !== undefined ? { priority } : {}),
...(query.type?.trim() ? { type: query.type.trim() } : {}),
...(search
? {
OR: [
{ code: { contains: search, mode: 'insensitive' as const } },
{ title: { contains: search, mode: 'insensitive' as const } },
],
}
: {}),
},
orderBy: { createdAt: 'desc' },
include: { creator: { select: { id: true, name: true } } },
orderBy: parseRequirementSort(query.sort),
take: limit + 1,
...(query.cursor
? {
cursor: { id_productId: { id: query.cursor, productId: normalizedProductId } },
skip: 1,
}
: {}),
});
const hasNext = rows.length > limit;
return {
items: hasNext ? rows.slice(0, limit) : rows,
nextCursor: hasNext ? rows[limit]?.id : undefined,
};
}
async findOne(productId: string, id: string) {
@@ -60,12 +110,7 @@ export class RequirementService {
await this.findOne(productId, id);
return this.prisma.requirement.update({
where: { id_productId: { id, productId } },
data: {
...(dto.code !== undefined && { code: dto.code }),
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.priority !== undefined && { priority: dto.priority }),
},
data: this.toRequirementData(dto),
});
}
@@ -87,4 +132,75 @@ export class RequirementService {
await this.findOne(productId, id);
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
}
private toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) {
const type = dto.type ?? dto.typeId;
const platform = dto.platform ?? arrayToCsv(dto.platforms);
const priority = parsePriorityValue(dto.priority);
return {
...(dto.code !== undefined && { code: dto.code }),
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.projectId !== undefined && { projectId: emptyToNull(dto.projectId) }),
...(dto.versionId !== undefined && { versionId: emptyToNull(dto.versionId) }),
...(type !== undefined && { type: emptyToNull(type) }),
...(dto.sourceType !== undefined && { sourceType: emptyToNull(dto.sourceType) }),
...(dto.sourceTarget !== undefined && { sourceTarget: emptyToNull(dto.sourceTarget) }),
...(platform !== undefined && { platform: emptyToNull(platform) }),
...(dto.creatorId !== undefined && { creatorId: emptyToNull(dto.creatorId) }),
...(priority !== undefined && { priority }),
};
}
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function arrayToCsv(value: string[] | undefined): string | undefined {
if (!value) return undefined;
return value.map((item) => item.trim()).filter(Boolean).join(',');
}
function parseLimit(raw?: string | number): number {
const parsed = raw === undefined ? 50 : Number(raw);
if (!Number.isFinite(parsed)) return 50;
return Math.max(1, Math.min(200, Math.floor(parsed)));
}
function parsePriorityValue(raw?: string | number | null): number | undefined {
if (raw === null || raw === undefined || raw === '') return undefined;
if (typeof raw === 'number') return Number.isFinite(raw) ? Math.max(0, Math.min(4, Math.floor(raw))) : undefined;
const normalized = raw.trim().toUpperCase();
const prefixed = /^P([0-4])$/.exec(normalized);
if (prefixed) return Number(prefixed[1]);
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) return undefined;
return Math.max(0, Math.min(4, Math.floor(parsed)));
}
function parseRequirementSort(sort?: string) {
switch (sort) {
case 'created_at_asc':
return { createdAt: 'asc' as const };
case 'priority_asc':
return { priority: 'asc' as const };
case 'priority_desc':
return { priority: 'desc' as const };
case 'code_asc':
return { code: 'asc' as const };
case 'code_desc':
return { code: 'desc' as const };
case 'updated_at_asc':
return { updatedAt: 'asc' as const };
case 'updated_at_desc':
return { updatedAt: 'desc' as const };
case 'created_at_desc':
default:
return { createdAt: 'desc' as const };
}
}

View File

@@ -0,0 +1,24 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class CreateTaskCategoryDto {
@IsString()
name!: string;
@IsString()
group!: string;
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
color?: string;
@IsOptional()
sortOrder?: number;
@IsBoolean()
@IsOptional()
isSystem?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTaskCategoryDto } from './create-task-category.dto';
export class UpdateTaskCategoryDto extends PartialType(CreateTaskCategoryDto) {}

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateTaskCategoryDto } from './dto/create-task-category.dto';
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
import { TaskCategoryService } from './task-category.service';
@Controller('task-categories')
export class TaskCategoryController {
constructor(private readonly taskCategoryService: TaskCategoryService) {}
@Get()
findAll() {
return this.taskCategoryService.findAll();
}
@Post()
@ProtectedMutation('task-category:manage', {}, { action: 'task_category.create', entityType: 'task_category' })
create(@Body() dto: CreateTaskCategoryDto) {
return this.taskCategoryService.create(dto);
}
@Patch(':id')
@ProtectedMutation('task-category:manage', {}, {
action: 'task_category.update',
entityType: 'task_category',
entityIdParam: 'id',
})
update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) {
return this.taskCategoryService.update(id, dto);
}
@Delete(':id')
@ProtectedMutation('task-category:manage', {}, {
action: 'task_category.delete',
entityType: 'task_category',
entityIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.taskCategoryService.remove(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TaskCategoryController } from './task-category.controller';
import { TaskCategoryService } from './task-category.service';
@Module({
controllers: [TaskCategoryController],
providers: [TaskCategoryService],
exports: [TaskCategoryService],
})
export class TaskCategoryModule {}

View File

@@ -0,0 +1,41 @@
import { TaskCategoryService } from './task-category.service';
describe('TaskCategoryService domain writes', () => {
const makeService = () => {
const prisma = {
taskCategory: {
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return { prisma, service: new TaskCategoryService(prisma as any) };
};
it('creates task categories with uniqueness scoped by group and name', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' });
await service.create({ name: '前端开发', group: 'development', code: 'frontend_development' });
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: '前端开发',
group: 'development',
code: 'frontend_development',
isSystem: false,
}),
});
});
it('does not delete system categories', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-system', isSystem: true }]);
const result = await service.remove('cat-system');
expect(result).toEqual({ deleted: false });
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTaskCategoryDto } from './dto/create-task-category.dto';
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
@Injectable()
export class TaskCategoryService {
constructor(private readonly prisma: PrismaService) {}
findAll() {
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
}
create(dto: CreateTaskCategoryDto) {
return this.prisma.taskCategory.create({
data: {
name: dto.name,
group: dto.group,
code: dto.code,
isSystem: dto.isSystem ?? false,
},
});
}
update(id: string, dto: UpdateTaskCategoryDto) {
return this.prisma.taskCategory.update({
where: { id },
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.group !== undefined && { group: dto.group }),
...(dto.code !== undefined && { code: dto.code }),
...(dto.isSystem !== undefined && { isSystem: dto.isSystem }),
},
});
}
async remove(id: string) {
const [target] = await this.prisma.taskCategory.findMany({ where: { id }, take: 1 });
if (!target || target.isSystem) return { deleted: false };
await this.prisma.taskCategory.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -0,0 +1,35 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTaskWorklogDto {
@IsString()
taskId!: string;
@IsString()
userId!: string;
@IsString()
date!: string;
@IsOptional()
hours?: number;
@IsString()
@IsOptional()
workContent?: string;
@IsString()
@IsOptional()
sourceType?: string;
@IsString()
@IsOptional()
versionId?: string;
@IsString()
@IsOptional()
productId?: string;
@IsString()
@IsOptional()
projectId?: string;
}

View File

@@ -0,0 +1,34 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
import { TaskWorklogService } from './task-worklog.service';
@Controller('task-worklogs')
export class TaskWorklogController {
constructor(private readonly taskWorklogService: TaskWorklogService) {}
@Get()
findAll() {
return this.taskWorklogService.findAll();
}
@Post()
@ProtectedMutation('version.devtask:manage', { versionIdBody: 'versionId' }, {
action: 'task_worklog.create',
entityType: 'task_worklog',
versionIdBody: 'versionId',
})
create(@Body() dto: CreateTaskWorklogDto) {
return this.taskWorklogService.create(dto);
}
@Delete(':id')
@ProtectedMutation('version.devtask:manage', {}, {
action: 'task_worklog.delete',
entityType: 'task_worklog',
entityIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.taskWorklogService.remove(id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { TaskWorklogController } from './task-worklog.controller';
import { TaskWorklogService } from './task-worklog.service';
@Module({
imports: [WorkActivityModule],
controllers: [TaskWorklogController],
providers: [TaskWorklogService],
exports: [TaskWorklogService],
})
export class TaskWorklogModule {}

View File

@@ -0,0 +1,36 @@
import { TaskWorklogService } from './task-worklog.service';
describe('TaskWorklogService append writes', () => {
it('appends task worklogs as relational evidence rows', async () => {
const prisma = {
taskWorklog: {
create: jest.fn().mockResolvedValue({ id: 'wl-1' }),
deleteMany: jest.fn(),
findMany: jest.fn(),
},
};
const service = new TaskWorklogService(prisma as any);
await service.create({
taskId: 'task-1',
userId: 'member-1',
date: '2026-07-08',
hours: 2,
workContent: '补充日报',
versionId: 'version-1',
});
expect(prisma.taskWorklog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
sourceType: 'dev_task',
sourceId: 'task-1',
sourceVersionId: 'version-1',
versionId: 'version-1',
userId: 'member-1',
workDate: new Date('2026-07-08T00:00:00.000Z'),
hours: 2,
content: '补充日报',
}),
});
});
});

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
@Injectable()
export class TaskWorklogService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity?: WorkActivityService,
) {}
async create(dto: CreateTaskWorklogDto) {
const item = await this.prisma.taskWorklog.create({
data: {
versionId: dto.versionId ?? null,
productId: dto.productId ?? null,
projectId: dto.projectId ?? null,
userId: dto.userId,
sourceType: dto.sourceType ?? 'dev_task',
sourceId: dto.taskId,
sourceVersionId: dto.versionId ?? null,
workDate: parseDateOnly(dto.date),
hours: dto.hours ?? 0,
content: dto.workContent ?? '',
},
});
if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId);
return item;
}
findAll() {
return this.prisma.taskWorklog.findMany({ orderBy: { createdAt: 'desc' } });
}
async remove(id: string) {
await this.prisma.taskWorklog.deleteMany({ where: { id } });
return { deleted: true };
}
}
function parseDateOnly(value: string): Date {
return new Date(`${value.slice(0, 10)}T00:00:00.000Z`);
}

View File

@@ -0,0 +1,86 @@
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
export class CreateTestCaseDto {
@IsString()
@IsOptional()
requirementId?: string;
@IsString()
@IsOptional()
requirementProductId?: string;
@IsString()
@IsOptional()
categoryId?: string;
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
caseNo?: string;
@IsString()
title!: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
status?: string;
@IsOptional()
roundNo?: number;
@IsOptional()
priority?: string | number;
@IsString()
@IsOptional()
assigneeId?: string;
@IsString()
@IsOptional()
creatorId?: string;
@IsString()
@IsOptional()
createdBy?: string;
@IsString()
@IsOptional()
plannedTestAt?: string;
@IsString()
@IsOptional()
plannedEndAt?: string;
@IsString()
@IsOptional()
startedAt?: string;
@IsString()
@IsOptional()
completedAt?: string;
@IsOptional()
estimateHours?: number;
@IsOptional()
aiEstimateHours?: number;
@IsArray()
@IsOptional()
references?: unknown[];
@IsBoolean()
@IsOptional()
aiDraft?: boolean;
@IsString()
@IsOptional()
aiDraftAt?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTestCaseDto } from './create-test-case.dto';
export class UpdateTestCaseDto extends PartialType(CreateTestCaseDto) {}

View File

@@ -0,0 +1,72 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateTestCaseDto } from './dto/create-test-case.dto';
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
import { TestCaseService } from './test-case.service';
@Controller('versions/:versionId/test-cases')
export class TestCaseController {
constructor(private readonly testCaseService: TestCaseService) {}
@Post()
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
action: 'test_case.create',
entityType: 'test_case',
versionIdParam: 'versionId',
})
create(@Param('versionId') versionId: string, @Body() dto: CreateTestCaseDto) {
return this.testCaseService.create(versionId, dto);
}
@Post('batch')
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
action: 'test_case.batch_create',
entityType: 'test_case',
versionIdParam: 'versionId',
})
createMany(@Param('versionId') versionId: string, @Body('items') items: CreateTestCaseDto[]) {
return this.testCaseService.createMany(versionId, items ?? []);
}
@Get()
findAll(@Param('versionId') versionId: string) {
return this.testCaseService.findAll(versionId);
}
@Patch(':id')
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
action: 'test_case.update',
entityType: 'test_case',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateTestCaseDto) {
return this.testCaseService.update(versionId, id, dto);
}
@Patch(':id/status')
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
action: 'test_case.status',
entityType: 'test_case',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
updateStatus(
@Param('versionId') versionId: string,
@Param('id') id: string,
@Body('status') status: string,
) {
return this.testCaseService.updateStatus(versionId, id, status);
}
@Delete(':id')
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
action: 'test_case.delete',
entityType: 'test_case',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
return this.testCaseService.remove(versionId, id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { TestCaseController } from './test-case.controller';
import { TestCaseService } from './test-case.service';
@Module({
imports: [WorkActivityModule],
controllers: [TestCaseController],
providers: [TestCaseService],
exports: [TestCaseService],
})
export class TestCaseModule {}

View File

@@ -0,0 +1,141 @@
import { NotFoundException } from '@nestjs/common';
import { TestCaseService } from './test-case.service';
describe('TestCaseService domain writes', () => {
const makeService = () => {
const workActivity = {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
};
const prisma = {
version: {
findUnique: jest.fn(),
},
testCase: {
create: jest.fn(),
createMany: jest.fn(),
delete: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return {
prisma,
workActivity,
service: new TestCaseService(prisma as any, workActivity as any),
};
};
it('creates test cases directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.testCase.create.mockResolvedValue({ id: 'tc-1', versionId: 'version-1', title: '登录冒烟' });
await service.create('version-1', {
requirementId: 'req-1',
requirementProductId: 'product-1',
caseNo: 'TC-001',
title: '登录冒烟',
categoryId: 'cat-test',
assigneeId: 'tester-1',
priority: 'P1',
plannedTestAt: '2026-07-08T09:00:00.000Z',
plannedEndAt: '2026-07-08T10:00:00.000Z',
estimateHours: 1,
references: [{ type: 'requirement', id: 'REQ-001', label: 'REQ-001 登录' }],
createdBy: 'member-pm',
} as any);
expect(prisma.testCase.create).toHaveBeenCalledWith({
data: expect.objectContaining({
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
requirementId: 'req-1',
requirementProductId: 'product-1',
code: 'TC-001',
title: '登录冒烟',
categoryId: 'cat-test',
assigneeId: 'tester-1',
priority: 1,
plannedTestAt: new Date('2026-07-08T09:00:00.000Z'),
plannedEndAt: new Date('2026-07-08T10:00:00.000Z'),
estimateHours: 1,
status: 'pending',
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'test_case',
sourceId: 'tc-1',
action: 'test_case_created',
}));
});
it('changes status by id plus version id and records activity evidence', async () => {
const { prisma, workActivity, service } = makeService();
prisma.testCase.findFirst.mockResolvedValue({
id: 'tc-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'pending',
title: '登录冒烟',
assigneeId: 'tester-1',
});
prisma.testCase.update.mockResolvedValue({
id: 'tc-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'running',
title: '登录冒烟',
assigneeId: 'tester-1',
});
await service.updateStatus('version-1', 'tc-1', 'running');
expect(prisma.testCase.update).toHaveBeenCalledWith({
where: { id_versionId: { id: 'tc-1', versionId: 'version-1' } },
data: expect.objectContaining({
status: 'running',
startedAt: expect.any(Date),
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'test_case',
sourceId: 'tc-1',
action: 'test_case_started',
}));
});
it('creates copied round test cases in the same version partition', async () => {
const { prisma, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.testCase.createMany.mockResolvedValue({ count: 2 });
prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-copy-1' }, { id: 'tc-copy-2' }]);
await service.createMany('version-1', [
{ caseNo: 'TC-101', title: '第二轮登录', roundNo: 2, createdBy: 'tester-1' },
{ caseNo: 'TC-102', title: '第二轮支付', roundNo: 2, createdBy: 'tester-1' },
] as any);
expect(prisma.testCase.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({ versionId: 'version-1', productId: 'product-1', projectId: 'project-1', roundNo: 2, status: 'pending' }),
expect.objectContaining({ versionId: 'version-1', productId: 'product-1', projectId: 'project-1', roundNo: 2, status: 'pending' }),
],
skipDuplicates: true,
});
});
it('rejects updates outside the version partition', async () => {
const { prisma, service } = makeService();
prisma.testCase.findFirst.mockResolvedValue(null);
await expect(service.update('version-1', 'missing-case', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.testCase.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,194 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateTestCaseDto } from './dto/create-test-case.dto';
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
@Injectable()
export class TestCaseService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity: WorkActivityService,
) {}
async create(versionId: string, dto: CreateTestCaseDto) {
const version = await this.ensureVersion(versionId);
const item = await this.prisma.testCase.create({
data: {
...this.toTestCaseData(dto),
versionId,
productId: version.productId,
projectId: version.projectId,
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
title: dto.title,
status: dto.status ?? 'pending',
},
});
const activity = await this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`);
return { item, activities: [activity] };
}
async createMany(versionId: string, dtos: CreateTestCaseDto[]) {
if (dtos.length === 0) return { items: [], activities: [] };
const version = await this.ensureVersion(versionId);
const rows = dtos.map((dto) => ({
...this.toTestCaseData(dto),
versionId,
productId: version.productId,
projectId: version.projectId,
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
title: dto.title,
status: dto.status ?? 'pending',
}));
await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true });
const items = await this.prisma.testCase.findMany({
where: { versionId, code: { in: rows.map((row) => row.code) } },
});
const activities = await Promise.all(items.map((item) => (
this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`)
)));
return { items, activities };
}
findAll(versionId: string) {
return this.prisma.testCase.findMany({
where: { versionId },
orderBy: [{ roundNo: 'asc' }, { code: 'asc' }],
});
}
async update(versionId: string, id: string, dto: UpdateTestCaseDto) {
await this.ensureTestCaseInVersion(versionId, id);
const item = await this.prisma.testCase.update({
where: { id_versionId: { id, versionId } },
data: this.toTestCaseData(dto),
});
return { item, activities: [] };
}
async updateStatus(versionId: string, id: string, status: string) {
const current = await this.ensureTestCaseInVersion(versionId, id);
const data: Record<string, unknown> = { status };
if (status === 'running' && !current.startedAt) data.startedAt = new Date();
if ((status === 'passed' || status === 'failed' || status === 'blocked') && !current.completedAt) {
data.completedAt = new Date();
}
const item = await this.prisma.testCase.update({
where: { id_versionId: { id, versionId } },
data,
});
const activity = await this.recordStatusActivity(item, current.status, status);
return { item, activities: activity ? [activity] : [] };
}
async remove(versionId: string, id: string) {
await this.ensureTestCaseInVersion(versionId, id);
return this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } });
}
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
return {
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
...(dto.categoryId !== undefined && { categoryId: emptyToNull(dto.categoryId) }),
...(dto.code !== undefined || dto.caseNo !== undefined ? { code: dto.code?.trim() || dto.caseNo?.trim() } : {}),
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description ?? '' }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }),
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }),
...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }),
...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }),
...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }),
...(dto.estimateHours !== undefined && { estimateHours: dto.estimateHours }),
...(dto.aiEstimateHours !== undefined && { aiEstimateHours: dto.aiEstimateHours }),
...(dto.references !== undefined && { references: toJsonInput(dto.references) }),
...(dto.aiDraft !== undefined && { aiDraft: dto.aiDraft }),
...(dto.aiDraftAt !== undefined && { aiDraftAt: parseOptionalDate(dto.aiDraftAt) }),
};
}
private async ensureVersion(versionId: string) {
const version = await this.prisma.version.findUnique({ where: { id: versionId } });
if (!version) throw new NotFoundException('版本不存在');
if (!version.projectId) throw new BadRequestException('测试用例所属版本必须归属于项目');
return { ...version, projectId: version.projectId };
}
private async ensureTestCaseInVersion(versionId: string, id: string) {
const testCase = await this.prisma.testCase.findFirst({ where: { id, versionId } });
if (!testCase) throw new NotFoundException('测试用例不存在');
return testCase;
}
private recordStatusActivity(testCase: any, fromStatus: string, toStatus: string) {
if (toStatus === 'running') {
return this.recordTestCaseActivity(testCase, 'test_case_started', 'progress', `开始测试:${testCase.title}`, { fromStatus, toStatus });
}
if (toStatus === 'passed') {
return this.recordTestCaseActivity(testCase, 'test_case_passed', 'delivery', `测试通过:${testCase.title}`, { fromStatus, toStatus });
}
if (toStatus === 'failed') {
return this.recordTestCaseActivity(testCase, 'test_case_failed', 'risk', `测试不通过:${testCase.title}`, { fromStatus, toStatus });
}
if (toStatus === 'blocked') {
return this.recordTestCaseActivity(testCase, 'test_case_blocked', 'risk', `测试阻塞:${testCase.title}`, { fromStatus, toStatus });
}
return undefined;
}
private recordTestCaseActivity(testCase: any, action: string, category: string, summary: string, metadata: Record<string, unknown> = {}) {
return this.workActivity.record({
versionId: testCase.versionId,
productId: testCase.productId,
projectId: testCase.projectId,
actorId: testCase.assigneeId ?? testCase.creatorId,
sourceType: 'test_case',
sourceId: testCase.id,
action,
category,
title: testCase.title,
summary,
metadata,
});
}
}
function createFallbackCode(prefix: string) {
return `${prefix}-${Date.now().toString(36).toUpperCase()}`;
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function parseOptionalDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : null;
}
function normalizeRoundNo(value: number | null | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return 1;
return Math.floor(value);
}
function parsePriority(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined || value === '') return undefined;
if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined;
const match = /^P([0-4])$/i.exec(value.trim());
if (match) return Number(match[1]);
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined;
}
function toJsonInput(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}

View File

@@ -0,0 +1,77 @@
import { Reflector } from '@nestjs/core';
import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from '../common/auth/permission.decorator';
import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from '../common/audit/audit-mutation.decorator';
import { ProductController } from './product/product.controller';
import { ProjectController } from './project/project.controller';
import { VersionController } from './version/version.controller';
import { RequirementController } from './requirement/requirement.controller';
import { VersionPlanController } from './version-plan/version-plan.controller';
import { DevTaskController } from './dev-task/dev-task.controller';
import { TestCaseController } from './test-case/test-case.controller';
import { BugController } from './bug/bug.controller';
import { MemberController } from './member/member.controller';
import { TaskCategoryController } from './task-category/task-category.controller';
import { TaskWorklogController } from './task-worklog/task-worklog.controller';
import { OvertimeController } from './overtime/overtime.controller';
import { WorkActivityController } from './work-activity/work-activity.controller';
describe('V2.5 domain mutation contracts', () => {
const reflector = new Reflector();
const cases: Array<[Function, string, RequiredPermissionMetadata, AuditMutationMetadata]> = [
[ProductController, 'create', { permission: 'product:create' }, { action: 'product.create', entityType: 'product' }],
[ProductController, 'update', { permission: 'product:edit', productIdParam: 'id' }, { action: 'product.update', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }],
[ProductController, 'remove', { permission: 'product:delete', productIdParam: 'id' }, { action: 'product.delete', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }],
[ProjectController, 'create', { permission: 'project:create', productIdParam: 'productId' }, { action: 'project.create', entityType: 'project', productIdParam: 'productId' }],
[ProjectController, 'update', { permission: 'project:edit', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.update', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }],
[ProjectController, 'remove', { permission: 'project:delete', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.delete', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }],
[VersionController, 'create', { permission: 'version:create', productIdParam: 'productId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId' }],
[VersionController, 'createForProject', { permission: 'version:create', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId', projectIdParam: 'projectId' }],
[VersionController, 'update', { permission: 'version:edit', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.update', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }],
[VersionController, 'remove', { permission: 'version:delete', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.delete', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }],
[RequirementController, 'create', { permission: 'requirement:create', productIdParam: 'productId' }, { action: 'requirement.create', entityType: 'requirement', productIdParam: 'productId' }],
[RequirementController, 'update', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.update', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }],
[RequirementController, 'updateStatus', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.status', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }],
[RequirementController, 'remove', { permission: 'requirement:delete', productIdParam: 'productId' }, { action: 'requirement.delete', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }],
[VersionPlanController, 'create', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.create', entityType: 'version_plan', versionIdParam: 'versionId' }],
[VersionPlanController, 'update', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.update', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }],
[VersionPlanController, 'complete', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.complete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }],
[VersionPlanController, 'remove', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.delete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }],
[DevTaskController, 'create', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.create', entityType: 'dev_task', versionIdParam: 'versionId' }],
[DevTaskController, 'update', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.update', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
[DevTaskController, 'updateStatus', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.status', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
[DevTaskController, 'setBlocked', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.block', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
[DevTaskController, 'transfer', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.transfer', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
[DevTaskController, 'remove', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.delete', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
[TestCaseController, 'create', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.create', entityType: 'test_case', versionIdParam: 'versionId' }],
[TestCaseController, 'createMany', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.batch_create', entityType: 'test_case', versionIdParam: 'versionId' }],
[TestCaseController, 'update', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.update', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }],
[TestCaseController, 'updateStatus', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.status', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }],
[TestCaseController, 'remove', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.delete', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }],
[BugController, 'create', { permission: 'version.bug:create', versionIdParam: 'versionId' }, { action: 'bug.create', entityType: 'bug', versionIdParam: 'versionId' }],
[BugController, 'update', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.update', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
[BugController, 'updateStatus', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.status', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
[BugController, 'transfer', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.transfer', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
[BugController, 'remove', { permission: 'version.bug:delete', versionIdParam: 'versionId' }, { action: 'bug.delete', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
[MemberController, 'create', { permission: 'member:create' }, { action: 'member.create', entityType: 'member' }],
[MemberController, 'update', { permission: 'member:edit' }, { action: 'member.update', entityType: 'member', entityIdParam: 'id' }],
[MemberController, 'remove', { permission: 'member:delete' }, { action: 'member.delete', entityType: 'member', entityIdParam: 'id' }],
[TaskCategoryController, 'create', { permission: 'task-category:manage' }, { action: 'task_category.create', entityType: 'task_category' }],
[TaskCategoryController, 'update', { permission: 'task-category:manage' }, { action: 'task_category.update', entityType: 'task_category', entityIdParam: 'id' }],
[TaskCategoryController, 'remove', { permission: 'task-category:manage' }, { action: 'task_category.delete', entityType: 'task_category', entityIdParam: 'id' }],
[TaskWorklogController, 'create', { permission: 'version.devtask:manage', versionIdBody: 'versionId' }, { action: 'task_worklog.create', entityType: 'task_worklog', versionIdBody: 'versionId' }],
[TaskWorklogController, 'remove', { permission: 'version.devtask:manage' }, { action: 'task_worklog.delete', entityType: 'task_worklog', entityIdParam: 'id' }],
[OvertimeController, 'create', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.create', entityType: 'overtime', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }],
[OvertimeController, 'update', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.update', entityType: 'overtime', entityIdParam: 'id', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }],
[OvertimeController, 'remove', { permission: 'overtime:delete' }, { action: 'overtime.delete', entityType: 'overtime', entityIdParam: 'id' }],
[WorkActivityController, 'create', { permission: 'work-activity:manage', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'work_activity.create', entityType: 'work_activity', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }],
[WorkActivityController, 'remove', { permission: 'work-activity:manage' }, { action: 'work_activity.delete', entityType: 'work_activity', entityIdParam: 'id' }],
];
it.each(cases)('%p.%s declares server permission and audit metadata', (controller, methodName, permission, audit) => {
const handler = controller.prototype[methodName];
expect(reflector.get(PERMISSION_METADATA_KEY, handler)).toEqual(permission);
expect(reflector.get(AUDIT_MUTATION_METADATA_KEY, handler)).toEqual(audit);
});
});

View File

@@ -0,0 +1,59 @@
import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
export class CreateVersionPlanDto {
@IsIn(['research', 'product', 'ui'])
type!: 'research' | 'product' | 'ui';
@IsString()
title!: string;
@IsString()
@IsOptional()
status?: string;
@IsString()
@IsOptional()
owner?: string;
@IsString()
@IsOptional()
ownerId?: string;
@IsString()
@IsOptional()
startTime?: string;
@IsString()
@IsOptional()
endTime?: string;
@IsString()
@IsOptional()
expectedStartAt?: string;
@IsString()
@IsOptional()
expectedEndAt?: string;
@IsString()
@IsOptional()
actualStartAt?: string;
@IsString()
@IsOptional()
completedAt?: string;
@IsString()
@IsOptional()
resultUrl?: string;
@IsArray()
@IsOptional()
linkedRequirementIds?: string[];
@IsOptional()
requirementCoverage?: unknown[];
@IsOptional()
logs?: unknown[];
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateVersionPlanDto } from './create-version-plan.dto';
export class UpdateVersionPlanDto extends PartialType(CreateVersionPlanDto) {}

View File

@@ -0,0 +1,58 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
import { VersionPlanService } from './version-plan.service';
@Controller('versions/:versionId/plans')
export class VersionPlanController {
constructor(private readonly versionPlanService: VersionPlanService) {}
@Post()
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
action: 'version_plan.create',
entityType: 'version_plan',
versionIdParam: 'versionId',
})
create(@Param('versionId') versionId: string, @Body() dto: CreateVersionPlanDto) {
return this.versionPlanService.create(versionId, dto);
}
@Get()
findAll(@Param('versionId') versionId: string) {
return this.versionPlanService.findAll(versionId);
}
@Patch(':id')
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
action: 'version_plan.update',
entityType: 'version_plan',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) {
return this.versionPlanService.update(versionId, id, dto);
}
@Patch(':id/complete')
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
action: 'version_plan.complete',
entityType: 'version_plan',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
complete(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) {
return this.versionPlanService.complete(versionId, id, dto);
}
@Delete(':id')
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
action: 'version_plan.delete',
entityType: 'version_plan',
entityIdParam: 'id',
versionIdParam: 'versionId',
})
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
return this.versionPlanService.remove(versionId, id);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkActivityModule } from '../work-activity/work-activity.module';
import { VersionPlanController } from './version-plan.controller';
import { VersionPlanService } from './version-plan.service';
@Module({
imports: [WorkActivityModule],
controllers: [VersionPlanController],
providers: [VersionPlanService],
exports: [VersionPlanService],
})
export class VersionPlanModule {}

View File

@@ -0,0 +1,109 @@
import { NotFoundException } from '@nestjs/common';
import { VersionPlanService } from './version-plan.service';
describe('VersionPlanService domain writes', () => {
const makeService = () => {
const workActivity = {
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
};
const prisma = {
version: {
findUnique: jest.fn(),
},
versionPlan: {
create: jest.fn(),
delete: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};
return {
prisma,
workActivity,
service: new VersionPlanService(prisma as any, workActivity as any),
};
};
it('creates version plans directly under a version partition', async () => {
const { prisma, workActivity, service } = makeService();
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
prisma.versionPlan.create.mockResolvedValue({ id: 'plan-1', versionId: 'version-1', title: '产品方案' });
await service.create('version-1', {
type: 'product',
title: '产品方案',
owner: 'member-1',
startTime: '2026-07-08T09:00:00.000Z',
endTime: '2026-07-08T18:00:00.000Z',
linkedRequirementIds: ['req-1'],
} as any);
expect(prisma.versionPlan.create).toHaveBeenCalledWith({
data: expect.objectContaining({
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
type: 'product',
title: '产品方案',
ownerId: 'member-1',
expectedStartAt: new Date('2026-07-08T09:00:00.000Z'),
expectedEndAt: new Date('2026-07-08T18:00:00.000Z'),
requirementCoverage: [{ requirementId: 'req-1', status: 'not_started' }],
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'version_plan',
sourceId: 'plan-1',
action: 'version_plan_created',
}));
});
it('updates plan status inside version scope and records activity evidence', async () => {
const { prisma, workActivity, service } = makeService();
prisma.versionPlan.findFirst.mockResolvedValue({
id: 'plan-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'pending',
title: '产品方案',
ownerId: 'member-1',
});
prisma.versionPlan.update.mockResolvedValue({
id: 'plan-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
status: 'in_progress',
title: '产品方案',
ownerId: 'member-1',
});
await service.update('version-1', 'plan-1', { status: 'in_progress' });
expect(prisma.versionPlan.update).toHaveBeenCalledWith({
where: { id: 'plan-1' },
data: expect.objectContaining({
status: 'in_progress',
actualStartAt: expect.any(Date),
}),
});
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'version-1',
sourceType: 'version_plan',
sourceId: 'plan-1',
action: 'version_plan_started',
}));
});
it('rejects plan updates outside the version partition', async () => {
const { prisma, service } = makeService();
prisma.versionPlan.findFirst.mockResolvedValue(null);
await expect(service.update('version-1', 'missing-plan', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.versionPlan.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,152 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { WorkActivityService } from '../work-activity/work-activity.service';
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
@Injectable()
export class VersionPlanService {
constructor(
private readonly prisma: PrismaService,
private readonly workActivity: WorkActivityService,
) {}
async create(versionId: string, dto: CreateVersionPlanDto) {
const version = await this.ensureVersion(versionId);
const item = await this.prisma.versionPlan.create({
data: {
...this.toPlanData(dto),
versionId,
productId: version.productId,
projectId: version.projectId,
type: dto.type,
title: dto.title,
status: dto.status ?? 'pending',
},
});
const activity = await this.recordPlanActivity(item, 'version_plan_created', 'creation', `新建计划:${item.title}`);
return { item, activities: [activity] };
}
findAll(versionId: string) {
return this.prisma.versionPlan.findMany({
where: { versionId },
orderBy: [{ type: 'asc' }, { createdAt: 'desc' }],
});
}
async update(versionId: string, id: string, dto: UpdateVersionPlanDto) {
const current = await this.ensurePlanInVersion(versionId, id);
const data = this.toPlanData(dto);
if (dto.status === 'in_progress' && !current.actualStartAt) {
data.actualStartAt = new Date();
}
if (dto.status === 'completed' && !current.completedAt) {
data.completedAt = new Date();
}
const item = await this.prisma.versionPlan.update({
where: { id },
data,
});
const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined;
return { item, activities: activity ? [activity] : [] };
}
async complete(versionId: string, id: string, dto: UpdateVersionPlanDto) {
return this.update(versionId, id, {
...dto,
status: 'completed',
completedAt: dto.completedAt ?? new Date().toISOString(),
});
}
async remove(versionId: string, id: string) {
await this.ensurePlanInVersion(versionId, id);
return this.prisma.versionPlan.delete({ where: { id } });
}
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
return {
...(dto.type !== undefined && { type: dto.type }),
...(dto.title !== undefined && { title: dto.title }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.owner !== undefined || dto.ownerId !== undefined ? { ownerId: emptyToNull(dto.ownerId ?? dto.owner) } : {}),
...(dto.startTime !== undefined || dto.expectedStartAt !== undefined
? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) }
: {}),
...(dto.endTime !== undefined || dto.expectedEndAt !== undefined
? { expectedEndAt: parseOptionalDate(dto.expectedEndAt ?? dto.endTime) }
: {}),
...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }),
...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }),
...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }),
...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined
? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) }
: {}),
...(dto.logs !== undefined && { logs: toJsonInput(dto.logs) }),
};
}
private async ensureVersion(versionId: string) {
const version = await this.prisma.version.findUnique({ where: { id: versionId } });
if (!version) throw new NotFoundException('版本不存在');
return version;
}
private async ensurePlanInVersion(versionId: string, id: string) {
const plan = await this.prisma.versionPlan.findFirst({ where: { id, versionId } });
if (!plan) throw new NotFoundException('计划不存在');
return plan;
}
private recordStatusActivity(plan: any, fromStatus: string, toStatus: string) {
if (toStatus === 'in_progress') {
return this.recordPlanActivity(plan, 'version_plan_started', 'progress', `开始计划:${plan.title}`, { fromStatus, toStatus });
}
if (toStatus === 'completed') {
return this.recordPlanActivity(plan, 'version_plan_completed', 'delivery', `完成计划:${plan.title}`, { fromStatus, toStatus });
}
return undefined;
}
private recordPlanActivity(plan: any, action: string, category: string, summary: string, metadata: Record<string, unknown> = {}) {
return this.workActivity.record({
versionId: plan.versionId,
productId: plan.productId,
projectId: plan.projectId,
actorId: plan.ownerId,
sourceType: 'version_plan',
sourceId: plan.id,
action,
category,
title: plan.title,
summary,
metadata,
});
}
}
function buildRequirementCoverage(requirementIds: string[] | undefined) {
return (requirementIds ?? []).map((requirementId) => ({
requirementId,
status: 'not_started',
}));
}
function emptyToNull(value: string | null | undefined): string | null {
if (value === null) return null;
if (value === undefined) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function parseOptionalDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : null;
}
function toJsonInput(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}

View File

@@ -0,0 +1,50 @@
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
export class CreateVersionDto {
@IsString()
@IsOptional()
projectId?: string;
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
status?: string;
@IsString()
@IsOptional()
currentStage?: string;
@IsString()
@IsOptional()
startDate?: string | null;
@IsString()
@IsOptional()
expectedReleaseDate?: string | null;
@IsString()
@IsOptional()
releaseDate?: string | null;
@IsArray()
@IsOptional()
members?: unknown[];
@IsArray()
@IsOptional()
progress?: unknown[];
@IsOptional()
priority?: string | number | null;
@IsObject()
@IsOptional()
links?: Record<string, unknown>;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateVersionDto } from './create-version.dto';
export class UpdateVersionDto extends PartialType(CreateVersionDto) {}

View File

@@ -0,0 +1,73 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
import { VersionService } from './version.service';
import { CreateVersionDto } from './dto/create-version.dto';
import { UpdateVersionDto } from './dto/update-version.dto';
@Controller('products/:productId')
export class VersionController {
constructor(private readonly versionService: VersionService) {}
@Post('versions')
@ProtectedMutation('version:create', { productIdParam: 'productId' }, {
action: 'version.create',
entityType: 'version',
productIdParam: 'productId',
})
create(@Param('productId') productId: string, @Body() dto: CreateVersionDto) {
return this.versionService.create(productId, dto);
}
@Get('versions')
findAll(@Param('productId') productId: string) {
return this.versionService.findAll(productId);
}
@Post('projects/:projectId/versions')
@ProtectedMutation('version:create', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
action: 'version.create',
entityType: 'version',
productIdParam: 'productId',
projectIdParam: 'projectId',
})
createForProject(
@Param('productId') productId: string,
@Param('projectId') projectId: string,
@Body() dto: CreateVersionDto,
) {
return this.versionService.create(productId, dto, projectId);
}
@Get('projects/:projectId/versions')
findAllForProject(@Param('productId') productId: string, @Param('projectId') projectId: string) {
return this.versionService.findAll(productId, projectId);
}
@Patch('versions/:versionId')
@ProtectedMutation('version:edit', { productIdParam: 'productId', versionIdParam: 'versionId' }, {
action: 'version.update',
entityType: 'version',
entityIdParam: 'versionId',
productIdParam: 'productId',
versionIdParam: 'versionId',
})
update(
@Param('productId') productId: string,
@Param('versionId') versionId: string,
@Body() dto: UpdateVersionDto,
) {
return this.versionService.update(productId, versionId, dto);
}
@Delete('versions/:versionId')
@ProtectedMutation('version:delete', { productIdParam: 'productId', versionIdParam: 'versionId' }, {
action: 'version.delete',
entityType: 'version',
entityIdParam: 'versionId',
productIdParam: 'productId',
versionIdParam: 'versionId',
})
remove(@Param('productId') productId: string, @Param('versionId') versionId: string) {
return this.versionService.remove(productId, versionId);
}
}

Some files were not shown because too many files have changed in this diff Show More