From 95523cd4a1bef01ae0aaa8a3504c247ba6c23819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:13:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2.7):=20=E5=A2=9E=E5=8A=A0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E4=B8=8E=E8=AF=84=E8=AE=BA=E5=8D=8F=E4=BD=9C=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 4 +- .../server/src/common/common-domain.module.ts | 9 + .../src/modules/comment/comment.controller.ts | 24 +++ .../src/modules/comment/comment.module.ts | 13 ++ .../modules/comment/comment.service.spec.ts | 112 +++++++++++++ .../src/modules/comment/comment.service.ts | 157 ++++++++++++++++++ .../modules/comment/dto/create-comment.dto.ts | 37 +++++ .../modules/comment/dto/delete-comment.dto.ts | 6 + .../dto/mark-notification-read.dto.ts | 6 + .../notification/notification.controller.ts | 31 ++++ .../notification/notification.module.ts | 10 ++ .../notification/notification.service.spec.ts | 79 +++++++++ .../notification/notification.service.ts | 99 +++++++++++ 13 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/common/common-domain.module.ts create mode 100644 apps/server/src/modules/comment/comment.controller.ts create mode 100644 apps/server/src/modules/comment/comment.module.ts create mode 100644 apps/server/src/modules/comment/comment.service.spec.ts create mode 100644 apps/server/src/modules/comment/comment.service.ts create mode 100644 apps/server/src/modules/comment/dto/create-comment.dto.ts create mode 100644 apps/server/src/modules/comment/dto/delete-comment.dto.ts create mode 100644 apps/server/src/modules/notification/dto/mark-notification-read.dto.ts create mode 100644 apps/server/src/modules/notification/notification.controller.ts create mode 100644 apps/server/src/modules/notification/notification.module.ts create mode 100644 apps/server/src/modules/notification/notification.service.spec.ts create mode 100644 apps/server/src/modules/notification/notification.service.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index f8f95ef..c08eb6d 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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: [ { diff --git a/apps/server/src/common/common-domain.module.ts b/apps/server/src/common/common-domain.module.ts new file mode 100644 index 0000000..96781c0 --- /dev/null +++ b/apps/server/src/common/common-domain.module.ts @@ -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 {} diff --git a/apps/server/src/modules/comment/comment.controller.ts b/apps/server/src/modules/comment/comment.controller.ts new file mode 100644 index 0000000..6940582 --- /dev/null +++ b/apps/server/src/modules/comment/comment.controller.ts @@ -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); + } +} diff --git a/apps/server/src/modules/comment/comment.module.ts b/apps/server/src/modules/comment/comment.module.ts new file mode 100644 index 0000000..a97cc7e --- /dev/null +++ b/apps/server/src/modules/comment/comment.module.ts @@ -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 {} diff --git a/apps/server/src/modules/comment/comment.service.spec.ts b/apps/server/src/modules/comment/comment.service.spec.ts new file mode 100644 index 0000000..bc87a7d --- /dev/null +++ b/apps/server/src/modules/comment/comment.service.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/modules/comment/comment.service.ts b/apps/server/src/modules/comment/comment.service.ts new file mode 100644 index 0000000..1063f90 --- /dev/null +++ b/apps/server/src/modules/comment/comment.service.ts @@ -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 { + const ids = new Set(); + 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; +} diff --git a/apps/server/src/modules/comment/dto/create-comment.dto.ts b/apps/server/src/modules/comment/dto/create-comment.dto.ts new file mode 100644 index 0000000..d023103 --- /dev/null +++ b/apps/server/src/modules/comment/dto/create-comment.dto.ts @@ -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[]; +} diff --git a/apps/server/src/modules/comment/dto/delete-comment.dto.ts b/apps/server/src/modules/comment/dto/delete-comment.dto.ts new file mode 100644 index 0000000..9bac9dc --- /dev/null +++ b/apps/server/src/modules/comment/dto/delete-comment.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class DeleteCommentDto { + @IsString() + actorId!: string; +} diff --git a/apps/server/src/modules/notification/dto/mark-notification-read.dto.ts b/apps/server/src/modules/notification/dto/mark-notification-read.dto.ts new file mode 100644 index 0000000..ae2c2cf --- /dev/null +++ b/apps/server/src/modules/notification/dto/mark-notification-read.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class MarkNotificationReadDto { + @IsString() + recipientId!: string; +} diff --git a/apps/server/src/modules/notification/notification.controller.ts b/apps/server/src/modules/notification/notification.controller.ts new file mode 100644 index 0000000..5c51112 --- /dev/null +++ b/apps/server/src/modules/notification/notification.controller.ts @@ -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); + } +} diff --git a/apps/server/src/modules/notification/notification.module.ts b/apps/server/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..d94cc81 --- /dev/null +++ b/apps/server/src/modules/notification/notification.module.ts @@ -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 {} diff --git a/apps/server/src/modules/notification/notification.service.spec.ts b/apps/server/src/modules/notification/notification.service.spec.ts new file mode 100644 index 0000000..5778472 --- /dev/null +++ b/apps/server/src/modules/notification/notification.service.spec.ts @@ -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' }, + }), + }); + }); +}); diff --git a/apps/server/src/modules/notification/notification.service.ts b/apps/server/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..0f2c5b6 --- /dev/null +++ b/apps/server/src/modules/notification/notification.service.ts @@ -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; +}