Compare commits

..

32 Commits

Author SHA1 Message Date
32aaf53b26 docs(workflow): 明确 GitHub 优先推送规则
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
2026-07-08 20:13:53 +08:00
e949d0f5d3 feat(ai): complete v3.2 risk watch agent 2026-07-08 19:57:38 +08:00
deebc5404a fix(权限): 修复版本详情写入权限校验 2026-07-08 19:27:41 +08:00
cafa0f134a chore(deploy): 增强生产部署校验与本地忽略规则 2026-07-08 18:31:49 +08:00
c5b086f88a test(deploy): 适配V2.8发布smoke验证 2026-07-08 18:21:58 +08:00
a845792369 merge: 集成V2.8 生产硬化与运维闭环
# Conflicts:
#	.gitignore
#	docs/deployment.md
#	docs/roadmap.md
#	package.json
2026-07-08 18:15:07 +08:00
d18bbfa14b merge: 集成V2.7 企业协作与管理治理
# Conflicts:
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	apps/web/lib/permissions.ts
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
2026-07-08 18:10:08 +08:00
7677fd0d71 merge: 集成V2.6 性能增强与小宝后台化
# Conflicts:
#	apps/server/prisma/schema.prisma
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
2026-07-08 18:04:39 +08:00
82e4b20b93 merge: 集成V2.5 AppData退场与权限审计 2026-07-08 17:58:38 +08:00
15653b5b35 feat(ops): 增加运行时性能看板 2026-07-08 17:47:36 +08:00
f766eb86cf docs(plan): 拆解V2.5到V2.8并行任务 2026-07-08 17:17:37 +08:00
e46757487b fix(auth): 允许版本工作动态证据写入 2026-07-08 17:14:54 +08:00
a1007fd33d docs(v25): 更新退场权限审计收口说明 2026-07-08 17:10:10 +08:00
988d659fcc feat(admin): 增加审计与一致性页面 2026-07-08 17:05:16 +08:00
ea0b631a83 feat(consistency): 增加V2.5一致性校验 2026-07-08 16:55:06 +08:00
f69ec83193 feat(v2.7): 收口治理权限适配器 2026-07-08 16:44:57 +08:00
869b1c1060 feat(appdata): 增加归档导出校验脚本 2026-07-08 16:42:40 +08:00
36202028d2 feat(appdata): 冻结兼容写入入口 2026-07-08 16:36:16 +08:00
9ba9449c1a feat(v2.7): 接入协作治理前端体验 2026-07-08 16:31:06 +08:00
18380edda8 feat(audit): 收口领域写接口权限审计 2026-07-08 16:29:22 +08:00
72a59f125c docs(ops): 补齐生产 runbook 和 readiness 清单
- 新增迁移回滚、AppData 退场、小宝后台任务 runbook\n- 新增生产 readiness 证据清单和 runbook placeholder 扫描\n- 更新部署文档与路线图到 V2.8 运维闭环阶段\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
2026-07-08 16:28:02 +08:00
7837a809ca feat(ops): 添加生产监控告警基线
- 新增 Prometheus/Grafana/Loki/Promtail 监控 profile\n- 覆盖 DB、磁盘、慢 API、慢 Prisma、任务失败和小宝摘要 stale 告警\n- 补充 postgres-exporter 自定义查询、Dashboard、部署文档和校验\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
2026-07-08 16:21:07 +08:00
bcbe84bb6e feat(v2.7): 增加管理驾驶舱与治理字典服务 2026-07-08 16:19:29 +08:00
73e8dfa8c8 feat(audit): 增加审计事件基础模块 2026-07-08 16:17:36 +08:00
0f57e75689 feat(v2.7): 增加项目成员治理接口 2026-07-08 16:15:52 +08:00
eef09d5af4 feat(ops): 添加发布 smoke test
- 新增只读发布 smoke runner 和 dry-run 测试\n- 将生产部署 workflow 从单点版本检查升级为 smoke test\n- 补充 Docker web runtime 脚本复制、package script 和部署文档\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
2026-07-08 16:14:48 +08:00
64f49c512f feat(auth): 增加服务端权限上下文 2026-07-08 16:13:55 +08:00
95523cd4a1 feat(v2.7): 增加通知与评论协作模块 2026-07-08 16:13:33 +08:00
2161970543 feat(ops): 添加生产备份恢复脚本
- 新增 PostgreSQL 备份、fresh DB 恢复和 server_data volume 备份脚本\n- 恢复默认拒绝覆盖,必须显式 --confirm-overwrite\n- 补充 package scripts、deploy verify 校验和部署文档\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
2026-07-08 16:10:15 +08:00
ad36ffda17 feat(v2.7): 建立协作治理基础合同 2026-07-08 16:09:45 +08:00
8043fcf293 docs(路线图): 合并V2.5阶段基线 2026-07-08 16:07:39 +08:00
27cc1badc7 docs(roadmap): 明确V2关系化阶段链路 2026-07-08 11:58:09 +08:00
173 changed files with 10559 additions and 123 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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/

View File

@@ -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

View File

@@ -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");

View File

@@ -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);

View File

@@ -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")
} }
@@ -170,30 +169,100 @@ 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")
authorId String @map("author_id") entityId String @map("entity_id")
content String entityVersionId String? @map("entity_version_id")
createdAt DateTime @default(now()) @map("created_at") productId String? @map("product_id")
projectId String? @map("project_id")
task Task @relation(fields: [taskId], references: [id]) versionId String? @map("version_id")
authorId String @map("author_id")
content String
mentionedMemberIds Json @default("[]") @map("mentioned_member_ids")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
@@index([authorId, createdAt], name: "comments_author_created_idx")
@@map("comments") @@map("comments")
} }
model ProjectMember { model ProjectMember {
id String @id @default(cuid()) id String @id @default(cuid())
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

View 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/);
});
});

View File

@@ -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: [],

View 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);
}

View File

@@ -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;
}

View 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 };
}

View 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();
});
});

View 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}`);
}
}
}

View 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),
);
}

View 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;
}
}

View 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 {}

View 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;
},
);

View 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 });
}

View 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;
}

View 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;
}

View 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,
};
}

View 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;
});
}

View File

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

View File

@@ -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 });
}), }),
); );
} }

View 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);
});
});

View 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));
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AppDataRetirementService } from './app-data-retirement.service';
@Module({
providers: [AppDataRetirementService],
exports: [AppDataRetirementService],
})
export class AppDataRetirementModule {}

View File

@@ -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',
});
}
});
});

View File

@@ -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,
});
}
}

View 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' });
});
});

View 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);
}
}

View 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 {}

View 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,
});
});
});

View 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;
}

View 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;
}

View File

@@ -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);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();
});
});

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -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,
}),
]),
);
});
});

View 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).`;
}

View File

@@ -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],
}) })

View File

@@ -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[] = [];

View File

@@ -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}`);
} }

View File

@@ -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);
} }

View File

@@ -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[];
}

View 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);
}
}

View 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 {}

View File

@@ -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: '新功能' }],
});
});
});

View 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;
}

View 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) : [],
});
}
}

View 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 {}

View File

@@ -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();
});
});

View 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));
}

View File

@@ -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);
} }

View File

@@ -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>;
}

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View 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 };
}
}

View 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');
});
});

View 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;
}

View 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();
});
});

View 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();
}
}

View 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 {}

View 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',
});
});
});

View 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;
}

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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;
}

View File

@@ -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 });
}
}

View File

@@ -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 {}

View File

@@ -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' }),
}));
});
});

View 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;
}

View File

@@ -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);
} }

View File

@@ -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,

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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,
});
}); });
} }

View 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', ' ');
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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