feat(v2.7): 增加通知与评论协作模块

This commit is contained in:
2026-07-08 16:13:33 +08:00
parent ad36ffda17
commit 95523cd4a1
13 changed files with 586 additions and 1 deletions

View File

@@ -10,9 +10,11 @@ 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 { NotificationModule } from './modules/notification/notification.module';
import { CommentModule } from './modules/comment/comment.module';
@Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule],
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule],
controllers: [],
providers: [
{

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit/audit.service';
import { RbacService } from './rbac/rbac.service';
@Module({
providers: [AuditService, RbacService],
exports: [AuditService, RbacService],
})
export class CommonDomainModule {}

View File

@@ -0,0 +1,24 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { CommentService, type CommentEntityType } from './comment.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { DeleteCommentDto } from './dto/delete-comment.dto';
@Controller('comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Get()
list(@Query('entityType') entityType: CommentEntityType, @Query('entityId') entityId: string) {
return this.commentService.list(entityType, entityId);
}
@Post()
create(@Body() dto: CreateCommentDto) {
return this.commentService.create(dto);
}
@Delete(':id')
remove(@Param('id') id: string, @Body() dto: DeleteCommentDto) {
return this.commentService.remove(id, dto.actorId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { NotificationModule } from '../notification/notification.module';
import { CommentController } from './comment.controller';
import { CommentService } from './comment.service';
@Module({
imports: [CommonDomainModule, NotificationModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
})
export class CommentModule {}

View File

@@ -0,0 +1,112 @@
import { BadRequestException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { NotificationService } from '../notification/notification.service';
import { CommentService } from './comment.service';
describe('CommentService', () => {
const makeService = () => {
const prisma = {
comment: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
projectMember: {
findMany: jest.fn(),
},
};
const notifications = {
createMany: jest.fn(),
} as unknown as NotificationService;
const audit = {
record: jest.fn(),
} as unknown as AuditService;
return {
prisma,
notifications,
audit,
service: new CommentService(prisma as any, notifications, audit),
};
};
it('creates a polymorphic comment, extracts @mentions, notifies mentioned members, and writes audit', async () => {
const { prisma, notifications, audit, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([
{ userId: 'm-alice', user: { id: 'm-alice', name: 'Alice' } },
{ userId: 'm-bob', user: { id: 'm-bob', name: 'Bob' } },
]);
prisma.comment.create.mockResolvedValue({
id: 'comment-1',
entityType: 'dev_task',
entityId: 'task-1',
mentionedMemberIds: ['m-alice', 'm-bob'],
});
const result = await service.create({
actorId: 'm-author',
entityType: 'dev_task',
entityId: 'task-1',
entityVersionId: 'ver-1',
projectId: 'project-1',
versionId: 'ver-1',
content: '请 @Alice 看一下接口Bob 也同步一下',
mentionMemberIds: ['m-bob'],
});
expect(result.id).toBe('comment-1');
expect(prisma.comment.create).toHaveBeenCalledWith({
data: expect.objectContaining({
entityType: 'dev_task',
entityId: 'task-1',
authorId: 'm-author',
mentionedMemberIds: ['m-alice', 'm-bob'],
}),
});
expect((notifications.createMany as jest.Mock)).toHaveBeenCalledWith([
expect.objectContaining({ recipientId: 'm-alice', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
expect.objectContaining({ recipientId: 'm-bob', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
]);
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-author',
action: 'comment.created',
resourceType: 'comment',
resourceId: 'comment-1',
}));
});
it('soft deletes a comment and writes audit', async () => {
const { prisma, audit, service } = makeService();
prisma.comment.findUnique.mockResolvedValue({
id: 'comment-1',
authorId: 'm-author',
entityType: 'bug',
entityId: 'bug-1',
deletedAt: null,
});
prisma.comment.update.mockResolvedValue({ id: 'comment-1', deletedAt: new Date('2026-07-08T08:00:00.000Z') });
await service.remove('comment-1', 'm-author');
expect(prisma.comment.update).toHaveBeenCalledWith({
where: { id: 'comment-1' },
data: { deletedAt: expect.any(Date) },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-author',
action: 'comment.deleted',
resourceType: 'comment',
resourceId: 'comment-1',
}));
});
it('rejects unsupported comment entity types', async () => {
const { service } = makeService();
await expect(service.create({
actorId: 'm-author',
entityType: 'task',
entityId: 'task-1',
content: 'legacy task comment',
} as any)).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -0,0 +1,157 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
export const COMMENT_ENTITY_TYPES = ['dev_task', 'test_case', 'bug', 'requirement', 'version_plan'] as const;
export type CommentEntityType = (typeof COMMENT_ENTITY_TYPES)[number];
export interface CommentCreateInput {
actorId: string;
entityType: CommentEntityType;
entityId: string;
entityVersionId?: string | null;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
content: string;
mentionMemberIds?: string[];
}
@Injectable()
export class CommentService {
constructor(
private readonly prisma: PrismaService,
private readonly notificationService: NotificationService,
private readonly auditService: AuditService,
) {}
list(entityType: CommentEntityType, entityId: string) {
assertCommentEntityType(entityType);
return this.prisma.comment.findMany({
where: { entityType, entityId: requireText(entityId, 'entityId'), deletedAt: null },
orderBy: { createdAt: 'asc' },
});
}
async create(input: CommentCreateInput) {
assertCommentEntityType(input.entityType);
const actorId = requireText(input.actorId, 'actorId');
const content = requireText(input.content, 'content');
const mentionedMemberIds = await this.resolveMentionMemberIds(content, input.projectId, input.mentionMemberIds);
const comment = await this.prisma.comment.create({
data: {
entityType: input.entityType,
entityId: requireText(input.entityId, 'entityId'),
entityVersionId: input.entityVersionId ?? null,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
authorId: actorId,
content,
mentionedMemberIds,
},
});
await this.notificationService.createMany(mentionedMemberIds
.filter((recipientId) => recipientId !== actorId)
.map((recipientId) => ({
recipientId,
actorId,
type: 'mention',
title: '你被提及了',
body: content,
resourceType: 'comment',
resourceId: comment.id,
resourceVersionId: input.entityVersionId ?? null,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
metadata: {
entityType: input.entityType,
entityId: input.entityId,
},
})));
await this.auditService.record({
actorId,
action: 'comment.created',
resourceType: 'comment',
resourceId: comment.id,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
after: comment,
});
return comment;
}
async remove(id: string, actorId: string) {
const comment = await this.prisma.comment.findUnique({ where: { id: requireText(id, 'id') } });
if (!comment || comment.deletedAt) throw new NotFoundException('Comment not found');
const removed = await this.prisma.comment.update({
where: { id: comment.id },
data: { deletedAt: new Date() },
});
await this.auditService.record({
actorId: requireText(actorId, 'actorId'),
action: 'comment.deleted',
resourceType: 'comment',
resourceId: comment.id,
productId: comment.productId,
projectId: comment.projectId,
versionId: comment.versionId,
before: comment,
after: removed,
});
return removed;
}
private async resolveMentionMemberIds(content: string, projectId?: string | null, explicitIds: string[] = []): Promise<string[]> {
const ids = new Set<string>();
const mentionNames = extractMentionNames(content);
if (mentionNames.length > 0 && projectId?.trim()) {
const projectMembers = await this.prisma.projectMember.findMany({
where: { projectId: projectId.trim() },
include: { user: { select: { id: true, name: true } } },
});
const wanted = new Set(mentionNames.map(normalizeMentionName));
for (const member of projectMembers as Array<{ userId: string; user?: { id?: string; name?: string } }>) {
const name = normalizeMentionName(member.user?.name);
if (name && wanted.has(name)) ids.add(member.user?.id ?? member.userId);
}
}
for (const explicitId of explicitIds) {
const id = explicitId.trim();
if (id) ids.add(id);
}
return Array.from(ids);
}
}
export function extractMentionNames(content: string): string[] {
const names: string[] = [];
const pattern = /@([\p{L}\p{N}_\-.]+)/gu;
for (const match of content.matchAll(pattern)) {
if (match[1]) names.push(match[1]);
}
return names;
}
function assertCommentEntityType(entityType: string): asserts entityType is CommentEntityType {
if (!COMMENT_ENTITY_TYPES.includes(entityType as CommentEntityType)) {
throw new BadRequestException(`Unsupported comment entity type: ${entityType}`);
}
}
function normalizeMentionName(name?: string | null): string {
return name?.trim().toLowerCase() ?? '';
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}

View File

@@ -0,0 +1,37 @@
import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
import { COMMENT_ENTITY_TYPES, type CommentEntityType } from '../comment.service';
export class CreateCommentDto {
@IsString()
actorId!: string;
@IsIn(COMMENT_ENTITY_TYPES)
entityType!: CommentEntityType;
@IsString()
entityId!: string;
@IsOptional()
@IsString()
entityVersionId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
versionId?: string;
@IsString()
content!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
mentionMemberIds?: string[];
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class DeleteCommentDto {
@IsString()
actorId!: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class MarkNotificationReadDto {
@IsString()
recipientId!: string;
}

View File

@@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
import { MarkNotificationReadDto } from './dto/mark-notification-read.dto';
import { NotificationService } from './notification.service';
@Controller('notifications')
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
@Get()
list(
@Query('recipientId') recipientId: string,
@Query('unreadOnly') unreadOnly?: string,
@Query('limit') limit?: string,
) {
return this.notificationService.list({
recipientId,
unreadOnly: unreadOnly === 'true',
limit: limit ? Number(limit) : undefined,
});
}
@Patch(':id/read')
markRead(@Param('id') id: string, @Body() dto: MarkNotificationReadDto) {
return this.notificationService.markRead(id, dto.recipientId);
}
@Patch('read-all')
markAllRead(@Body() dto: MarkNotificationReadDto) {
return this.notificationService.markAllRead(dto.recipientId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationController } from './notification.controller';
import { NotificationService } from './notification.service';
@Module({
controllers: [NotificationController],
providers: [NotificationService],
exports: [NotificationService],
})
export class NotificationModule {}

View File

@@ -0,0 +1,79 @@
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
const makeService = () => {
const prisma = {
notification: {
create: jest.fn(),
createMany: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
},
};
return { prisma, service: new NotificationService(prisma as any) };
};
it('lists unread notifications for one recipient in newest-first order', async () => {
const { prisma, service } = makeService();
prisma.notification.findMany.mockResolvedValue([{ id: 'n-1' }]);
await expect(service.list({ recipientId: 'm-1', unreadOnly: true, limit: 20 })).resolves.toEqual([{ id: 'n-1' }]);
expect(prisma.notification.findMany).toHaveBeenCalledWith({
where: { recipientId: 'm-1', readAt: null },
orderBy: { createdAt: 'desc' },
take: 20,
});
});
it('marks one notification as read only for the requesting recipient', async () => {
const { prisma, service } = makeService();
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
await service.markRead('n-1', 'm-1');
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n-1', recipientId: 'm-1', readAt: null },
data: { readAt: expect.any(Date) },
});
});
it('marks all unread notifications for one recipient as read', async () => {
const { prisma, service } = makeService();
prisma.notification.updateMany.mockResolvedValue({ count: 2 });
await service.markAllRead('m-1');
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { recipientId: 'm-1', readAt: null },
data: { readAt: expect.any(Date) },
});
});
it('creates notifications with stable V2.7 event types', async () => {
const { prisma, service } = makeService();
prisma.notification.create.mockResolvedValue({ id: 'n-risk' });
await service.create({
recipientId: 'm-manager',
actorId: 'xiaobao',
type: 'risk_alert',
title: '版本存在延期风险',
resourceType: 'version',
resourceId: 'ver-1',
projectId: 'project-1',
versionId: 'ver-1',
metadata: { riskLevel: 'likely_delayed' },
});
expect(prisma.notification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
recipientId: 'm-manager',
type: 'risk_alert',
resourceType: 'version',
resourceId: 'ver-1',
metadata: { riskLevel: 'likely_delayed' },
}),
});
});
});

View File

@@ -0,0 +1,99 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export const NOTIFICATION_TYPES = ['assignment', 'mention', 'risk_alert', 'overdue_item'] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
export interface NotificationListQuery {
recipientId: string;
unreadOnly?: boolean;
limit?: number;
}
export interface NotificationCreateInput {
recipientId: string;
actorId?: string | null;
type: NotificationType;
title: string;
body?: string;
resourceType: string;
resourceId: string;
resourceVersionId?: string | null;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
metadata?: unknown;
}
@Injectable()
export class NotificationService {
constructor(private readonly prisma: PrismaService) {}
list(query: NotificationListQuery) {
const recipientId = requireText(query.recipientId, 'recipientId');
return this.prisma.notification.findMany({
where: {
recipientId,
...(query.unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: 'desc' },
take: normalizeLimit(query.limit),
});
}
create(input: NotificationCreateInput) {
const data = normalizeNotificationInput(input);
return this.prisma.notification.create({ data });
}
async createMany(items: NotificationCreateInput[]) {
const data = items.map(normalizeNotificationInput);
if (data.length === 0) return { count: 0 };
return this.prisma.notification.createMany({ data });
}
markRead(id: string, recipientId: string) {
return this.prisma.notification.updateMany({
where: { id: requireText(id, 'id'), recipientId: requireText(recipientId, 'recipientId'), readAt: null },
data: { readAt: new Date() },
});
}
markAllRead(recipientId: string) {
return this.prisma.notification.updateMany({
where: { recipientId: requireText(recipientId, 'recipientId'), readAt: null },
data: { readAt: new Date() },
});
}
}
function normalizeNotificationInput(input: NotificationCreateInput) {
if (!NOTIFICATION_TYPES.includes(input.type)) {
throw new BadRequestException(`Unsupported notification type: ${input.type}`);
}
return {
recipientId: requireText(input.recipientId, 'recipientId'),
actorId: input.actorId ?? null,
type: input.type,
title: requireText(input.title, 'title'),
body: input.body ?? '',
resourceType: requireText(input.resourceType, 'resourceType'),
resourceId: requireText(input.resourceId, 'resourceId'),
resourceVersionId: input.resourceVersionId ?? null,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
metadata: input.metadata ?? {},
};
}
function normalizeLimit(limit?: number): number {
if (!Number.isFinite(limit)) return 50;
return Math.max(1, Math.min(100, Math.floor(limit as number)));
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}