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_PORT=465
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASS=
|
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
|
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
|
||||||
for attempt in $(seq 1 30); do
|
for attempt in $(seq 1 30); do
|
||||||
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e "
|
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
|
||||||
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
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Runtime version check failed after retries"
|
echo "Release smoke check failed after retries"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,11 +1,28 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
|
# Local environment files. Keep example templates tracked.
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
!apps/**/.env.example
|
||||||
.env.local
|
.env.local
|
||||||
.env.local-server
|
.env.local-server
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
|
# Logs and local diagnostics.
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Local IDE/editor state.
|
||||||
|
.idea/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
.tmp/
|
.tmp/
|
||||||
@@ -15,3 +32,5 @@ next-env.d.ts
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
apps/server/data/
|
apps/server/data/
|
||||||
.worktrees/
|
.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/node_modules ./node_modules
|
||||||
COPY --from=builder /app/packages/shared ./packages/shared
|
COPY --from=builder /app/packages/shared ./packages/shared
|
||||||
COPY --from=builder /app/apps/web ./apps/web
|
COPY --from=builder /app/apps/web ./apps/web
|
||||||
|
COPY scripts ./scripts
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
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])
|
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
|
||||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||||
watchers TaskWatcher[]
|
watchers TaskWatcher[]
|
||||||
comments Comment[]
|
|
||||||
|
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
}
|
}
|
||||||
@@ -171,13 +170,21 @@ model TaskWatcher {
|
|||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String @map("task_id")
|
entityType String @map("entity_type")
|
||||||
|
entityId String @map("entity_id")
|
||||||
|
entityVersionId String? @map("entity_version_id")
|
||||||
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
authorId String @map("author_id")
|
authorId String @map("author_id")
|
||||||
content String
|
content String
|
||||||
|
mentionedMemberIds Json @default("[]") @map("mentioned_member_ids")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
task Task @relation(fields: [taskId], references: [id])
|
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
|
||||||
|
@@index([authorId, createdAt], name: "comments_author_created_idx")
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +193,76 @@ model ProjectMember {
|
|||||||
projectId String @map("project_id")
|
projectId String @map("project_id")
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
role String @default("member")
|
role String @default("member")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@unique([projectId, userId])
|
@@unique([projectId, userId])
|
||||||
|
@@index([userId, role], name: "project_members_user_role_idx")
|
||||||
@@map("project_members")
|
@@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 {
|
model TaskCategory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -423,6 +492,26 @@ model AiLog {
|
|||||||
@@map("ai_logs")
|
@@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 {
|
model BackgroundJob {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
type String
|
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 { Module } from '@nestjs/common';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
||||||
|
import { AuthModule } from './common/auth/auth.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
import { ProductModule } from './modules/product/product.module';
|
import { ProductModule } from './modules/product/product.module';
|
||||||
import { ProjectModule } from './modules/project/project.module';
|
import { ProjectModule } from './modules/project/project.module';
|
||||||
import { RequirementModule } from './modules/requirement/requirement.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 { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
|
import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||||
import { JobsModule } from './modules/jobs/jobs.module';
|
import { JobsModule } from './modules/jobs/jobs.module';
|
||||||
import { XiaobaoModule } from './modules/xiaobao/xiaobao.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
AuditModule,
|
||||||
ProductModule,
|
ProductModule,
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
VersionModule,
|
VersionModule,
|
||||||
@@ -42,9 +53,16 @@ import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
|||||||
DataModule,
|
DataModule,
|
||||||
MigrationModule,
|
MigrationModule,
|
||||||
V22QueryModule,
|
V22QueryModule,
|
||||||
|
ConsistencyModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
XiaobaoModule,
|
XiaobaoModule,
|
||||||
|
OpsModule,
|
||||||
|
NotificationModule,
|
||||||
|
CommentModule,
|
||||||
|
ProjectMemberModule,
|
||||||
|
ManagementModule,
|
||||||
|
GovernanceModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
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 { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
import { finalize, Observable } from 'rxjs';
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
import { recordSlowRequest } from '../../modules/ops/ops-runtime.store';
|
||||||
|
|
||||||
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor {
|
|||||||
const method = request.method ?? 'UNKNOWN';
|
const method = request.method ?? 'UNKNOWN';
|
||||||
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
||||||
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
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 { 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 { CreateBugDto } from './dto/create-bug.dto';
|
||||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||||
import { BugService } from './bug.service';
|
import { BugService } from './bug.service';
|
||||||
@@ -8,6 +9,11 @@ export class BugController {
|
|||||||
constructor(private readonly bugService: BugService) {}
|
constructor(private readonly bugService: BugService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('version.bug:create', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.create',
|
||||||
|
entityType: 'bug',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) {
|
create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) {
|
||||||
return this.bugService.create(versionId, dto);
|
return this.bugService.create(versionId, dto);
|
||||||
}
|
}
|
||||||
@@ -18,11 +24,23 @@ export class BugController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@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) {
|
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateBugDto) {
|
||||||
return this.bugService.update(versionId, id, dto);
|
return this.bugService.update(versionId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
|
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.status',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -33,6 +51,12 @@ export class BugController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/transfer')
|
@Patch(':id/transfer')
|
||||||
|
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.transfer',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
transfer(
|
transfer(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -43,6 +67,12 @@ export class BugController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@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) {
|
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||||
return this.bugService.remove(versionId, id);
|
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 { Module } from '@nestjs/common';
|
||||||
|
import { AppDataRetirementModule } from '../app-data-retirement/app-data-retirement.module';
|
||||||
import { MigrationModule } from '../migration/migration.module';
|
import { MigrationModule } from '../migration/migration.module';
|
||||||
import { DataController } from './data.controller';
|
import { DataController } from './data.controller';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MigrationModule],
|
imports: [MigrationModule, AppDataRetirementModule],
|
||||||
controllers: [DataController],
|
controllers: [DataController],
|
||||||
providers: [DataService],
|
providers: [DataService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ describe('DataService', () => {
|
|||||||
const syncService = {
|
const syncService = {
|
||||||
syncAfterAppDataPut: jest.fn(),
|
syncAfterAppDataPut: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const retirementService = {
|
||||||
|
assertWritable: jest.fn(),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
prisma,
|
prisma,
|
||||||
syncService,
|
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 () => {
|
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 value = [{ id: 'p1', name: 'Product 1' }];
|
||||||
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
||||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||||
@@ -70,9 +74,34 @@ describe('DataService', () => {
|
|||||||
update: { value },
|
update: { value },
|
||||||
create: { key: 'products-overview', value },
|
create: { key: 'products-overview', value },
|
||||||
});
|
});
|
||||||
|
expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview');
|
||||||
expect(syncService.syncAfterAppDataPut).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 () => {
|
it('allows supporting business data keys migrated from browser storage', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
const value: unknown[] = [];
|
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 { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
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 { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
|
||||||
import { isAppDataKey } from './data-keys';
|
import { type AppDataKey, isAppDataKey } from './data-keys';
|
||||||
|
|
||||||
type AppDataRow = {
|
type AppDataRow = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -17,6 +18,8 @@ export class DataService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private readonly appDataSync?: AppDataV23SyncService,
|
private readonly appDataSync?: AppDataV23SyncService,
|
||||||
|
@Optional()
|
||||||
|
private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(key: string) {
|
async get(key: string) {
|
||||||
@@ -27,6 +30,7 @@ export class DataService {
|
|||||||
|
|
||||||
async put(key: string, value: unknown, version?: string | null) {
|
async put(key: string, value: unknown, version?: string | null) {
|
||||||
this.ensureAllowedKey(key);
|
this.ensureAllowedKey(key);
|
||||||
|
this.appDataRetirement.assertWritable(key);
|
||||||
const jsonValue = value as Prisma.InputJsonValue;
|
const jsonValue = value as Prisma.InputJsonValue;
|
||||||
|
|
||||||
if (version === null) {
|
if (version === null) {
|
||||||
@@ -66,7 +70,7 @@ export class DataService {
|
|||||||
return this.toResponseAfterSync(key, row);
|
return this.toResponseAfterSync(key, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAllowedKey(key: string) {
|
private ensureAllowedKey(key: string): asserts key is AppDataKey {
|
||||||
if (!isAppDataKey(key)) {
|
if (!isAppDataKey(key)) {
|
||||||
throw new BadRequestException(`Unsupported data key: ${key}`);
|
throw new BadRequestException(`Unsupported data key: ${key}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
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 { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||||
import { DevTaskService } from './dev-task.service';
|
import { DevTaskService } from './dev-task.service';
|
||||||
@@ -8,6 +9,11 @@ export class DevTaskController {
|
|||||||
constructor(private readonly devTaskService: DevTaskService) {}
|
constructor(private readonly devTaskService: DevTaskService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.create',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) {
|
create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) {
|
||||||
return this.devTaskService.create(versionId, dto);
|
return this.devTaskService.create(versionId, dto);
|
||||||
}
|
}
|
||||||
@@ -18,11 +24,23 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@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) {
|
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) {
|
||||||
return this.devTaskService.update(versionId, id, dto);
|
return this.devTaskService.update(versionId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.status',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -32,6 +50,12 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/block')
|
@Patch(':id/block')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.block',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
setBlocked(
|
setBlocked(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -42,6 +66,12 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/transfer')
|
@Patch(':id/transfer')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.transfer',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
transfer(
|
transfer(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -51,6 +81,12 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@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) {
|
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||||
return this.devTaskService.remove(versionId, id);
|
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 { 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 { CreateMemberDto } from './dto/create-member.dto';
|
||||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||||
import { MemberService } from './member.service';
|
import { MemberService } from './member.service';
|
||||||
@@ -13,16 +14,27 @@ export class MemberController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('member:create', {}, { action: 'member.create', entityType: 'member' })
|
||||||
create(@Body() dto: CreateMemberDto) {
|
create(@Body() dto: CreateMemberDto) {
|
||||||
return this.memberService.create(dto);
|
return this.memberService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('member:edit', {}, {
|
||||||
|
action: 'member.update',
|
||||||
|
entityType: 'member',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
})
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateMemberDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateMemberDto) {
|
||||||
return this.memberService.update(id, dto);
|
return this.memberService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('member:delete', {}, {
|
||||||
|
action: 'member.delete',
|
||||||
|
entityType: 'member',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.memberService.remove(id);
|
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 { 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 { CreateOvertimeDto } from './dto/create-overtime.dto';
|
||||||
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
|
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
|
||||||
import { OvertimeService } from './overtime.service';
|
import { OvertimeService } from './overtime.service';
|
||||||
@@ -13,16 +14,36 @@ export class OvertimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@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) {
|
create(@Body() dto: CreateOvertimeDto) {
|
||||||
return this.overtimeService.create(dto);
|
return this.overtimeService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@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) {
|
update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) {
|
||||||
return this.overtimeService.update(id, dto);
|
return this.overtimeService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('overtime:delete', {}, {
|
||||||
|
action: 'overtime.delete',
|
||||||
|
entityType: 'overtime',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.overtimeService.remove(id);
|
return this.overtimeService.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common';
|
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 { ProductService } from './product.service';
|
||||||
import { CreateProductDto } from './dto/create-product.dto';
|
import { CreateProductDto } from './dto/create-product.dto';
|
||||||
import { UpdateProductDto } from './dto/update-product.dto';
|
import { UpdateProductDto } from './dto/update-product.dto';
|
||||||
@@ -8,6 +9,7 @@ export class ProductController {
|
|||||||
constructor(private readonly productService: ProductService) {}
|
constructor(private readonly productService: ProductService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('product:create', {}, { action: 'product.create', entityType: 'product' })
|
||||||
create(@Body() dto: CreateProductDto) {
|
create(@Body() dto: CreateProductDto) {
|
||||||
return this.productService.create(dto);
|
return this.productService.create(dto);
|
||||||
}
|
}
|
||||||
@@ -28,11 +30,23 @@ export class ProductController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('product:edit', { productIdParam: 'id' }, {
|
||||||
|
action: 'product.update',
|
||||||
|
entityType: 'product',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'id',
|
||||||
|
})
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||||
return this.productService.update(id, dto);
|
return this.productService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('product:delete', { productIdParam: 'id' }, {
|
||||||
|
action: 'product.delete',
|
||||||
|
entityType: 'product',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'id',
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.productService.remove(id);
|
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 { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
|
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||||
import { ProjectService } from './project.service';
|
import { ProjectService } from './project.service';
|
||||||
import { CreateProjectDto } from './dto/create-project.dto';
|
import { CreateProjectDto } from './dto/create-project.dto';
|
||||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||||
@@ -8,6 +9,11 @@ export class ProjectController {
|
|||||||
constructor(private readonly projectService: ProjectService) {}
|
constructor(private readonly projectService: ProjectService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('project:create', { productIdParam: 'productId' }, {
|
||||||
|
action: 'project.create',
|
||||||
|
entityType: 'project',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
create(@Param('productId') productId: string, @Body() dto: CreateProjectDto) {
|
create(@Param('productId') productId: string, @Body() dto: CreateProjectDto) {
|
||||||
return this.projectService.create(productId, dto);
|
return this.projectService.create(productId, dto);
|
||||||
}
|
}
|
||||||
@@ -18,6 +24,13 @@ export class ProjectController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':projectId')
|
@Patch(':projectId')
|
||||||
|
@ProtectedMutation('project:edit', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
|
||||||
|
action: 'project.update',
|
||||||
|
entityType: 'project',
|
||||||
|
entityIdParam: 'projectId',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
projectIdParam: 'projectId',
|
||||||
|
})
|
||||||
update(
|
update(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('projectId') projectId: string,
|
@Param('projectId') projectId: string,
|
||||||
@@ -27,6 +40,13 @@ export class ProjectController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':projectId')
|
@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) {
|
remove(@Param('productId') productId: string, @Param('projectId') projectId: string) {
|
||||||
return this.projectService.remove(productId, projectId);
|
return this.projectService.remove(productId, projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common';
|
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 { RequirementService } from './requirement.service';
|
||||||
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
||||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||||
@@ -9,6 +10,11 @@ export class RequirementController {
|
|||||||
constructor(private readonly requirementService: RequirementService) {}
|
constructor(private readonly requirementService: RequirementService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('requirement:create', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.create',
|
||||||
|
entityType: 'requirement',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
create(
|
create(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Body() dto: CreateRequirementDto,
|
@Body() dto: CreateRequirementDto,
|
||||||
@@ -51,6 +57,12 @@ export class RequirementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.update',
|
||||||
|
entityType: 'requirement',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
update(
|
update(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -60,6 +72,12 @@ export class RequirementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
|
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.status',
|
||||||
|
entityType: 'requirement',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -69,6 +87,12 @@ export class RequirementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('requirement:delete', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.delete',
|
||||||
|
entityType: 'requirement',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
remove(
|
remove(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
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 { CreateTaskCategoryDto } from './dto/create-task-category.dto';
|
||||||
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
|
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
|
||||||
import { TaskCategoryService } from './task-category.service';
|
import { TaskCategoryService } from './task-category.service';
|
||||||
@@ -13,16 +14,27 @@ export class TaskCategoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('task-category:manage', {}, { action: 'task_category.create', entityType: 'task_category' })
|
||||||
create(@Body() dto: CreateTaskCategoryDto) {
|
create(@Body() dto: CreateTaskCategoryDto) {
|
||||||
return this.taskCategoryService.create(dto);
|
return this.taskCategoryService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('task-category:manage', {}, {
|
||||||
|
action: 'task_category.update',
|
||||||
|
entityType: 'task_category',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
})
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) {
|
||||||
return this.taskCategoryService.update(id, dto);
|
return this.taskCategoryService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('task-category:manage', {}, {
|
||||||
|
action: 'task_category.delete',
|
||||||
|
entityType: 'task_category',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.taskCategoryService.remove(id);
|
return this.taskCategoryService.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
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 { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
|
||||||
import { TaskWorklogService } from './task-worklog.service';
|
import { TaskWorklogService } from './task-worklog.service';
|
||||||
|
|
||||||
@@ -12,11 +13,21 @@ export class TaskWorklogController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdBody: 'versionId' }, {
|
||||||
|
action: 'task_worklog.create',
|
||||||
|
entityType: 'task_worklog',
|
||||||
|
versionIdBody: 'versionId',
|
||||||
|
})
|
||||||
create(@Body() dto: CreateTaskWorklogDto) {
|
create(@Body() dto: CreateTaskWorklogDto) {
|
||||||
return this.taskWorklogService.create(dto);
|
return this.taskWorklogService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('version.devtask:manage', {}, {
|
||||||
|
action: 'task_worklog.delete',
|
||||||
|
entityType: 'task_worklog',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.taskWorklogService.remove(id);
|
return this.taskWorklogService.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
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 { CreateTestCaseDto } from './dto/create-test-case.dto';
|
||||||
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
||||||
import { TestCaseService } from './test-case.service';
|
import { TestCaseService } from './test-case.service';
|
||||||
@@ -8,11 +9,21 @@ export class TestCaseController {
|
|||||||
constructor(private readonly testCaseService: TestCaseService) {}
|
constructor(private readonly testCaseService: TestCaseService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'test_case.create',
|
||||||
|
entityType: 'test_case',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
create(@Param('versionId') versionId: string, @Body() dto: CreateTestCaseDto) {
|
create(@Param('versionId') versionId: string, @Body() dto: CreateTestCaseDto) {
|
||||||
return this.testCaseService.create(versionId, dto);
|
return this.testCaseService.create(versionId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('batch')
|
@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[]) {
|
createMany(@Param('versionId') versionId: string, @Body('items') items: CreateTestCaseDto[]) {
|
||||||
return this.testCaseService.createMany(versionId, items ?? []);
|
return this.testCaseService.createMany(versionId, items ?? []);
|
||||||
}
|
}
|
||||||
@@ -23,11 +34,23 @@ export class TestCaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@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) {
|
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateTestCaseDto) {
|
||||||
return this.testCaseService.update(versionId, id, dto);
|
return this.testCaseService.update(versionId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
|
@ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'test_case.status',
|
||||||
|
entityType: 'test_case',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -37,6 +60,12 @@ export class TestCaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@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) {
|
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||||
return this.testCaseService.remove(versionId, id);
|
return this.testCaseService.remove(versionId, id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ function buildPrismaMock() {
|
|||||||
xiaobaoRiskSummary: {
|
xiaobaoRiskSummary: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
},
|
},
|
||||||
|
xiaobaoRiskInsight: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +187,7 @@ describe('V22QueryService', () => {
|
|||||||
prisma.testCase.findMany.mockResolvedValue([{ versionId: 'version-2' }]);
|
prisma.testCase.findMany.mockResolvedValue([{ versionId: 'version-2' }]);
|
||||||
prisma.bug.findMany.mockResolvedValue([{ versionId: 'version-3' }]);
|
prisma.bug.findMany.mockResolvedValue([{ versionId: 'version-3' }]);
|
||||||
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([{ versionId: 'version-2', riskScore: 70 }]);
|
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([{ versionId: 'version-2', riskScore: 70 }]);
|
||||||
|
prisma.xiaobaoRiskInsight.findMany.mockResolvedValue([]);
|
||||||
const service = new V22QueryService(prisma as any);
|
const service = new V22QueryService(prisma as any);
|
||||||
|
|
||||||
const userResult = await service.getXiaobaoWarnings({ userId: 'member-1' });
|
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 () => {
|
it('throws when a version detail query targets a missing version', async () => {
|
||||||
const prisma = buildPrismaMock();
|
const prisma = buildPrismaMock();
|
||||||
prisma.version.findUnique.mockResolvedValue(null);
|
prisma.version.findUnique.mockResolvedValue(null);
|
||||||
|
|||||||
@@ -163,10 +163,11 @@ export class V22QueryService {
|
|||||||
|
|
||||||
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
|
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
|
||||||
if (query.manager === 'true') {
|
if (query.manager === 'true') {
|
||||||
return this.prisma.xiaobaoRiskSummary.findMany({
|
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
||||||
where: { riskLevel: { not: 'on_track' } },
|
where: { riskLevel: { not: 'on_track' } },
|
||||||
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||||||
});
|
});
|
||||||
|
return this.attachLatestXiaobaoInsights(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = query.userId?.trim();
|
const userId = query.userId?.trim();
|
||||||
@@ -174,13 +175,40 @@ export class V22QueryService {
|
|||||||
const versionIds = await this.getUserOwnedVersionIds(userId);
|
const versionIds = await this.getUserOwnedVersionIds(userId);
|
||||||
if (versionIds.length === 0) return [];
|
if (versionIds.length === 0) return [];
|
||||||
|
|
||||||
return this.prisma.xiaobaoRiskSummary.findMany({
|
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
||||||
where: {
|
where: {
|
||||||
versionId: { in: versionIds },
|
versionId: { in: versionIds },
|
||||||
riskLevel: { not: 'on_track' },
|
riskLevel: { not: 'on_track' },
|
||||||
},
|
},
|
||||||
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
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[]> {
|
private async getUserOwnedVersionIds(userId: string): Promise<string[]> {
|
||||||
@@ -264,3 +292,52 @@ function parsePriority(raw?: string): number | undefined {
|
|||||||
if (!Number.isFinite(parsed)) return undefined;
|
if (!Number.isFinite(parsed)) return undefined;
|
||||||
return Math.max(0, Math.min(4, Math.floor(parsed)));
|
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 { 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 { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
||||||
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
||||||
import { VersionPlanService } from './version-plan.service';
|
import { VersionPlanService } from './version-plan.service';
|
||||||
@@ -8,6 +9,11 @@ export class VersionPlanController {
|
|||||||
constructor(private readonly versionPlanService: VersionPlanService) {}
|
constructor(private readonly versionPlanService: VersionPlanService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'version_plan.create',
|
||||||
|
entityType: 'version_plan',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
create(@Param('versionId') versionId: string, @Body() dto: CreateVersionPlanDto) {
|
create(@Param('versionId') versionId: string, @Body() dto: CreateVersionPlanDto) {
|
||||||
return this.versionPlanService.create(versionId, dto);
|
return this.versionPlanService.create(versionId, dto);
|
||||||
}
|
}
|
||||||
@@ -18,16 +24,34 @@ export class VersionPlanController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@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) {
|
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) {
|
||||||
return this.versionPlanService.update(versionId, id, dto);
|
return this.versionPlanService.update(versionId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/complete')
|
@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) {
|
complete(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) {
|
||||||
return this.versionPlanService.complete(versionId, id, dto);
|
return this.versionPlanService.complete(versionId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@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) {
|
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||||
return this.versionPlanService.remove(versionId, id);
|
return this.versionPlanService.remove(versionId, id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
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 { VersionService } from './version.service';
|
||||||
import { CreateVersionDto } from './dto/create-version.dto';
|
import { CreateVersionDto } from './dto/create-version.dto';
|
||||||
import { UpdateVersionDto } from './dto/update-version.dto';
|
import { UpdateVersionDto } from './dto/update-version.dto';
|
||||||
@@ -8,6 +9,11 @@ export class VersionController {
|
|||||||
constructor(private readonly versionService: VersionService) {}
|
constructor(private readonly versionService: VersionService) {}
|
||||||
|
|
||||||
@Post('versions')
|
@Post('versions')
|
||||||
|
@ProtectedMutation('version:create', { productIdParam: 'productId' }, {
|
||||||
|
action: 'version.create',
|
||||||
|
entityType: 'version',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
create(@Param('productId') productId: string, @Body() dto: CreateVersionDto) {
|
create(@Param('productId') productId: string, @Body() dto: CreateVersionDto) {
|
||||||
return this.versionService.create(productId, dto);
|
return this.versionService.create(productId, dto);
|
||||||
}
|
}
|
||||||
@@ -18,6 +24,12 @@ export class VersionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('projects/:projectId/versions')
|
@Post('projects/:projectId/versions')
|
||||||
|
@ProtectedMutation('version:create', { productIdParam: 'productId', projectIdParam: 'projectId' }, {
|
||||||
|
action: 'version.create',
|
||||||
|
entityType: 'version',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
projectIdParam: 'projectId',
|
||||||
|
})
|
||||||
createForProject(
|
createForProject(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('projectId') projectId: string,
|
@Param('projectId') projectId: string,
|
||||||
@@ -32,6 +44,13 @@ export class VersionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('versions/:versionId')
|
@Patch('versions/:versionId')
|
||||||
|
@ProtectedMutation('version:edit', { productIdParam: 'productId', versionIdParam: 'versionId' }, {
|
||||||
|
action: 'version.update',
|
||||||
|
entityType: 'version',
|
||||||
|
entityIdParam: 'versionId',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
update(
|
update(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@@ -41,6 +60,13 @@ export class VersionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('versions/:versionId')
|
@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) {
|
remove(@Param('productId') productId: string, @Param('versionId') versionId: string) {
|
||||||
return this.versionService.remove(productId, versionId);
|
return this.versionService.remove(productId, versionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
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';
|
import { WorkActivityRecordInput, WorkActivityService } from './work-activity.service';
|
||||||
|
|
||||||
@Controller('work-activities')
|
@Controller('work-activities')
|
||||||
@@ -11,11 +12,23 @@ export class WorkActivityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@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) {
|
create(@Body() dto: WorkActivityRecordInput) {
|
||||||
return this.workActivityService.record(dto);
|
return this.workActivityService.record(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('work-activity:manage', {}, {
|
||||||
|
action: 'work_activity.delete',
|
||||||
|
entityType: 'work_activity',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.workActivityService.remove(id);
|
return this.workActivityService.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { recordSlowPrismaQuery } from '../modules/ops/ops-runtime.store';
|
||||||
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,6 +17,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
(this as any).$on('query', (event: { duration: number; query: string }) => {
|
||||||
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
|
||||||
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
|
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