Compare commits
32 Commits
58c98a3a3d
...
32aaf53b26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32aaf53b26 | ||
|
|
e949d0f5d3 | ||
|
|
deebc5404a | ||
|
|
cafa0f134a | ||
|
|
c5b086f88a | ||
|
|
a845792369 | ||
|
|
d18bbfa14b | ||
|
|
7677fd0d71 | ||
|
|
82e4b20b93 | ||
|
|
15653b5b35 | ||
|
|
f766eb86cf | ||
|
|
e46757487b | ||
|
|
a1007fd33d | ||
|
|
988d659fcc | ||
|
|
ea0b631a83 | ||
|
|
f69ec83193 | ||
|
|
869b1c1060 | ||
|
|
36202028d2 | ||
|
|
9ba9449c1a | ||
|
|
18380edda8 | ||
|
|
72a59f125c | ||
|
|
7837a809ca | ||
|
|
bcbe84bb6e | ||
|
|
73e8dfa8c8 | ||
|
|
0f57e75689 | ||
|
|
eef09d5af4 | ||
|
|
64f49c512f | ||
|
|
95523cd4a1 | ||
|
|
2161970543 | ||
|
|
ad36ffda17 | ||
|
|
8043fcf293 | ||
|
|
27cc1badc7 |
@@ -39,3 +39,10 @@ SMTP_HOST=
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
|
||||
# Optional monitoring profile. Do not commit real production passwords.
|
||||
PROMETHEUS_PORT=9090
|
||||
PROMETHEUS_RETENTION=15d
|
||||
GRAFANA_PORT=3002
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=change-me-monitoring-password
|
||||
|
||||
19
.github/workflows/deploy-production.yml
vendored
19
.github/workflows/deploy-production.yml
vendored
@@ -123,26 +123,11 @@ jobs:
|
||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e "
|
||||
const expected = process.argv[1];
|
||||
fetch('http://nginx/api/v1/health/version')
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
const payload = await response.json();
|
||||
if (payload.version !== expected) {
|
||||
throw new Error('Expected ' + expected + ', got ' + payload.version);
|
||||
}
|
||||
console.log('Runtime version verified: ' + payload.version);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
" "${{ github.sha }}"; then
|
||||
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node scripts/smoke-test-release.mjs --base-url http://nginx --expected-version "${{ github.sha }}"; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Runtime version check failed after retries"
|
||||
echo "Release smoke check failed after retries"
|
||||
exit 1
|
||||
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,11 +1,28 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
|
||||
# Local environment files. Keep example templates tracked.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
!apps/**/.env.example
|
||||
.env.local
|
||||
.env.local-server
|
||||
.env.production
|
||||
|
||||
# Logs and local diagnostics.
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Local IDE/editor state.
|
||||
.idea/
|
||||
.turbo/
|
||||
coverage/
|
||||
.tmp/
|
||||
@@ -15,3 +32,5 @@ next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
apps/server/data/
|
||||
.worktrees/
|
||||
appdata-archive-*.json
|
||||
backups/
|
||||
|
||||
@@ -57,6 +57,7 @@ COPY --from=builder /app/turbo.json ./turbo.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages/shared ./packages/shared
|
||||
COPY --from=builder /app/apps/web ./apps/web
|
||||
COPY scripts ./scripts
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "audit_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actor_id" TEXT,
|
||||
"actor_name" TEXT NOT NULL DEFAULT '',
|
||||
"action" TEXT NOT NULL,
|
||||
"entity_type" TEXT NOT NULL,
|
||||
"entity_id" TEXT NOT NULL,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"scope" JSONB NOT NULL DEFAULT '{}',
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id", "created_at")
|
||||
) PARTITION BY RANGE ("created_at");
|
||||
|
||||
CREATE TABLE "audit_events_default" PARTITION OF "audit_events" DEFAULT;
|
||||
CREATE INDEX "audit_events_actor_created_at_idx" ON "audit_events"("actor_id", "created_at" DESC);
|
||||
CREATE INDEX "audit_events_entity_created_at_idx" ON "audit_events"("entity_type", "entity_id", "created_at" DESC);
|
||||
CREATE INDEX "audit_events_product_created_at_idx" ON "audit_events"("product_id", "created_at" DESC);
|
||||
CREATE INDEX "audit_events_project_created_at_idx" ON "audit_events"("project_id", "created_at" DESC);
|
||||
CREATE INDEX "audit_events_version_created_at_idx" ON "audit_events"("version_id", "created_at" DESC);
|
||||
@@ -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")
|
||||
}
|
||||
@@ -170,30 +169,100 @@ model TaskWatcher {
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
taskId String @map("task_id")
|
||||
authorId String @map("author_id")
|
||||
content String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id])
|
||||
id String @id @default(cuid())
|
||||
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")
|
||||
|
||||
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
|
||||
@@index([authorId, createdAt], name: "comments_author_created_idx")
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
model ProjectMember {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
userId String @map("user_id")
|
||||
role String @default("member")
|
||||
id String @id @default(cuid())
|
||||
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
|
||||
@@ -423,6 +492,26 @@ model AiLog {
|
||||
@@map("ai_logs")
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id String @default(cuid())
|
||||
actorId String? @map("actor_id")
|
||||
actorName String @default("") @map("actor_name")
|
||||
action String
|
||||
entityType String @map("entity_type")
|
||||
entityId String @map("entity_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
scope Json @default("{}")
|
||||
before Json?
|
||||
after Json?
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@id([id, createdAt])
|
||||
@@map("audit_events")
|
||||
}
|
||||
|
||||
model BackgroundJob {
|
||||
id String @id @default(cuid())
|
||||
type 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/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
||||
import { AuthModule } from './common/auth/auth.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuditModule } from './modules/audit/audit.module';
|
||||
import { ProductModule } from './modules/product/product.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { RequirementModule } from './modules/requirement/requirement.module';
|
||||
@@ -20,12 +22,21 @@ import { DataModule } from './modules/data/data.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||
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: [
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
ProductModule,
|
||||
ProjectModule,
|
||||
VersionModule,
|
||||
@@ -42,9 +53,16 @@ import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||
DataModule,
|
||||
MigrationModule,
|
||||
V22QueryModule,
|
||||
ConsistencyModule,
|
||||
HealthModule,
|
||||
JobsModule,
|
||||
XiaobaoModule,
|
||||
OpsModule,
|
||||
NotificationModule,
|
||||
CommentModule,
|
||||
ProjectMemberModule,
|
||||
ManagementModule,
|
||||
GovernanceModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { PermissionScopeOptions } from '../auth/permission.decorator';
|
||||
|
||||
export const AUDIT_MUTATION_METADATA_KEY = 'ftb:audit-mutation';
|
||||
|
||||
export interface AuditMutationMetadata extends PermissionScopeOptions {
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityIdParam?: string;
|
||||
}
|
||||
|
||||
export function AuditMutation(metadata: AuditMutationMetadata) {
|
||||
return SetMetadata(AUDIT_MUTATION_METADATA_KEY, metadata);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { lastValueFrom, of } from 'rxjs';
|
||||
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
|
||||
import { AuditMutation } from './audit-mutation.decorator';
|
||||
|
||||
describe('AuditMutationInterceptor', () => {
|
||||
it('writes an audit event after a successful mutation response', async () => {
|
||||
const record = jest.fn().mockResolvedValue({ id: 'audit-1' });
|
||||
const resolveCurrentUser = jest.fn().mockResolvedValue({ id: 'm-8', name: '超级管理员', roleId: 'role-admin' });
|
||||
const interceptor = new AuditMutationInterceptor(
|
||||
new (jest.requireActual('@nestjs/core').Reflector)(),
|
||||
{ record } as any,
|
||||
{ resolveCurrentUser } as any,
|
||||
);
|
||||
const handler = decorate(() => undefined);
|
||||
|
||||
const result = await lastValueFrom(interceptor.intercept(contextFor(handler), {
|
||||
handle: () => of({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }),
|
||||
} as any));
|
||||
|
||||
expect(result).toEqual({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } });
|
||||
expect(record).toHaveBeenCalledWith({
|
||||
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
|
||||
action: 'dev_task.update',
|
||||
entityType: 'dev_task',
|
||||
entityId: 'task-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
scope: { productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
|
||||
after: { item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } },
|
||||
metadata: { route: 'PATCH /versions/version-1/dev-tasks/task-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function decorate(handler: Function) {
|
||||
AuditMutation({
|
||||
action: 'dev_task.update',
|
||||
entityType: 'dev_task',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})(handler as any, undefined as any, undefined as any);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function contextFor(handler: Function) {
|
||||
const request = {
|
||||
method: 'PATCH',
|
||||
originalUrl: '/versions/version-1/dev-tasks/task-1',
|
||||
params: { id: 'task-1', versionId: 'version-1' },
|
||||
body: {},
|
||||
headers: {},
|
||||
};
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => class TestController {},
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as any;
|
||||
}
|
||||
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { mergeMap, Observable } from 'rxjs';
|
||||
import { AuthContextService, type AuthenticatedRequest } from '../auth/auth-context.service';
|
||||
import { AuditService } from '../../modules/audit/audit.service';
|
||||
import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from './audit-mutation.decorator';
|
||||
|
||||
type MutationRequest = AuthenticatedRequest & {
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
params?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuditMutationInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly audit: AuditService,
|
||||
private readonly authContext: AuthContextService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const metadata = this.reflector.getAllAndOverride<AuditMutationMetadata>(AUDIT_MUTATION_METADATA_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!metadata) return next.handle();
|
||||
|
||||
const request = context.switchToHttp().getRequest<MutationRequest>();
|
||||
return next.handle().pipe(mergeMap(async (result) => {
|
||||
const actor = await this.authContext.resolveCurrentUser(request);
|
||||
const entity = extractEntity(result);
|
||||
const scope = resolveScope(metadata, request, entity);
|
||||
await this.audit.record({
|
||||
actor,
|
||||
action: metadata.action,
|
||||
entityType: metadata.entityType,
|
||||
entityId: resolveEntityId(metadata, request, entity),
|
||||
productId: scope.productId,
|
||||
projectId: scope.projectId,
|
||||
versionId: scope.versionId,
|
||||
scope,
|
||||
after: result,
|
||||
metadata: { route: `${request.method ?? 'UNKNOWN'} ${request.originalUrl ?? request.url ?? ''}`.trim() },
|
||||
});
|
||||
return result;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function extractEntity(result: unknown): Record<string, unknown> | undefined {
|
||||
if (!result || typeof result !== 'object') return undefined;
|
||||
const record = result as Record<string, unknown>;
|
||||
if (record.item && typeof record.item === 'object') return record.item as Record<string, unknown>;
|
||||
if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record<string, unknown>;
|
||||
return record;
|
||||
}
|
||||
|
||||
function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
|
||||
const fromParam = metadata.entityIdParam ? request.params?.[metadata.entityIdParam] : undefined;
|
||||
const fromEntity = entity?.id;
|
||||
return fromParam ?? (typeof fromEntity === 'string' ? fromEntity : 'unknown');
|
||||
}
|
||||
|
||||
function resolveScope(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
|
||||
return compact({
|
||||
productId: scopedValue(metadata.productIdParam, metadata.productIdBody, 'productId', request, entity),
|
||||
projectId: scopedValue(metadata.projectIdParam, metadata.projectIdBody, 'projectId', request, entity),
|
||||
versionId: scopedValue(metadata.versionIdParam, metadata.versionIdBody, 'versionId', request, entity),
|
||||
});
|
||||
}
|
||||
|
||||
function scopedValue(
|
||||
paramKey: string | undefined,
|
||||
bodyKey: string | undefined,
|
||||
resultKey: string,
|
||||
request: MutationRequest,
|
||||
entity: Record<string, unknown> | undefined,
|
||||
): string | undefined {
|
||||
const value = (paramKey ? request.params?.[paramKey] : undefined)
|
||||
?? (bodyKey ? request.body?.[bodyKey] : undefined)
|
||||
?? entity?.[resultKey];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
function compact<T extends Record<string, string | undefined>>(value: T): { [K in keyof T]?: string } {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string };
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { PermissionGuard } from '../auth/permission.guard';
|
||||
import { RequirePermission, type PermissionScopeOptions } from '../auth/permission.decorator';
|
||||
import { AuditMutation, type AuditMutationMetadata } from './audit-mutation.decorator';
|
||||
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
|
||||
|
||||
export function ProtectedMutation(
|
||||
permission: string,
|
||||
scope: PermissionScopeOptions,
|
||||
audit: AuditMutationMetadata,
|
||||
) {
|
||||
return applyDecorators(
|
||||
UseGuards(PermissionGuard),
|
||||
RequirePermission(permission, scope),
|
||||
UseInterceptors(AuditMutationInterceptor),
|
||||
AuditMutation(audit),
|
||||
);
|
||||
}
|
||||
70
apps/server/src/common/auth/auth-context.service.ts
Normal file
70
apps/server/src/common/auth/auth-context.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
roleId: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
currentUser?: CurrentUser | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthContextService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveCurrentUser(request: AuthenticatedRequest): Promise<CurrentUser | null> {
|
||||
if (request.currentUser !== undefined) return request.currentUser;
|
||||
|
||||
const userId = headerValue(request, 'x-ftb-user-id') || headerValue(request, 'x-user-id');
|
||||
if (!userId) {
|
||||
request.currentUser = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerUser: CurrentUser = {
|
||||
id: userId,
|
||||
name: decodeHeader(headerValue(request, 'x-ftb-user-name')),
|
||||
username: headerValue(request, 'x-ftb-user-username'),
|
||||
roleId: headerValue(request, 'x-ftb-user-role-id') || '',
|
||||
email: headerValue(request, 'x-ftb-user-email'),
|
||||
};
|
||||
|
||||
if (headerUser.roleId) {
|
||||
request.currentUser = headerUser;
|
||||
return headerUser;
|
||||
}
|
||||
|
||||
const row = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, name: true, username: true, roleId: true, email: true },
|
||||
});
|
||||
request.currentUser = row ? {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
username: row.username ?? undefined,
|
||||
roleId: row.roleId || 'member',
|
||||
email: row.email,
|
||||
} : null;
|
||||
return request.currentUser;
|
||||
}
|
||||
}
|
||||
|
||||
function headerValue(request: AuthenticatedRequest, name: string): string {
|
||||
const value = request.headers?.[name] ?? request.headers?.[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0] ?? '';
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
function decodeHeader(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
11
apps/server/src/common/auth/auth.module.ts
Normal file
11
apps/server/src/common/auth/auth.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuthContextService } from './auth-context.service';
|
||||
import { PermissionGuard } from './permission.guard';
|
||||
import { PermissionService } from './permission.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuthContextService, PermissionGuard, PermissionService],
|
||||
exports: [AuthContextService, PermissionGuard, PermissionService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { AuthenticatedRequest, CurrentUser as ResolvedCurrentUser } from './auth-context.service';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): ResolvedCurrentUser | null => {
|
||||
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.currentUser ?? null;
|
||||
},
|
||||
);
|
||||
20
apps/server/src/common/auth/permission.decorator.ts
Normal file
20
apps/server/src/common/auth/permission.decorator.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSION_METADATA_KEY = 'ftb:required-permission';
|
||||
|
||||
export interface PermissionScopeOptions {
|
||||
productIdParam?: string;
|
||||
projectIdParam?: string;
|
||||
versionIdParam?: string;
|
||||
productIdBody?: string;
|
||||
projectIdBody?: string;
|
||||
versionIdBody?: string;
|
||||
}
|
||||
|
||||
export interface RequiredPermissionMetadata extends PermissionScopeOptions {
|
||||
permission: string;
|
||||
}
|
||||
|
||||
export function RequirePermission(permission: string, scope: PermissionScopeOptions = {}) {
|
||||
return SetMetadata(PERMISSION_METADATA_KEY, { permission, ...scope });
|
||||
}
|
||||
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthContextService } from './auth-context.service';
|
||||
import { PermissionGuard } from './permission.guard';
|
||||
import { RequirePermission } from './permission.decorator';
|
||||
|
||||
describe('PermissionGuard', () => {
|
||||
it('rejects protected routes when no current user can be resolved', async () => {
|
||||
const guard = new PermissionGuard(
|
||||
new Reflector(),
|
||||
{ resolveCurrentUser: jest.fn().mockResolvedValue(null) } as unknown as AuthContextService,
|
||||
{ can: jest.fn() } as any,
|
||||
);
|
||||
const handler = decorate(() => undefined, 'product:create');
|
||||
|
||||
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects users without the required permission', async () => {
|
||||
const guard = new PermissionGuard(
|
||||
new Reflector(),
|
||||
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
|
||||
{ can: jest.fn().mockResolvedValue(false) } as any,
|
||||
);
|
||||
const handler = decorate(() => undefined, 'product:delete');
|
||||
|
||||
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('passes params-derived resource scope to the permission service', async () => {
|
||||
const can = jest.fn().mockResolvedValue(true);
|
||||
const guard = new PermissionGuard(
|
||||
new Reflector(),
|
||||
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
|
||||
{ can } as any,
|
||||
);
|
||||
const handler = decorate(() => undefined, 'version.devtask:manage', {
|
||||
versionIdParam: 'versionId',
|
||||
projectIdParam: 'projectId',
|
||||
productIdParam: 'productId',
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(contextFor(handler, {
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
}))).resolves.toBe(true);
|
||||
|
||||
expect(can).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', roleId: 'role-dev' },
|
||||
'version.devtask:manage',
|
||||
{ productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function decorate(handler: Function, permission: string, scope?: {
|
||||
productIdParam?: string;
|
||||
projectIdParam?: string;
|
||||
versionIdParam?: string;
|
||||
}) {
|
||||
RequirePermission(permission, scope)(handler as any, undefined as any, undefined as any);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function contextFor(handler: Function, params: Record<string, string> = {}) {
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => class TestController {},
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ params, headers: {} }),
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
46
apps/server/src/common/auth/permission.guard.ts
Normal file
46
apps/server/src/common/auth/permission.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthContextService, type AuthenticatedRequest } from './auth-context.service';
|
||||
import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from './permission.decorator';
|
||||
import { PermissionService } from './permission.service';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly authContext: AuthContextService,
|
||||
private readonly permissions: PermissionService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const metadata = this.reflector.getAllAndOverride<RequiredPermissionMetadata>(PERMISSION_METADATA_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!metadata) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest & {
|
||||
params?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
}>();
|
||||
const user = await this.authContext.resolveCurrentUser(request);
|
||||
if (!user) throw new UnauthorizedException('Authentication required');
|
||||
|
||||
const allowed = await this.permissions.can(user, metadata.permission, {
|
||||
productId: scopedValue(request, metadata.productIdParam, metadata.productIdBody),
|
||||
projectId: scopedValue(request, metadata.projectIdParam, metadata.projectIdBody),
|
||||
versionId: scopedValue(request, metadata.versionIdParam, metadata.versionIdBody),
|
||||
});
|
||||
if (!allowed) throw new ForbiddenException(`Missing permission: ${metadata.permission}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function scopedValue(
|
||||
request: { params?: Record<string, string>; body?: Record<string, unknown> },
|
||||
paramKey?: string,
|
||||
bodyKey?: string,
|
||||
): string | undefined {
|
||||
const value = (paramKey ? request.params?.[paramKey] : undefined) ?? (bodyKey ? request.body?.[bodyKey] : undefined);
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
128
apps/server/src/common/auth/permission.service.spec.ts
Normal file
128
apps/server/src/common/auth/permission.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { PermissionService } from './permission.service';
|
||||
import type { CurrentUser } from './auth-context.service';
|
||||
|
||||
describe('PermissionService', () => {
|
||||
const versionFindUnique = jest.fn();
|
||||
const projectMemberFindUnique = jest.fn();
|
||||
const appDataFindUnique = jest.fn();
|
||||
|
||||
const prisma = {
|
||||
version: { findUnique: versionFindUnique },
|
||||
projectMember: { findUnique: projectMemberFindUnique },
|
||||
appData: { findUnique: appDataFindUnique },
|
||||
} as any;
|
||||
|
||||
const service = new PermissionService(prisma);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appDataFindUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('allows the built-in super admin wildcard for any permission without a scope lookup', async () => {
|
||||
const user = currentUser({ id: 'm-8', roleId: 'role-admin' });
|
||||
|
||||
await expect(service.can(user, 'audit:view', { versionId: 'version-1' })).resolves.toBe(true);
|
||||
|
||||
expect(versionFindUnique).not.toHaveBeenCalled();
|
||||
expect(projectMemberFindUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies version work mutations for project members outside the version members list', async () => {
|
||||
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
|
||||
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
||||
|
||||
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
|
||||
versionId: 'version-1',
|
||||
})).resolves.toBe(false);
|
||||
|
||||
expect(versionFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'version-1' },
|
||||
select: { id: true, projectId: true, members: true },
|
||||
});
|
||||
expect(projectMemberFindUnique).toHaveBeenCalledWith({
|
||||
where: { projectId_userId: { projectId: 'project-1', userId: 'dev-1' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows version work mutations when the user has the role permission and is a version member', async () => {
|
||||
versionFindUnique.mockResolvedValue({
|
||||
id: 'version-1',
|
||||
projectId: 'project-1',
|
||||
members: [{ id: 'dev-1', name: 'Dev One' }],
|
||||
});
|
||||
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
||||
|
||||
await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', {
|
||||
versionId: 'version-1',
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('uses configured role permissions before built-in role defaults', async () => {
|
||||
appDataFindUnique.mockResolvedValue({
|
||||
value: {
|
||||
roles: [
|
||||
{ id: 'role-dev', permissions: ['version.devtask:view'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
versionFindUnique.mockResolvedValue({
|
||||
id: 'version-1',
|
||||
projectId: 'project-1',
|
||||
members: [{ id: 'dev-1', name: 'Dev One' }],
|
||||
});
|
||||
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
||||
|
||||
await expect(service.can(currentUser({ id: 'dev-1', name: 'Dev One', roleId: 'role-dev' }), 'version.devtask:manage', {
|
||||
versionId: 'version-1',
|
||||
})).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('allows a version member when no project membership exists', async () => {
|
||||
versionFindUnique.mockResolvedValue({
|
||||
id: 'version-1',
|
||||
projectId: 'project-1',
|
||||
members: [{ id: 'tester-1', name: 'Tester One' }],
|
||||
});
|
||||
projectMemberFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.can(currentUser({ id: 'tester-1', name: 'Tester One', roleId: 'role-test' }), 'version.testcase:manage', {
|
||||
versionId: 'version-1',
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('allows a project member to write version-scoped work activity evidence', async () => {
|
||||
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
|
||||
projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' });
|
||||
|
||||
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'work-activity:manage', {
|
||||
versionId: 'version-1',
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('denies users who have a global role permission but are outside the resource scope', async () => {
|
||||
versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] });
|
||||
projectMemberFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', {
|
||||
versionId: 'version-1',
|
||||
})).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('throws explicit auth exceptions for assertion callers', async () => {
|
||||
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
|
||||
return {
|
||||
id: 'user-1',
|
||||
name: 'User One',
|
||||
username: 'user.one',
|
||||
roleId: 'role-dev',
|
||||
email: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
223
apps/server/src/common/auth/permission.service.ts
Normal file
223
apps/server/src/common/auth/permission.service.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { CurrentUser } from './auth-context.service';
|
||||
|
||||
export interface ResourceScope {
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
interface ResolvedScope extends ResourceScope {
|
||||
versionMembers?: unknown;
|
||||
}
|
||||
|
||||
const VIEW_ONLY = ['product:view', 'project:view', 'version:view', 'requirement:view', 'version.req:view'];
|
||||
|
||||
const SYSTEM_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
'role-admin': ['*'],
|
||||
'role-pm': [
|
||||
...std4('product'),
|
||||
...std4('project'),
|
||||
...std4('version'),
|
||||
...std4('requirement'),
|
||||
'version.req:view', 'version.req:manage',
|
||||
'version.product_plan:view', 'version.product_plan:manage',
|
||||
'xiaobao.warning:view', 'xiaobao.warning:manage',
|
||||
'overtime:view', 'member:view', 'role:view',
|
||||
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||
'version.testcase:view', 'version.bug:view',
|
||||
'work-activity:manage',
|
||||
],
|
||||
'role-dev': [
|
||||
...VIEW_ONLY,
|
||||
'version.devtask:view', 'version.devtask:manage',
|
||||
'version.bug:view', 'version.bug:edit',
|
||||
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view',
|
||||
'xiaobao.warning:view',
|
||||
'overtime:view', 'overtime:create',
|
||||
'work-activity:manage',
|
||||
],
|
||||
'role-test': [
|
||||
...VIEW_ONLY,
|
||||
'version.testcase:view', 'version.testcase:manage',
|
||||
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
|
||||
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||
'xiaobao.warning:view',
|
||||
'overtime:view', 'overtime:create',
|
||||
'work-activity:manage',
|
||||
],
|
||||
'role-design': [
|
||||
...VIEW_ONLY,
|
||||
'version.ui_plan:view', 'version.ui_plan:manage',
|
||||
'version.research:view', 'version.product_plan:view', 'version.devtask:view',
|
||||
'version.testcase:view', 'version.bug:view',
|
||||
'xiaobao.warning:view',
|
||||
'overtime:view', 'overtime:create',
|
||||
'work-activity:manage',
|
||||
],
|
||||
'role-lead': [
|
||||
...VIEW_ONLY,
|
||||
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view',
|
||||
'version.devtask:view', 'version.devtask:manage',
|
||||
'version.testcase:view', 'version.testcase:manage',
|
||||
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
|
||||
'xiaobao.warning:view',
|
||||
'overtime:view', 'overtime:create', 'overtime:export',
|
||||
'work-activity:manage',
|
||||
],
|
||||
};
|
||||
|
||||
const PROJECT_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
owner: ['*'],
|
||||
admin: ['*'],
|
||||
member: [
|
||||
'project:view',
|
||||
'version:view',
|
||||
'requirement:view',
|
||||
'version.req:view',
|
||||
'version.research:view', 'version.research:manage',
|
||||
'version.product_plan:view', 'version.product_plan:manage',
|
||||
'version.ui_plan:view', 'version.ui_plan:manage',
|
||||
'version.devtask:view', 'version.devtask:manage',
|
||||
'version.testcase:view', 'version.testcase:manage',
|
||||
'version.bug:view', 'version.bug:create', 'version.bug:edit',
|
||||
'overtime:view', 'overtime:create',
|
||||
'xiaobao.warning:view',
|
||||
'work-activity:manage',
|
||||
],
|
||||
viewer: [
|
||||
'project:view',
|
||||
'version:view',
|
||||
'requirement:view',
|
||||
'version.req:view',
|
||||
'version.research:view',
|
||||
'version.product_plan:view',
|
||||
'version.ui_plan:view',
|
||||
'version.devtask:view',
|
||||
'version.testcase:view',
|
||||
'version.bug:view',
|
||||
'xiaobao.warning:view',
|
||||
],
|
||||
};
|
||||
|
||||
const VERSION_WORK_MUTATION_PERMISSIONS = new Set([
|
||||
'version.req:manage',
|
||||
'version.research:manage',
|
||||
'version.product_plan:manage',
|
||||
'version.ui_plan:manage',
|
||||
'version.devtask:manage',
|
||||
'version.testcase:manage',
|
||||
'version.bug:create',
|
||||
'version.bug:edit',
|
||||
'version.bug:delete',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;
|
||||
const systemPermissions = await this.resolveRolePermissions(user.roleId);
|
||||
if (systemPermissions.includes('*')) return true;
|
||||
|
||||
const hasSystemPermission = systemPermissions.includes(permission);
|
||||
const hasScopedResource = Boolean(scope.projectId || scope.versionId);
|
||||
if (!hasScopedResource) return hasSystemPermission;
|
||||
|
||||
const resolvedScope = await this.resolveScope(scope);
|
||||
const projectMember = resolvedScope.projectId ? await this.prisma.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId: resolvedScope.projectId, userId: user.id } },
|
||||
}) : null;
|
||||
|
||||
if (requiresVersionWorkMembership(permission, resolvedScope)) {
|
||||
return hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers);
|
||||
}
|
||||
|
||||
if (projectMember && roleAllows(PROJECT_ROLE_PERMISSIONS[projectMember.role] ?? [], permission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async assertCan(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<void> {
|
||||
if (!user) throw new UnauthorizedException('Authentication required');
|
||||
if (!(await this.can(user, permission, scope))) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveScope(scope: ResourceScope): Promise<ResolvedScope> {
|
||||
if (!scope.versionId || scope.projectId) return scope;
|
||||
const version = await this.prisma.version.findUnique({
|
||||
where: { id: scope.versionId },
|
||||
select: { id: true, projectId: true, members: true },
|
||||
});
|
||||
return {
|
||||
...scope,
|
||||
projectId: version?.projectId ?? scope.projectId,
|
||||
versionMembers: version?.members,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveRolePermissions(roleId: string): Promise<string[]> {
|
||||
const configuredPermissions = await this.findConfiguredRolePermissions(roleId);
|
||||
return configuredPermissions ?? SYSTEM_ROLE_PERMISSIONS[roleId] ?? [];
|
||||
}
|
||||
|
||||
private async findConfiguredRolePermissions(roleId: string): Promise<string[] | null> {
|
||||
const findUnique = (this.prisma as unknown as {
|
||||
appData?: { findUnique?: (args: unknown) => Promise<{ value?: unknown } | null> };
|
||||
}).appData?.findUnique;
|
||||
if (!findUnique) return null;
|
||||
|
||||
const row = await findUnique({
|
||||
where: { key: 'members' },
|
||||
select: { value: true },
|
||||
}).catch(() => null);
|
||||
|
||||
if (!row) return null;
|
||||
return readRolePermissions(row.value, roleId);
|
||||
}
|
||||
}
|
||||
|
||||
function std4(module: string): string[] {
|
||||
return [`${module}:view`, `${module}:create`, `${module}:edit`, `${module}:delete`];
|
||||
}
|
||||
|
||||
function roleAllows(permissions: string[], permission: string): boolean {
|
||||
return permissions.includes('*') || permissions.includes(permission);
|
||||
}
|
||||
|
||||
function requiresVersionWorkMembership(permission: string, scope: ResourceScope): boolean {
|
||||
return Boolean(scope.versionId && VERSION_WORK_MUTATION_PERMISSIONS.has(permission));
|
||||
}
|
||||
|
||||
function readRolePermissions(value: unknown, roleId: string): string[] | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const roles = (value as { roles?: unknown }).roles;
|
||||
if (!Array.isArray(roles)) return null;
|
||||
const role = roles.find((item) => (
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
(item as { id?: unknown }).id === roleId
|
||||
));
|
||||
if (!role || typeof role !== 'object') return null;
|
||||
const permissions = (role as { permissions?: unknown }).permissions;
|
||||
if (!Array.isArray(permissions)) return null;
|
||||
return permissions.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isVersionMember(user: CurrentUser, members: unknown): boolean {
|
||||
if (!Array.isArray(members)) return false;
|
||||
return members.some((member) => {
|
||||
if (typeof member === 'string') return member === user.id || member === user.name;
|
||||
if (!member || typeof member !== 'object') return false;
|
||||
const record = member as { id?: unknown; userId?: unknown; name?: unknown };
|
||||
return record.id === user.id || record.userId === user.id || record.name === user.name;
|
||||
});
|
||||
}
|
||||
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 {}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { finalize, Observable } from 'rxjs';
|
||||
import { recordSlowRequest } from '../../modules/ops/ops-runtime.store';
|
||||
|
||||
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
||||
|
||||
@@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor {
|
||||
const method = request.method ?? 'UNKNOWN';
|
||||
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
||||
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
||||
recordSlowRequest({ method, url, durationMs, thresholdMs: this.thresholdMs });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppDataRetirementService } from './app-data-retirement.service';
|
||||
|
||||
@Module({
|
||||
providers: [AppDataRetirementService],
|
||||
exports: [AppDataRetirementService],
|
||||
})
|
||||
export class AppDataRetirementModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { APP_DATA_KEYS } from '../data/data-keys';
|
||||
import {
|
||||
APP_DATA_RETIREMENT_CONFIG,
|
||||
AppDataRetirementService,
|
||||
} from './app-data-retirement.service';
|
||||
|
||||
describe('AppDataRetirementService', () => {
|
||||
const service = new AppDataRetirementService();
|
||||
|
||||
it('declares a retirement state for every allowed AppData key', () => {
|
||||
expect(Object.keys(APP_DATA_RETIREMENT_CONFIG).sort()).toEqual([...APP_DATA_KEYS].sort());
|
||||
});
|
||||
|
||||
it('rejects writes to frozen business documents with replacement guidance', () => {
|
||||
expect(() => service.assertWritable('dev-tasks')).toThrow(ConflictException);
|
||||
|
||||
try {
|
||||
service.assertWritable('dev-tasks');
|
||||
} catch (error: any) {
|
||||
expect(error.getResponse()).toMatchObject({
|
||||
code: 'APP_DATA_WRITE_FROZEN',
|
||||
key: 'dev-tasks',
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/dev-tasks',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('treats archived documents as read-only', () => {
|
||||
try {
|
||||
service.assertWritable('xiaobao-risk-snapshots');
|
||||
} catch (error: any) {
|
||||
expect(error.getResponse()).toMatchObject({
|
||||
code: 'APP_DATA_WRITE_FROZEN',
|
||||
key: 'xiaobao-risk-snapshots',
|
||||
state: 'read_only_archive',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import type { AppDataKey } from '../data/data-keys';
|
||||
|
||||
export type AppDataRetirementState = 'active' | 'write_frozen' | 'read_only_archive';
|
||||
|
||||
export interface AppDataRetirementEntry {
|
||||
state: AppDataRetirementState;
|
||||
replacement: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const APP_DATA_RETIREMENT_CONFIG = {
|
||||
'products-overview': {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/products, /api/v1/products/:productId/projects, /api/v1/products/:productId/versions',
|
||||
},
|
||||
requirements: {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/products/:productId/requirements',
|
||||
},
|
||||
'version-plans': {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/plans',
|
||||
},
|
||||
'dev-tasks': {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/dev-tasks',
|
||||
},
|
||||
'test-cases': {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/test-cases',
|
||||
},
|
||||
bugs: {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/bugs',
|
||||
},
|
||||
members: {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/members',
|
||||
note: '成员身份已迁移;部门、角色和密码策略仍需 V2.7 配置表承接。',
|
||||
},
|
||||
'task-categories': {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/task-categories',
|
||||
},
|
||||
'task-worklogs': {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/task-worklogs',
|
||||
},
|
||||
'work-activities': {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/work-activities',
|
||||
},
|
||||
'xiaobao-risk-insights': {
|
||||
state: 'read_only_archive',
|
||||
replacement: 'V2.6 Xiaobao relation writer backed by xiaobao_risk_insights',
|
||||
note: '风险解读缓存不再扩大 AppData 主写路径,后台化由 V2.6 承接。',
|
||||
},
|
||||
'xiaobao-risk-snapshots': {
|
||||
state: 'read_only_archive',
|
||||
replacement: 'V2.6 Xiaobao relation writer backed by xiaobao_risk_snapshots',
|
||||
note: '风险快照不再扩大 AppData 主写路径,后台化由 V2.6 承接。',
|
||||
},
|
||||
'xiaobao-warning-views': {
|
||||
state: 'read_only_archive',
|
||||
replacement: 'V2.7 per-user warning read-state API',
|
||||
note: '个人已读状态等待企业协作/通知治理阶段承接。',
|
||||
},
|
||||
overtime: {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/overtime',
|
||||
note: '加班记录已迁移;加班原因配置仍需 V2.7 配置表承接。',
|
||||
},
|
||||
} satisfies Record<AppDataKey, AppDataRetirementEntry>;
|
||||
|
||||
@Injectable()
|
||||
export class AppDataRetirementService {
|
||||
getEntry(key: AppDataKey): AppDataRetirementEntry {
|
||||
return APP_DATA_RETIREMENT_CONFIG[key];
|
||||
}
|
||||
|
||||
assertWritable(key: AppDataKey) {
|
||||
const entry = this.getEntry(key);
|
||||
if (entry.state === 'active') return;
|
||||
|
||||
throw new ConflictException({
|
||||
code: 'APP_DATA_WRITE_FROZEN',
|
||||
message: `AppData key "${key}" is ${entry.state}; use ${entry.replacement} instead.`,
|
||||
key,
|
||||
state: entry.state,
|
||||
replacement: entry.replacement,
|
||||
note: entry.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal file
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
describe('AuditController', () => {
|
||||
it('requires audit:view for audit queries', () => {
|
||||
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, AuditController.prototype.findAll);
|
||||
|
||||
expect(metadata).toEqual({ permission: 'audit:view' });
|
||||
});
|
||||
|
||||
it('delegates list query parameters to the audit service', async () => {
|
||||
const service = { query: jest.fn().mockResolvedValue([{ id: 'audit-1' }]) };
|
||||
const controller = new AuditController(service as any);
|
||||
|
||||
await expect(controller.findAll({ actorId: 'm-8' })).resolves.toEqual([{ id: 'audit-1' }]);
|
||||
|
||||
expect(service.query).toHaveBeenCalledWith({ actorId: 'm-8' });
|
||||
});
|
||||
});
|
||||
17
apps/server/src/modules/audit/audit.controller.ts
Normal file
17
apps/server/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { PermissionGuard } from '../../common/auth/permission.guard';
|
||||
import { RequirePermission } from '../../common/auth/permission.decorator';
|
||||
import { AuditService } from './audit.service';
|
||||
import { QueryAuditEventsDto } from './dto/query-audit-events.dto';
|
||||
|
||||
@Controller('audit')
|
||||
export class AuditController {
|
||||
constructor(private readonly auditService: AuditService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(PermissionGuard)
|
||||
@RequirePermission('audit:view')
|
||||
findAll(@Query() query: QueryAuditEventsDto) {
|
||||
return this.auditService.query(query);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/audit/audit.module.ts
Normal file
12
apps/server/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditMutationInterceptor } from '../../common/audit/audit-mutation.interceptor';
|
||||
import { AuditController } from './audit.controller';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [AuditController],
|
||||
providers: [AuditService, AuditMutationInterceptor],
|
||||
exports: [AuditService, AuditMutationInterceptor],
|
||||
})
|
||||
export class AuditModule {}
|
||||
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal file
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe('AuditService', () => {
|
||||
const create = jest.fn();
|
||||
const findMany = jest.fn();
|
||||
const prisma = { auditEvent: { create, findMany } } as any;
|
||||
const service = new AuditService(prisma);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('writes append-only audit events with sensitive fields redacted', async () => {
|
||||
create.mockResolvedValue({ id: 'audit-1' });
|
||||
|
||||
await service.record({
|
||||
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
|
||||
action: 'product.update',
|
||||
entityType: 'product',
|
||||
entityId: 'product-1',
|
||||
productId: 'product-1',
|
||||
before: { name: 'Old', password: '123456' },
|
||||
after: { name: 'New', nested: { apiKey: 'sk-test', keep: 'visible' } },
|
||||
metadata: { authorization: 'Bearer token', reason: 'manual edit' },
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
actorId: 'm-8',
|
||||
actorName: '超级管理员',
|
||||
action: 'product.update',
|
||||
entityType: 'product',
|
||||
entityId: 'product-1',
|
||||
productId: 'product-1',
|
||||
before: { name: 'Old', password: '[REDACTED]' },
|
||||
after: { name: 'New', nested: { apiKey: '[REDACTED]', keep: 'visible' } },
|
||||
metadata: { authorization: '[REDACTED]', reason: 'manual edit' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('queries by actor, entity, scope, and date range with bounded page size', async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
|
||||
await service.query({
|
||||
actorId: 'm-8',
|
||||
entityType: 'bug',
|
||||
entityId: 'bug-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
dateFrom: '2026-07-01T00:00:00.000Z',
|
||||
dateTo: '2026-07-08T23:59:59.000Z',
|
||||
take: '500',
|
||||
});
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
actorId: 'm-8',
|
||||
entityType: 'bug',
|
||||
entityId: 'bug-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
createdAt: {
|
||||
gte: new Date('2026-07-01T00:00:00.000Z'),
|
||||
lte: new Date('2026-07-08T23:59:59.000Z'),
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/server/src/modules/audit/audit.service.ts
Normal file
90
apps/server/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { CurrentUser } from '../../common/auth/auth-context.service';
|
||||
import type { QueryAuditEventsDto } from './dto/query-audit-events.dto';
|
||||
|
||||
export interface AuditRecordInput {
|
||||
actor?: CurrentUser | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
scope?: unknown;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
record(input: AuditRecordInput) {
|
||||
return this.prisma.auditEvent.create({
|
||||
data: {
|
||||
actorId: input.actor?.id ?? null,
|
||||
actorName: input.actor?.name ?? input.actor?.username ?? '',
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
productId: input.productId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
versionId: input.versionId ?? null,
|
||||
scope: toJson(input.scope ?? {}),
|
||||
before: input.before === undefined ? undefined : toJson(redactSensitive(input.before)),
|
||||
after: input.after === undefined ? undefined : toJson(redactSensitive(input.after)),
|
||||
metadata: toJson(redactSensitive(input.metadata ?? {})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
query(query: QueryAuditEventsDto) {
|
||||
return this.prisma.auditEvent.findMany({
|
||||
where: buildWhere(query),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: clampTake(query.take),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(query: QueryAuditEventsDto) {
|
||||
const where: Record<string, unknown> = {};
|
||||
for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) {
|
||||
if (query[key]) where[key] = query[key];
|
||||
}
|
||||
|
||||
const dateRange: Record<string, Date> = {};
|
||||
if (query.dateFrom) dateRange.gte = new Date(query.dateFrom);
|
||||
if (query.dateTo) dateRange.lte = new Date(query.dateTo);
|
||||
if (Object.keys(dateRange).length > 0) where.createdAt = dateRange;
|
||||
return where;
|
||||
}
|
||||
|
||||
function clampTake(value: string | undefined): number {
|
||||
const parsed = Number(value ?? 50);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 50;
|
||||
return Math.min(100, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function redactSensitive(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => redactSensitive(item));
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||
key,
|
||||
isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(item),
|
||||
]));
|
||||
}
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return ['password', 'token', 'secret', 'apikey', 'authorization'].some((sensitive) => normalized.includes(sensitive));
|
||||
}
|
||||
|
||||
function toJson(value: unknown): Prisma.InputJsonValue {
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal file
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class QueryAuditEventsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
entityType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
entityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
versionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
take?: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateBugDto } from './dto/create-bug.dto';
|
||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||
import { BugService } from './bug.service';
|
||||
@@ -8,6 +9,11 @@ export class BugController {
|
||||
constructor(private readonly bugService: BugService) {}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('version.bug:create', { versionIdParam: 'versionId' }, {
|
||||
action: 'bug.create',
|
||||
entityType: 'bug',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) {
|
||||
return this.bugService.create(versionId, dto);
|
||||
}
|
||||
@@ -18,11 +24,23 @@ export class BugController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||
action: 'bug.update',
|
||||
entityType: 'bug',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateBugDto) {
|
||||
return this.bugService.update(versionId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||
action: 'bug.status',
|
||||
entityType: 'bug',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
updateStatus(
|
||||
@Param('versionId') versionId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -33,6 +51,12 @@ export class BugController {
|
||||
}
|
||||
|
||||
@Patch(':id/transfer')
|
||||
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||
action: 'bug.transfer',
|
||||
entityType: 'bug',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
transfer(
|
||||
@Param('versionId') versionId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -43,6 +67,12 @@ export class BugController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('version.bug:delete', { versionIdParam: 'versionId' }, {
|
||||
action: 'bug.delete',
|
||||
entityType: 'bug',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||
return this.bugService.remove(versionId, id);
|
||||
}
|
||||
|
||||
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,19 @@
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
|
||||
import { ConsistencyController } from './consistency.controller';
|
||||
|
||||
describe('ConsistencyController', () => {
|
||||
it('requires consistency:view for consistency checks', () => {
|
||||
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, ConsistencyController.prototype.run);
|
||||
|
||||
expect(metadata).toEqual({ permission: 'consistency:view' });
|
||||
});
|
||||
|
||||
it('delegates consistency checks to the service', async () => {
|
||||
const service = { run: jest.fn().mockResolvedValue({ status: 'pass' }) };
|
||||
const controller = new ConsistencyController(service as any);
|
||||
|
||||
await expect(controller.run()).resolves.toEqual({ status: 'pass' });
|
||||
expect(service.run).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { PermissionGuard } from '../../common/auth/permission.guard';
|
||||
import { RequirePermission } from '../../common/auth/permission.decorator';
|
||||
import { ConsistencyService } from './consistency.service';
|
||||
|
||||
@Controller('consistency')
|
||||
export class ConsistencyController {
|
||||
constructor(private readonly consistencyService: ConsistencyService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(PermissionGuard)
|
||||
@RequirePermission('consistency:view')
|
||||
run() {
|
||||
return this.consistencyService.run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConsistencyController } from './consistency.controller';
|
||||
import { ConsistencyService } from './consistency.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ConsistencyController],
|
||||
providers: [ConsistencyService],
|
||||
})
|
||||
export class ConsistencyModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ConsistencyService } from './consistency.service';
|
||||
|
||||
describe('ConsistencyService', () => {
|
||||
const makePrisma = () => {
|
||||
const counts = {
|
||||
product: 1,
|
||||
project: 2,
|
||||
version: 3,
|
||||
requirement: 4,
|
||||
versionPlan: 5,
|
||||
devTask: 6,
|
||||
testCase: 7,
|
||||
bug: 8,
|
||||
user: 9,
|
||||
taskCategory: 10,
|
||||
taskWorklog: 11,
|
||||
overtimeRecord: 12,
|
||||
workActivity: 13,
|
||||
auditEvent: 14,
|
||||
};
|
||||
const prisma: any = {
|
||||
$queryRawUnsafe: jest.fn((sql: string) => {
|
||||
if (sql.includes('dev_tasks') && sql.includes("version_id = ''")) return Promise.resolve([{ count: 1n }]);
|
||||
if (sql.includes('requirements') && sql.includes('missing_version')) return Promise.resolve([{ count: 2n }]);
|
||||
if (sql.includes('audit_events') && sql.includes("entity_type = 'bug'")) return Promise.resolve([{ count: 0n }]);
|
||||
return Promise.resolve([{ count: 0n }]);
|
||||
}),
|
||||
};
|
||||
for (const [model, count] of Object.entries(counts)) {
|
||||
prisma[model] = { count: jest.fn().mockResolvedValue(count) };
|
||||
}
|
||||
return prisma;
|
||||
};
|
||||
|
||||
it('returns counts plus error/warn consistency groups', async () => {
|
||||
const prisma = makePrisma();
|
||||
const service = new ConsistencyService(prisma);
|
||||
|
||||
const result = await service.run();
|
||||
|
||||
expect(result.status).toBe('fail');
|
||||
expect(result.counts.devTasks).toBe(6);
|
||||
expect(result.summary.errors).toBeGreaterThan(0);
|
||||
expect(result.summary.warnings).toBeGreaterThan(0);
|
||||
expect(result.checks.partitionKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'dev_tasks.version_id.present',
|
||||
severity: 'error',
|
||||
count: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.checks.orphanReferences).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'requirements.version_id.exists',
|
||||
severity: 'error',
|
||||
count: 2,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.checks.auditCoverage).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'audit.coverage.bug',
|
||||
severity: 'warn',
|
||||
count: 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
186
apps/server/src/modules/consistency/consistency.service.ts
Normal file
186
apps/server/src/modules/consistency/consistency.service.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export type ConsistencySeverity = 'ok' | 'warn' | 'error';
|
||||
export type ConsistencyStatus = 'pass' | 'fail';
|
||||
|
||||
export interface ConsistencyCheckResult {
|
||||
id: string;
|
||||
label: string;
|
||||
severity: ConsistencySeverity;
|
||||
count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ConsistencyResult {
|
||||
generatedAt: string;
|
||||
status: ConsistencyStatus;
|
||||
counts: Record<string, number>;
|
||||
checks: {
|
||||
partitionKeys: ConsistencyCheckResult[];
|
||||
orphanReferences: ConsistencyCheckResult[];
|
||||
auditCoverage: ConsistencyCheckResult[];
|
||||
};
|
||||
summary: {
|
||||
errors: number;
|
||||
warnings: number;
|
||||
human: string;
|
||||
};
|
||||
}
|
||||
|
||||
const COUNT_MODELS: Array<[string, string]> = [
|
||||
['products', 'product'],
|
||||
['projects', 'project'],
|
||||
['versions', 'version'],
|
||||
['requirements', 'requirement'],
|
||||
['versionPlans', 'versionPlan'],
|
||||
['devTasks', 'devTask'],
|
||||
['testCases', 'testCase'],
|
||||
['bugs', 'bug'],
|
||||
['members', 'user'],
|
||||
['taskCategories', 'taskCategory'],
|
||||
['taskWorklogs', 'taskWorklog'],
|
||||
['overtimeRecords', 'overtimeRecord'],
|
||||
['workActivities', 'workActivity'],
|
||||
['auditEvents', 'auditEvent'],
|
||||
];
|
||||
|
||||
const PARTITION_KEY_CHECKS = [
|
||||
check('requirements.product_id.present', 'requirements must keep product_id partition key', 'error', "SELECT COUNT(*) AS count FROM requirements WHERE product_id IS NULL OR product_id = ''"),
|
||||
check('dev_tasks.version_id.present', 'dev_tasks must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM dev_tasks WHERE version_id IS NULL OR version_id = ''"),
|
||||
check('test_cases.version_id.present', 'test_cases must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM test_cases WHERE version_id IS NULL OR version_id = ''"),
|
||||
check('bugs.version_id.present', 'bugs must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM bugs WHERE version_id IS NULL OR version_id = ''"),
|
||||
check('work_activities.created_at.present', 'work_activities must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM work_activities WHERE created_at IS NULL'),
|
||||
check('task_worklogs.created_at.present', 'task_worklogs must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM task_worklogs WHERE created_at IS NULL'),
|
||||
check('overtime_records.created_at.present', 'overtime_records must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM overtime_records WHERE created_at IS NULL'),
|
||||
check('audit_events.created_at.present', 'audit_events must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM audit_events WHERE created_at IS NULL'),
|
||||
];
|
||||
|
||||
const ORPHAN_REFERENCE_CHECKS = [
|
||||
check('projects.product_id.exists', 'projects.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM projects p LEFT JOIN products pr ON pr.id = p.product_id WHERE pr.id IS NULL'),
|
||||
check('versions.product_id.exists', 'versions.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN products p ON p.id = v.product_id WHERE p.id IS NULL'),
|
||||
check('versions.project_id.exists', 'versions.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN projects p ON p.id = v.project_id WHERE v.project_id IS NOT NULL AND p.id IS NULL'),
|
||||
check('requirements.product_id.exists', 'requirements.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN products p ON p.id = r.product_id WHERE p.id IS NULL'),
|
||||
check('requirements.project_id.exists', 'requirements.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN projects p ON p.id = r.project_id WHERE r.project_id IS NOT NULL AND p.id IS NULL'),
|
||||
check('requirements.version_id.exists', 'requirements.version_id must reference versions.id when present', 'error', 'SELECT COUNT(*) AS count /* missing_version */ FROM requirements r LEFT JOIN versions v ON v.id = r.version_id WHERE r.version_id IS NOT NULL AND v.id IS NULL'),
|
||||
check('version_plans.version_id.exists', 'version_plans.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM version_plans vp LEFT JOIN versions v ON v.id = vp.version_id WHERE v.id IS NULL'),
|
||||
check('dev_tasks.version_id.exists', 'dev_tasks.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN versions v ON v.id = dt.version_id WHERE v.id IS NULL'),
|
||||
check('dev_tasks.requirement.exists', 'dev_tasks requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN requirements r ON r.id = dt.requirement_id AND r.product_id = dt.requirement_product_id WHERE dt.requirement_id IS NOT NULL AND r.id IS NULL'),
|
||||
check('test_cases.version_id.exists', 'test_cases.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN versions v ON v.id = tc.version_id WHERE v.id IS NULL'),
|
||||
check('test_cases.requirement.exists', 'test_cases requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN requirements r ON r.id = tc.requirement_id AND r.product_id = tc.requirement_product_id WHERE tc.requirement_id IS NOT NULL AND r.id IS NULL'),
|
||||
check('bugs.version_id.exists', 'bugs.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN versions v ON v.id = b.version_id WHERE v.id IS NULL'),
|
||||
check('bugs.test_case.exists', 'bugs test_case composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN test_cases tc ON tc.id = b.test_case_id AND tc.version_id = b.test_case_version_id WHERE b.test_case_id IS NOT NULL AND tc.id IS NULL'),
|
||||
];
|
||||
|
||||
const AUDIT_ENTITY_TYPES = [
|
||||
'product',
|
||||
'project',
|
||||
'version',
|
||||
'requirement',
|
||||
'version_plan',
|
||||
'dev_task',
|
||||
'test_case',
|
||||
'bug',
|
||||
'member',
|
||||
'task_category',
|
||||
'task_worklog',
|
||||
'overtime',
|
||||
'work_activity',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ConsistencyService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async run(): Promise<ConsistencyResult> {
|
||||
const [counts, partitionKeys, orphanReferences, auditCoverage] = await Promise.all([
|
||||
this.collectCounts(),
|
||||
this.runChecks(PARTITION_KEY_CHECKS),
|
||||
this.runChecks(ORPHAN_REFERENCE_CHECKS),
|
||||
this.runAuditCoverageChecks(),
|
||||
]);
|
||||
const allChecks = [...partitionKeys, ...orphanReferences, ...auditCoverage];
|
||||
const errors = allChecks.filter((item) => item.severity === 'error').length;
|
||||
const warnings = allChecks.filter((item) => item.severity === 'warn').length;
|
||||
const status: ConsistencyStatus = errors > 0 ? 'fail' : 'pass';
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
status,
|
||||
counts,
|
||||
checks: { partitionKeys, orphanReferences, auditCoverage },
|
||||
summary: {
|
||||
errors,
|
||||
warnings,
|
||||
human: buildHumanSummary(status, errors, warnings, counts),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async collectCounts() {
|
||||
const entries = await Promise.all(
|
||||
COUNT_MODELS.map(async ([label, model]) => [label, await (this.prisma as any)[model].count()] as const),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
private async runChecks(checks: ConsistencyCheck[]) {
|
||||
return Promise.all(checks.map((item) => this.runSqlCheck(item)));
|
||||
}
|
||||
|
||||
private async runAuditCoverageChecks() {
|
||||
return Promise.all(AUDIT_ENTITY_TYPES.map(async (entityType) => {
|
||||
const count = await this.rawCount(`SELECT COUNT(*) AS count FROM audit_events WHERE entity_type = '${entityType}'`);
|
||||
const label = `audit_events should contain mutation events for ${entityType}`;
|
||||
return {
|
||||
id: `audit.coverage.${entityType}`,
|
||||
label,
|
||||
severity: count === 0 ? 'warn' : 'ok',
|
||||
count,
|
||||
message: count === 0 ? `${label}: no events yet` : `${label}: ${count}`,
|
||||
} satisfies ConsistencyCheckResult;
|
||||
}));
|
||||
}
|
||||
|
||||
private async runSqlCheck(item: ConsistencyCheck): Promise<ConsistencyCheckResult> {
|
||||
const count = await this.rawCount(item.sql);
|
||||
const severity = count > 0 ? item.severityWhenNonZero : 'ok';
|
||||
return {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
severity,
|
||||
count,
|
||||
message: count > 0 ? `${item.label}: ${count}` : `${item.label}: ok`,
|
||||
};
|
||||
}
|
||||
|
||||
private async rawCount(sql: string): Promise<number> {
|
||||
const rows = await this.prisma.$queryRawUnsafe<Array<{ count: bigint | number | string }>>(sql);
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsistencyCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
function check(
|
||||
id: string,
|
||||
label: string,
|
||||
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>,
|
||||
sql: string,
|
||||
): ConsistencyCheck {
|
||||
return { id, label, severityWhenNonZero, sql };
|
||||
}
|
||||
|
||||
function buildHumanSummary(
|
||||
status: ConsistencyStatus,
|
||||
errors: number,
|
||||
warnings: number,
|
||||
counts: Record<string, number>,
|
||||
) {
|
||||
return `V2.5 consistency ${status}: ${errors} error(s), ${warnings} warning(s), ${counts.auditEvents ?? 0} audit event(s).`;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppDataRetirementModule } from '../app-data-retirement/app-data-retirement.module';
|
||||
import { MigrationModule } from '../migration/migration.module';
|
||||
import { DataController } from './data.controller';
|
||||
import { DataService } from './data.service';
|
||||
|
||||
@Module({
|
||||
imports: [MigrationModule],
|
||||
imports: [MigrationModule, AppDataRetirementModule],
|
||||
controllers: [DataController],
|
||||
providers: [DataService],
|
||||
})
|
||||
|
||||
@@ -14,10 +14,14 @@ describe('DataService', () => {
|
||||
const syncService = {
|
||||
syncAfterAppDataPut: jest.fn(),
|
||||
};
|
||||
const retirementService = {
|
||||
assertWritable: jest.fn(),
|
||||
};
|
||||
return {
|
||||
prisma,
|
||||
syncService,
|
||||
service: new DataService(prisma as any, syncService as any),
|
||||
retirementService,
|
||||
service: new (DataService as any)(prisma, syncService, retirementService) as DataService,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,7 +59,7 @@ describe('DataService', () => {
|
||||
});
|
||||
|
||||
it('upserts JSON values for allowed keys', async () => {
|
||||
const { prisma, service, syncService } = makeService();
|
||||
const { prisma, service, syncService, retirementService } = makeService();
|
||||
const value = [{ id: 'p1', name: 'Product 1' }];
|
||||
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||
@@ -70,9 +74,34 @@ describe('DataService', () => {
|
||||
update: { value },
|
||||
create: { key: 'products-overview', value },
|
||||
});
|
||||
expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview');
|
||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
||||
});
|
||||
|
||||
it('rejects frozen AppData writes before touching storage or relation sync', async () => {
|
||||
const { prisma, service, syncService, retirementService } = makeService();
|
||||
retirementService.assertWritable.mockImplementation(() => {
|
||||
throw new ConflictException({
|
||||
code: 'APP_DATA_WRITE_FROZEN',
|
||||
key: 'dev-tasks',
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/dev-tasks',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'APP_DATA_WRITE_FROZEN',
|
||||
key: 'dev-tasks',
|
||||
state: 'write_frozen',
|
||||
}),
|
||||
});
|
||||
expect(prisma.appData.create).not.toHaveBeenCalled();
|
||||
expect(prisma.appData.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows supporting business data keys migrated from browser storage', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const value: unknown[] = [];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
|
||||
import { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
|
||||
import { isAppDataKey } from './data-keys';
|
||||
import { type AppDataKey, isAppDataKey } from './data-keys';
|
||||
|
||||
type AppDataRow = {
|
||||
key: string;
|
||||
@@ -17,6 +18,8 @@ export class DataService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private readonly appDataSync?: AppDataV23SyncService,
|
||||
@Optional()
|
||||
private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(),
|
||||
) {}
|
||||
|
||||
async get(key: string) {
|
||||
@@ -27,6 +30,7 @@ export class DataService {
|
||||
|
||||
async put(key: string, value: unknown, version?: string | null) {
|
||||
this.ensureAllowedKey(key);
|
||||
this.appDataRetirement.assertWritable(key);
|
||||
const jsonValue = value as Prisma.InputJsonValue;
|
||||
|
||||
if (version === null) {
|
||||
@@ -66,7 +70,7 @@ export class DataService {
|
||||
return this.toResponseAfterSync(key, row);
|
||||
}
|
||||
|
||||
private ensureAllowedKey(key: string) {
|
||||
private ensureAllowedKey(key: string): asserts key is AppDataKey {
|
||||
if (!isAppDataKey(key)) {
|
||||
throw new BadRequestException(`Unsupported data key: ${key}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||
import { DevTaskService } from './dev-task.service';
|
||||
@@ -8,6 +9,11 @@ export class DevTaskController {
|
||||
constructor(private readonly devTaskService: DevTaskService) {}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'dev_task.create',
|
||||
entityType: 'dev_task',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) {
|
||||
return this.devTaskService.create(versionId, dto);
|
||||
}
|
||||
@@ -18,11 +24,23 @@ export class DevTaskController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'dev_task.update',
|
||||
entityType: 'dev_task',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) {
|
||||
return this.devTaskService.update(versionId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'dev_task.status',
|
||||
entityType: 'dev_task',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
updateStatus(
|
||||
@Param('versionId') versionId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -32,6 +50,12 @@ export class DevTaskController {
|
||||
}
|
||||
|
||||
@Patch(':id/block')
|
||||
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'dev_task.block',
|
||||
entityType: 'dev_task',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
setBlocked(
|
||||
@Param('versionId') versionId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -42,6 +66,12 @@ export class DevTaskController {
|
||||
}
|
||||
|
||||
@Patch(':id/transfer')
|
||||
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'dev_task.transfer',
|
||||
entityType: 'dev_task',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
transfer(
|
||||
@Param('versionId') versionId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -51,6 +81,12 @@ export class DevTaskController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'dev_task.delete',
|
||||
entityType: 'dev_task',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||
return this.devTaskService.remove(versionId, id);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateMemberDto } from './dto/create-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
import { MemberService } from './member.service';
|
||||
@@ -13,16 +14,27 @@ export class MemberController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('member:create', {}, { action: 'member.create', entityType: 'member' })
|
||||
create(@Body() dto: CreateMemberDto) {
|
||||
return this.memberService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('member:edit', {}, {
|
||||
action: 'member.update',
|
||||
entityType: 'member',
|
||||
entityIdParam: 'id',
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateMemberDto) {
|
||||
return this.memberService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('member:delete', {}, {
|
||||
action: 'member.delete',
|
||||
entityType: 'member',
|
||||
entityIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.memberService.remove(id);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
11
apps/server/src/modules/ops/ops-permission.adapter.ts
Normal file
11
apps/server/src/modules/ops/ops-permission.adapter.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OPS_VIEW_PERMISSION } from './ops.service';
|
||||
|
||||
@Injectable()
|
||||
export class OpsPermissionAdapter {
|
||||
assertCanViewOps(_request: unknown) {
|
||||
// V2.5 backend RBAC is not landed yet. Keep this adapter as the single
|
||||
// replacement point for a real guard instead of coupling Ops to a temporary shape.
|
||||
return { requiredPermission: OPS_VIEW_PERMISSION, enforced: false };
|
||||
}
|
||||
}
|
||||
53
apps/server/src/modules/ops/ops-runtime.store.spec.ts
Normal file
53
apps/server/src/modules/ops/ops-runtime.store.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
clearOpsRuntimeEventsForTests,
|
||||
getOpsRuntimeEvents,
|
||||
recordSlowPrismaQuery,
|
||||
recordSlowRequest,
|
||||
} from './ops-runtime.store';
|
||||
|
||||
describe('ops runtime store', () => {
|
||||
afterEach(() => clearOpsRuntimeEventsForTests());
|
||||
|
||||
it('records recent slow requests without leaking query-string secrets', () => {
|
||||
recordSlowRequest({
|
||||
method: 'GET',
|
||||
url: '/api/v1/config/ai?apiKey=sk-secret&visible=1',
|
||||
durationMs: 1300,
|
||||
thresholdMs: 1000,
|
||||
occurredAt: new Date('2026-07-08T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
const events = getOpsRuntimeEvents();
|
||||
|
||||
expect(events.slowRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
path: '/api/v1/config/ai',
|
||||
durationMs: 1300,
|
||||
thresholdMs: 1000,
|
||||
occurredAt: '2026-07-08T08:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain('sk-secret');
|
||||
});
|
||||
|
||||
it('records recent slow Prisma queries with redacted and bounded previews', () => {
|
||||
recordSlowPrismaQuery({
|
||||
query: `SELECT * FROM ai_logs WHERE metadata::text LIKE '%sk-secret-token%' ${'x'.repeat(400)}`,
|
||||
durationMs: 450,
|
||||
thresholdMs: 300,
|
||||
occurredAt: new Date('2026-07-08T08:01:00.000Z'),
|
||||
});
|
||||
|
||||
const [event] = getOpsRuntimeEvents().slowQueries;
|
||||
|
||||
expect(event).toEqual(expect.objectContaining({
|
||||
durationMs: 450,
|
||||
thresholdMs: 300,
|
||||
occurredAt: '2026-07-08T08:01:00.000Z',
|
||||
}));
|
||||
expect(event.queryPreview).toContain('[redacted]');
|
||||
expect(event.queryPreview.length).toBeLessThanOrEqual(240);
|
||||
expect(event.queryPreview).not.toContain('sk-secret-token');
|
||||
});
|
||||
});
|
||||
116
apps/server/src/modules/ops/ops-runtime.store.ts
Normal file
116
apps/server/src/modules/ops/ops-runtime.store.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
const MAX_EVENTS = 50;
|
||||
const MAX_PREVIEW_LENGTH = 240;
|
||||
|
||||
export interface SlowRequestEvent {
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface SlowPrismaQueryEvent {
|
||||
id: string;
|
||||
queryPreview: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface OpsRuntimeEvents {
|
||||
slowRequests: SlowRequestEvent[];
|
||||
slowQueries: SlowPrismaQueryEvent[];
|
||||
}
|
||||
|
||||
interface RecordSlowRequestInput {
|
||||
method: string;
|
||||
url: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt?: Date;
|
||||
}
|
||||
|
||||
interface RecordSlowPrismaQueryInput {
|
||||
query: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt?: Date;
|
||||
}
|
||||
|
||||
const slowRequests: SlowRequestEvent[] = [];
|
||||
const slowQueries: SlowPrismaQueryEvent[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
export function recordSlowRequest(input: RecordSlowRequestInput) {
|
||||
slowRequests.unshift({
|
||||
id: makeId('req'),
|
||||
method: normalizeMethod(input.method),
|
||||
path: sanitizeRequestPath(input.url),
|
||||
durationMs: Math.round(input.durationMs),
|
||||
thresholdMs: Math.round(input.thresholdMs),
|
||||
occurredAt: (input.occurredAt ?? new Date()).toISOString(),
|
||||
});
|
||||
trim(slowRequests);
|
||||
}
|
||||
|
||||
export function recordSlowPrismaQuery(input: RecordSlowPrismaQueryInput) {
|
||||
slowQueries.unshift({
|
||||
id: makeId('qry'),
|
||||
queryPreview: truncate(redactText(input.query.replace(/\s+/g, ' ').trim()), MAX_PREVIEW_LENGTH),
|
||||
durationMs: Math.round(input.durationMs),
|
||||
thresholdMs: Math.round(input.thresholdMs),
|
||||
occurredAt: (input.occurredAt ?? new Date()).toISOString(),
|
||||
});
|
||||
trim(slowQueries);
|
||||
}
|
||||
|
||||
export function getOpsRuntimeEvents(): OpsRuntimeEvents {
|
||||
return {
|
||||
slowRequests: slowRequests.map((event) => ({ ...event })),
|
||||
slowQueries: slowQueries.map((event) => ({ ...event })),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearOpsRuntimeEventsForTests() {
|
||||
slowRequests.splice(0, slowRequests.length);
|
||||
slowQueries.splice(0, slowQueries.length);
|
||||
nextId = 1;
|
||||
}
|
||||
|
||||
export function redactText(value: string, maxLength = 500): string {
|
||||
const redacted = value
|
||||
.replace(/(^|[^A-Za-z0-9])sk-[A-Za-z0-9_-]+/g, '$1[redacted]')
|
||||
.replace(/(api[_-]?key|token|secret|password|authorization)(\s*[=:]\s*)(["']?)[^&\s"']+/gi, '$1$2$3[redacted]');
|
||||
return truncate(redacted, maxLength);
|
||||
}
|
||||
|
||||
function sanitizeRequestPath(url: string): string {
|
||||
const raw = String(url || 'unknown-url');
|
||||
try {
|
||||
const parsed = new URL(raw, 'http://local.invalid');
|
||||
return redactText(parsed.pathname || '/');
|
||||
} catch {
|
||||
return redactText(raw.split('?')[0] || 'unknown-url');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMethod(method: string): string {
|
||||
const value = String(method || 'UNKNOWN').toUpperCase();
|
||||
return /^[A-Z]+$/.test(value) ? value : 'UNKNOWN';
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
||||
}
|
||||
|
||||
function trim<T>(items: T[]) {
|
||||
if (items.length > MAX_EVENTS) items.splice(MAX_EVENTS);
|
||||
}
|
||||
|
||||
function makeId(prefix: string): string {
|
||||
const id = `${prefix}-${Date.now().toString(36)}-${nextId.toString(36)}`;
|
||||
nextId += 1;
|
||||
return id;
|
||||
}
|
||||
19
apps/server/src/modules/ops/ops.controller.spec.ts
Normal file
19
apps/server/src/modules/ops/ops.controller.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { OpsController } from './ops.controller';
|
||||
|
||||
describe('OpsController', () => {
|
||||
it('checks the ops:view adapter before returning runtime snapshot', async () => {
|
||||
const service = {
|
||||
getRuntimeSnapshot: jest.fn().mockResolvedValue({ ok: true }),
|
||||
};
|
||||
const permissions = {
|
||||
assertCanViewOps: jest.fn(),
|
||||
};
|
||||
const controller = new OpsController(service as any, permissions as any);
|
||||
const request = { headers: {} };
|
||||
|
||||
await expect(controller.getRuntime(request as any)).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(permissions.assertCanViewOps).toHaveBeenCalledWith(request);
|
||||
expect(service.getRuntimeSnapshot).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
17
apps/server/src/modules/ops/ops.controller.ts
Normal file
17
apps/server/src/modules/ops/ops.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Req } from '@nestjs/common';
|
||||
import { OpsPermissionAdapter } from './ops-permission.adapter';
|
||||
import { OpsService } from './ops.service';
|
||||
|
||||
@Controller('ops')
|
||||
export class OpsController {
|
||||
constructor(
|
||||
private readonly ops: OpsService,
|
||||
private readonly permissions: OpsPermissionAdapter,
|
||||
) {}
|
||||
|
||||
@Get('runtime')
|
||||
getRuntime(@Req() request: unknown) {
|
||||
this.permissions.assertCanViewOps(request);
|
||||
return this.ops.getRuntimeSnapshot();
|
||||
}
|
||||
}
|
||||
16
apps/server/src/modules/ops/ops.module.ts
Normal file
16
apps/server/src/modules/ops/ops.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OpsController } from './ops.controller';
|
||||
import { OpsPermissionAdapter } from './ops-permission.adapter';
|
||||
import { OPS_PRISMA, OpsService } from './ops.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OpsController],
|
||||
providers: [
|
||||
{ provide: OPS_PRISMA, useExisting: PrismaService },
|
||||
OpsService,
|
||||
OpsPermissionAdapter,
|
||||
],
|
||||
exports: [OpsService, OpsPermissionAdapter],
|
||||
})
|
||||
export class OpsModule {}
|
||||
89
apps/server/src/modules/ops/ops.service.spec.ts
Normal file
89
apps/server/src/modules/ops/ops.service.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { recordSlowPrismaQuery, recordSlowRequest, clearOpsRuntimeEventsForTests } from './ops-runtime.store';
|
||||
import { OpsService } from './ops.service';
|
||||
|
||||
describe('OpsService', () => {
|
||||
afterEach(() => clearOpsRuntimeEventsForTests());
|
||||
|
||||
it('returns runtime performance counters without exposing secrets', async () => {
|
||||
const prisma = {
|
||||
backgroundJob: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'job-queued',
|
||||
type: 'xiaobao.summary.refresh',
|
||||
status: 'queued',
|
||||
attempts: 0,
|
||||
maxAttempts: 5,
|
||||
availableAt: new Date('2026-07-08T08:00:00.000Z'),
|
||||
lockedUntil: null,
|
||||
lastError: null,
|
||||
updatedAt: new Date('2026-07-08T08:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 'job-running',
|
||||
type: 'xiaobao.ai.interpret',
|
||||
status: 'running',
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
availableAt: new Date('2026-07-08T08:01:00.000Z'),
|
||||
lockedUntil: new Date('2026-07-08T08:05:00.000Z'),
|
||||
lastError: null,
|
||||
updatedAt: new Date('2026-07-08T08:02:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 'job-failed',
|
||||
type: 'xiaobao.ai.interpret',
|
||||
status: 'failed',
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
availableAt: new Date('2026-07-08T08:03:00.000Z'),
|
||||
lockedUntil: null,
|
||||
lastError: 'provider failed with sk-secret-token',
|
||||
updatedAt: new Date('2026-07-08T08:04:00.000Z'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
xiaobaoRiskSummary: {
|
||||
count: jest.fn().mockResolvedValue(7),
|
||||
},
|
||||
};
|
||||
recordSlowRequest({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ai/risk-interpret?token=sk-secret-token',
|
||||
durationMs: 1500,
|
||||
thresholdMs: 1000,
|
||||
occurredAt: new Date('2026-07-08T08:05:00.000Z'),
|
||||
});
|
||||
recordSlowPrismaQuery({
|
||||
query: 'SELECT * FROM background_jobs WHERE last_error = "sk-secret-token"',
|
||||
durationMs: 420,
|
||||
thresholdMs: 300,
|
||||
occurredAt: new Date('2026-07-08T08:06:00.000Z'),
|
||||
});
|
||||
const service = new OpsService(prisma as any);
|
||||
|
||||
const snapshot = await service.getRuntimeSnapshot(new Date('2026-07-08T08:07:00.000Z'));
|
||||
|
||||
expect(snapshot.dirtySummaryCount).toBe(7);
|
||||
expect(snapshot.jobQueue.totals).toEqual({ queued: 1, running: 1, succeeded: 0, failed: 1, total: 3 });
|
||||
expect(snapshot.jobQueue.byType).toEqual([
|
||||
expect.objectContaining({ type: 'xiaobao.ai.interpret', running: 1, failed: 1, total: 2 }),
|
||||
expect.objectContaining({ type: 'xiaobao.summary.refresh', queued: 1, total: 1 }),
|
||||
]);
|
||||
expect(snapshot.jobQueue.recentFailures).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'job-failed',
|
||||
type: 'xiaobao.ai.interpret',
|
||||
lastError: expect.stringContaining('[redacted]'),
|
||||
}),
|
||||
]);
|
||||
expect(snapshot.slowRequests[0].path).toBe('/api/v1/ai/risk-interpret');
|
||||
expect(snapshot.slowQueries[0].queryPreview).toContain('[redacted]');
|
||||
expect(JSON.stringify(snapshot)).not.toContain('sk-secret-token');
|
||||
expect(snapshot.access).toEqual({
|
||||
requiredPermission: 'ops:view',
|
||||
backendEnforced: false,
|
||||
adapter: 'OpsPermissionAdapter',
|
||||
});
|
||||
});
|
||||
});
|
||||
148
apps/server/src/modules/ops/ops.service.ts
Normal file
148
apps/server/src/modules/ops/ops.service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { resolveApiSlowRequestThreshold } from '../../common/interceptors/api-timing.interceptor';
|
||||
import { resolvePrismaSlowQueryThreshold } from '../../prisma/prisma-monitoring';
|
||||
import { getOpsRuntimeEvents, redactText } from './ops-runtime.store';
|
||||
|
||||
export const OPS_PRISMA = 'OPS_PRISMA';
|
||||
export const OPS_VIEW_PERMISSION = 'ops:view';
|
||||
|
||||
type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
interface BackgroundJobRow {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
attempts?: number | null;
|
||||
maxAttempts?: number | null;
|
||||
availableAt?: Date | string | null;
|
||||
lockedUntil?: Date | string | null;
|
||||
lastError?: string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpsService {
|
||||
constructor(@Inject(OPS_PRISMA) private readonly prisma: any) {}
|
||||
|
||||
async getRuntimeSnapshot(now = new Date()) {
|
||||
const events = getOpsRuntimeEvents();
|
||||
const database = { ok: true, error: undefined as string | undefined };
|
||||
let jobRows: BackgroundJobRow[] = [];
|
||||
let dirtySummaryCount = 0;
|
||||
|
||||
try {
|
||||
[jobRows, dirtySummaryCount] = await Promise.all([
|
||||
this.prisma.backgroundJob.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
attempts: true,
|
||||
maxAttempts: true,
|
||||
availableAt: true,
|
||||
lockedUntil: true,
|
||||
lastError: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 200,
|
||||
}),
|
||||
this.prisma.xiaobaoRiskSummary.count({ where: { dirty: true } }),
|
||||
]);
|
||||
} catch (error) {
|
||||
database.ok = false;
|
||||
database.error = redactText(error instanceof Error ? error.message : String(error), 300);
|
||||
}
|
||||
|
||||
return {
|
||||
collectedAt: now.toISOString(),
|
||||
thresholds: {
|
||||
apiSlowRequestMs: resolveApiSlowRequestThreshold(),
|
||||
prismaSlowQueryMs: resolvePrismaSlowQueryThreshold(),
|
||||
},
|
||||
database,
|
||||
slowRequests: events.slowRequests,
|
||||
slowQueries: events.slowQueries,
|
||||
jobQueue: buildJobQueue(jobRows),
|
||||
dirtySummaryCount,
|
||||
access: {
|
||||
requiredPermission: OPS_VIEW_PERMISSION,
|
||||
backendEnforced: false,
|
||||
adapter: 'OpsPermissionAdapter',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildJobQueue(rows: BackgroundJobRow[]) {
|
||||
const totals = emptyStatusCounts();
|
||||
const byType = new Map<string, ReturnType<typeof emptyTypeCounts>>();
|
||||
const recentFailures = rows
|
||||
.filter((row) => normalizeStatus(row.status) === 'failed')
|
||||
.slice(0, 10)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
attempts: row.attempts ?? 0,
|
||||
maxAttempts: row.maxAttempts ?? 0,
|
||||
lastError: redactText(row.lastError ?? '', 300),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
}));
|
||||
|
||||
for (const row of rows) {
|
||||
const status = normalizeStatus(row.status);
|
||||
totals[status] += 1;
|
||||
totals.total += 1;
|
||||
|
||||
const current = byType.get(row.type) ?? emptyTypeCounts(row.type);
|
||||
current[status] += 1;
|
||||
current.total += 1;
|
||||
if (status === 'queued') {
|
||||
current.oldestQueuedAt = minIso(current.oldestQueuedAt, toIso(row.availableAt));
|
||||
}
|
||||
if (status === 'running') {
|
||||
current.nextLeaseExpiresAt = minIso(current.nextLeaseExpiresAt, toIso(row.lockedUntil));
|
||||
}
|
||||
byType.set(row.type, current);
|
||||
}
|
||||
|
||||
return {
|
||||
totals,
|
||||
byType: Array.from(byType.values()).sort((a, b) => b.total - a.total || a.type.localeCompare(b.type)),
|
||||
recentFailures,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyStatusCounts(): Record<JobStatus, number> & { total: number } {
|
||||
return { queued: 0, running: 0, succeeded: 0, failed: 0, total: 0 };
|
||||
}
|
||||
|
||||
function emptyTypeCounts(type: string) {
|
||||
return {
|
||||
type,
|
||||
queued: 0,
|
||||
running: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
oldestQueuedAt: undefined as string | undefined,
|
||||
nextLeaseExpiresAt: undefined as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStatus(status: string): JobStatus {
|
||||
return status === 'running' || status === 'succeeded' || status === 'failed' ? status : 'queued';
|
||||
}
|
||||
|
||||
function toIso(value: Date | string | null | undefined): string | undefined {
|
||||
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : undefined;
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
||||
}
|
||||
|
||||
function minIso(current: string | undefined, next: string | undefined): string | undefined {
|
||||
if (!next) return current;
|
||||
if (!current) return next;
|
||||
return next < current ? next : current;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateOvertimeDto } from './dto/create-overtime.dto';
|
||||
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
|
||||
import { OvertimeService } from './overtime.service';
|
||||
@@ -13,16 +14,36 @@ export class OvertimeController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, {
|
||||
action: 'overtime.create',
|
||||
entityType: 'overtime',
|
||||
productIdBody: 'productId',
|
||||
projectIdBody: 'projectId',
|
||||
versionIdBody: 'versionId',
|
||||
})
|
||||
create(@Body() dto: CreateOvertimeDto) {
|
||||
return this.overtimeService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, {
|
||||
action: 'overtime.update',
|
||||
entityType: 'overtime',
|
||||
entityIdParam: 'id',
|
||||
productIdBody: 'productId',
|
||||
projectIdBody: 'projectId',
|
||||
versionIdBody: 'versionId',
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) {
|
||||
return this.overtimeService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('overtime:delete', {}, {
|
||||
action: 'overtime.delete',
|
||||
entityType: 'overtime',
|
||||
entityIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.overtimeService.remove(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { ProductService } from './product.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
@@ -8,6 +9,7 @@ export class ProductController {
|
||||
constructor(private readonly productService: ProductService) {}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('product:create', {}, { action: 'product.create', entityType: 'product' })
|
||||
create(@Body() dto: CreateProductDto) {
|
||||
return this.productService.create(dto);
|
||||
}
|
||||
@@ -28,11 +30,23 @@ export class ProductController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('product:edit', { productIdParam: 'id' }, {
|
||||
action: 'product.update',
|
||||
entityType: 'product',
|
||||
entityIdParam: 'id',
|
||||
productIdParam: 'id',
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||
return this.productService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('product:delete', { productIdParam: 'id' }, {
|
||||
action: 'product.delete',
|
||||
entityType: 'product',
|
||||
entityIdParam: 'id',
|
||||
productIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.productService.remove(id);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { ProjectService } from './project.service';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
@@ -8,6 +9,11 @@ export class ProjectController {
|
||||
constructor(private readonly projectService: ProjectService) {}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('project:create', { productIdParam: 'productId' }, {
|
||||
action: 'project.create',
|
||||
entityType: 'project',
|
||||
productIdParam: 'productId',
|
||||
})
|
||||
create(@Param('productId') productId: string, @Body() dto: CreateProjectDto) {
|
||||
return this.projectService.create(productId, dto);
|
||||
}
|
||||
@@ -18,6 +24,13 @@ export class ProjectController {
|
||||
}
|
||||
|
||||
@Patch(':projectId')
|
||||
@ProtectedMutation('project:edit', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
|
||||
action: 'project.update',
|
||||
entityType: 'project',
|
||||
entityIdParam: 'projectId',
|
||||
productIdParam: 'productId',
|
||||
projectIdParam: 'projectId',
|
||||
})
|
||||
update(
|
||||
@Param('productId') productId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@@ -27,6 +40,13 @@ export class ProjectController {
|
||||
}
|
||||
|
||||
@Delete(':projectId')
|
||||
@ProtectedMutation('project:delete', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
|
||||
action: 'project.delete',
|
||||
entityType: 'project',
|
||||
entityIdParam: 'projectId',
|
||||
productIdParam: 'productId',
|
||||
projectIdParam: 'projectId',
|
||||
})
|
||||
remove(@Param('productId') productId: string, @Param('projectId') projectId: string) {
|
||||
return this.projectService.remove(productId, projectId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { RequirementService } from './requirement.service';
|
||||
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||
@@ -9,6 +10,11 @@ export class RequirementController {
|
||||
constructor(private readonly requirementService: RequirementService) {}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('requirement:create', { productIdParam: 'productId' }, {
|
||||
action: 'requirement.create',
|
||||
entityType: 'requirement',
|
||||
productIdParam: 'productId',
|
||||
})
|
||||
create(
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: CreateRequirementDto,
|
||||
@@ -51,6 +57,12 @@ export class RequirementController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
|
||||
action: 'requirement.update',
|
||||
entityType: 'requirement',
|
||||
entityIdParam: 'id',
|
||||
productIdParam: 'productId',
|
||||
})
|
||||
update(
|
||||
@Param('productId') productId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -60,6 +72,12 @@ export class RequirementController {
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
|
||||
action: 'requirement.status',
|
||||
entityType: 'requirement',
|
||||
entityIdParam: 'id',
|
||||
productIdParam: 'productId',
|
||||
})
|
||||
updateStatus(
|
||||
@Param('productId') productId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -69,6 +87,12 @@ export class RequirementController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('requirement:delete', { productIdParam: 'productId' }, {
|
||||
action: 'requirement.delete',
|
||||
entityType: 'requirement',
|
||||
entityIdParam: 'id',
|
||||
productIdParam: 'productId',
|
||||
})
|
||||
remove(
|
||||
@Param('productId') productId: string,
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateTaskCategoryDto } from './dto/create-task-category.dto';
|
||||
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
|
||||
import { TaskCategoryService } from './task-category.service';
|
||||
@@ -13,16 +14,27 @@ export class TaskCategoryController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('task-category:manage', {}, { action: 'task_category.create', entityType: 'task_category' })
|
||||
create(@Body() dto: CreateTaskCategoryDto) {
|
||||
return this.taskCategoryService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('task-category:manage', {}, {
|
||||
action: 'task_category.update',
|
||||
entityType: 'task_category',
|
||||
entityIdParam: 'id',
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) {
|
||||
return this.taskCategoryService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('task-category:manage', {}, {
|
||||
action: 'task_category.delete',
|
||||
entityType: 'task_category',
|
||||
entityIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.taskCategoryService.remove(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
|
||||
import { TaskWorklogService } from './task-worklog.service';
|
||||
|
||||
@@ -12,11 +13,21 @@ export class TaskWorklogController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('version.devtask:manage', { versionIdBody: 'versionId' }, {
|
||||
action: 'task_worklog.create',
|
||||
entityType: 'task_worklog',
|
||||
versionIdBody: 'versionId',
|
||||
})
|
||||
create(@Body() dto: CreateTaskWorklogDto) {
|
||||
return this.taskWorklogService.create(dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('version.devtask:manage', {}, {
|
||||
action: 'task_worklog.delete',
|
||||
entityType: 'task_worklog',
|
||||
entityIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.taskWorklogService.remove(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
||||
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
||||
import { TestCaseService } from './test-case.service';
|
||||
@@ -8,11 +9,21 @@ export class TestCaseController {
|
||||
constructor(private readonly testCaseService: TestCaseService) {}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'test_case.create',
|
||||
entityType: 'test_case',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
create(@Param('versionId') versionId: string, @Body() dto: CreateTestCaseDto) {
|
||||
return this.testCaseService.create(versionId, dto);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'test_case.batch_create',
|
||||
entityType: 'test_case',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
createMany(@Param('versionId') versionId: string, @Body('items') items: CreateTestCaseDto[]) {
|
||||
return this.testCaseService.createMany(versionId, items ?? []);
|
||||
}
|
||||
@@ -23,11 +34,23 @@ export class TestCaseController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'test_case.update',
|
||||
entityType: 'test_case',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateTestCaseDto) {
|
||||
return this.testCaseService.update(versionId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'test_case.status',
|
||||
entityType: 'test_case',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
updateStatus(
|
||||
@Param('versionId') versionId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -37,6 +60,12 @@ export class TestCaseController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
|
||||
action: 'test_case.delete',
|
||||
entityType: 'test_case',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||
return this.testCaseService.remove(versionId, id);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ function buildPrismaMock() {
|
||||
xiaobaoRiskSummary: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
xiaobaoRiskInsight: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +187,7 @@ describe('V22QueryService', () => {
|
||||
prisma.testCase.findMany.mockResolvedValue([{ versionId: 'version-2' }]);
|
||||
prisma.bug.findMany.mockResolvedValue([{ versionId: 'version-3' }]);
|
||||
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([{ versionId: 'version-2', riskScore: 70 }]);
|
||||
prisma.xiaobaoRiskInsight.findMany.mockResolvedValue([]);
|
||||
const service = new V22QueryService(prisma as any);
|
||||
|
||||
const userResult = await service.getXiaobaoWarnings({ userId: 'member-1' });
|
||||
@@ -203,6 +207,75 @@ describe('V22QueryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches latest generated Xiaobao AI insight to warning summaries', async () => {
|
||||
const prisma = buildPrismaMock();
|
||||
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
|
||||
{ versionId: 'version-1', riskLevel: 'at_risk', riskScore: 70 },
|
||||
{ versionId: 'version-2', riskLevel: 'blocked', riskScore: 95 },
|
||||
]);
|
||||
prisma.xiaobaoRiskInsight.findMany.mockResolvedValue([
|
||||
{
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'signature-current',
|
||||
status: 'generated',
|
||||
createdAt: new Date('2026-07-08T10:00:00.000Z'),
|
||||
insight: {
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'signature-current',
|
||||
generatedAt: '2026-07-08T10:00:00.000Z',
|
||||
insight: {
|
||||
summary: '高风险版本需要收敛关键 Bug。',
|
||||
why: ['仍有 P1 Bug'],
|
||||
forecast: '预计延期 1 天。',
|
||||
suggestedActions: ['优先修复 P1 Bug'],
|
||||
ownerHints: ['确认修复负责人'],
|
||||
generatedAt: '2026-07-08T10:00:00.000Z',
|
||||
},
|
||||
providerInfo: { model: 'claude-test' },
|
||||
},
|
||||
},
|
||||
{
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'signature-old',
|
||||
status: 'generated',
|
||||
createdAt: new Date('2026-07-08T09:00:00.000Z'),
|
||||
insight: {
|
||||
summary: '旧解读不应被选中。',
|
||||
why: [],
|
||||
forecast: '',
|
||||
suggestedActions: [],
|
||||
ownerHints: [],
|
||||
generatedAt: '2026-07-08T09:00:00.000Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const service = new V22QueryService(prisma as any);
|
||||
|
||||
const result = await service.getXiaobaoWarnings({ manager: 'true' });
|
||||
|
||||
expect(prisma.xiaobaoRiskInsight.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
versionId: { in: ['version-1', 'version-2'] },
|
||||
status: 'generated',
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
});
|
||||
expect(result[0]).toEqual(expect.objectContaining({
|
||||
versionId: 'version-1',
|
||||
latestInsight: {
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'signature-current',
|
||||
generatedAt: '2026-07-08T10:00:00.000Z',
|
||||
insight: expect.objectContaining({
|
||||
summary: '高风险版本需要收敛关键 Bug。',
|
||||
forecast: '预计延期 1 天。',
|
||||
}),
|
||||
providerInfo: { model: 'claude-test' },
|
||||
},
|
||||
}));
|
||||
expect(result[1]).not.toHaveProperty('latestInsight');
|
||||
});
|
||||
|
||||
it('throws when a version detail query targets a missing version', async () => {
|
||||
const prisma = buildPrismaMock();
|
||||
prisma.version.findUnique.mockResolvedValue(null);
|
||||
|
||||
@@ -163,10 +163,11 @@ export class V22QueryService {
|
||||
|
||||
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
|
||||
if (query.manager === 'true') {
|
||||
return this.prisma.xiaobaoRiskSummary.findMany({
|
||||
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
||||
where: { riskLevel: { not: 'on_track' } },
|
||||
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
return this.attachLatestXiaobaoInsights(rows);
|
||||
}
|
||||
|
||||
const userId = query.userId?.trim();
|
||||
@@ -174,13 +175,40 @@ export class V22QueryService {
|
||||
const versionIds = await this.getUserOwnedVersionIds(userId);
|
||||
if (versionIds.length === 0) return [];
|
||||
|
||||
return this.prisma.xiaobaoRiskSummary.findMany({
|
||||
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
||||
where: {
|
||||
versionId: { in: versionIds },
|
||||
riskLevel: { not: 'on_track' },
|
||||
},
|
||||
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
return this.attachLatestXiaobaoInsights(rows);
|
||||
}
|
||||
|
||||
private async attachLatestXiaobaoInsights(rows: any[]) {
|
||||
const versionIds = Array.from(new Set(rows.map((row) => readString(row?.versionId)).filter(isText)));
|
||||
if (versionIds.length === 0) return rows;
|
||||
|
||||
const insights = await this.prisma.xiaobaoRiskInsight.findMany({
|
||||
where: {
|
||||
versionId: { in: versionIds },
|
||||
status: 'generated',
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
});
|
||||
const latestByVersion = new Map<string, any>();
|
||||
for (const insight of insights) {
|
||||
const versionId = readString(insight?.versionId);
|
||||
if (versionId && !latestByVersion.has(versionId)) {
|
||||
latestByVersion.set(versionId, insight);
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const latest = latestByVersion.get(String(row.versionId));
|
||||
const latestInsight = latest ? normalizeXiaobaoInsight(latest) : undefined;
|
||||
return latestInsight ? { ...row, latestInsight } : row;
|
||||
});
|
||||
}
|
||||
|
||||
private async getUserOwnedVersionIds(userId: string): Promise<string[]> {
|
||||
@@ -264,3 +292,52 @@ function parsePriority(raw?: string): number | undefined {
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return Math.max(0, Math.min(4, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
function normalizeXiaobaoInsight(row: any) {
|
||||
const payload = readRecord(row?.insight);
|
||||
const nestedInsight = readRecord(payload.insight);
|
||||
const insight = Object.keys(nestedInsight).length > 0 ? nestedInsight : payload;
|
||||
const versionId = readString(payload.versionId) ?? readString(row?.versionId);
|
||||
const riskSignature = readString(payload.riskSignature) ?? readString(row?.riskSignature);
|
||||
if (!versionId || !riskSignature || Object.keys(insight).length === 0) return undefined;
|
||||
|
||||
return {
|
||||
versionId,
|
||||
riskSignature,
|
||||
insight,
|
||||
generatedAt: readString(payload.generatedAt) ?? toIso(row?.createdAt) ?? new Date(0).toISOString(),
|
||||
providerInfo: readProviderInfo(payload.providerInfo),
|
||||
};
|
||||
}
|
||||
|
||||
function readProviderInfo(value: unknown): { providerId?: string; model?: string } | undefined {
|
||||
const row = readRecord(value);
|
||||
const providerId = readString(row.providerId);
|
||||
const model = readString(row.model);
|
||||
if (!providerId && !model) return undefined;
|
||||
return {
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, any> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : {};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isText(value: string | undefined): value is string {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function toIso(value: unknown): string | undefined {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'string') {
|
||||
const time = new Date(value).getTime();
|
||||
if (Number.isFinite(time)) return new Date(time).toISOString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from '../common/auth/permission.decorator';
|
||||
import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from '../common/audit/audit-mutation.decorator';
|
||||
import { ProductController } from './product/product.controller';
|
||||
import { ProjectController } from './project/project.controller';
|
||||
import { VersionController } from './version/version.controller';
|
||||
import { RequirementController } from './requirement/requirement.controller';
|
||||
import { VersionPlanController } from './version-plan/version-plan.controller';
|
||||
import { DevTaskController } from './dev-task/dev-task.controller';
|
||||
import { TestCaseController } from './test-case/test-case.controller';
|
||||
import { BugController } from './bug/bug.controller';
|
||||
import { MemberController } from './member/member.controller';
|
||||
import { TaskCategoryController } from './task-category/task-category.controller';
|
||||
import { TaskWorklogController } from './task-worklog/task-worklog.controller';
|
||||
import { OvertimeController } from './overtime/overtime.controller';
|
||||
import { WorkActivityController } from './work-activity/work-activity.controller';
|
||||
|
||||
describe('V2.5 domain mutation contracts', () => {
|
||||
const reflector = new Reflector();
|
||||
|
||||
const cases: Array<[Function, string, RequiredPermissionMetadata, AuditMutationMetadata]> = [
|
||||
[ProductController, 'create', { permission: 'product:create' }, { action: 'product.create', entityType: 'product' }],
|
||||
[ProductController, 'update', { permission: 'product:edit', productIdParam: 'id' }, { action: 'product.update', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }],
|
||||
[ProductController, 'remove', { permission: 'product:delete', productIdParam: 'id' }, { action: 'product.delete', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }],
|
||||
[ProjectController, 'create', { permission: 'project:create', productIdParam: 'productId' }, { action: 'project.create', entityType: 'project', productIdParam: 'productId' }],
|
||||
[ProjectController, 'update', { permission: 'project:edit', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.update', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }],
|
||||
[ProjectController, 'remove', { permission: 'project:delete', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.delete', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }],
|
||||
[VersionController, 'create', { permission: 'version:create', productIdParam: 'productId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId' }],
|
||||
[VersionController, 'createForProject', { permission: 'version:create', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId', projectIdParam: 'projectId' }],
|
||||
[VersionController, 'update', { permission: 'version:edit', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.update', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }],
|
||||
[VersionController, 'remove', { permission: 'version:delete', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.delete', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }],
|
||||
[RequirementController, 'create', { permission: 'requirement:create', productIdParam: 'productId' }, { action: 'requirement.create', entityType: 'requirement', productIdParam: 'productId' }],
|
||||
[RequirementController, 'update', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.update', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }],
|
||||
[RequirementController, 'updateStatus', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.status', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }],
|
||||
[RequirementController, 'remove', { permission: 'requirement:delete', productIdParam: 'productId' }, { action: 'requirement.delete', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }],
|
||||
[VersionPlanController, 'create', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.create', entityType: 'version_plan', versionIdParam: 'versionId' }],
|
||||
[VersionPlanController, 'update', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.update', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[VersionPlanController, 'complete', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.complete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[VersionPlanController, 'remove', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.delete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[DevTaskController, 'create', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.create', entityType: 'dev_task', versionIdParam: 'versionId' }],
|
||||
[DevTaskController, 'update', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.update', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[DevTaskController, 'updateStatus', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.status', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[DevTaskController, 'setBlocked', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.block', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[DevTaskController, 'transfer', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.transfer', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[DevTaskController, 'remove', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.delete', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[TestCaseController, 'create', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.create', entityType: 'test_case', versionIdParam: 'versionId' }],
|
||||
[TestCaseController, 'createMany', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.batch_create', entityType: 'test_case', versionIdParam: 'versionId' }],
|
||||
[TestCaseController, 'update', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.update', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[TestCaseController, 'updateStatus', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.status', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[TestCaseController, 'remove', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.delete', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[BugController, 'create', { permission: 'version.bug:create', versionIdParam: 'versionId' }, { action: 'bug.create', entityType: 'bug', versionIdParam: 'versionId' }],
|
||||
[BugController, 'update', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.update', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[BugController, 'updateStatus', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.status', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[BugController, 'transfer', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.transfer', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[BugController, 'remove', { permission: 'version.bug:delete', versionIdParam: 'versionId' }, { action: 'bug.delete', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }],
|
||||
[MemberController, 'create', { permission: 'member:create' }, { action: 'member.create', entityType: 'member' }],
|
||||
[MemberController, 'update', { permission: 'member:edit' }, { action: 'member.update', entityType: 'member', entityIdParam: 'id' }],
|
||||
[MemberController, 'remove', { permission: 'member:delete' }, { action: 'member.delete', entityType: 'member', entityIdParam: 'id' }],
|
||||
[TaskCategoryController, 'create', { permission: 'task-category:manage' }, { action: 'task_category.create', entityType: 'task_category' }],
|
||||
[TaskCategoryController, 'update', { permission: 'task-category:manage' }, { action: 'task_category.update', entityType: 'task_category', entityIdParam: 'id' }],
|
||||
[TaskCategoryController, 'remove', { permission: 'task-category:manage' }, { action: 'task_category.delete', entityType: 'task_category', entityIdParam: 'id' }],
|
||||
[TaskWorklogController, 'create', { permission: 'version.devtask:manage', versionIdBody: 'versionId' }, { action: 'task_worklog.create', entityType: 'task_worklog', versionIdBody: 'versionId' }],
|
||||
[TaskWorklogController, 'remove', { permission: 'version.devtask:manage' }, { action: 'task_worklog.delete', entityType: 'task_worklog', entityIdParam: 'id' }],
|
||||
[OvertimeController, 'create', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.create', entityType: 'overtime', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }],
|
||||
[OvertimeController, 'update', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.update', entityType: 'overtime', entityIdParam: 'id', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }],
|
||||
[OvertimeController, 'remove', { permission: 'overtime:delete' }, { action: 'overtime.delete', entityType: 'overtime', entityIdParam: 'id' }],
|
||||
[WorkActivityController, 'create', { permission: 'work-activity:manage', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'work_activity.create', entityType: 'work_activity', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }],
|
||||
[WorkActivityController, 'remove', { permission: 'work-activity:manage' }, { action: 'work_activity.delete', entityType: 'work_activity', entityIdParam: 'id' }],
|
||||
];
|
||||
|
||||
it.each(cases)('%p.%s declares server permission and audit metadata', (controller, methodName, permission, audit) => {
|
||||
const handler = controller.prototype[methodName];
|
||||
|
||||
expect(reflector.get(PERMISSION_METADATA_KEY, handler)).toEqual(permission);
|
||||
expect(reflector.get(AUDIT_MUTATION_METADATA_KEY, handler)).toEqual(audit);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
||||
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
||||
import { VersionPlanService } from './version-plan.service';
|
||||
@@ -8,6 +9,11 @@ export class VersionPlanController {
|
||||
constructor(private readonly versionPlanService: VersionPlanService) {}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
|
||||
action: 'version_plan.create',
|
||||
entityType: 'version_plan',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
create(@Param('versionId') versionId: string, @Body() dto: CreateVersionPlanDto) {
|
||||
return this.versionPlanService.create(versionId, dto);
|
||||
}
|
||||
@@ -18,16 +24,34 @@ export class VersionPlanController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
|
||||
action: 'version_plan.update',
|
||||
entityType: 'version_plan',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) {
|
||||
return this.versionPlanService.update(versionId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
|
||||
action: 'version_plan.complete',
|
||||
entityType: 'version_plan',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
complete(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) {
|
||||
return this.versionPlanService.complete(versionId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
|
||||
action: 'version_plan.delete',
|
||||
entityType: 'version_plan',
|
||||
entityIdParam: 'id',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||
return this.versionPlanService.remove(versionId, id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { VersionService } from './version.service';
|
||||
import { CreateVersionDto } from './dto/create-version.dto';
|
||||
import { UpdateVersionDto } from './dto/update-version.dto';
|
||||
@@ -8,6 +9,11 @@ export class VersionController {
|
||||
constructor(private readonly versionService: VersionService) {}
|
||||
|
||||
@Post('versions')
|
||||
@ProtectedMutation('version:create', { productIdParam: 'productId' }, {
|
||||
action: 'version.create',
|
||||
entityType: 'version',
|
||||
productIdParam: 'productId',
|
||||
})
|
||||
create(@Param('productId') productId: string, @Body() dto: CreateVersionDto) {
|
||||
return this.versionService.create(productId, dto);
|
||||
}
|
||||
@@ -18,6 +24,12 @@ export class VersionController {
|
||||
}
|
||||
|
||||
@Post('projects/:projectId/versions')
|
||||
@ProtectedMutation('version:create', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
|
||||
action: 'version.create',
|
||||
entityType: 'version',
|
||||
productIdParam: 'productId',
|
||||
projectIdParam: 'projectId',
|
||||
})
|
||||
createForProject(
|
||||
@Param('productId') productId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@@ -32,6 +44,13 @@ export class VersionController {
|
||||
}
|
||||
|
||||
@Patch('versions/:versionId')
|
||||
@ProtectedMutation('version:edit', { productIdParam: 'productId', versionIdParam: 'versionId' }, {
|
||||
action: 'version.update',
|
||||
entityType: 'version',
|
||||
entityIdParam: 'versionId',
|
||||
productIdParam: 'productId',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
update(
|
||||
@Param('productId') productId: string,
|
||||
@Param('versionId') versionId: string,
|
||||
@@ -41,6 +60,13 @@ export class VersionController {
|
||||
}
|
||||
|
||||
@Delete('versions/:versionId')
|
||||
@ProtectedMutation('version:delete', { productIdParam: 'productId', versionIdParam: 'versionId' }, {
|
||||
action: 'version.delete',
|
||||
entityType: 'version',
|
||||
entityIdParam: 'versionId',
|
||||
productIdParam: 'productId',
|
||||
versionIdParam: 'versionId',
|
||||
})
|
||||
remove(@Param('productId') productId: string, @Param('versionId') versionId: string) {
|
||||
return this.versionService.remove(productId, versionId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
||||
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||
import { WorkActivityRecordInput, WorkActivityService } from './work-activity.service';
|
||||
|
||||
@Controller('work-activities')
|
||||
@@ -11,11 +12,23 @@ export class WorkActivityController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ProtectedMutation('work-activity:manage', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, {
|
||||
action: 'work_activity.create',
|
||||
entityType: 'work_activity',
|
||||
productIdBody: 'productId',
|
||||
projectIdBody: 'projectId',
|
||||
versionIdBody: 'versionId',
|
||||
})
|
||||
create(@Body() dto: WorkActivityRecordInput) {
|
||||
return this.workActivityService.record(dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ProtectedMutation('work-activity:manage', {}, {
|
||||
action: 'work_activity.delete',
|
||||
entityType: 'work_activity',
|
||||
entityIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.workActivityService.remove(id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { recordSlowPrismaQuery } from '../modules/ops/ops-runtime.store';
|
||||
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,6 +17,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
||||
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
||||
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
|
||||
recordSlowPrismaQuery({
|
||||
query: event.query,
|
||||
durationMs: event.duration,
|
||||
thresholdMs: this.slowQueryThresholdMs,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
166
apps/web/app/admin/audit/page.tsx
Normal file
166
apps/web/app/admin/audit/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Clock3, RefreshCw, Search, ShieldCheck } from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { type AuditEvent, type AuditQuery, listAuditEvents } from '@/lib/audit-api';
|
||||
|
||||
const EMPTY_QUERY: AuditQuery = { take: '50' };
|
||||
|
||||
export default function AuditPage() {
|
||||
return (
|
||||
<RouteGuard permission="audit:view">
|
||||
<AuditPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditPageContent() {
|
||||
const [query, setQuery] = useState<AuditQuery>(EMPTY_QUERY);
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setEvents(await listAuditEvents(query));
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? '读取审计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void fetchEvents(); }, []);
|
||||
|
||||
const entityTypes = useMemo(
|
||||
() => Array.from(new Set(events.map((event) => event.entityType))).sort(),
|
||||
[events],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldCheck className="h-4 w-4 text-[var(--accent)]" strokeWidth={2} />
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">审计事件</h1>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||
{events.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void fetchEvents()}
|
||||
disabled={loading}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
<section className="mb-3 grid gap-2 border-b border-[var(--line)] pb-3 lg:grid-cols-[repeat(7,minmax(0,1fr))_auto]">
|
||||
<FilterInput label="操作人" value={query.actorId} onChange={(actorId) => setQuery((prev) => ({ ...prev, actorId }))} />
|
||||
<FilterInput label="实体类型" value={query.entityType} list="audit-entity-types" onChange={(entityType) => setQuery((prev) => ({ ...prev, entityType }))} />
|
||||
<FilterInput label="实体 ID" value={query.entityId} onChange={(entityId) => setQuery((prev) => ({ ...prev, entityId }))} />
|
||||
<FilterInput label="产品 ID" value={query.productId} onChange={(productId) => setQuery((prev) => ({ ...prev, productId }))} />
|
||||
<FilterInput label="项目 ID" value={query.projectId} onChange={(projectId) => setQuery((prev) => ({ ...prev, projectId }))} />
|
||||
<FilterInput label="版本 ID" value={query.versionId} onChange={(versionId) => setQuery((prev) => ({ ...prev, versionId }))} />
|
||||
<FilterInput label="数量" value={query.take} onChange={(take) => setQuery((prev) => ({ ...prev, take }))} />
|
||||
<button
|
||||
onClick={() => void fetchEvents()}
|
||||
className="mt-5 flex h-8 items-center justify-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[12px] font-medium text-white hover:bg-[var(--accent-hover)]"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
查询
|
||||
</button>
|
||||
<datalist id="audit-entity-types">
|
||||
{entityTypes.map((type) => <option key={type} value={type} />)}
|
||||
</datalist>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<table className="w-full table-fixed text-left text-[12px]">
|
||||
<thead className="bg-[var(--bg-subtle)] text-[11px] uppercase text-[var(--ink-muted)]">
|
||||
<tr>
|
||||
<th className="w-40 px-3 py-2 font-semibold">时间</th>
|
||||
<th className="w-36 px-3 py-2 font-semibold">操作</th>
|
||||
<th className="w-32 px-3 py-2 font-semibold">实体</th>
|
||||
<th className="w-32 px-3 py-2 font-semibold">操作人</th>
|
||||
<th className="px-3 py-2 font-semibold">作用域</th>
|
||||
<th className="w-32 px-3 py-2 font-semibold">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--line)]">
|
||||
{events.map((event) => (
|
||||
<tr key={`${event.id}-${event.createdAt}`} className="hover:bg-[var(--bg-subtle)]/70">
|
||||
<td className="px-3 py-2 text-[var(--ink-soft)]"><TimeLabel value={event.createdAt} /></td>
|
||||
<td className="truncate px-3 py-2 font-medium text-[var(--ink)]">{event.action}</td>
|
||||
<td className="truncate px-3 py-2 text-[var(--ink-soft)]">{event.entityType}<span className="ml-1 text-[var(--ink-muted)]">{event.entityId}</span></td>
|
||||
<td className="truncate px-3 py-2 text-[var(--ink-soft)]">{event.actorName || event.actorId || '-'}</td>
|
||||
<td className="truncate px-3 py-2 text-[var(--ink-soft)]">{formatScope(event)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<details>
|
||||
<summary className="cursor-pointer text-[var(--accent)]">查看 JSON</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-zinc-950 p-2 text-[10px] leading-4 text-zinc-100">{JSON.stringify({ before: event.before, after: event.after, metadata: event.metadata }, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-10 text-center text-[13px] text-[var(--ink-muted)]">
|
||||
{loading ? '加载中...' : '暂无审计事件'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterInput({ label, value, onChange, list }: { label: string; value?: string; onChange: (value: string) => void; list?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] font-medium text-[var(--ink-muted)]">{label}</span>
|
||||
<input
|
||||
value={value ?? ''}
|
||||
list={list}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-8 w-full rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[12px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeLabel({ value }: { value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock3 className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||
{formatDateTime(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatScope(event: AuditEvent) {
|
||||
return [
|
||||
event.productId ? `P:${event.productId}` : '',
|
||||
event.projectId ? `J:${event.projectId}` : '',
|
||||
event.versionId ? `V:${event.versionId}` : '',
|
||||
].filter(Boolean).join(' / ') || '-';
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return value;
|
||||
return date.toISOString().slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
164
apps/web/app/admin/consistency/page.tsx
Normal file
164
apps/web/app/admin/consistency/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Database, RefreshCw } from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import {
|
||||
type ConsistencyCheckResult,
|
||||
type ConsistencyResult,
|
||||
getConsistencyReport,
|
||||
summarizeConsistencyResult,
|
||||
} from '@/lib/consistency-api';
|
||||
|
||||
export default function ConsistencyPage() {
|
||||
return (
|
||||
<RouteGuard permission="consistency:view">
|
||||
<ConsistencyPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsistencyPageContent() {
|
||||
const [report, setReport] = useState<ConsistencyResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchReport = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setReport(await getConsistencyReport());
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? '读取一致性报告失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void fetchReport(); }, []);
|
||||
|
||||
const totals = useMemo(() => report ? summarizeConsistencyResult(report) : null, [report]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Database className="h-4 w-4 text-[var(--accent)]" strokeWidth={2} />
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">一致性校验</h1>
|
||||
{report && <StatusPill status={report.status} />}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void fetchReport()}
|
||||
disabled={loading}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
重新校验
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{error && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{report && totals && (
|
||||
<>
|
||||
<section className="mb-4 grid gap-3 md:grid-cols-4">
|
||||
<Metric label="错误" value={totals.error} tone="error" />
|
||||
<Metric label="告警" value={totals.warn} tone="warn" />
|
||||
<Metric label="通过" value={totals.ok} tone="ok" />
|
||||
<Metric label="检查项" value={totals.total} />
|
||||
</section>
|
||||
|
||||
<section className="mb-4 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="border-b border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2 text-[12px] font-semibold text-[var(--ink)]">
|
||||
数据计数
|
||||
</div>
|
||||
<div className="grid gap-px bg-[var(--line)] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Object.entries(report.counts).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between bg-[var(--bg-card)] px-3 py-2">
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">{key}</span>
|
||||
<span className="font-mono text-[12px] font-semibold tabular-nums text-[var(--ink)]">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CheckGroup title="分区键" checks={report.checks.partitionKeys} />
|
||||
<CheckGroup title="孤儿引用" checks={report.checks.orphanReferences} />
|
||||
<CheckGroup title="审计覆盖" checks={report.checks.auditCoverage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!report && !error && (
|
||||
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-12 text-center text-[13px] text-[var(--ink-muted)]">
|
||||
{loading ? '校验中...' : '暂无校验结果'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }: { label: string; value: number; tone?: 'ok' | 'warn' | 'error' }) {
|
||||
const toneClass = tone === 'error'
|
||||
? 'text-red-600'
|
||||
: tone === 'warn'
|
||||
? 'text-amber-600'
|
||||
: tone === 'ok'
|
||||
? 'text-emerald-600'
|
||||
: 'text-[var(--ink)]';
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3">
|
||||
<div className={`font-mono text-[20px] font-semibold tabular-nums ${toneClass}`}>{value}</div>
|
||||
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckGroup({ title, checks }: { title: string; checks: ConsistencyCheckResult[] }) {
|
||||
return (
|
||||
<section className="mb-4 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="flex items-center justify-between border-b border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2">
|
||||
<h2 className="text-[12px] font-semibold text-[var(--ink)]">{title}</h2>
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{checks.length}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{checks.map((check) => (
|
||||
<div key={check.id} className="grid grid-cols-[120px_1fr_80px] items-center gap-3 px-3 py-2 text-[12px]">
|
||||
<SeverityBadge severity={check.severity} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-[var(--ink)]">{check.id}</div>
|
||||
<div className="truncate text-[var(--ink-muted)]">{check.message}</div>
|
||||
</div>
|
||||
<div className="text-right font-mono text-[12px] tabular-nums text-[var(--ink-soft)]">{check.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: ConsistencyResult['status'] }) {
|
||||
const ok = status === 'pass';
|
||||
return (
|
||||
<span className={`inline-flex h-6 items-center gap-1 rounded-md px-2 text-[11px] font-semibold ${ok ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{ok ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertTriangle className="h-3.5 w-3.5" />}
|
||||
{ok ? 'PASS' : 'FAIL'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: ConsistencyCheckResult['severity'] }) {
|
||||
const className = severity === 'error'
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: severity === 'warn'
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
: 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
||||
return (
|
||||
<span className={`inline-flex h-6 w-20 items-center justify-center rounded-md border text-[11px] font-semibold uppercase ${className}`}>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
391
apps/web/app/admin/ops/page.tsx
Normal file
391
apps/web/app/admin/ops/page.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Database,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ServerCog,
|
||||
} from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
|
||||
interface OpsRuntimeSnapshot {
|
||||
collectedAt: string;
|
||||
thresholds: {
|
||||
apiSlowRequestMs: number;
|
||||
prismaSlowQueryMs: number;
|
||||
};
|
||||
database: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
slowRequests: Array<{
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
slowQueries: Array<{
|
||||
id: string;
|
||||
queryPreview: string;
|
||||
durationMs: number;
|
||||
thresholdMs: number;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
jobQueue: {
|
||||
totals: Record<JobStatus, number> & { total: number };
|
||||
byType: Array<Record<JobStatus, number> & {
|
||||
type: string;
|
||||
total: number;
|
||||
oldestQueuedAt?: string;
|
||||
nextLeaseExpiresAt?: string;
|
||||
}>;
|
||||
recentFailures: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
lastError: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
};
|
||||
dirtySummaryCount: number;
|
||||
access: {
|
||||
requiredPermission: string;
|
||||
backendEnforced: boolean;
|
||||
adapter: string;
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<JobStatus, string> = {
|
||||
queued: '排队',
|
||||
running: '运行',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
export default function OpsPage() {
|
||||
return (
|
||||
<RouteGuard permission="ops:view">
|
||||
<OpsPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function OpsPageContent() {
|
||||
const [snapshot, setSnapshot] = useState<OpsRuntimeSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSnapshot = async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
try {
|
||||
const next = await api.get<OpsRuntimeSnapshot>('/ops/runtime');
|
||||
setSnapshot(next);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '读取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSnapshot(true);
|
||||
const timer = window.setInterval(() => { void fetchSnapshot(); }, 30_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Activity className="h-4 w-4 text-blue-600" strokeWidth={2} />
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">运维看板</h1>
|
||||
{snapshot && (
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||
{formatClock(snapshot.collectedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fetchSnapshot()}
|
||||
disabled={refreshing}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{refreshing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
刷新
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">{error}</div>
|
||||
) : snapshot ? (
|
||||
<div className="space-y-4">
|
||||
<OverviewStrip snapshot={snapshot} />
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div className="space-y-4">
|
||||
<SlowRequestsPanel snapshot={snapshot} />
|
||||
<SlowQueriesPanel snapshot={snapshot} />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<JobQueuePanel snapshot={snapshot} />
|
||||
<FailuresPanel snapshot={snapshot} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewStrip({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
const cards = [
|
||||
{
|
||||
label: '慢请求',
|
||||
value: snapshot.slowRequests.length,
|
||||
sub: `阈值 ${snapshot.thresholds.apiSlowRequestMs}ms`,
|
||||
icon: Clock3,
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
label: '慢查询',
|
||||
value: snapshot.slowQueries.length,
|
||||
sub: `阈值 ${snapshot.thresholds.prismaSlowQueryMs}ms`,
|
||||
icon: Database,
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: '后台任务',
|
||||
value: snapshot.jobQueue.totals.total,
|
||||
sub: `${snapshot.jobQueue.totals.queued} 排队 / ${snapshot.jobQueue.totals.running} 运行`,
|
||||
icon: ServerCog,
|
||||
tone: 'zinc',
|
||||
},
|
||||
{
|
||||
label: '脏 Summary',
|
||||
value: snapshot.dirtySummaryCount,
|
||||
sub: 'xiaobao_risk_summaries',
|
||||
icon: AlertTriangle,
|
||||
tone: snapshot.dirtySummaryCount > 0 ? 'red' : 'emerald',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<section key={card.label} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 shadow-[var(--shadow-sm)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium text-[var(--ink-muted)]">{card.label}</p>
|
||||
<p className="mt-1 text-[24px] font-semibold leading-none tabular-nums text-[var(--ink)]">{card.value}</p>
|
||||
<p className="mt-1 truncate text-[11px] text-[var(--ink-soft)]">{card.sub}</p>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${toneClass(card.tone)}`}>
|
||||
<Icon className="h-4 w-4" strokeWidth={2} />
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlowRequestsPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="慢请求" right={`${snapshot.slowRequests.length}`} />
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{snapshot.slowRequests.length === 0 ? (
|
||||
<EmptyRow label="暂无慢请求" />
|
||||
) : snapshot.slowRequests.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[76px_minmax(0,1fr)_92px_96px] items-center gap-3 px-4 py-2.5 text-[12px]">
|
||||
<span className="font-mono font-medium text-blue-700">{item.method}</span>
|
||||
<span className="truncate font-mono text-[var(--ink)]">{item.path}</span>
|
||||
<span className="text-right font-mono tabular-nums text-red-600">{item.durationMs}ms</span>
|
||||
<span className="text-right text-[var(--ink-muted)]">{formatClock(item.occurredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SlowQueriesPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="慢查询" right={`${snapshot.slowQueries.length}`} />
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{snapshot.slowQueries.length === 0 ? (
|
||||
<EmptyRow label="暂无慢查询" />
|
||||
) : snapshot.slowQueries.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[minmax(0,1fr)_92px_96px] items-start gap-3 px-4 py-2.5 text-[12px]">
|
||||
<code className="min-w-0 break-words font-mono text-[11px] leading-5 text-[var(--ink)]">{item.queryPreview}</code>
|
||||
<span className="text-right font-mono tabular-nums text-amber-700">{item.durationMs}ms</span>
|
||||
<span className="text-right text-[var(--ink-muted)]">{formatClock(item.occurredAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function JobQueuePanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
const rows = snapshot.jobQueue.byType;
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="后台任务" right={`${snapshot.jobQueue.totals.total}`} />
|
||||
<div className="border-b border-[var(--line)] px-4 py-3">
|
||||
<StatusBars totals={snapshot.jobQueue.totals} />
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{rows.length === 0 ? (
|
||||
<EmptyRow label="暂无任务" />
|
||||
) : rows.map((row) => (
|
||||
<div key={row.type} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate font-mono text-[12px] font-medium text-[var(--ink)]">{row.type}</span>
|
||||
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-muted)]">{row.total}</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1.5">
|
||||
{(['queued', 'running', 'succeeded', 'failed'] as JobStatus[]).map((status) => (
|
||||
<StatusPill key={status} status={status} count={row[status]} />
|
||||
))}
|
||||
</div>
|
||||
{(row.oldestQueuedAt || row.nextLeaseExpiresAt) && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-[var(--ink-muted)]">
|
||||
{row.oldestQueuedAt && <span>最早排队 {formatClock(row.oldestQueuedAt)}</span>}
|
||||
{row.nextLeaseExpiresAt && <span>Lease {formatClock(row.nextLeaseExpiresAt)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FailuresPanel({ snapshot }: { snapshot: OpsRuntimeSnapshot }) {
|
||||
const failures = snapshot.jobQueue.recentFailures;
|
||||
const dbOk = snapshot.database.ok;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<PanelHeader title="运行状态" right={dbOk ? 'OK' : 'DB'} />
|
||||
<div className="border-b border-[var(--line)] px-4 py-3">
|
||||
<div className={`flex items-center gap-2 rounded-md border px-3 py-2 text-[12px] ${dbOk ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
|
||||
{dbOk ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
|
||||
<span className="min-w-0 truncate">{dbOk ? '数据库可读' : snapshot.database.error || '数据库不可读'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{failures.length === 0 ? (
|
||||
<EmptyRow label="暂无失败任务" />
|
||||
) : failures.map((item) => (
|
||||
<div key={item.id} className="px-4 py-3 text-[12px]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate font-mono font-medium text-[var(--ink)]">{item.type}</span>
|
||||
<span className="shrink-0 font-mono tabular-nums text-red-600">{item.attempts}/{item.maxAttempts}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-5 text-[var(--ink-soft)]">{item.lastError || '-'}</p>
|
||||
{item.updatedAt && <p className="mt-1 text-[11px] text-[var(--ink-muted)]">{formatClock(item.updatedAt)}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBars({ totals }: { totals: OpsRuntimeSnapshot['jobQueue']['totals'] }) {
|
||||
const statuses: JobStatus[] = ['queued', 'running', 'succeeded', 'failed'];
|
||||
const total = Math.max(1, totals.total);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
|
||||
{statuses.map((status) => (
|
||||
<span
|
||||
key={status}
|
||||
className={statusBarClass(status)}
|
||||
style={{ width: `${(totals[status] / total) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{statuses.map((status) => <StatusPill key={status} status={status} count={totals[status]} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status, count }: { status: JobStatus; count: number }) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-1 rounded-md px-2 py-1 text-[11px] ${statusPillClass(status)}`}>
|
||||
<span>{STATUS_LABEL[status]}</span>
|
||||
<span className="font-mono tabular-nums">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ title, right }: { title: string; right: string }) {
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--line)] px-4">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--ink)]">{title}</h2>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{right}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ label }: { label: string }) {
|
||||
return <div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">{label}</div>;
|
||||
}
|
||||
|
||||
function formatClock(value?: string) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return '-';
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function toneClass(tone: string) {
|
||||
if (tone === 'blue') return 'bg-blue-50 text-blue-700';
|
||||
if (tone === 'amber') return 'bg-amber-50 text-amber-700';
|
||||
if (tone === 'red') return 'bg-red-50 text-red-700';
|
||||
if (tone === 'emerald') return 'bg-emerald-50 text-emerald-700';
|
||||
return 'bg-zinc-100 text-zinc-700';
|
||||
}
|
||||
|
||||
function statusBarClass(status: JobStatus) {
|
||||
if (status === 'queued') return 'bg-blue-500';
|
||||
if (status === 'running') return 'bg-amber-500';
|
||||
if (status === 'succeeded') return 'bg-emerald-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function statusPillClass(status: JobStatus) {
|
||||
if (status === 'queued') return 'bg-blue-50 text-blue-700';
|
||||
if (status === 'running') return 'bg-amber-50 text-amber-700';
|
||||
if (status === 'succeeded') return 'bg-emerald-50 text-emerald-700';
|
||||
return 'bg-red-50 text-red-700';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user