merge: 集成V2.7 企业协作与管理治理
# Conflicts: # apps/server/src/app.module.ts # apps/web/components/layout/Sidebar.tsx # apps/web/lib/permissions.ts # docs/architecture.md # docs/decisions.md # docs/roadmap.md
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
ALTER TABLE "comments" DROP CONSTRAINT IF EXISTS "comments_task_id_fkey";
|
||||
DROP TABLE IF EXISTS "comments";
|
||||
|
||||
CREATE TABLE "comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"entity_type" TEXT NOT NULL,
|
||||
"entity_id" TEXT NOT NULL,
|
||||
"entity_version_id" TEXT,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"author_id" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"mentioned_member_ids" JSONB NOT NULL DEFAULT '[]',
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "comments_entity_created_idx" ON "comments"("entity_type", "entity_id", "created_at");
|
||||
CREATE INDEX "comments_author_created_idx" ON "comments"("author_id", "created_at");
|
||||
|
||||
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
CREATE INDEX IF NOT EXISTS "project_members_user_role_idx" ON "project_members"("user_id", "role");
|
||||
|
||||
CREATE TABLE "notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"recipient_id" TEXT NOT NULL,
|
||||
"actor_id" TEXT,
|
||||
"type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL DEFAULT '',
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_id" TEXT NOT NULL,
|
||||
"resource_version_id" TEXT,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"read_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "notifications_recipient_read_created_idx" ON "notifications"("recipient_id", "read_at", "created_at");
|
||||
CREATE INDEX "notifications_resource_idx" ON "notifications"("resource_type", "resource_id");
|
||||
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actor_id" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_id" TEXT NOT NULL,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"before" JSONB NOT NULL DEFAULT '{}',
|
||||
"after" JSONB NOT NULL DEFAULT '{}',
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "audit_logs_resource_created_idx" ON "audit_logs"("resource_type", "resource_id", "created_at");
|
||||
CREATE INDEX "audit_logs_actor_created_idx" ON "audit_logs"("actor_id", "created_at");
|
||||
|
||||
CREATE TABLE "governance_dictionaries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL DEFAULT 'global',
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT,
|
||||
"group" TEXT,
|
||||
"value" JSONB NOT NULL DEFAULT '{}',
|
||||
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "governance_dictionaries_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE ("scope", "kind", "name")
|
||||
);
|
||||
|
||||
CREATE INDEX "governance_dictionaries_kind_deleted_idx" ON "governance_dictionaries"("kind", "deleted_at");
|
||||
@@ -152,7 +152,6 @@ model Task {
|
||||
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
|
||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||
watchers TaskWatcher[]
|
||||
comments Comment[]
|
||||
|
||||
@@map("tasks")
|
||||
}
|
||||
@@ -171,13 +170,21 @@ model TaskWatcher {
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
taskId String @map("task_id")
|
||||
entityType String @map("entity_type")
|
||||
entityId String @map("entity_id")
|
||||
entityVersionId String? @map("entity_version_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
authorId String @map("author_id")
|
||||
content String
|
||||
mentionedMemberIds Json @default("[]") @map("mentioned_member_ids")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id])
|
||||
|
||||
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
|
||||
@@index([authorId, createdAt], name: "comments_author_created_idx")
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
@@ -186,14 +193,76 @@ model ProjectMember {
|
||||
projectId String @map("project_id")
|
||||
userId String @map("user_id")
|
||||
role String @default("member")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([projectId, userId])
|
||||
@@index([userId, role], name: "project_members_user_role_idx")
|
||||
@@map("project_members")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
recipientId String @map("recipient_id")
|
||||
actorId String? @map("actor_id")
|
||||
type String
|
||||
title String
|
||||
body String @default("")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
resourceVersionId String? @map("resource_version_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
metadata Json @default("{}")
|
||||
readAt DateTime? @map("read_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([recipientId, readAt, createdAt], name: "notifications_recipient_read_created_idx")
|
||||
@@index([resourceType, resourceId], name: "notifications_resource_idx")
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
actorId String? @map("actor_id")
|
||||
action String
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
before Json @default("{}")
|
||||
after Json @default("{}")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([resourceType, resourceId, createdAt], name: "audit_logs_resource_created_idx")
|
||||
@@index([actorId, createdAt], name: "audit_logs_actor_created_idx")
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
model GovernanceDictionary {
|
||||
id String @id @default(cuid())
|
||||
scope String @default("global")
|
||||
kind String
|
||||
name String
|
||||
code String?
|
||||
group String?
|
||||
value Json @default("{}")
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([scope, kind, name])
|
||||
@@index([kind, deletedAt], name: "governance_dictionaries_kind_deleted_idx")
|
||||
@@map("governance_dictionaries")
|
||||
}
|
||||
|
||||
model TaskCategory {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
|
||||
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const migrationPath = join(
|
||||
process.cwd(),
|
||||
'prisma',
|
||||
'migrations',
|
||||
'20260708000000_v27_enterprise_collab_governance',
|
||||
'migration.sql',
|
||||
);
|
||||
|
||||
function readMigrationSql() {
|
||||
if (!existsSync(migrationPath)) {
|
||||
throw new Error(`Missing V2.7 migration: ${migrationPath}`);
|
||||
}
|
||||
return readFileSync(migrationPath, 'utf8');
|
||||
}
|
||||
|
||||
describe('V2.7 enterprise collaboration schema migration', () => {
|
||||
it('creates notification records for assignment, mention, risk alert, and overdue events', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(/CREATE TABLE "notifications"/);
|
||||
expect(sql).toMatch(/"type" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"recipient_id" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"read_at" TIMESTAMP\(3\)/);
|
||||
expect(sql).toMatch(/CREATE INDEX "notifications_recipient_read_created_idx"/);
|
||||
});
|
||||
|
||||
it('creates polymorphic comments with mention metadata', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(/CREATE TABLE "comments"/);
|
||||
expect(sql).toMatch(/"entity_type" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"entity_id" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"mentioned_member_ids" JSONB NOT NULL DEFAULT '\[\]'/);
|
||||
expect(sql).toMatch(/CREATE INDEX "comments_entity_created_idx"/);
|
||||
});
|
||||
|
||||
it('adds audit logs and governance dictionaries with soft-delete support', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(/CREATE TABLE "audit_logs"/);
|
||||
expect(sql).toMatch(/"before" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||
expect(sql).toMatch(/"after" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||
expect(sql).toMatch(/CREATE TABLE "governance_dictionaries"/);
|
||||
expect(sql).toMatch(/"deleted_at" TIMESTAMP\(3\)/);
|
||||
expect(sql).toMatch(/CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE/);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,11 @@ import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||
import { JobsModule } from './modules/jobs/jobs.module';
|
||||
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||
import { OpsModule } from './modules/ops/ops.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { CommentModule } from './modules/comment/comment.module';
|
||||
import { ProjectMemberModule } from './modules/project-member/project-member.module';
|
||||
import { ManagementModule } from './modules/management/management.module';
|
||||
import { GovernanceModule } from './modules/governance/governance.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -53,6 +58,11 @@ import { OpsModule } from './modules/ops/ops.module';
|
||||
JobsModule,
|
||||
XiaobaoModule,
|
||||
OpsModule,
|
||||
NotificationModule,
|
||||
CommentModule,
|
||||
ProjectMemberModule,
|
||||
ManagementModule,
|
||||
GovernanceModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe('AuditService V2.7 adapter contract', () => {
|
||||
it('writes an append-only audit event with actor and resource scope', async () => {
|
||||
const prisma = {
|
||||
auditLog: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||
},
|
||||
};
|
||||
const service = new AuditService(prisma as any);
|
||||
|
||||
await service.record({
|
||||
actorId: 'm-admin',
|
||||
action: 'project_member.role_changed',
|
||||
resourceType: 'project_member',
|
||||
resourceId: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
before: { role: 'member' },
|
||||
after: { role: 'admin' },
|
||||
});
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
actorId: 'm-admin',
|
||||
action: 'project_member.role_changed',
|
||||
resourceType: 'project_member',
|
||||
resourceId: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
before: { role: 'member' },
|
||||
after: { role: 'admin' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fail the business action when audit storage is unavailable', async () => {
|
||||
const prisma = {
|
||||
auditLog: {
|
||||
create: jest.fn().mockRejectedValue(new Error('database unavailable')),
|
||||
},
|
||||
};
|
||||
const service = new AuditService(prisma as any);
|
||||
(service as any).logger.warn = jest.fn();
|
||||
|
||||
await expect(service.record({
|
||||
actorId: 'm-admin',
|
||||
action: 'comment.created',
|
||||
resourceType: 'comment',
|
||||
resourceId: 'comment-1',
|
||||
})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
43
apps/server/src/common/audit/audit.service.ts
Normal file
43
apps/server/src/common/audit/audit.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(input: AuditEventInput): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
productId: input.productId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
versionId: input.versionId ?? null,
|
||||
before: input.before ?? {},
|
||||
after: input.after ?? {},
|
||||
metadata: input.metadata ?? {},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Audit write failed for ${input.action}: ${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/server/src/common/common-domain.module.ts
Normal file
9
apps/server/src/common/common-domain.module.ts
Normal 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 {}
|
||||
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService V2.7 adapter contract', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
projectMember: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { prisma, service: new RbacService(prisma as any) };
|
||||
};
|
||||
|
||||
it('allows a system admin actor without querying project membership', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
|
||||
await expect(service.assertProjectRole({
|
||||
actorId: 'm-admin',
|
||||
projectId: 'project-1',
|
||||
allowedRoles: ['owner'],
|
||||
permissions: ['*'],
|
||||
})).resolves.toEqual({
|
||||
actorId: 'm-admin',
|
||||
projectId: 'project-1',
|
||||
role: 'owner',
|
||||
via: 'system',
|
||||
});
|
||||
|
||||
expect(prisma.projectMember.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows actors with an explicit global permission', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.assertGlobalPermission({
|
||||
actorId: 'm-pm',
|
||||
permissions: ['management:view'],
|
||||
requiredPermissions: ['management:view'],
|
||||
})).resolves.toEqual({
|
||||
actorId: 'm-pm',
|
||||
via: 'permission',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects actors without the required global permission', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.assertGlobalPermission({
|
||||
actorId: 'm-dev',
|
||||
permissions: ['project:view'],
|
||||
requiredPermissions: ['governance:manage'],
|
||||
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows project owners to perform admin-scoped actions', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findUnique.mockResolvedValue({
|
||||
id: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-owner',
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
await expect(service.assertProjectRole({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
allowedRoles: ['admin'],
|
||||
permissions: [],
|
||||
})).resolves.toEqual({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
role: 'owner',
|
||||
via: 'project_member',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects actors below the required project role', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findUnique.mockResolvedValue({
|
||||
id: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-viewer',
|
||||
role: 'viewer',
|
||||
});
|
||||
|
||||
await expect(service.assertProjectRole({
|
||||
actorId: 'm-viewer',
|
||||
projectId: 'project-1',
|
||||
allowedRoles: ['member'],
|
||||
permissions: [],
|
||||
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export type ProjectGovernanceRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
export interface ProjectRoleAssertion {
|
||||
actorId?: string;
|
||||
projectId?: string | null;
|
||||
allowedRoles: ProjectGovernanceRole[];
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface GlobalPermissionAssertion {
|
||||
actorId?: string;
|
||||
permissions?: string[];
|
||||
requiredPermissions: string[];
|
||||
}
|
||||
|
||||
export interface ProjectRoleDecision {
|
||||
actorId: string;
|
||||
projectId: string;
|
||||
role: ProjectGovernanceRole;
|
||||
via: 'system' | 'project_member';
|
||||
}
|
||||
|
||||
export interface GlobalPermissionDecision {
|
||||
actorId: string;
|
||||
via: 'system' | 'permission';
|
||||
}
|
||||
|
||||
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
|
||||
owner: 4,
|
||||
admin: 3,
|
||||
member: 2,
|
||||
viewer: 1,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RbacService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async assertGlobalPermission(input: GlobalPermissionAssertion): Promise<GlobalPermissionDecision> {
|
||||
const actorId = input.actorId?.trim();
|
||||
if (!actorId) {
|
||||
throw new ForbiddenException('Missing actor scope');
|
||||
}
|
||||
if (input.permissions?.includes('*')) {
|
||||
return { actorId, via: 'system' };
|
||||
}
|
||||
if (hasAnyPermission(input.permissions ?? [], input.requiredPermissions)) {
|
||||
return { actorId, via: 'permission' };
|
||||
}
|
||||
throw new ForbiddenException('Insufficient global permission');
|
||||
}
|
||||
|
||||
async assertProjectRole(input: ProjectRoleAssertion): Promise<ProjectRoleDecision> {
|
||||
const actorId = input.actorId?.trim();
|
||||
const projectId = input.projectId?.trim();
|
||||
if (!actorId || !projectId) {
|
||||
throw new ForbiddenException('Missing actor or project scope');
|
||||
}
|
||||
|
||||
if (input.permissions?.includes('*')) {
|
||||
return { actorId, projectId, role: 'owner', via: 'system' };
|
||||
}
|
||||
|
||||
const membership = await this.prisma.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId, userId: actorId } },
|
||||
});
|
||||
const role = normalizeProjectRole(membership?.role);
|
||||
if (!role || !hasRequiredRole(role, input.allowedRoles)) {
|
||||
throw new ForbiddenException('Insufficient project role');
|
||||
}
|
||||
|
||||
return { actorId, projectId, role, via: 'project_member' };
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectRole(role: string | null | undefined): ProjectGovernanceRole | null {
|
||||
if (role === 'owner' || role === 'admin' || role === 'member' || role === 'viewer') return role;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: ProjectGovernanceRole[]): boolean {
|
||||
if (allowedRoles.length === 0) return false;
|
||||
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
|
||||
return ROLE_RANK[role] >= minimumRank;
|
||||
}
|
||||
|
||||
export function hasAnyPermission(permissions: string[], requiredPermissions: string[]): boolean {
|
||||
if (requiredPermissions.length === 0) return false;
|
||||
const granted = new Set(permissions);
|
||||
return requiredPermissions.some((permission) => granted.has(permission));
|
||||
}
|
||||
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
24
apps/server/src/modules/comment/comment.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/server/src/modules/comment/comment.module.ts
Normal file
13
apps/server/src/modules/comment/comment.module.ts
Normal 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 {}
|
||||
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
157
apps/server/src/modules/comment/comment.service.ts
Normal file
157
apps/server/src/modules/comment/comment.service.ts
Normal 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;
|
||||
}
|
||||
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DeleteCommentDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { GOVERNANCE_DICTIONARY_KINDS, type GovernanceDictionaryKind } from '../governance.service';
|
||||
|
||||
export class GovernanceDictionaryDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
|
||||
@IsIn(GOVERNANCE_DICTIONARY_KINDS)
|
||||
kind!: GovernanceDictionaryKind;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
group?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class RemoveGovernanceDictionaryDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class ImportGovernanceDictionariesDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => GovernanceDictionaryDto)
|
||||
items!: GovernanceDictionaryDto[];
|
||||
}
|
||||
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { GovernanceDictionaryDto, ImportGovernanceDictionariesDto, RemoveGovernanceDictionaryDto } from './dto/governance-dictionary.dto';
|
||||
import { GovernanceDictionaryKind, GovernanceService } from './governance.service';
|
||||
|
||||
@Controller('governance')
|
||||
export class GovernanceController {
|
||||
constructor(private readonly governanceService: GovernanceService) {}
|
||||
|
||||
@Get('dictionaries')
|
||||
list(@Query('kind') kind: GovernanceDictionaryKind) {
|
||||
return this.governanceService.list(kind);
|
||||
}
|
||||
|
||||
@Post('dictionaries')
|
||||
create(@Body() dto: GovernanceDictionaryDto) {
|
||||
return this.governanceService.create(dto);
|
||||
}
|
||||
|
||||
@Patch('dictionaries/:kind/:id')
|
||||
update(
|
||||
@Param('kind') kind: GovernanceDictionaryKind,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: GovernanceDictionaryDto,
|
||||
) {
|
||||
return this.governanceService.update({ ...dto, kind, id });
|
||||
}
|
||||
|
||||
@Delete('dictionaries/:kind/:id')
|
||||
remove(
|
||||
@Param('kind') kind: GovernanceDictionaryKind,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RemoveGovernanceDictionaryDto,
|
||||
) {
|
||||
return this.governanceService.remove({ actorId: dto.actorId, permissions: dto.permissions, kind, id });
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
exportAll() {
|
||||
return this.governanceService.exportAll();
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
importAll(@Body() dto: ImportGovernanceDictionariesDto) {
|
||||
return this.governanceService.importAll(dto.actorId, dto.items, dto.permissions);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/governance/governance.module.ts
Normal file
12
apps/server/src/modules/governance/governance.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||
import { GovernanceController } from './governance.controller';
|
||||
import { GovernanceService } from './governance.service';
|
||||
|
||||
@Module({
|
||||
imports: [CommonDomainModule],
|
||||
controllers: [GovernanceController],
|
||||
providers: [GovernanceService],
|
||||
exports: [GovernanceService],
|
||||
})
|
||||
export class GovernanceModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AuditService } from '../../common/audit/audit.service';
|
||||
import { RbacService } from '../../common/rbac/rbac.service';
|
||||
import { GovernanceService } from './governance.service';
|
||||
|
||||
describe('GovernanceService', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
taskCategory: {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
devTask: { count: jest.fn() },
|
||||
testCase: { count: jest.fn() },
|
||||
governanceDictionary: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
};
|
||||
const audit = { record: jest.fn() } as unknown as AuditService;
|
||||
const rbac = {
|
||||
assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-admin', via: 'system' }),
|
||||
} as unknown as RbacService;
|
||||
return { prisma, audit, rbac, service: new GovernanceService(prisma as any, audit, rbac) };
|
||||
};
|
||||
|
||||
it('requires governance manage permission through the RBAC adapter', async () => {
|
||||
const { prisma, rbac, service } = makeService();
|
||||
(rbac.assertGlobalPermission as jest.Mock).mockRejectedValue(new Error('forbidden'));
|
||||
|
||||
await expect(service.create({
|
||||
actorId: 'm-dev',
|
||||
permissions: ['project:view'],
|
||||
kind: 'requirement_type',
|
||||
name: '新功能',
|
||||
})).rejects.toThrow('forbidden');
|
||||
|
||||
expect(prisma.governanceDictionary.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => {
|
||||
const { prisma, rbac, service } = makeService();
|
||||
prisma.devTask.count.mockResolvedValue(1);
|
||||
prisma.testCase.count.mockResolvedValue(0);
|
||||
|
||||
await expect(service.remove({
|
||||
actorId: 'm-admin',
|
||||
permissions: ['governance:manage'],
|
||||
kind: 'task_category',
|
||||
id: 'cat-1',
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({
|
||||
actorId: 'm-admin',
|
||||
permissions: ['governance:manage'],
|
||||
requiredPermissions: ['governance:manage'],
|
||||
});
|
||||
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('soft deletes requirement dictionaries and writes audit', async () => {
|
||||
const { prisma, audit, service } = makeService();
|
||||
prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') });
|
||||
|
||||
await service.remove({ actorId: 'm-admin', permissions: ['governance:manage'], kind: 'requirement_type', id: 'dict-1' });
|
||||
|
||||
expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dict-1' },
|
||||
data: { deletedAt: expect.any(Date) },
|
||||
});
|
||||
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||
actorId: 'm-admin',
|
||||
action: 'governance.dictionary_deleted',
|
||||
resourceType: 'governance_dictionary',
|
||||
resourceId: 'dict-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('exports task categories and governance dictionaries together', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);
|
||||
prisma.governanceDictionary.findMany.mockResolvedValue([{ id: 'type-1', kind: 'requirement_type', name: '新功能' }]);
|
||||
|
||||
await expect(service.exportAll()).resolves.toEqual({
|
||||
taskCategories: [{ id: 'cat-1', name: '前端' }],
|
||||
dictionaries: [{ id: 'type-1', kind: 'requirement_type', name: '新功能' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
227
apps/server/src/modules/governance/governance.service.ts
Normal file
227
apps/server/src/modules/governance/governance.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AuditService } from '../../common/audit/audit.service';
|
||||
import { RbacService } from '../../common/rbac/rbac.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const;
|
||||
export type GovernanceDictionaryKind = (typeof GOVERNANCE_DICTIONARY_KINDS)[number];
|
||||
|
||||
export interface GovernanceDictionaryInput {
|
||||
actorId: string;
|
||||
kind: GovernanceDictionaryKind;
|
||||
id?: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
group?: string | null;
|
||||
scope?: string;
|
||||
value?: unknown;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface GovernanceRemoveInput {
|
||||
actorId: string;
|
||||
kind: GovernanceDictionaryKind;
|
||||
id: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GovernanceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly rbacService: RbacService,
|
||||
) {}
|
||||
|
||||
list(kind: GovernanceDictionaryKind) {
|
||||
assertDictionaryKind(kind);
|
||||
if (kind === 'task_category') {
|
||||
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
|
||||
}
|
||||
return this.prisma.governanceDictionary.findMany({
|
||||
where: { kind, deletedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(input: GovernanceDictionaryInput) {
|
||||
assertDictionaryKind(input.kind);
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
await this.assertManagePermission(actorId, input.permissions);
|
||||
const created = input.kind === 'task_category'
|
||||
? await this.prisma.taskCategory.create({
|
||||
data: {
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? 'other',
|
||||
},
|
||||
})
|
||||
: await this.prisma.governanceDictionary.create({
|
||||
data: {
|
||||
scope: input.scope ?? 'global',
|
||||
kind: input.kind,
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? null,
|
||||
value: input.value ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_created',
|
||||
resourceType: this.resourceType(input.kind),
|
||||
resourceId: created.id,
|
||||
after: created,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(input: GovernanceDictionaryInput & { id: string }) {
|
||||
assertDictionaryKind(input.kind);
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
await this.assertManagePermission(actorId, input.permissions);
|
||||
const id = requireText(input.id, 'id');
|
||||
const updated = input.kind === 'task_category'
|
||||
? await this.prisma.taskCategory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? 'other',
|
||||
},
|
||||
})
|
||||
: await this.prisma.governanceDictionary.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? null,
|
||||
value: input.value ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_updated',
|
||||
resourceType: this.resourceType(input.kind),
|
||||
resourceId: id,
|
||||
after: updated,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(input: GovernanceRemoveInput) {
|
||||
assertDictionaryKind(input.kind);
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
await this.assertManagePermission(actorId, input.permissions);
|
||||
const id = requireText(input.id, 'id');
|
||||
if (input.kind === 'task_category') {
|
||||
const [devTaskCount, testCaseCount] = await Promise.all([
|
||||
this.prisma.devTask.count({ where: { categoryId: id } }),
|
||||
this.prisma.testCase.count({ where: { categoryId: id } }),
|
||||
]);
|
||||
if (devTaskCount + testCaseCount > 0) {
|
||||
throw new BadRequestException('Dictionary item is in use and cannot be hard deleted');
|
||||
}
|
||||
const removed = await this.prisma.taskCategory.delete({ where: { id } });
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_deleted',
|
||||
resourceType: 'task_category',
|
||||
resourceId: id,
|
||||
before: removed,
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
const removed = await this.prisma.governanceDictionary.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_deleted',
|
||||
resourceType: 'governance_dictionary',
|
||||
resourceId: id,
|
||||
after: removed,
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
async exportAll() {
|
||||
const [taskCategories, dictionaries] = await Promise.all([
|
||||
this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }),
|
||||
this.prisma.governanceDictionary.findMany({ where: { deletedAt: null }, orderBy: [{ kind: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
return { taskCategories, dictionaries };
|
||||
}
|
||||
|
||||
async importAll(actorId: string, items: GovernanceDictionaryInput[], permissions: string[] = []) {
|
||||
await this.assertManagePermission(actorId, permissions);
|
||||
const results = [];
|
||||
for (const item of items) {
|
||||
assertDictionaryKind(item.kind);
|
||||
if (item.kind === 'task_category') {
|
||||
results.push(await this.create({ ...item, actorId, permissions }));
|
||||
} else {
|
||||
const saved = await this.prisma.governanceDictionary.upsert({
|
||||
where: {
|
||||
scope_kind_name: {
|
||||
scope: item.scope ?? 'global',
|
||||
kind: item.kind,
|
||||
name: requireText(item.name, 'name'),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
code: item.code ?? null,
|
||||
group: item.group ?? null,
|
||||
value: item.value ?? {},
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
scope: item.scope ?? 'global',
|
||||
kind: item.kind,
|
||||
name: requireText(item.name, 'name'),
|
||||
code: item.code ?? null,
|
||||
group: item.group ?? null,
|
||||
value: item.value ?? {},
|
||||
},
|
||||
});
|
||||
results.push(saved);
|
||||
}
|
||||
}
|
||||
await this.auditService.record({
|
||||
actorId: requireText(actorId, 'actorId'),
|
||||
action: 'governance.dictionary_imported',
|
||||
resourceType: 'governance_dictionary',
|
||||
resourceId: 'bulk',
|
||||
after: { count: results.length },
|
||||
});
|
||||
return { count: results.length, items: results };
|
||||
}
|
||||
|
||||
private resourceType(kind: GovernanceDictionaryKind) {
|
||||
return kind === 'task_category' ? 'task_category' : 'governance_dictionary';
|
||||
}
|
||||
|
||||
private assertManagePermission(actorId: string, permissions: string[] = []) {
|
||||
return this.rbacService.assertGlobalPermission({
|
||||
actorId,
|
||||
permissions,
|
||||
requiredPermissions: ['governance:manage'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
|
||||
if (!GOVERNANCE_DICTIONARY_KINDS.includes(kind as GovernanceDictionaryKind)) {
|
||||
throw new BadRequestException(`Unsupported governance dictionary kind: ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireText(value: string | undefined | null, field: string): string {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||
return normalized;
|
||||
}
|
||||
18
apps/server/src/modules/management/management.controller.ts
Normal file
18
apps/server/src/modules/management/management.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ManagementService } from './management.service';
|
||||
|
||||
@Controller('management')
|
||||
export class ManagementController {
|
||||
constructor(private readonly managementService: ManagementService) {}
|
||||
|
||||
@Get('overview')
|
||||
getOverview(
|
||||
@Query('actorId') actorId: string,
|
||||
@Query('permissions') permissions?: string,
|
||||
) {
|
||||
return this.managementService.getOverview({
|
||||
actorId,
|
||||
permissions: permissions ? permissions.split(',').map((item) => item.trim()).filter(Boolean) : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
11
apps/server/src/modules/management/management.module.ts
Normal file
11
apps/server/src/modules/management/management.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||
import { ManagementController } from './management.controller';
|
||||
import { ManagementService } from './management.service';
|
||||
|
||||
@Module({
|
||||
imports: [CommonDomainModule],
|
||||
controllers: [ManagementController],
|
||||
providers: [ManagementService],
|
||||
})
|
||||
export class ManagementModule {}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ManagementService } from './management.service';
|
||||
|
||||
describe('ManagementService', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
appData: { findMany: jest.fn() },
|
||||
projectMember: { findMany: jest.fn() },
|
||||
version: { findMany: jest.fn() },
|
||||
versionPlan: { findMany: jest.fn() },
|
||||
devTask: { findMany: jest.fn() },
|
||||
testCase: { findMany: jest.fn() },
|
||||
bug: { findMany: jest.fn() },
|
||||
xiaobaoRiskSummary: { findMany: jest.fn() },
|
||||
};
|
||||
const rbac = {
|
||||
assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-manager', via: 'permission' }),
|
||||
};
|
||||
return { prisma, rbac, service: new ManagementService(prisma as any, rbac as any) };
|
||||
};
|
||||
|
||||
it('requires management view permission through the RBAC adapter', async () => {
|
||||
const { rbac, service } = makeService();
|
||||
rbac.assertGlobalPermission.mockRejectedValue(new Error('forbidden'));
|
||||
|
||||
await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).rejects.toThrow('forbidden');
|
||||
});
|
||||
|
||||
it('returns an empty dashboard when actor has no managed projects', async () => {
|
||||
const { prisma, rbac, service } = makeService();
|
||||
prisma.projectMember.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(service.getOverview({ actorId: 'm-dev', permissions: ['management:view'] })).resolves.toEqual({
|
||||
activeVersionCount: 0,
|
||||
overdueItemCount: 0,
|
||||
blockedItemCount: 0,
|
||||
riskCounts: {},
|
||||
memberLoads: [],
|
||||
activeVersions: [],
|
||||
overdueItems: [],
|
||||
blockedItems: [],
|
||||
highRiskVersions: [],
|
||||
});
|
||||
expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({
|
||||
actorId: 'm-dev',
|
||||
permissions: ['management:view'],
|
||||
requiredPermissions: ['management:view'],
|
||||
});
|
||||
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aggregates active versions, overdue work, blockers, risks, and member load from relation tables only', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }]);
|
||||
prisma.version.findMany.mockResolvedValue([
|
||||
{ id: 'ver-1', projectId: 'project-1', name: 'V1', releaseDate: new Date('2026-07-20T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.versionPlan.findMany.mockResolvedValue([
|
||||
{ id: 'plan-1', versionId: 'ver-1', title: '产品方案', ownerId: 'm-pm', status: 'in_progress', expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.devTask.findMany.mockResolvedValue([
|
||||
{ id: 'task-1', versionId: 'ver-1', title: '接口开发', assigneeId: 'm-dev', status: 'in_progress', isBlocked: true, expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.testCase.findMany.mockResolvedValue([
|
||||
{ id: 'tc-1', versionId: 'ver-1', title: '权限测试', assigneeId: 'm-qa', status: 'running', plannedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.bug.findMany.mockResolvedValue([
|
||||
{ id: 'bug-1', versionId: 'ver-1', title: '线上缺陷', assigneeId: 'm-dev', status: 'open', plannedFixAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
|
||||
{ versionId: 'ver-1', riskLevel: 'likely_delayed', riskScore: 82 },
|
||||
]);
|
||||
|
||||
const result = await service.getOverview({
|
||||
actorId: 'm-manager',
|
||||
permissions: [],
|
||||
now: new Date('2026-07-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result.activeVersionCount).toBe(1);
|
||||
expect(result.overdueItemCount).toBe(4);
|
||||
expect(result.blockedItemCount).toBe(1);
|
||||
expect(result.riskCounts).toEqual({ likely_delayed: 1 });
|
||||
expect(result.memberLoads).toEqual([
|
||||
{ memberId: 'm-dev', openItemCount: 2 },
|
||||
{ memberId: 'm-pm', openItemCount: 1 },
|
||||
{ memberId: 'm-qa', openItemCount: 1 },
|
||||
]);
|
||||
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
162
apps/server/src/modules/management/management.service.ts
Normal file
162
apps/server/src/modules/management/management.service.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { RbacService } from '../../common/rbac/rbac.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface ManagementOverviewQuery {
|
||||
actorId?: string;
|
||||
permissions?: string[];
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
type WorkItem = {
|
||||
id: string;
|
||||
versionId: string;
|
||||
title: string;
|
||||
ownerId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
status?: string;
|
||||
isBlocked?: boolean;
|
||||
expectedEndAt?: Date | string | null;
|
||||
plannedEndAt?: Date | string | null;
|
||||
plannedFixAt?: Date | string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ManagementService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly rbacService: RbacService,
|
||||
) {}
|
||||
|
||||
async getOverview(query: ManagementOverviewQuery) {
|
||||
const actorId = query.actorId?.trim();
|
||||
if (!actorId) throw new BadRequestException('actorId is required');
|
||||
const permission = await this.rbacService.assertGlobalPermission({
|
||||
actorId,
|
||||
permissions: query.permissions ?? [],
|
||||
requiredPermissions: ['management:view'],
|
||||
});
|
||||
const now = query.now ?? new Date();
|
||||
const allProjects = permission.via === 'system';
|
||||
const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId);
|
||||
if (!allProjects && projectIds?.length === 0) return emptyOverview();
|
||||
|
||||
const activeVersions = await this.prisma.version.findMany({
|
||||
where: {
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
OR: [{ releaseDate: null }, { releaseDate: { gte: now } }],
|
||||
},
|
||||
orderBy: { releaseDate: 'asc' },
|
||||
});
|
||||
const versionIds = activeVersions.map((version: { id: string }) => version.id);
|
||||
if (versionIds.length === 0) {
|
||||
return { ...emptyOverview(), activeVersions };
|
||||
}
|
||||
|
||||
const [versionPlans, devTasks, testCases, bugs, highRiskVersions] = await Promise.all([
|
||||
this.prisma.versionPlan.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { not: 'completed' } },
|
||||
}),
|
||||
this.prisma.devTask.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { not: 'submitted' } },
|
||||
}),
|
||||
this.prisma.testCase.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { notIn: ['passed', 'failed', 'blocked'] } },
|
||||
}),
|
||||
this.prisma.bug.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
|
||||
}),
|
||||
this.prisma.xiaobaoRiskSummary.findMany({
|
||||
where: { versionId: { in: versionIds }, riskLevel: { not: 'on_track' } },
|
||||
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const overdueItems = [
|
||||
...collectOverdue(versionPlans, 'version_plan', now, 'expectedEndAt'),
|
||||
...collectOverdue(devTasks, 'dev_task', now, 'expectedEndAt'),
|
||||
...collectOverdue(testCases, 'test_case', now, 'plannedEndAt'),
|
||||
...collectOverdue(bugs, 'bug', now, 'plannedFixAt'),
|
||||
];
|
||||
const blockedItems = [
|
||||
...devTasks.filter((item: WorkItem) => item.isBlocked).map((item: WorkItem) => toDashboardItem(item, 'dev_task')),
|
||||
...testCases.filter((item: WorkItem) => item.status === 'blocked').map((item: WorkItem) => toDashboardItem(item, 'test_case')),
|
||||
];
|
||||
|
||||
return {
|
||||
activeVersionCount: activeVersions.length,
|
||||
overdueItemCount: overdueItems.length,
|
||||
blockedItemCount: blockedItems.length,
|
||||
riskCounts: countByRiskLevel(highRiskVersions),
|
||||
memberLoads: buildMemberLoads(versionPlans, devTasks, testCases, bugs),
|
||||
activeVersions,
|
||||
overdueItems,
|
||||
blockedItems,
|
||||
highRiskVersions,
|
||||
};
|
||||
}
|
||||
|
||||
private async getManagedProjectIds(actorId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.projectMember.findMany({
|
||||
where: { userId: actorId, role: { in: ['owner', 'admin'] } },
|
||||
select: { projectId: true },
|
||||
});
|
||||
return Array.from(new Set(rows.map((row: { projectId: string }) => row.projectId)));
|
||||
}
|
||||
}
|
||||
|
||||
function emptyOverview() {
|
||||
return {
|
||||
activeVersionCount: 0,
|
||||
overdueItemCount: 0,
|
||||
blockedItemCount: 0,
|
||||
riskCounts: {},
|
||||
memberLoads: [],
|
||||
activeVersions: [],
|
||||
overdueItems: [],
|
||||
blockedItems: [],
|
||||
highRiskVersions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function collectOverdue(items: WorkItem[], type: string, now: Date, dateField: keyof WorkItem) {
|
||||
return items
|
||||
.filter((item) => isBefore(item[dateField], now))
|
||||
.map((item) => ({ ...toDashboardItem(item, type), dueAt: item[dateField] }));
|
||||
}
|
||||
|
||||
function toDashboardItem(item: WorkItem, type: string) {
|
||||
return {
|
||||
type,
|
||||
id: item.id,
|
||||
versionId: item.versionId,
|
||||
title: item.title,
|
||||
ownerId: item.ownerId ?? item.assigneeId ?? null,
|
||||
status: item.status,
|
||||
};
|
||||
}
|
||||
|
||||
function isBefore(value: unknown, now: Date): boolean {
|
||||
if (!value) return false;
|
||||
const time = value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
|
||||
return Number.isFinite(time) && time < now.getTime();
|
||||
}
|
||||
|
||||
function countByRiskLevel(rows: Array<{ riskLevel: string }>): Record<string, number> {
|
||||
return rows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.riskLevel] = (acc[row.riskLevel] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildMemberLoads(...groups: WorkItem[][]) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of groups.flat()) {
|
||||
const memberId = item.ownerId ?? item.assigneeId;
|
||||
if (!memberId) continue;
|
||||
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([memberId, openItemCount]) => ({ memberId, openItemCount }))
|
||||
.sort((a, b) => b.openItemCount - a.openItemCount || a.memberId.localeCompare(b.memberId));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { NOTIFICATION_TYPES, type NotificationType } from '../notification.service';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsString()
|
||||
recipientId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorId?: string;
|
||||
|
||||
@IsIn(NOTIFICATION_TYPES)
|
||||
type!: NotificationType;
|
||||
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
body?: string;
|
||||
|
||||
@IsString()
|
||||
resourceType!: string;
|
||||
|
||||
@IsString()
|
||||
resourceId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resourceVersionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
versionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class MarkNotificationReadDto {
|
||||
@IsString()
|
||||
recipientId!: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { CreateNotificationDto } from './dto/create-notification.dto';
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateNotificationDto) {
|
||||
return this.notificationService.create(dto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/notification/notification.module.ts
Normal file
10
apps/server/src/modules/notification/notification.module.ts
Normal 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 {}
|
||||
@@ -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' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
99
apps/server/src/modules/notification/notification.service.ts
Normal file
99
apps/server/src/modules/notification/notification.service.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
import type { ProjectGovernanceRole } from '../../../common/rbac/rbac.service';
|
||||
|
||||
const PROJECT_ROLES = ['owner', 'admin', 'member', 'viewer'] as const;
|
||||
|
||||
export class AddProjectMemberDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(PROJECT_ROLES)
|
||||
role?: ProjectGovernanceRole;
|
||||
}
|
||||
|
||||
export class UpdateProjectMemberRoleDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
|
||||
@IsIn(PROJECT_ROLES)
|
||||
role!: ProjectGovernanceRole;
|
||||
}
|
||||
|
||||
export class RemoveProjectMemberDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { AddProjectMemberDto, RemoveProjectMemberDto, UpdateProjectMemberRoleDto } from './dto/project-member.dto';
|
||||
import { ProjectMemberService } from './project-member.service';
|
||||
|
||||
@Controller('projects/:projectId/members')
|
||||
export class ProjectMemberController {
|
||||
constructor(private readonly projectMemberService: ProjectMemberService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('projectId') projectId: string) {
|
||||
return this.projectMemberService.list(projectId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
add(@Param('projectId') projectId: string, @Body() dto: AddProjectMemberDto) {
|
||||
return this.projectMemberService.add({ ...dto, projectId });
|
||||
}
|
||||
|
||||
@Patch(':userId/role')
|
||||
updateRole(
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpdateProjectMemberRoleDto,
|
||||
) {
|
||||
return this.projectMemberService.updateRole({ ...dto, projectId, userId });
|
||||
}
|
||||
|
||||
@Delete(':userId')
|
||||
remove(
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: RemoveProjectMemberDto,
|
||||
) {
|
||||
return this.projectMemberService.remove({ ...dto, projectId, userId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||
import { ProjectMemberController } from './project-member.controller';
|
||||
import { ProjectMemberService } from './project-member.service';
|
||||
|
||||
@Module({
|
||||
imports: [CommonDomainModule],
|
||||
controllers: [ProjectMemberController],
|
||||
providers: [ProjectMemberService],
|
||||
exports: [ProjectMemberService],
|
||||
})
|
||||
export class ProjectMemberModule {}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AuditService } from '../../common/audit/audit.service';
|
||||
import { RbacService } from '../../common/rbac/rbac.service';
|
||||
import { ProjectMemberService } from './project-member.service';
|
||||
|
||||
describe('ProjectMemberService', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
projectMember: {
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
const rbac = {
|
||||
assertProjectRole: jest.fn().mockResolvedValue({ role: 'owner' }),
|
||||
} as unknown as RbacService;
|
||||
const audit = {
|
||||
record: jest.fn(),
|
||||
} as unknown as AuditService;
|
||||
return {
|
||||
prisma,
|
||||
rbac,
|
||||
audit,
|
||||
service: new ProjectMemberService(prisma as any, rbac, audit),
|
||||
};
|
||||
};
|
||||
|
||||
it('lists project members with their user profile', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findMany.mockResolvedValue([{ id: 'pm-1', role: 'owner' }]);
|
||||
|
||||
await expect(service.list('project-1')).resolves.toEqual([{ id: 'pm-1', role: 'owner' }]);
|
||||
|
||||
expect(prisma.projectMember.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: 'project-1' },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a project member after admin-or-owner authorization and writes audit', async () => {
|
||||
const { prisma, rbac, audit, service } = makeService();
|
||||
prisma.projectMember.create.mockResolvedValue({ id: 'pm-2', role: 'member' });
|
||||
|
||||
await service.add({ actorId: 'm-owner', projectId: 'project-1', userId: 'm-dev', role: 'member' });
|
||||
|
||||
expect((rbac.assertProjectRole as jest.Mock)).toHaveBeenCalledWith({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
allowedRoles: ['admin'],
|
||||
});
|
||||
expect(prisma.projectMember.create).toHaveBeenCalledWith({
|
||||
data: { projectId: 'project-1', userId: 'm-dev', role: 'member' },
|
||||
});
|
||||
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||
actorId: 'm-owner',
|
||||
action: 'project_member.created',
|
||||
resourceType: 'project_member',
|
||||
projectId: 'project-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects demoting the last project owner', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findUnique.mockResolvedValue({
|
||||
id: 'pm-owner',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-owner',
|
||||
role: 'owner',
|
||||
});
|
||||
prisma.projectMember.count.mockResolvedValue(1);
|
||||
|
||||
await expect(service.updateRole({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-owner',
|
||||
role: 'admin',
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(prisma.projectMember.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects removing the last project owner', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findUnique.mockResolvedValue({
|
||||
id: 'pm-owner',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-owner',
|
||||
role: 'owner',
|
||||
});
|
||||
prisma.projectMember.count.mockResolvedValue(1);
|
||||
|
||||
await expect(service.remove({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-owner',
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(prisma.projectMember.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates roles and writes an audit diff', async () => {
|
||||
const { prisma, audit, service } = makeService();
|
||||
prisma.projectMember.findUnique.mockResolvedValue({
|
||||
id: 'pm-dev',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-dev',
|
||||
role: 'member',
|
||||
});
|
||||
prisma.projectMember.update.mockResolvedValue({
|
||||
id: 'pm-dev',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-dev',
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
await service.updateRole({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-dev',
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
expect(prisma.projectMember.update).toHaveBeenCalledWith({
|
||||
where: { projectId_userId: { projectId: 'project-1', userId: 'm-dev' } },
|
||||
data: { role: 'admin' },
|
||||
});
|
||||
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'project_member.role_changed',
|
||||
before: expect.objectContaining({ role: 'member' }),
|
||||
after: expect.objectContaining({ role: 'admin' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
129
apps/server/src/modules/project-member/project-member.service.ts
Normal file
129
apps/server/src/modules/project-member/project-member.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AuditService } from '../../common/audit/audit.service';
|
||||
import { ProjectGovernanceRole, RbacService, normalizeProjectRole } from '../../common/rbac/rbac.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface ProjectMemberMutationInput {
|
||||
actorId: string;
|
||||
projectId: string;
|
||||
userId: string;
|
||||
role?: ProjectGovernanceRole;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProjectMemberService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly rbacService: RbacService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
list(projectId: string) {
|
||||
return this.prisma.projectMember.findMany({
|
||||
where: { projectId: requireText(projectId, 'projectId') },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async add(input: ProjectMemberMutationInput) {
|
||||
const projectId = requireText(input.projectId, 'projectId');
|
||||
const userId = requireText(input.userId, 'userId');
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
const role = normalizeRequiredRole(input.role ?? 'member');
|
||||
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
|
||||
|
||||
const created = await this.prisma.projectMember.create({
|
||||
data: { projectId, userId, role },
|
||||
});
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'project_member.created',
|
||||
resourceType: 'project_member',
|
||||
resourceId: created.id,
|
||||
projectId,
|
||||
after: created,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateRole(input: Required<ProjectMemberMutationInput>) {
|
||||
const projectId = requireText(input.projectId, 'projectId');
|
||||
const userId = requireText(input.userId, 'userId');
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
const role = normalizeRequiredRole(input.role);
|
||||
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
|
||||
|
||||
const existing = await this.findMembership(projectId, userId);
|
||||
if (existing.role === 'owner' && role !== 'owner') {
|
||||
await this.assertOwnerWillRemain(projectId);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.projectMember.update({
|
||||
where: { projectId_userId: { projectId, userId } },
|
||||
data: { role },
|
||||
});
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'project_member.role_changed',
|
||||
resourceType: 'project_member',
|
||||
resourceId: updated.id,
|
||||
projectId,
|
||||
before: existing,
|
||||
after: updated,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(input: Omit<ProjectMemberMutationInput, 'role'>) {
|
||||
const projectId = requireText(input.projectId, 'projectId');
|
||||
const userId = requireText(input.userId, 'userId');
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
|
||||
|
||||
const existing = await this.findMembership(projectId, userId);
|
||||
if (existing.role === 'owner') {
|
||||
await this.assertOwnerWillRemain(projectId);
|
||||
}
|
||||
|
||||
const removed = await this.prisma.projectMember.delete({
|
||||
where: { projectId_userId: { projectId, userId } },
|
||||
});
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'project_member.deleted',
|
||||
resourceType: 'project_member',
|
||||
resourceId: removed.id,
|
||||
projectId,
|
||||
before: existing,
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
private async findMembership(projectId: string, userId: string) {
|
||||
const membership = await this.prisma.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId, userId } },
|
||||
});
|
||||
if (!membership) throw new NotFoundException('Project member not found');
|
||||
return membership;
|
||||
}
|
||||
|
||||
private async assertOwnerWillRemain(projectId: string) {
|
||||
const ownerCount = await this.prisma.projectMember.count({ where: { projectId, role: 'owner' } });
|
||||
if (ownerCount <= 1) {
|
||||
throw new BadRequestException('Cannot remove or demote the last project owner');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRequiredRole(role: string | undefined): ProjectGovernanceRole {
|
||||
const normalized = normalizeProjectRole(role);
|
||||
if (!normalized) throw new BadRequestException(`Unsupported project member role: ${role}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireText(value: string | undefined | null, field: string): string {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||
return normalized;
|
||||
}
|
||||
152
apps/web/app/admin/governance/page.tsx
Normal file
152
apps/web/app/admin/governance/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Plus, RefreshCcw, Trash2, Upload } from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
type GovernanceKind = 'task_category' | 'requirement_type' | 'requirement_platform' | 'requirement_source';
|
||||
|
||||
interface GovernanceItem {
|
||||
id: string;
|
||||
kind?: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
group?: string | null;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<GovernanceKind, string> = {
|
||||
task_category: '任务类型',
|
||||
requirement_type: '需求类型',
|
||||
requirement_platform: '支持端',
|
||||
requirement_source: '需求来源',
|
||||
};
|
||||
|
||||
function GovernancePageInner() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
||||
const [kind, setKind] = useState<GovernanceKind>('task_category');
|
||||
const [items, setItems] = useState<GovernanceItem[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [group, setGroup] = useState('other');
|
||||
const [exportText, setExportText] = useState('');
|
||||
const actorId = user?.id ?? '';
|
||||
const permissions = role?.permissions ?? [];
|
||||
|
||||
const reload = async () => {
|
||||
const rows = await api.get<GovernanceItem[]>(`/governance/dictionaries?kind=${kind}`);
|
||||
setItems(rows);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload().catch(() => setItems([]));
|
||||
}, [kind]);
|
||||
|
||||
const create = async () => {
|
||||
if (!actorId || !name.trim()) return;
|
||||
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group });
|
||||
setName('');
|
||||
await reload();
|
||||
};
|
||||
|
||||
const remove = async (item: GovernanceItem) => {
|
||||
if (!actorId) return;
|
||||
await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId, permissions });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const exportAll = async () => {
|
||||
const data = await api.get('/governance/export');
|
||||
setExportText(JSON.stringify(data, null, 2));
|
||||
};
|
||||
|
||||
const importAll = async () => {
|
||||
if (!actorId || !exportText.trim()) return;
|
||||
const parsed = JSON.parse(exportText);
|
||||
const sourceItems = Array.isArray(parsed.items)
|
||||
? parsed.items
|
||||
: [...(parsed.dictionaries ?? []), ...(parsed.taskCategories ?? []).map((item: GovernanceItem) => ({ ...item, kind: 'task_category' }))];
|
||||
await api.post('/governance/import', { actorId, permissions, items: sourceItems });
|
||||
await reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[var(--bg)] p-6">
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[18px] font-semibold text-[var(--ink)]">治理设置</h1>
|
||||
<p className="mt-1 text-[12px] text-[var(--ink-muted)]">统一维护任务类型、需求类型、支持端与来源字典。</p>
|
||||
</div>
|
||||
<button onClick={reload} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)]">
|
||||
<RefreshCcw className="h-3.5 w-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(KIND_LABEL).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setKind(key as GovernanceKind)}
|
||||
className={`h-8 rounded-md px-3 text-[12px] font-medium ${kind === key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)]'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="grid grid-cols-[1fr_140px_auto] gap-2 border-b border-[var(--line)] p-3">
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<input value={group} onChange={(event) => setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<button onClick={create} disabled={!name.trim()} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||
<Plus className="h-3.5 w-3.5" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[1fr_160px_80px_32px] items-center gap-3 px-4 py-2.5">
|
||||
<span className="truncate text-[13px] text-[var(--ink)]">{item.name}</span>
|
||||
<span className="truncate text-[11px] text-[var(--ink-muted)]">{item.code ?? '-'}</span>
|
||||
<span className="truncate text-[11px] text-[var(--ink-muted)]">{item.group ?? '-'}</span>
|
||||
<button onClick={() => remove(item).catch(() => {})} disabled={item.isSystem} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600 disabled:opacity-30" title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无字典项</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<button onClick={exportAll} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] px-3 text-[12px] text-[var(--ink-soft)]">
|
||||
<Download className="h-3.5 w-3.5" /> 导出
|
||||
</button>
|
||||
<button onClick={importAll} disabled={!exportText.trim()} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] px-3 text-[12px] text-[var(--ink-soft)] disabled:opacity-50">
|
||||
<Upload className="h-3.5 w-3.5" /> 导入
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={exportText}
|
||||
onChange={(event) => setExportText(event.target.value)}
|
||||
rows={10}
|
||||
className="w-full rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 py-2 font-mono text-[11px] leading-5 focus:border-[var(--accent)] focus:outline-none"
|
||||
placeholder="点击导出生成 JSON,也可以粘贴 JSON 后导入"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GovernancePage() {
|
||||
return (
|
||||
<RouteGuard permission="governance:manage">
|
||||
<GovernancePageInner />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
132
apps/web/app/admin/management/page.tsx
Normal file
132
apps/web/app/admin/management/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, AlertTriangle, Blocks, Gauge, Users } from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
interface ManagementOverview {
|
||||
activeVersionCount: number;
|
||||
overdueItemCount: number;
|
||||
blockedItemCount: number;
|
||||
riskCounts: Record<string, number>;
|
||||
memberLoads: Array<{ memberId: string; openItemCount: number }>;
|
||||
activeVersions: Array<{ id: string; name: string; projectId?: string | null; releaseDate?: string | null }>;
|
||||
overdueItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null; dueAt?: string | null }>;
|
||||
blockedItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null }>;
|
||||
highRiskVersions: Array<{ versionId: string; riskLevel: string; riskScore: number }>;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
version_plan: '计划',
|
||||
dev_task: '开发',
|
||||
test_case: '测试',
|
||||
bug: 'Bug',
|
||||
};
|
||||
|
||||
function ManagementPageInner() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
||||
const [overview, setOverview] = useState<ManagementOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const permissions = useMemo(() => role?.permissions ?? [], [role?.permissions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ actorId: user.id });
|
||||
if (permissions.length > 0) params.set('permissions', permissions.join(','));
|
||||
api.get<ManagementOverview>(`/management/overview?${params.toString()}`)
|
||||
.then(setOverview)
|
||||
.catch(() => setOverview(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [permissions, user?.id]);
|
||||
|
||||
const riskTotal = overview ? Object.values(overview.riskCounts).reduce((sum, count) => sum + count, 0) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[var(--bg)] p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[18px] font-semibold text-[var(--ink)]">管理驾驶舱</h1>
|
||||
<p className="mt-1 text-[12px] text-[var(--ink-muted)]">关系表实时聚合,不读取 AppData。</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-3 py-1.5 text-[11px] text-[var(--ink-muted)]">
|
||||
{loading ? '刷新中' : '已同步'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Metric icon={Activity} label="活跃版本" value={overview?.activeVersionCount ?? 0} />
|
||||
<Metric icon={AlertTriangle} label="逾期事项" value={overview?.overdueItemCount ?? 0} tone="warn" />
|
||||
<Metric icon={Blocks} label="阻塞事项" value={overview?.blockedItemCount ?? 0} tone="danger" />
|
||||
<Metric icon={Gauge} label="风险版本" value={riskTotal} tone="risk" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1.15fr_0.85fr] gap-4">
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]">逾期与阻塞</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{[...(overview?.overdueItems ?? []), ...(overview?.blockedItems ?? [])].slice(0, 12).map((item) => (
|
||||
<div key={`${item.type}-${item.id}`} className="grid grid-cols-[72px_1fr_120px] gap-3 px-4 py-2.5 text-[12px]">
|
||||
<span className="rounded bg-[var(--bg-subtle)] px-2 py-1 text-center text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
|
||||
<span className="min-w-0 truncate text-[var(--ink)]">{item.title}</span>
|
||||
<span className="truncate text-right text-[var(--ink-muted)]">{item.ownerId ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!overview || (overview.overdueItems.length + overview.blockedItems.length) === 0) && (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无逾期或阻塞事项</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]">
|
||||
<Users className="h-4 w-4 text-[var(--accent)]" /> 成员负载
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{(overview?.memberLoads ?? []).slice(0, 10).map((item) => (
|
||||
<div key={item.memberId} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.memberId}</span>
|
||||
<span className="rounded bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">{item.openItemCount}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!overview || overview.memberLoads.length === 0) && (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无负载数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value, tone = 'neutral' }: { icon: any; label: string; value: number; tone?: 'neutral' | 'warn' | 'danger' | 'risk' }) {
|
||||
const toneClass = {
|
||||
neutral: 'text-[var(--accent)] bg-blue-50',
|
||||
warn: 'text-orange-700 bg-orange-50',
|
||||
danger: 'text-red-700 bg-red-50',
|
||||
risk: 'text-purple-700 bg-purple-50',
|
||||
}[tone];
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
|
||||
<span className={`rounded-md p-1.5 ${toneClass}`}><Icon className="h-4 w-4" /></span>
|
||||
</div>
|
||||
<div className="text-[26px] font-semibold tabular-nums text-[var(--ink)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManagementPage() {
|
||||
return (
|
||||
<RouteGuard permission="management:view">
|
||||
<ManagementPageInner />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/ve
|
||||
import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from '@/lib/dev-task';
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { MemberChips } from '@/components/version/MemberChips';
|
||||
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
|
||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
||||
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
||||
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
||||
@@ -451,6 +452,8 @@ export default function ProjectDetailPage() {
|
||||
|
||||
<TeamSection teamByRole={teamByRole} />
|
||||
|
||||
<ProjectMemberPanel projectId={projectId} />
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
|
||||
import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
|
||||
import { BugStatusBadge } from './BugStatusBadge';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
@@ -228,6 +229,13 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false
|
||||
)}
|
||||
|
||||
<ActivityLogPanel sourceType="bug" sourceId={bug.id} legacyEntries={legacyLogEntries} />
|
||||
<CommentPanel
|
||||
entityType="bug"
|
||||
entityId={bug.id}
|
||||
entityVersionId={bug.versionId}
|
||||
versionId={bug.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
126
apps/web/components/comment/CommentPanel.tsx
Normal file
126
apps/web/components/comment/CommentPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Send, Trash2 } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useCommentStore, commentKey } from '@/stores/useCommentStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { extractMentionNames, mergeMentionMemberIds } from '@/lib/comment-mentions';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
export type CommentEntityType = 'dev_task' | 'test_case' | 'bug' | 'requirement' | 'version_plan';
|
||||
|
||||
interface Props {
|
||||
entityType: CommentEntityType;
|
||||
entityId: string;
|
||||
entityVersionId?: string | null;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function CommentPanel({ entityType, entityId, entityVersionId, productId, projectId, versionId, readOnly = false }: Props) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { members, fetchMembers } = useMemberStore();
|
||||
const { commentsByKey, fetchComments, createComment, deleteComment } = useCommentStore();
|
||||
const [content, setContent] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const key = commentKey(entityType, entityId);
|
||||
const comments = commentsByKey[key] ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
void fetchComments(entityType, entityId).catch(() => {});
|
||||
void fetchMembers().catch(() => {});
|
||||
}, [entityId, entityType, fetchComments, fetchMembers]);
|
||||
|
||||
const mentionNames = useMemo(() => extractMentionNames(content), [content]);
|
||||
const mentionMemberIds = useMemo(() => mergeMentionMemberIds({
|
||||
content,
|
||||
explicitMemberIds: selectedIds,
|
||||
members: members.map((member) => ({ id: member.id, name: member.name })),
|
||||
}), [content, members, selectedIds]);
|
||||
|
||||
const submit = async () => {
|
||||
if (readOnly || !user?.id || !content.trim()) return;
|
||||
await createComment({
|
||||
actorId: user.id,
|
||||
entityType,
|
||||
entityId,
|
||||
entityVersionId,
|
||||
productId,
|
||||
projectId,
|
||||
versionId,
|
||||
content: content.trim(),
|
||||
mentionMemberIds,
|
||||
});
|
||||
setContent('');
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const toggleMember = (id: string) => {
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-[var(--ink-muted)]">
|
||||
<MessageSquare className="h-3 w-3" /> 评论
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">暂无评论</p>
|
||||
) : (
|
||||
comments.map((comment) => {
|
||||
const author = members.find((member) => member.id === comment.authorId)?.name ?? comment.authorId;
|
||||
return (
|
||||
<div key={comment.id} className="rounded-md bg-[var(--bg-subtle)] px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-[var(--ink)]">{author}</span>
|
||||
<span className="text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(comment.createdAt)}</span>
|
||||
{!readOnly && user?.id === comment.authorId && (
|
||||
<button onClick={() => deleteComment(entityType, entityId, comment.id, user.id).catch(() => {})} className="ml-auto rounded p-1 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="删除评论">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-[12px] leading-5 text-[var(--ink-soft)]">{comment.content}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="mt-3 space-y-2 border-t border-[var(--line)] pt-3">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="写评论,输入 @成员名 可提及"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[12px] leading-5 focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.slice(0, 8).map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => toggleMember(member.id)}
|
||||
className={`h-6 rounded-md border px-2 text-[11px] ${selectedIds.includes(member.id) ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-[var(--line)] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
@{member.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
{mentionNames.length > 0 || selectedIds.length > 0 ? `将通知 ${mentionMemberIds.length} 人` : '未提及成员'}
|
||||
</span>
|
||||
<button onClick={submit} disabled={!content.trim() || !user?.id} className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||
<Send className="h-3 w-3" /> 发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2,
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { CategoryChip } from './CategoryChip';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
@@ -507,6 +508,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
|
||||
<ActivityLogPanel sourceType="dev_task" sourceId={task.id} />
|
||||
|
||||
<CommentPanel
|
||||
entityType="dev_task"
|
||||
entityId={task.id}
|
||||
entityVersionId={task.versionId}
|
||||
versionId={task.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{predecessors.length > 0 && (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity } from 'lucide-react';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity, BarChart3, SlidersHorizontal } from 'lucide-react';
|
||||
import { useHasPermission } from '@/components/auth/Guard';
|
||||
import { NotificationBell } from '@/components/notification/NotificationBell';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
@@ -48,6 +49,8 @@ const NAV_GROUPS = [
|
||||
{ label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' },
|
||||
{ label: '一致性', path: '/admin/consistency', icon: Database, permission: 'consistency:view' },
|
||||
{ label: '运维', path: '/admin/ops', icon: Activity, permission: 'ops:view' },
|
||||
{ label: '管理驾驶舱', path: '/admin/management', icon: BarChart3, permission: 'management:view' },
|
||||
{ label: '治理设置', path: '/admin/governance', icon: SlidersHorizontal, permission: 'governance:manage' },
|
||||
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||
],
|
||||
},
|
||||
@@ -119,6 +122,7 @@ function UserBlock() {
|
||||
<p className="truncate text-[13px] font-medium text-[var(--ink)]">{user.name}</p>
|
||||
<p className="truncate text-[11px] text-[var(--ink-muted)]">{role?.name ?? '-'}</p>
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
|
||||
78
apps/web/components/notification/NotificationBell.tsx
Normal file
78
apps/web/components/notification/NotificationBell.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, CheckCheck } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
assignment: '分配',
|
||||
mention: '提及',
|
||||
risk_alert: '风险',
|
||||
overdue_item: '逾期',
|
||||
};
|
||||
|
||||
export function NotificationBell() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) void fetchNotifications(user.id).catch(() => {});
|
||||
}, [fetchNotifications, user?.id]);
|
||||
|
||||
if (!user?.id) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="h-4 w-4" strokeWidth={1.8} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[9px] font-semibold leading-none text-white">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-9 right-0 z-50 w-80 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl">
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--line)] px-3">
|
||||
<span className="text-[12px] font-semibold text-[var(--ink)]">通知</span>
|
||||
<button
|
||||
onClick={() => markAllRead(user.id).catch(() => {})}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" /> 全部已读
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无通知</div>
|
||||
) : (
|
||||
notifications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => !item.readAt && markRead(item.id, user.id).catch(() => {})}
|
||||
className={`block w-full border-b border-[var(--line)] px-3 py-2.5 text-left last:border-b-0 hover:bg-[var(--bg-subtle)] ${item.readAt ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${item.readAt ? 'bg-zinc-300' : 'bg-blue-600'}`} />
|
||||
<span className="rounded bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[10px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
|
||||
<span className="ml-auto text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="line-clamp-1 text-[12px] font-medium text-[var(--ink)]">{item.title}</div>
|
||||
{item.body && <div className="mt-0.5 line-clamp-2 text-[11px] leading-4 text-[var(--ink-muted)]">{item.body}</div>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/project/ProjectMemberPanel.tsx
Normal file
105
apps/web/components/project/ProjectMemberPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ShieldCheck, Trash2, UserPlus } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
type ProjectRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
interface ProjectMemberRow {
|
||||
id: string;
|
||||
projectId: string;
|
||||
userId: string;
|
||||
role: ProjectRole;
|
||||
user?: { id: string; name: string; email?: string };
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<ProjectRole, string> = {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
member: 'Member',
|
||||
viewer: 'Viewer',
|
||||
};
|
||||
|
||||
export function ProjectMemberPanel({ projectId }: { projectId: string }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { members, fetchMembers } = useMemberStore();
|
||||
const [rows, setRows] = useState<ProjectMemberRow[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<ProjectRole>('member');
|
||||
const actorId = user?.id ?? '';
|
||||
|
||||
const reload = async () => {
|
||||
if (!projectId) return;
|
||||
const data = await api.get<ProjectMemberRow[]>(`/projects/${projectId}/members`);
|
||||
setRows(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchMembers().catch(() => {});
|
||||
void reload().catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const add = async () => {
|
||||
if (!actorId || !selectedUserId) return;
|
||||
await api.post(`/projects/${projectId}/members`, { actorId, userId: selectedUserId, role: selectedRole });
|
||||
setSelectedUserId('');
|
||||
await reload();
|
||||
};
|
||||
|
||||
const updateRole = async (member: ProjectMemberRow, role: ProjectRole) => {
|
||||
if (!actorId) return;
|
||||
await api.patch(`/projects/${projectId}/members/${member.userId}/role`, { actorId, role });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const remove = async (member: ProjectMemberRow) => {
|
||||
if (!actorId) return;
|
||||
await api.deleteWithBody(`/projects/${projectId}/members/${member.userId}`, { actorId });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const existingUserIds = new Set(rows.map((row) => row.userId));
|
||||
const candidates = members.filter((member) => !existingUserIds.has(member.id));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center gap-1.5 text-[12px] font-semibold text-[var(--ink)]">
|
||||
<ShieldCheck className="h-4 w-4 text-[var(--accent)]" /> 项目成员治理
|
||||
</div>
|
||||
<div className="mb-3 grid grid-cols-[1fr_108px_auto] gap-2">
|
||||
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
||||
<option value="">选择成员</option>
|
||||
{candidates.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}
|
||||
</select>
|
||||
<select value={selectedRole} onChange={(event) => setSelectedRole(event.target.value as ProjectRole)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
||||
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
||||
</select>
|
||||
<button onClick={add} disabled={!selectedUserId} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||
<UserPlus className="h-3 w-3" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--line)] rounded-md border border-[var(--line)]">
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-[12px] text-[var(--ink-muted)]">暂无项目成员</div>
|
||||
) : rows.map((row) => (
|
||||
<div key={row.id} className="grid grid-cols-[1fr_112px_32px] items-center gap-2 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-medium text-[var(--ink)]">{row.user?.name ?? row.userId}</div>
|
||||
<div className="truncate text-[10px] text-[var(--ink-muted)]">{row.user?.email ?? row.userId}</div>
|
||||
</div>
|
||||
<select value={row.role} onChange={(event) => updateRole(row, event.target.value as ProjectRole).catch(() => {})} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[11px]">
|
||||
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => remove(row).catch(() => {})} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="移除成员">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
|
||||
@@ -114,6 +115,16 @@ export function RequirementDetail({
|
||||
<InfoItem label="来源对象" value={sourceTargetName} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<section className="border-b border-[var(--line)] px-5 py-4 last:border-b-0">
|
||||
<CommentPanel
|
||||
entityType="requirement"
|
||||
entityId={req.id}
|
||||
entityVersionId={req.versionId}
|
||||
projectId={req.projectId}
|
||||
versionId={req.versionId}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2, ArrowRig
|
||||
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
||||
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
@@ -328,6 +329,13 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
|
||||
<ActivityLogPanel sourceType="test_case" sourceId={tc.id} />
|
||||
<CommentPanel
|
||||
entityType="test_case"
|
||||
entityId={tc.id}
|
||||
entityVersionId={tc.versionId}
|
||||
versionId={tc.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import {
|
||||
getResearchDirectionProgressSummary,
|
||||
getRequirementCoverageSummary,
|
||||
@@ -242,6 +243,14 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel, readOnly = fal
|
||||
|
||||
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
|
||||
|
||||
<CommentPanel
|
||||
entityType="version_plan"
|
||||
entityId={plan.id}
|
||||
entityVersionId={plan.versionId}
|
||||
versionId={plan.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{/* Result */}
|
||||
{plan.status === 'completed' && plan.resultUrl && (
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
||||
|
||||
@@ -92,6 +92,8 @@ export const api = {
|
||||
patch: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
deleteWithBody: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'DELETE', body: JSON.stringify(data) }),
|
||||
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
|
||||
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
|
||||
const controller = new AbortController();
|
||||
|
||||
20
apps/web/lib/comment-mentions.test.ts
Normal file
20
apps/web/lib/comment-mentions.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractMentionNames, mergeMentionMemberIds } from './comment-mentions';
|
||||
|
||||
test('extractMentionNames reads @memberName mentions from comment content', () => {
|
||||
assert.deepEqual(extractMentionNames('请 @Alice 和 @张三 看一下'), ['Alice', '张三']);
|
||||
});
|
||||
|
||||
test('mergeMentionMemberIds keeps text mention matches before explicit selection and dedupes', () => {
|
||||
const ids = mergeMentionMemberIds({
|
||||
content: '请 @Alice 看一下',
|
||||
explicitMemberIds: ['m-bob', 'm-alice'],
|
||||
members: [
|
||||
{ id: 'm-alice', name: 'Alice' },
|
||||
{ id: 'm-bob', name: 'Bob' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(ids, ['m-alice', 'm-bob']);
|
||||
});
|
||||
36
apps/web/lib/comment-mentions.ts
Normal file
36
apps/web/lib/comment-mentions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface CommentMentionMember {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MergeMentionMemberIdsInput {
|
||||
content: string;
|
||||
explicitMemberIds: string[];
|
||||
members: CommentMentionMember[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function mergeMentionMemberIds(input: MergeMentionMemberIdsInput): string[] {
|
||||
const ids = new Set<string>();
|
||||
const names = new Set(extractMentionNames(input.content).map(normalizeName));
|
||||
for (const member of input.members) {
|
||||
if (names.has(normalizeName(member.name))) ids.add(member.id);
|
||||
}
|
||||
for (const id of input.explicitMemberIds) {
|
||||
const normalized = id.trim();
|
||||
if (normalized) ids.add(normalized);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
20
apps/web/lib/notification.test.ts
Normal file
20
apps/web/lib/notification.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { countUnreadNotifications, sortNotificationsNewestFirst } from './notification';
|
||||
|
||||
test('countUnreadNotifications counts only rows without readAt', () => {
|
||||
assert.equal(countUnreadNotifications([
|
||||
{ id: 'n-1', readAt: null } as any,
|
||||
{ id: 'n-2', readAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||
{ id: 'n-3' } as any,
|
||||
]), 2);
|
||||
});
|
||||
|
||||
test('sortNotificationsNewestFirst keeps newest createdAt first', () => {
|
||||
const sorted = sortNotificationsNewestFirst([
|
||||
{ id: 'old', createdAt: '2026-07-07T08:00:00.000Z' } as any,
|
||||
{ id: 'new', createdAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||
]);
|
||||
|
||||
assert.deepEqual(sorted.map((item) => item.id), ['new', 'old']);
|
||||
});
|
||||
32
apps/web/lib/notification.ts
Normal file
32
apps/web/lib/notification.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type NotificationType = 'assignment' | 'mention' | 'risk_alert' | 'overdue_item';
|
||||
|
||||
export interface NotificationRecord {
|
||||
id: string;
|
||||
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?: Record<string, unknown>;
|
||||
readAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function countUnreadNotifications(items: Pick<NotificationRecord, 'readAt'>[]): number {
|
||||
return items.filter((item) => !item.readAt).length;
|
||||
}
|
||||
|
||||
export function sortNotificationsNewestFirst<T extends Pick<NotificationRecord, 'createdAt'>>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => dateValue(b.createdAt) - dateValue(a.createdAt));
|
||||
}
|
||||
|
||||
function dateValue(value: string): number {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
@@ -64,6 +64,22 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||
{ module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] },
|
||||
{ module: 'consistency', moduleLabel: '一致性', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'consistency:view' }] },
|
||||
{
|
||||
module: 'management',
|
||||
moduleLabel: '管理驾驶舱',
|
||||
category: 'main',
|
||||
actions: [
|
||||
{ action: 'view', label: '查看', permission: 'management:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
module: 'governance',
|
||||
moduleLabel: '治理设置',
|
||||
category: 'main',
|
||||
actions: [
|
||||
{ action: 'manage', label: '管理', permission: 'governance:manage' },
|
||||
],
|
||||
},
|
||||
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
||||
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
|
||||
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
|
||||
@@ -86,6 +102,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
...std4('requirement').map((a) => a.permission),
|
||||
'version.req:view', 'version.req:manage',
|
||||
'version.product_plan:view', 'version.product_plan:manage',
|
||||
'management:view',
|
||||
'xiaobao.warning:view', 'xiaobao.warning:manage',
|
||||
'overtime:view', 'member:view', 'role:view',
|
||||
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||
|
||||
79
apps/web/stores/useCommentStore.ts
Normal file
79
apps/web/stores/useCommentStore.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CommentEntityType } from '@/components/comment/CommentPanel';
|
||||
|
||||
export interface CommentRecord {
|
||||
id: string;
|
||||
entityType: CommentEntityType;
|
||||
entityId: string;
|
||||
entityVersionId?: string | null;
|
||||
authorId: string;
|
||||
content: string;
|
||||
mentionedMemberIds?: string[];
|
||||
createdAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
interface CreateCommentInput {
|
||||
actorId: string;
|
||||
entityType: CommentEntityType;
|
||||
entityId: string;
|
||||
entityVersionId?: string | null;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
content: string;
|
||||
mentionMemberIds?: string[];
|
||||
}
|
||||
|
||||
interface CommentState {
|
||||
commentsByKey: Record<string, CommentRecord[]>;
|
||||
loadingKeys: string[];
|
||||
fetchComments: (entityType: CommentEntityType, entityId: string) => Promise<void>;
|
||||
createComment: (input: CreateCommentInput) => Promise<void>;
|
||||
deleteComment: (entityType: CommentEntityType, entityId: string, id: string, actorId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function commentKey(entityType: CommentEntityType, entityId: string) {
|
||||
return `${entityType}:${entityId}`;
|
||||
}
|
||||
|
||||
export const useCommentStore = create<CommentState>((set, get) => ({
|
||||
commentsByKey: {},
|
||||
loadingKeys: [],
|
||||
|
||||
fetchComments: async (entityType, entityId) => {
|
||||
const key = commentKey(entityType, entityId);
|
||||
set({ loadingKeys: [...get().loadingKeys.filter((item) => item !== key), key] });
|
||||
const params = new URLSearchParams({ entityType, entityId });
|
||||
const rows = await api.get<CommentRecord[]>(`/comments?${params.toString()}`);
|
||||
set({
|
||||
commentsByKey: { ...get().commentsByKey, [key]: rows },
|
||||
loadingKeys: get().loadingKeys.filter((item) => item !== key),
|
||||
});
|
||||
},
|
||||
|
||||
createComment: async (input) => {
|
||||
const row = await api.post<CommentRecord>('/comments', input);
|
||||
const key = commentKey(input.entityType, input.entityId);
|
||||
set({
|
||||
commentsByKey: {
|
||||
...get().commentsByKey,
|
||||
[key]: [...(get().commentsByKey[key] ?? []), row],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
deleteComment: async (entityType, entityId, id, actorId) => {
|
||||
const row = await api.deleteWithBody<CommentRecord>(`/comments/${id}`, { actorId });
|
||||
const key = commentKey(entityType, entityId);
|
||||
set({
|
||||
commentsByKey: {
|
||||
...get().commentsByKey,
|
||||
[key]: (get().commentsByKey[key] ?? []).map((item) => item.id === id ? row : item).filter((item) => !item.deletedAt),
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
52
apps/web/stores/useNotificationStore.ts
Normal file
52
apps/web/stores/useNotificationStore.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import { countUnreadNotifications, sortNotificationsNewestFirst, type NotificationRecord } from '@/lib/notification';
|
||||
|
||||
interface NotificationState {
|
||||
notifications: NotificationRecord[];
|
||||
loading: boolean;
|
||||
loadedFor?: string;
|
||||
unreadCount: number;
|
||||
fetchNotifications: (recipientId: string, options?: { unreadOnly?: boolean }) => Promise<void>;
|
||||
markRead: (id: string, recipientId: string) => Promise<void>;
|
||||
markAllRead: (recipientId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
loading: false,
|
||||
loadedFor: undefined,
|
||||
unreadCount: 0,
|
||||
|
||||
fetchNotifications: async (recipientId, options) => {
|
||||
if (!recipientId) return;
|
||||
set({ loading: true });
|
||||
const params = new URLSearchParams({ recipientId });
|
||||
if (options?.unreadOnly) params.set('unreadOnly', 'true');
|
||||
const rows = await api.get<NotificationRecord[]>(`/notifications?${params.toString()}`);
|
||||
const notifications = sortNotificationsNewestFirst(rows);
|
||||
set({
|
||||
notifications,
|
||||
unreadCount: countUnreadNotifications(notifications),
|
||||
loadedFor: recipientId,
|
||||
loading: false,
|
||||
});
|
||||
},
|
||||
|
||||
markRead: async (id, recipientId) => {
|
||||
await api.patch(`/notifications/${id}/read`, { recipientId });
|
||||
const notifications = get().notifications.map((item) =>
|
||||
item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item,
|
||||
);
|
||||
set({ notifications, unreadCount: countUnreadNotifications(notifications) });
|
||||
},
|
||||
|
||||
markAllRead: async (recipientId) => {
|
||||
await api.patch('/notifications/read-all', { recipientId });
|
||||
const readAt = new Date().toISOString();
|
||||
const notifications = get().notifications.map((item) => item.readAt ? item : { ...item, readAt });
|
||||
set({ notifications, unreadCount: 0 });
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMemberStore } from './useMemberStore';
|
||||
import {
|
||||
mergeDailySnapshotCacheForSave,
|
||||
mergeInsightCacheForSave,
|
||||
@@ -74,6 +76,7 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
|
||||
set({ snapshots, error: undefined });
|
||||
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
||||
void notifyRiskManagers(item).catch(() => {});
|
||||
});
|
||||
snapshotSaveQueue = task.catch(() => undefined);
|
||||
try {
|
||||
@@ -119,3 +122,32 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||
set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) });
|
||||
},
|
||||
}));
|
||||
|
||||
async function notifyRiskManagers(item: XiaobaoRiskSnapshot) {
|
||||
if (!['at_risk', 'likely_delayed', 'blocked'].includes(item.riskLevel)) return;
|
||||
const memberStore = useMemberStore.getState();
|
||||
if (!memberStore.loaded) {
|
||||
await memberStore.fetchMembers().catch(() => undefined);
|
||||
}
|
||||
const state = useMemberStore.getState();
|
||||
const roleMap = new Map(state.roles.map((role) => [role.id, role]));
|
||||
const recipients = state.members.filter((member) => {
|
||||
const permissions = roleMap.get(member.roleId)?.permissions ?? [];
|
||||
return permissions.includes('*') || permissions.includes('xiaobao.warning:manage');
|
||||
});
|
||||
await Promise.all(recipients.map((member) => api.post('/notifications', {
|
||||
recipientId: member.id,
|
||||
actorId: 'xiaobao',
|
||||
type: 'risk_alert',
|
||||
title: '小宝预警更新',
|
||||
body: `版本 ${item.versionId} 当前风险 ${item.riskLevel},风险分 ${item.riskScore}`,
|
||||
resourceType: 'version',
|
||||
resourceId: item.versionId,
|
||||
versionId: item.versionId,
|
||||
metadata: {
|
||||
riskLevel: item.riskLevel,
|
||||
riskScore: item.riskScore,
|
||||
riskSignature: `${item.versionId}:${item.date}:${item.riskScore}:${item.riskLevel}`,
|
||||
},
|
||||
}).catch(() => undefined)));
|
||||
}
|
||||
|
||||
@@ -351,3 +351,22 @@ Current source-of-truth boundary:
|
||||
- V2.2 read APIs and V2.3 relation sync remain compatibility infrastructure for fast reads, historical AppData imports, and rollback. They are no longer the main proof of data freshness for domains that now write relation tables directly.
|
||||
- `packages/shared` status contracts have been aligned with the current workflow statuses before the V2.4 write switch.
|
||||
- V2.5 boundary: audit/RBAC/consistency are active on domain writes. Xiaobao risk snapshots/insights and warning read-state AppData keys are read-only archives pending V2.6 relation writer/backgrounding and V2.7 per-user read-state API.
|
||||
|
||||
## V2.7 Enterprise Collaboration And Governance Layer (2026-07-08)
|
||||
|
||||
V2.7 adds enterprise collaboration capabilities on top of the relational source-of-truth direction. New collaboration data does not add AppData keys:
|
||||
|
||||
- `notifications`: per-recipient notification records with stable event types `assignment / mention / risk_alert / overdue_item`.
|
||||
- `comments`: polymorphic comments for `dev_task / test_case / bug / requirement / version_plan`, with mention metadata and soft deletion.
|
||||
- `project_members`: project-level Owner/Admin/Member/Viewer governance, now exposed through server-enforced APIs.
|
||||
- `audit_logs`: append-only governance and collaboration audit events.
|
||||
- `governance_dictionaries`: centralized requirement type/platform/source dictionaries; task categories continue to use `task_categories`.
|
||||
|
||||
V2.7 uses stable server adapters for collaboration and governance modules:
|
||||
|
||||
- `RbacService`: project role and global permission assertion adapter. Feature modules call this instead of hard-coding permission checks, while V2.5 `PermissionGuard` / `@ProtectedMutation()` continue to protect existing domain mutation controllers.
|
||||
- `AuditService`: append-only collaboration/governance audit adapter. Feature modules call this instead of writing ad-hoc audit records; V2.5 `audit_events` remains the cross-domain mutation audit control plane.
|
||||
|
||||
When the later JWT/NextAuth server verification replaces the current header auth adapter, global permission sourcing should be swapped behind these auth/RBAC adapters; feature modules should keep depending on the adapter boundary.
|
||||
|
||||
Management overview reads only relation tables and summaries. It intentionally avoids AppData so it reflects the target backend boundary rather than the compatibility document store.
|
||||
|
||||
@@ -680,3 +680,17 @@
|
||||
- 前端 `/admin/ops` 使用 `RouteGuard permission="ops:view"`;权限字典新增 `ops:view`,但不默认授给非管理员 preset。后端通过 `OpsPermissionAdapter` 保留 `ops:view` 校验入口,待 V2.5 RBAC guard 落地后替换。
|
||||
|
||||
**理由**:当前目标是让 V2.6 的性能和后台化能力可观察,而不是建设完整监控平台。进程内 ring buffer 成本低、对生产数据无额外写放大;结合脱敏规则可避免把 secrets 带进管理端。权限 adapter 明确了未来替换点,避免 Ops 看板和未定型 RBAC/audit 合同互相绑死。
|
||||
|
||||
## 52. V2.7 协作治理先落稳定适配器,不硬编码临时权限
|
||||
|
||||
**问题**:V2.7 需要通知、评论、项目成员治理、管理驾驶舱和治理字典。如果各 V2.7 模块直接写临时权限判断和审计插入,就会绕开 V2.5 已落地的服务端权限、审计和资源作用域边界,后续认证治理也会再次返工。
|
||||
|
||||
**决策**:
|
||||
- 新增 `RbacService` 作为项目角色与全局权限断言适配器,Owner/Admin/Member/Viewer 的层级判断和 `management:view` / `governance:manage` 等全局权限入口集中在此处。
|
||||
- 新增 `AuditService` 作为审计写入适配器,业务模块只提交 `actorId/action/resource/before/after`。
|
||||
- 通知事件类型固定为 `assignment / mention / risk_alert / overdue_item`,跨模块通过这些稳定语义发通知。
|
||||
- 通用评论使用 `entityType + entityId + entityVersionId` 的多态引用,不给每个业务表单独建评论表。
|
||||
- 管理驾驶舱只读关系表和 `xiaobao_risk_summaries`,不回读 AppData。
|
||||
- 治理字典使用软删除或使用中禁止硬删,变更必须写审计。
|
||||
|
||||
**理由**:适配器把协作治理模块的权限和审计接入点收束在一层,既能复用 V2.5 的服务端控制面,也给后续 JWT/NextAuth 和企业级角色体系留下替换点。稳定事件名和多态评论引用能避免后续模块继续扩散 ad-hoc 字段。
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# 开发路线图
|
||||
|
||||
## 当前阶段:V2.6 已完成 — 下一阶段 V2.7 企业协作 + V2.8 运维闭环集成
|
||||
## 当前阶段:V2.7 已完成 — 下一阶段 V2.8 生产硬化与运维闭环集成
|
||||
|
||||
V2.7 已在关系表主源方向上补齐企业协作和治理能力:通知、评论与提及、项目成员治理、管理驾驶舱、治理字典、以及统一 RBAC/audit 适配器。V2.7 不新增 AppData 主存储。
|
||||
|
||||
### 当前重点
|
||||
|
||||
1. **协作通知**:通知记录、已读状态、NotificationBell,并覆盖 assignment / mention / risk_alert / overdue_item 稳定事件类型。
|
||||
2. **通用评论**:DevTask/TestCase/Bug/Requirement/VersionPlan 统一评论面板,支持 `@成员名` 和显式成员选择,创建/删除写 audit。
|
||||
3. **项目成员治理**:Owner/Admin/Member/Viewer 服务端强校验,禁止移除最后 Owner,角色变更写 audit。
|
||||
4. **管理驾驶舱**:只读关系表和 summary,聚合活跃版本、逾期、阻塞、风险和成员负载。
|
||||
5. **治理设置**:集中维护 task category、requirement type/platform/source,使用中的字典不可硬删,支持导入导出。
|
||||
|
||||
V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表 + AppData 兼容/迁移兜底”。V2.2 快读 API 和 V2.3 AppData 写后同步继续保留,但它们现在是兼容基础设施,不再是已迁移领域的数据新鲜度主链路。
|
||||
|
||||
@@ -8,7 +18,9 @@ V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性
|
||||
|
||||
V2.6 的目标是在关系表主源稳定后完成大数据性能增强、小宝风险后台化、AI 解读队列和运行时 Ops 看板,让高增长热路径、后台任务和风险摘要不再依赖页面打开。
|
||||
|
||||
### V2.5-V2.6 完成范围
|
||||
V2.8 的目标是在现有生产部署基线上补齐备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册,形成生产交付稳定版。
|
||||
|
||||
### V2.5-V2.7 完成范围
|
||||
|
||||
1. **RBAC 收口**:领域 mutation API 已接入服务端权限校验、资源作用域和当前用户上下文。
|
||||
2. **审计事件**:领域 mutation 通过 `audit_events` 写 append-only audit event,支持后台查询和敏感字段脱敏。
|
||||
@@ -20,6 +32,11 @@ V2.6 的目标是在关系表主源稳定后完成大数据性能增强、小宝
|
||||
8. **后台任务运行时**:已补 PostgreSQL-backed `background_jobs`、dedupe、lease、retry、失败记录和单步 worker。
|
||||
9. **小宝后台化**:已补服务端 summary refresh、dirty/enqueue 桥接和 `xiaobao.ai.interpret` AI 解读队列。
|
||||
10. **Ops 看板**:已补 `/admin/ops` 与 `GET /api/v1/ops/runtime`,展示慢请求、慢查询、job 队列和 dirty summary 数。
|
||||
11. **协作通知**:已补通知记录、已读状态和 `NotificationBell`,覆盖 assignment / mention / risk_alert / overdue_item 稳定事件类型。
|
||||
12. **通用评论**:DevTask/TestCase/Bug/Requirement/VersionPlan 已接入统一评论面板,支持 `@成员名`、显式成员选择、删除和审计。
|
||||
13. **项目成员治理**:已补项目成员 Owner/Admin/Member/Viewer 服务端治理,禁止移除最后 Owner,角色变更写审计。
|
||||
14. **管理驾驶舱**:已补只读关系表和 summary 的管理概览,聚合活跃版本、逾期、阻塞、风险和成员负载。
|
||||
15. **治理设置**:已补 task category、requirement type/platform/source 等治理字典能力,使用中的字典不可硬删,支持导入导出。
|
||||
|
||||
## V2 分阶段交付链路
|
||||
|
||||
@@ -54,10 +71,20 @@ V2.6 的目标是在关系表主源稳定后完成大数据性能增强、小宝
|
||||
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
||||
- V2.6.5 已新增服务端小宝 AI 解读队列,summary 刷新后按 signature/cooldown/escalation policy 入队,只写 `xiaobao_risk_insights` 缓存。
|
||||
- V2.6.6 已新增 `/admin/ops` 运行时看板和 `GET /api/v1/ops/runtime`,展示慢请求、慢查询、job 队列和 dirty summary 数。
|
||||
- V2.7.1 已新增通知和已读状态,前端 `NotificationBell` 可展示 assignment / mention / risk_alert / overdue_item。
|
||||
- V2.7.2 已新增通用评论和提及能力,覆盖 DevTask/TestCase/Bug/Requirement/VersionPlan。
|
||||
- V2.7.3 已新增项目成员治理 API 和项目页成员面板,服务端强校验 Owner/Admin/Member/Viewer 边界。
|
||||
- V2.7.4 已新增管理驾驶舱和治理设置,聚合关系表指标并维护治理字典。
|
||||
- V2.7.5 已新增协作治理 RBAC/audit adapter,避免新增模块绕开服务端权限和审计边界。
|
||||
|
||||
### 已完成(按时间倒序)
|
||||
|
||||
**2026-07-08**
|
||||
- V2.7.5 added shared collaboration/governance RBAC and audit adapters so notification, comment, project-member, management, and governance modules keep a single permission/audit boundary.
|
||||
- V2.7.4 added management and governance admin pages for relation-backed overview metrics and dictionary governance.
|
||||
- V2.7.3 added project-member governance APIs and project member panel with Owner/Admin/Member/Viewer safeguards.
|
||||
- V2.7.2 added polymorphic comments, mention parsing, and comment panels for core work entities.
|
||||
- V2.7.1 added notification records, read-state APIs, frontend notification store, and `NotificationBell`.
|
||||
- V2.6.6 added the Ops runtime dashboard with `ops:view`, redacted slow request/query buffers, background job queue summary, failed job list, and dirty Xiaobao summary count.
|
||||
- V2.6.5 moved Xiaobao AI interpretation behind the background job runtime, reusing `AiService.interpretRisk()` and writing only insight cache rows.
|
||||
- V2.6.4 moved deterministic Xiaobao risk summary refresh into the server, registered the `xiaobao.summary.refresh` background job handler, and enqueue refresh jobs from dirty domain writes.
|
||||
@@ -161,12 +188,12 @@ V2.6 的目标是在关系表主源稳定后完成大数据性能增强、小宝
|
||||
|
||||
### 进行中
|
||||
|
||||
- V2.6 大数据性能增强与小宝预警后台化:压测、慢查询治理、后台任务、Xiaobao relation writer、幂等与失败重试。
|
||||
- V2.8 生产硬化与运维闭环:备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册。
|
||||
- 项目详情页 VersionCard 状态胶囊数据联动(部分已完成)
|
||||
|
||||
## V2 — 后端接入
|
||||
|
||||
NestJS + Prisma + PostgreSQL 已推进到 V2.5。第一阶段用 `app_data` JSONB 文档表承接现有 store 数据形状,避免浏览器清站点数据导致业务数据丢失;第二阶段建立分区关系表、V2.2 快读 API 和 V2.3 AppData 写后同步;第三阶段 V2.4 已逐领域启用写 API,让前端 store 从 AppData 主写入迁移到领域 CRUD 主写;第四阶段 V2.5 已冻结 AppData 业务写入并收口服务端 RBAC、审计和一致性校验。
|
||||
NestJS + Prisma + PostgreSQL 已推进到 V2.7。第一阶段用 `app_data` JSONB 文档表承接现有 store 数据形状,避免浏览器清站点数据导致业务数据丢失;第二阶段建立分区关系表、V2.2 快读 API 和 V2.3 AppData 写后同步;第三阶段 V2.4 已逐领域启用写 API,让前端 store 从 AppData 主写入迁移到领域 CRUD 主写;第四阶段 V2.5 已冻结 AppData 业务写入并收口服务端 RBAC、审计和一致性校验;第五阶段 V2.6 已完成大数据性能和小宝后台化;第六阶段 V2.7 已补齐协作治理能力。
|
||||
|
||||
### 关键任务
|
||||
|
||||
@@ -178,7 +205,7 @@ NestJS + Prisma + PostgreSQL 已推进到 V2.5。第一阶段用 `app_data` JSON
|
||||
6. **基础权限/审计骨架**:领域 API 从迁移期开始接入用户身份、资源作用域、操作人和审计事件入口(V2.5 已收口)
|
||||
7. **AppData 主路径移除**:业务写入已冻结;后续按核对结果逐模块删除 JSON fallback 和 `/data/:key` 依赖
|
||||
8. **认证**:当前为 V2.5 header auth adapter;正式 NextAuth.js + JWT 服务端校验待后续治理
|
||||
9. **权限**:RBAC(Owner/Admin/Member/Viewer),按项目/版本级别(V2.5 服务端 guard 已启用,企业级配置表待 V2.7)
|
||||
9. **权限**:RBAC(Owner/Admin/Member/Viewer),按项目/版本级别(V2.5 服务端 guard 已启用,V2.7 项目成员治理和企业级配置已补齐)
|
||||
10. **版本规则引擎收敛**:VersionPlan 完成条件、关联需求候选、TaskCategory 语义码、TestCase.categoryId 统一收束到规则层
|
||||
|
||||
### 数据迁移策略
|
||||
@@ -279,6 +306,6 @@ V2.5 完成后的保留边界:`GET /api/v1/data/:key` 仍可读历史 JSON;X
|
||||
|------|------|
|
||||
| V1 业务流程打磨 | 进行中 |
|
||||
| V1 朋友试用反馈 | 持续中 |
|
||||
| V2 后端接入 | 进行中(V2.5 RBAC/审计/AppData 退场已完成;V2.6/V2.7 待推进) |
|
||||
| V2 后端接入 | 进行中(V2.7 已完成;V2.8 生产硬化与运维闭环待集成) |
|
||||
| V3 AI 集成 | 等 V2 数据沉淀 |
|
||||
| 公开发布 | TBD |
|
||||
|
||||
@@ -345,6 +345,21 @@ AI 解读不由人工按钮触发。服务端 summary 刷新后按 policy 排入
|
||||
|
||||
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
||||
|
||||
## V2.7 协作治理工作流
|
||||
|
||||
通知统一进入 `notifications` 关系表,事件类型固定为:
|
||||
|
||||
- `assignment`:负责人或处理人被分配工作。
|
||||
- `mention`:评论中 `@成员名` 或显式选择成员。
|
||||
- `risk_alert`:小宝预警保存高风险快照后提醒管理者。
|
||||
- `overdue_item`:逾期事项提醒。
|
||||
|
||||
评论统一使用 `CommentPanel`,支持 DevTask、TestCase、Bug、Requirement 和 VersionPlan。创建/删除评论必须写 audit;提及成员必须生成 mention 通知。
|
||||
|
||||
项目成员治理走 `/projects/:projectId/members` 服务端接口。角色为 Owner/Admin/Member/Viewer,Owner/Admin 可管理成员;服务端禁止移除或降级最后一个 Owner。版本成员可见性继续兼容旧 `version.members` 展示,但治理来源应逐步收敛到 ProjectMember。
|
||||
|
||||
管理驾驶舱 `/admin/management` 只查关系表和小宝 summary,不读取 AppData,并通过 RBAC adapter 校验 `management:view`。治理设置 `/admin/governance` 集中维护任务类型与需求字典;使用中的字典不可硬删,字典变更必须写 audit,并通过 RBAC adapter 校验 `governance:manage`。
|
||||
|
||||
## 日期选择与计划时间
|
||||
|
||||
- 调研、产品方案、UI 设计、开发任务、测试用例、Bug 创建时使用统一工作日日期时间选择器。
|
||||
|
||||
Reference in New Issue
Block a user