Compare commits
44 Commits
58c98a3a3d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e595c7e72 | ||
|
|
1e6fb0c7aa | ||
|
|
6d2999085e | ||
|
|
c724446bbd | ||
|
|
56c8f59d13 | ||
|
|
7cd18dabac | ||
|
|
889a34cfa1 | ||
|
|
89ee44422b | ||
|
|
04db0000b5 | ||
|
|
b6b7ebf44f | ||
|
|
74c55df59b | ||
|
|
5933b84bf6 | ||
|
|
32aaf53b26 | ||
|
|
e949d0f5d3 | ||
|
|
deebc5404a | ||
|
|
cafa0f134a | ||
|
|
c5b086f88a | ||
|
|
a845792369 | ||
|
|
d18bbfa14b | ||
|
|
7677fd0d71 | ||
|
|
82e4b20b93 | ||
|
|
15653b5b35 | ||
|
|
f766eb86cf | ||
|
|
e46757487b | ||
|
|
a1007fd33d | ||
|
|
988d659fcc | ||
|
|
ea0b631a83 | ||
|
|
f69ec83193 | ||
|
|
869b1c1060 | ||
|
|
36202028d2 | ||
|
|
9ba9449c1a | ||
|
|
18380edda8 | ||
|
|
72a59f125c | ||
|
|
7837a809ca | ||
|
|
bcbe84bb6e | ||
|
|
73e8dfa8c8 | ||
|
|
0f57e75689 | ||
|
|
eef09d5af4 | ||
|
|
64f49c512f | ||
|
|
95523cd4a1 | ||
|
|
2161970543 | ||
|
|
ad36ffda17 | ||
|
|
8043fcf293 | ||
|
|
27cc1badc7 |
@@ -39,3 +39,10 @@ SMTP_HOST=
|
|||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
|
|
||||||
|
# Optional monitoring profile. Do not commit real production passwords.
|
||||||
|
PROMETHEUS_PORT=9090
|
||||||
|
PROMETHEUS_RETENTION=15d
|
||||||
|
GRAFANA_PORT=3002
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=change-me-monitoring-password
|
||||||
|
|||||||
19
.github/workflows/deploy-production.yml
vendored
19
.github/workflows/deploy-production.yml
vendored
@@ -123,26 +123,11 @@ jobs:
|
|||||||
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
|
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
|
||||||
for attempt in $(seq 1 30); do
|
for attempt in $(seq 1 30); do
|
||||||
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e "
|
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node scripts/smoke-test-release.mjs --base-url http://nginx --expected-version "${{ github.sha }}"; then
|
||||||
const expected = process.argv[1];
|
|
||||||
fetch('http://nginx/api/v1/health/version')
|
|
||||||
.then(async (response) => {
|
|
||||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
||||||
const payload = await response.json();
|
|
||||||
if (payload.version !== expected) {
|
|
||||||
throw new Error('Expected ' + expected + ', got ' + payload.version);
|
|
||||||
}
|
|
||||||
console.log('Runtime version verified: ' + payload.version);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(error.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
" "${{ github.sha }}"; then
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Runtime version check failed after retries"
|
echo "Release smoke check failed after retries"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,11 +1,28 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
|
# Local environment files. Keep example templates tracked.
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
!apps/**/.env.example
|
||||||
.env.local
|
.env.local
|
||||||
.env.local-server
|
.env.local-server
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
|
# Logs and local diagnostics.
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Local IDE/editor state.
|
||||||
|
.idea/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
.tmp/
|
.tmp/
|
||||||
@@ -15,3 +32,5 @@ next-env.d.ts
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
apps/server/data/
|
apps/server/data/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
appdata-archive-*.json
|
||||||
|
backups/
|
||||||
|
|||||||
10
.superpowers/sdd/progress.md
Normal file
10
.superpowers/sdd/progress.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Business Analysis Agent implementation progress
|
||||||
|
|
||||||
|
Task 1: complete (commit b6b7ebf, shared contracts)
|
||||||
|
Task 2: complete (thread 019f41cd-e0f5-7e21-b8b3-f5d1c5d398c2, source commit 773505b, integrated commit 04db000)
|
||||||
|
Task 3: complete (thread 019f41cd-e105-7073-9fb6-867f3b37364e, source commit a6ef8e3, integrated commit 889a34c)
|
||||||
|
Task 6: complete (thread 019f41cd-e1a4-7b01-aa8a-a583820507ab, source commit f1958de, integrated commit 89ee444)
|
||||||
|
Task 4: complete (thread 019f41dd-baa5-78d3-8fd6-b82da2896c12, source commit 82fe7b4, integrated commit 7cd18da)
|
||||||
|
Task 5: complete (thread 019f41f2-5597-70b3-a40a-e284e147988f, source commits 11a1009+a83c6f2, integrated commits 56c8f59+c724446, review approved)
|
||||||
|
Task 7: complete (thread 019f4211-0692-7c32-bfe0-87513154b7e5, source commit 5cb2e57, integrated commit 6d29990, review approved)
|
||||||
|
Task 8: complete (thread 019f4482-1902-76a0-ae84-0cbbefa68127, source commit 8f125fb, integrated commit 1e6fb0c, review approved)
|
||||||
@@ -57,6 +57,7 @@ COPY --from=builder /app/turbo.json ./turbo.json
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/packages/shared ./packages/shared
|
COPY --from=builder /app/packages/shared ./packages/shared
|
||||||
COPY --from=builder /app/apps/web ./apps/web
|
COPY --from=builder /app/apps/web ./apps/web
|
||||||
|
COPY scripts ./scripts
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
ALTER TABLE "comments" DROP CONSTRAINT IF EXISTS "comments_task_id_fkey";
|
||||||
|
DROP TABLE IF EXISTS "comments";
|
||||||
|
|
||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"entity_type" TEXT NOT NULL,
|
||||||
|
"entity_id" TEXT NOT NULL,
|
||||||
|
"entity_version_id" TEXT,
|
||||||
|
"product_id" TEXT,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"version_id" TEXT,
|
||||||
|
"author_id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"mentioned_member_ids" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "comments_entity_created_idx" ON "comments"("entity_type", "entity_id", "created_at");
|
||||||
|
CREATE INDEX "comments_author_created_idx" ON "comments"("author_id", "created_at");
|
||||||
|
|
||||||
|
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
CREATE INDEX IF NOT EXISTS "project_members_user_role_idx" ON "project_members"("user_id", "role");
|
||||||
|
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"recipient_id" TEXT NOT NULL,
|
||||||
|
"actor_id" TEXT,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL DEFAULT '',
|
||||||
|
"resource_type" TEXT NOT NULL,
|
||||||
|
"resource_id" TEXT NOT NULL,
|
||||||
|
"resource_version_id" TEXT,
|
||||||
|
"product_id" TEXT,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"version_id" TEXT,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"read_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "notifications_recipient_read_created_idx" ON "notifications"("recipient_id", "read_at", "created_at");
|
||||||
|
CREATE INDEX "notifications_resource_idx" ON "notifications"("resource_type", "resource_id");
|
||||||
|
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"actor_id" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"resource_type" TEXT NOT NULL,
|
||||||
|
"resource_id" TEXT NOT NULL,
|
||||||
|
"product_id" TEXT,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"version_id" TEXT,
|
||||||
|
"before" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"after" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "audit_logs_resource_created_idx" ON "audit_logs"("resource_type", "resource_id", "created_at");
|
||||||
|
CREATE INDEX "audit_logs_actor_created_idx" ON "audit_logs"("actor_id", "created_at");
|
||||||
|
|
||||||
|
CREATE TABLE "governance_dictionaries" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL DEFAULT 'global',
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT,
|
||||||
|
"group" TEXT,
|
||||||
|
"value" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "governance_dictionaries_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE ("scope", "kind", "name")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "governance_dictionaries_kind_deleted_idx" ON "governance_dictionaries"("kind", "deleted_at");
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE "audit_events" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"actor_id" TEXT,
|
||||||
|
"actor_name" TEXT NOT NULL DEFAULT '',
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"entity_type" TEXT NOT NULL,
|
||||||
|
"entity_id" TEXT NOT NULL,
|
||||||
|
"product_id" TEXT,
|
||||||
|
"project_id" TEXT,
|
||||||
|
"version_id" TEXT,
|
||||||
|
"scope" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"before" JSONB,
|
||||||
|
"after" JSONB,
|
||||||
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id", "created_at")
|
||||||
|
) PARTITION BY RANGE ("created_at");
|
||||||
|
|
||||||
|
CREATE TABLE "audit_events_default" PARTITION OF "audit_events" DEFAULT;
|
||||||
|
CREATE INDEX "audit_events_actor_created_at_idx" ON "audit_events"("actor_id", "created_at" DESC);
|
||||||
|
CREATE INDEX "audit_events_entity_created_at_idx" ON "audit_events"("entity_type", "entity_id", "created_at" DESC);
|
||||||
|
CREATE INDEX "audit_events_product_created_at_idx" ON "audit_events"("product_id", "created_at" DESC);
|
||||||
|
CREATE INDEX "audit_events_project_created_at_idx" ON "audit_events"("project_id", "created_at" DESC);
|
||||||
|
CREATE INDEX "audit_events_version_created_at_idx" ON "audit_events"("version_id", "created_at" DESC);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "requirements" ADD COLUMN "product_owner_id" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "requirements"
|
||||||
|
ADD CONSTRAINT "requirements_product_owner_id_fkey"
|
||||||
|
FOREIGN KEY ("product_owner_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX "requirements_product_owner_created_at_idx"
|
||||||
|
ON "requirements"("product_owner_id", "created_at" DESC);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
WITH raw_members AS (
|
||||||
|
SELECT member
|
||||||
|
FROM app_data
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(value -> 'members') = 'array' THEN value -> 'members'
|
||||||
|
ELSE '[]'::jsonb
|
||||||
|
END
|
||||||
|
) AS member
|
||||||
|
WHERE key = 'members'
|
||||||
|
),
|
||||||
|
members AS (
|
||||||
|
SELECT
|
||||||
|
NULLIF(member ->> 'id', '') AS id,
|
||||||
|
NULLIF(member ->> 'username', '') AS username,
|
||||||
|
NULLIF(member ->> 'departmentId', '') AS department_id,
|
||||||
|
NULLIF(member ->> 'roleId', '') AS role_id,
|
||||||
|
COALESCE(member ->> 'phone', '') AS phone,
|
||||||
|
COALESCE(member ->> 'password', '') AS password,
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(member -> 'isSystem') = 'boolean' THEN (member ->> 'isSystem')::boolean
|
||||||
|
ELSE NULL
|
||||||
|
END AS is_system,
|
||||||
|
COUNT(*) OVER (PARTITION BY NULLIF(member ->> 'username', '')) AS username_count
|
||||||
|
FROM raw_members
|
||||||
|
)
|
||||||
|
UPDATE users AS u
|
||||||
|
SET
|
||||||
|
username = CASE
|
||||||
|
WHEN NULLIF(u.username, '') IS NULL
|
||||||
|
AND m.username IS NOT NULL
|
||||||
|
AND m.username_count = 1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users AS other_user
|
||||||
|
WHERE other_user.id <> u.id
|
||||||
|
AND other_user.username = m.username
|
||||||
|
)
|
||||||
|
THEN m.username
|
||||||
|
ELSE u.username
|
||||||
|
END,
|
||||||
|
department_id = COALESCE(m.department_id, u.department_id),
|
||||||
|
role_id = COALESCE(m.role_id, u.role_id, 'member'),
|
||||||
|
phone = COALESCE(m.phone, u.phone, ''),
|
||||||
|
password = COALESCE(m.password, u.password, ''),
|
||||||
|
is_system = COALESCE(m.is_system, u.is_system, false)
|
||||||
|
FROM members AS m
|
||||||
|
WHERE u.id = m.id;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "version_plans"
|
||||||
|
ADD COLUMN IF NOT EXISTS "tasks" JSONB NOT NULL DEFAULT '[]';
|
||||||
@@ -24,7 +24,8 @@ model User {
|
|||||||
createdTasks Task[] @relation("TaskCreator")
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
assignedTasks Task[] @relation("TaskAssignee")
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
projectMembers ProjectMember[]
|
projectMembers ProjectMember[]
|
||||||
requirements Requirement[]
|
requirements Requirement[] @relation("RequirementCreator")
|
||||||
|
productOwnedRequirements Requirement[] @relation("RequirementProductOwner")
|
||||||
watchedTasks TaskWatcher[]
|
watchedTasks TaskWatcher[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -116,11 +117,13 @@ model Requirement {
|
|||||||
sourceTarget String? @map("source_target")
|
sourceTarget String? @map("source_target")
|
||||||
platform String?
|
platform String?
|
||||||
creatorId String? @map("creator_id")
|
creatorId String? @map("creator_id")
|
||||||
|
productOwnerId String? @map("product_owner_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
product Product @relation(fields: [productId], references: [id])
|
product Product @relation(fields: [productId], references: [id])
|
||||||
creator User? @relation(fields: [creatorId], references: [id])
|
creator User? @relation("RequirementCreator", fields: [creatorId], references: [id])
|
||||||
|
productOwner User? @relation("RequirementProductOwner", fields: [productOwnerId], references: [id])
|
||||||
|
|
||||||
@@id([id, productId])
|
@@id([id, productId])
|
||||||
@@unique([productId, code])
|
@@unique([productId, code])
|
||||||
@@ -152,7 +155,6 @@ model Task {
|
|||||||
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
|
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
|
||||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||||
watchers TaskWatcher[]
|
watchers TaskWatcher[]
|
||||||
comments Comment[]
|
|
||||||
|
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
}
|
}
|
||||||
@@ -171,13 +173,21 @@ model TaskWatcher {
|
|||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String @map("task_id")
|
entityType String @map("entity_type")
|
||||||
|
entityId String @map("entity_id")
|
||||||
|
entityVersionId String? @map("entity_version_id")
|
||||||
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
authorId String @map("author_id")
|
authorId String @map("author_id")
|
||||||
content String
|
content String
|
||||||
|
mentionedMemberIds Json @default("[]") @map("mentioned_member_ids")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
task Task @relation(fields: [taskId], references: [id])
|
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
|
||||||
|
@@index([authorId, createdAt], name: "comments_author_created_idx")
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +196,76 @@ model ProjectMember {
|
|||||||
projectId String @map("project_id")
|
projectId String @map("project_id")
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
role String @default("member")
|
role String @default("member")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@unique([projectId, userId])
|
@@unique([projectId, userId])
|
||||||
|
@@index([userId, role], name: "project_members_user_role_idx")
|
||||||
@@map("project_members")
|
@@map("project_members")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
recipientId String @map("recipient_id")
|
||||||
|
actorId String? @map("actor_id")
|
||||||
|
type String
|
||||||
|
title String
|
||||||
|
body String @default("")
|
||||||
|
resourceType String @map("resource_type")
|
||||||
|
resourceId String @map("resource_id")
|
||||||
|
resourceVersionId String? @map("resource_version_id")
|
||||||
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
|
metadata Json @default("{}")
|
||||||
|
readAt DateTime? @map("read_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([recipientId, readAt, createdAt], name: "notifications_recipient_read_created_idx")
|
||||||
|
@@index([resourceType, resourceId], name: "notifications_resource_idx")
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
actorId String? @map("actor_id")
|
||||||
|
action String
|
||||||
|
resourceType String @map("resource_type")
|
||||||
|
resourceId String @map("resource_id")
|
||||||
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
|
before Json @default("{}")
|
||||||
|
after Json @default("{}")
|
||||||
|
metadata Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([resourceType, resourceId, createdAt], name: "audit_logs_resource_created_idx")
|
||||||
|
@@index([actorId, createdAt], name: "audit_logs_actor_created_idx")
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model GovernanceDictionary {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scope String @default("global")
|
||||||
|
kind String
|
||||||
|
name String
|
||||||
|
code String?
|
||||||
|
group String?
|
||||||
|
value Json @default("{}")
|
||||||
|
isSystem Boolean @default(false) @map("is_system")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([scope, kind, name])
|
||||||
|
@@index([kind, deletedAt], name: "governance_dictionaries_kind_deleted_idx")
|
||||||
|
@@map("governance_dictionaries")
|
||||||
|
}
|
||||||
|
|
||||||
model TaskCategory {
|
model TaskCategory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -221,6 +293,7 @@ model VersionPlan {
|
|||||||
actualStartAt DateTime? @map("actual_start_at")
|
actualStartAt DateTime? @map("actual_start_at")
|
||||||
completedAt DateTime? @map("completed_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
resultUrl String? @map("result_url")
|
resultUrl String? @map("result_url")
|
||||||
|
tasks Json @default("[]")
|
||||||
requirementCoverage Json @default("[]") @map("requirement_coverage")
|
requirementCoverage Json @default("[]") @map("requirement_coverage")
|
||||||
logs Json @default("[]")
|
logs Json @default("[]")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -423,6 +496,26 @@ model AiLog {
|
|||||||
@@map("ai_logs")
|
@@map("ai_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AuditEvent {
|
||||||
|
id String @default(cuid())
|
||||||
|
actorId String? @map("actor_id")
|
||||||
|
actorName String @default("") @map("actor_name")
|
||||||
|
action String
|
||||||
|
entityType String @map("entity_type")
|
||||||
|
entityId String @map("entity_id")
|
||||||
|
productId String? @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
|
scope Json @default("{}")
|
||||||
|
before Json?
|
||||||
|
after Json?
|
||||||
|
metadata Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@id([id, createdAt])
|
||||||
|
@@map("audit_events")
|
||||||
|
}
|
||||||
|
|
||||||
model BackgroundJob {
|
model BackgroundJob {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
type String
|
type String
|
||||||
|
|||||||
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const migrationPath = join(
|
||||||
|
process.cwd(),
|
||||||
|
'prisma',
|
||||||
|
'migrations',
|
||||||
|
'20260708000000_v27_enterprise_collab_governance',
|
||||||
|
'migration.sql',
|
||||||
|
);
|
||||||
|
|
||||||
|
function readMigrationSql() {
|
||||||
|
if (!existsSync(migrationPath)) {
|
||||||
|
throw new Error(`Missing V2.7 migration: ${migrationPath}`);
|
||||||
|
}
|
||||||
|
return readFileSync(migrationPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('V2.7 enterprise collaboration schema migration', () => {
|
||||||
|
it('creates notification records for assignment, mention, risk alert, and overdue events', () => {
|
||||||
|
const sql = readMigrationSql();
|
||||||
|
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "notifications"/);
|
||||||
|
expect(sql).toMatch(/"type" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"recipient_id" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"read_at" TIMESTAMP\(3\)/);
|
||||||
|
expect(sql).toMatch(/CREATE INDEX "notifications_recipient_read_created_idx"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates polymorphic comments with mention metadata', () => {
|
||||||
|
const sql = readMigrationSql();
|
||||||
|
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "comments"/);
|
||||||
|
expect(sql).toMatch(/"entity_type" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"entity_id" TEXT NOT NULL/);
|
||||||
|
expect(sql).toMatch(/"mentioned_member_ids" JSONB NOT NULL DEFAULT '\[\]'/);
|
||||||
|
expect(sql).toMatch(/CREATE INDEX "comments_entity_created_idx"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds audit logs and governance dictionaries with soft-delete support', () => {
|
||||||
|
const sql = readMigrationSql();
|
||||||
|
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "audit_logs"/);
|
||||||
|
expect(sql).toMatch(/"before" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||||
|
expect(sql).toMatch(/"after" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||||
|
expect(sql).toMatch(/CREATE TABLE "governance_dictionaries"/);
|
||||||
|
expect(sql).toMatch(/"deleted_at" TIMESTAMP\(3\)/);
|
||||||
|
expect(sql).toMatch(/CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
||||||
|
import { AuthModule } from './common/auth/auth.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
import { ProductModule } from './modules/product/product.module';
|
import { ProductModule } from './modules/product/product.module';
|
||||||
import { ProjectModule } from './modules/project/project.module';
|
import { ProjectModule } from './modules/project/project.module';
|
||||||
import { RequirementModule } from './modules/requirement/requirement.module';
|
import { RequirementModule } from './modules/requirement/requirement.module';
|
||||||
@@ -20,12 +22,21 @@ import { DataModule } from './modules/data/data.module';
|
|||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
|
import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||||
import { JobsModule } from './modules/jobs/jobs.module';
|
import { JobsModule } from './modules/jobs/jobs.module';
|
||||||
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||||
|
import { OpsModule } from './modules/ops/ops.module';
|
||||||
|
import { NotificationModule } from './modules/notification/notification.module';
|
||||||
|
import { CommentModule } from './modules/comment/comment.module';
|
||||||
|
import { ProjectMemberModule } from './modules/project-member/project-member.module';
|
||||||
|
import { ManagementModule } from './modules/management/management.module';
|
||||||
|
import { GovernanceModule } from './modules/governance/governance.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
AuditModule,
|
||||||
ProductModule,
|
ProductModule,
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
VersionModule,
|
VersionModule,
|
||||||
@@ -42,9 +53,16 @@ import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
|||||||
DataModule,
|
DataModule,
|
||||||
MigrationModule,
|
MigrationModule,
|
||||||
V22QueryModule,
|
V22QueryModule,
|
||||||
|
ConsistencyModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
XiaobaoModule,
|
XiaobaoModule,
|
||||||
|
OpsModule,
|
||||||
|
NotificationModule,
|
||||||
|
CommentModule,
|
||||||
|
ProjectMemberModule,
|
||||||
|
ManagementModule,
|
||||||
|
GovernanceModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
|
|||||||
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import type { PermissionScopeOptions } from '../auth/permission.decorator';
|
||||||
|
|
||||||
|
export const AUDIT_MUTATION_METADATA_KEY = 'ftb:audit-mutation';
|
||||||
|
|
||||||
|
export interface AuditMutationMetadata extends PermissionScopeOptions {
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityIdParam?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditMutation(metadata: AuditMutationMetadata) {
|
||||||
|
return SetMetadata(AUDIT_MUTATION_METADATA_KEY, metadata);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { lastValueFrom, of } from 'rxjs';
|
||||||
|
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
|
||||||
|
import { AuditMutation } from './audit-mutation.decorator';
|
||||||
|
|
||||||
|
describe('AuditMutationInterceptor', () => {
|
||||||
|
it('writes an audit event after a successful mutation response', async () => {
|
||||||
|
const record = jest.fn().mockResolvedValue({ id: 'audit-1' });
|
||||||
|
const resolveCurrentUser = jest.fn().mockResolvedValue({ id: 'm-8', name: '超级管理员', roleId: 'role-admin' });
|
||||||
|
const interceptor = new AuditMutationInterceptor(
|
||||||
|
new (jest.requireActual('@nestjs/core').Reflector)(),
|
||||||
|
{ record } as any,
|
||||||
|
{ resolveCurrentUser } as any,
|
||||||
|
);
|
||||||
|
const handler = decorate(() => undefined);
|
||||||
|
|
||||||
|
const result = await lastValueFrom(interceptor.intercept(contextFor(handler), {
|
||||||
|
handle: () => of({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
expect(result).toEqual({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } });
|
||||||
|
expect(record).toHaveBeenCalledWith({
|
||||||
|
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
|
||||||
|
action: 'dev_task.update',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
scope: { productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
|
||||||
|
after: { item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } },
|
||||||
|
metadata: { route: 'PATCH /versions/version-1/dev-tasks/task-1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function decorate(handler: Function) {
|
||||||
|
AuditMutation({
|
||||||
|
action: 'dev_task.update',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})(handler as any, undefined as any, undefined as any);
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextFor(handler: Function) {
|
||||||
|
const request = {
|
||||||
|
method: 'PATCH',
|
||||||
|
originalUrl: '/versions/version-1/dev-tasks/task-1',
|
||||||
|
params: { id: 'task-1', versionId: 'version-1' },
|
||||||
|
body: {},
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getHandler: () => handler,
|
||||||
|
getClass: () => class TestController {},
|
||||||
|
switchToHttp: () => ({ getRequest: () => request }),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { mergeMap, Observable } from 'rxjs';
|
||||||
|
import { AuthContextService, type AuthenticatedRequest } from '../auth/auth-context.service';
|
||||||
|
import { AuditService } from '../../modules/audit/audit.service';
|
||||||
|
import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from './audit-mutation.decorator';
|
||||||
|
|
||||||
|
type MutationRequest = AuthenticatedRequest & {
|
||||||
|
method?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditMutationInterceptor implements NestInterceptor {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly authContext: AuthContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const metadata = this.reflector.getAllAndOverride<AuditMutationMetadata>(AUDIT_MUTATION_METADATA_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!metadata) return next.handle();
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<MutationRequest>();
|
||||||
|
return next.handle().pipe(mergeMap(async (result) => {
|
||||||
|
const actor = await this.authContext.resolveCurrentUser(request);
|
||||||
|
const entity = extractEntity(result);
|
||||||
|
const scope = resolveScope(metadata, request, entity);
|
||||||
|
await this.audit.record({
|
||||||
|
actor,
|
||||||
|
action: metadata.action,
|
||||||
|
entityType: metadata.entityType,
|
||||||
|
entityId: resolveEntityId(metadata, request, entity),
|
||||||
|
productId: scope.productId,
|
||||||
|
projectId: scope.projectId,
|
||||||
|
versionId: scope.versionId,
|
||||||
|
scope,
|
||||||
|
after: result,
|
||||||
|
metadata: { route: `${request.method ?? 'UNKNOWN'} ${request.originalUrl ?? request.url ?? ''}`.trim() },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEntity(result: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!result || typeof result !== 'object') return undefined;
|
||||||
|
const record = result as Record<string, unknown>;
|
||||||
|
if (record.item && typeof record.item === 'object') return record.item as Record<string, unknown>;
|
||||||
|
if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record<string, unknown>;
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
|
||||||
|
const fromParam = metadata.entityIdParam ? request.params?.[metadata.entityIdParam] : undefined;
|
||||||
|
const fromEntity = entity?.id;
|
||||||
|
return fromParam ?? (typeof fromEntity === 'string' ? fromEntity : 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScope(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | undefined) {
|
||||||
|
return compact({
|
||||||
|
productId: scopedValue(metadata.productIdParam, metadata.productIdBody, 'productId', request, entity),
|
||||||
|
projectId: scopedValue(metadata.projectIdParam, metadata.projectIdBody, 'projectId', request, entity),
|
||||||
|
versionId: scopedValue(metadata.versionIdParam, metadata.versionIdBody, 'versionId', request, entity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedValue(
|
||||||
|
paramKey: string | undefined,
|
||||||
|
bodyKey: string | undefined,
|
||||||
|
resultKey: string,
|
||||||
|
request: MutationRequest,
|
||||||
|
entity: Record<string, unknown> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const value = (paramKey ? request.params?.[paramKey] : undefined)
|
||||||
|
?? (bodyKey ? request.body?.[bodyKey] : undefined)
|
||||||
|
?? entity?.[resultKey];
|
||||||
|
return typeof value === 'string' && value ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact<T extends Record<string, string | undefined>>(value: T): { [K in keyof T]?: string } {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string };
|
||||||
|
}
|
||||||
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
describe('AuditService V2.7 adapter contract', () => {
|
||||||
|
it('writes an append-only audit event with actor and resource scope', async () => {
|
||||||
|
const prisma = {
|
||||||
|
auditLog: {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new AuditService(prisma as any);
|
||||||
|
|
||||||
|
await service.record({
|
||||||
|
actorId: 'm-admin',
|
||||||
|
action: 'project_member.role_changed',
|
||||||
|
resourceType: 'project_member',
|
||||||
|
resourceId: 'pm-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
before: { role: 'member' },
|
||||||
|
after: { role: 'admin' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
actorId: 'm-admin',
|
||||||
|
action: 'project_member.role_changed',
|
||||||
|
resourceType: 'project_member',
|
||||||
|
resourceId: 'pm-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
before: { role: 'member' },
|
||||||
|
after: { role: 'admin' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fail the business action when audit storage is unavailable', async () => {
|
||||||
|
const prisma = {
|
||||||
|
auditLog: {
|
||||||
|
create: jest.fn().mockRejectedValue(new Error('database unavailable')),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = new AuditService(prisma as any);
|
||||||
|
(service as any).logger.warn = jest.fn();
|
||||||
|
|
||||||
|
await expect(service.record({
|
||||||
|
actorId: 'm-admin',
|
||||||
|
action: 'comment.created',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: 'comment-1',
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
apps/server/src/common/audit/audit.service.ts
Normal file
43
apps/server/src/common/audit/audit.service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface AuditEventInput {
|
||||||
|
actorId?: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
private readonly logger = new Logger(AuditService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async record(input: AuditEventInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId: input.actorId ?? null,
|
||||||
|
action: input.action,
|
||||||
|
resourceType: input.resourceType,
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
before: input.before ?? {},
|
||||||
|
after: input.after ?? {},
|
||||||
|
metadata: input.metadata ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`Audit write failed for ${input.action}: ${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { PermissionGuard } from '../auth/permission.guard';
|
||||||
|
import { RequirePermission, type PermissionScopeOptions } from '../auth/permission.decorator';
|
||||||
|
import { AuditMutation, type AuditMutationMetadata } from './audit-mutation.decorator';
|
||||||
|
import { AuditMutationInterceptor } from './audit-mutation.interceptor';
|
||||||
|
|
||||||
|
export function ProtectedMutation(
|
||||||
|
permission: string,
|
||||||
|
scope: PermissionScopeOptions,
|
||||||
|
audit: AuditMutationMetadata,
|
||||||
|
) {
|
||||||
|
return applyDecorators(
|
||||||
|
UseGuards(PermissionGuard),
|
||||||
|
RequirePermission(permission, scope),
|
||||||
|
UseInterceptors(AuditMutationInterceptor),
|
||||||
|
AuditMutation(audit),
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/server/src/common/auth/auth-context.service.ts
Normal file
70
apps/server/src/common/auth/auth-context.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
roleId: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest {
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
currentUser?: CurrentUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthContextService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async resolveCurrentUser(request: AuthenticatedRequest): Promise<CurrentUser | null> {
|
||||||
|
if (request.currentUser !== undefined) return request.currentUser;
|
||||||
|
|
||||||
|
const userId = headerValue(request, 'x-ftb-user-id') || headerValue(request, 'x-user-id');
|
||||||
|
if (!userId) {
|
||||||
|
request.currentUser = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerUser: CurrentUser = {
|
||||||
|
id: userId,
|
||||||
|
name: decodeHeader(headerValue(request, 'x-ftb-user-name')),
|
||||||
|
username: headerValue(request, 'x-ftb-user-username'),
|
||||||
|
roleId: headerValue(request, 'x-ftb-user-role-id') || '',
|
||||||
|
email: headerValue(request, 'x-ftb-user-email'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (headerUser.roleId) {
|
||||||
|
request.currentUser = headerUser;
|
||||||
|
return headerUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, name: true, username: true, roleId: true, email: true },
|
||||||
|
});
|
||||||
|
request.currentUser = row ? {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
username: row.username ?? undefined,
|
||||||
|
roleId: row.roleId || 'member',
|
||||||
|
email: row.email,
|
||||||
|
} : null;
|
||||||
|
return request.currentUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerValue(request: AuthenticatedRequest, name: string): string {
|
||||||
|
const value = request.headers?.[name] ?? request.headers?.[name.toLowerCase()];
|
||||||
|
if (Array.isArray(value)) return value[0] ?? '';
|
||||||
|
return value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHeader(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/server/src/common/auth/auth.module.ts
Normal file
11
apps/server/src/common/auth/auth.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuthContextService } from './auth-context.service';
|
||||||
|
import { PermissionGuard } from './permission.guard';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AuthContextService, PermissionGuard, PermissionService],
|
||||||
|
exports: [AuthContextService, PermissionGuard, PermissionService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest, CurrentUser as ResolvedCurrentUser } from './auth-context.service';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): ResolvedCurrentUser | null => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
return request.currentUser ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
20
apps/server/src/common/auth/permission.decorator.ts
Normal file
20
apps/server/src/common/auth/permission.decorator.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const PERMISSION_METADATA_KEY = 'ftb:required-permission';
|
||||||
|
|
||||||
|
export interface PermissionScopeOptions {
|
||||||
|
productIdParam?: string;
|
||||||
|
projectIdParam?: string;
|
||||||
|
versionIdParam?: string;
|
||||||
|
productIdBody?: string;
|
||||||
|
projectIdBody?: string;
|
||||||
|
versionIdBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequiredPermissionMetadata extends PermissionScopeOptions {
|
||||||
|
permission: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequirePermission(permission: string, scope: PermissionScopeOptions = {}) {
|
||||||
|
return SetMetadata(PERMISSION_METADATA_KEY, { permission, ...scope });
|
||||||
|
}
|
||||||
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthContextService } from './auth-context.service';
|
||||||
|
import { PermissionGuard } from './permission.guard';
|
||||||
|
import { RequirePermission } from './permission.decorator';
|
||||||
|
|
||||||
|
describe('PermissionGuard', () => {
|
||||||
|
it('rejects protected routes when no current user can be resolved', async () => {
|
||||||
|
const guard = new PermissionGuard(
|
||||||
|
new Reflector(),
|
||||||
|
{ resolveCurrentUser: jest.fn().mockResolvedValue(null) } as unknown as AuthContextService,
|
||||||
|
{ can: jest.fn() } as any,
|
||||||
|
);
|
||||||
|
const handler = decorate(() => undefined, 'product:create');
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects users without the required permission', async () => {
|
||||||
|
const guard = new PermissionGuard(
|
||||||
|
new Reflector(),
|
||||||
|
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
|
||||||
|
{ can: jest.fn().mockResolvedValue(false) } as any,
|
||||||
|
);
|
||||||
|
const handler = decorate(() => undefined, 'product:delete');
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes params-derived resource scope to the permission service', async () => {
|
||||||
|
const can = jest.fn().mockResolvedValue(true);
|
||||||
|
const guard = new PermissionGuard(
|
||||||
|
new Reflector(),
|
||||||
|
{ resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any,
|
||||||
|
{ can } as any,
|
||||||
|
);
|
||||||
|
const handler = decorate(() => undefined, 'version.devtask:manage', {
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
projectIdParam: 'projectId',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextFor(handler, {
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
}))).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(can).toHaveBeenCalledWith(
|
||||||
|
{ id: 'user-1', roleId: 'role-dev' },
|
||||||
|
'version.devtask:manage',
|
||||||
|
{ productId: 'product-1', projectId: 'project-1', versionId: 'version-1' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function decorate(handler: Function, permission: string, scope?: {
|
||||||
|
productIdParam?: string;
|
||||||
|
projectIdParam?: string;
|
||||||
|
versionIdParam?: string;
|
||||||
|
}) {
|
||||||
|
RequirePermission(permission, scope)(handler as any, undefined as any, undefined as any);
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextFor(handler: Function, params: Record<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
getHandler: () => handler,
|
||||||
|
getClass: () => class TestController {},
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ params, headers: {} }),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
46
apps/server/src/common/auth/permission.guard.ts
Normal file
46
apps/server/src/common/auth/permission.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthContextService, type AuthenticatedRequest } from './auth-context.service';
|
||||||
|
import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from './permission.decorator';
|
||||||
|
import { PermissionService } from './permission.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly authContext: AuthContextService,
|
||||||
|
private readonly permissions: PermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const metadata = this.reflector.getAllAndOverride<RequiredPermissionMetadata>(PERMISSION_METADATA_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!metadata) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest & {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
}>();
|
||||||
|
const user = await this.authContext.resolveCurrentUser(request);
|
||||||
|
if (!user) throw new UnauthorizedException('Authentication required');
|
||||||
|
|
||||||
|
const allowed = await this.permissions.can(user, metadata.permission, {
|
||||||
|
productId: scopedValue(request, metadata.productIdParam, metadata.productIdBody),
|
||||||
|
projectId: scopedValue(request, metadata.projectIdParam, metadata.projectIdBody),
|
||||||
|
versionId: scopedValue(request, metadata.versionIdParam, metadata.versionIdBody),
|
||||||
|
});
|
||||||
|
if (!allowed) throw new ForbiddenException(`Missing permission: ${metadata.permission}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedValue(
|
||||||
|
request: { params?: Record<string, string>; body?: Record<string, unknown> },
|
||||||
|
paramKey?: string,
|
||||||
|
bodyKey?: string,
|
||||||
|
): string | undefined {
|
||||||
|
const value = (paramKey ? request.params?.[paramKey] : undefined) ?? (bodyKey ? request.body?.[bodyKey] : undefined);
|
||||||
|
return typeof value === 'string' && value ? value : undefined;
|
||||||
|
}
|
||||||
144
apps/server/src/common/auth/permission.service.spec.ts
Normal file
144
apps/server/src/common/auth/permission.service.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves configured role permissions for the current user', async () => {
|
||||||
|
appDataFindUnique.mockResolvedValue({
|
||||||
|
value: {
|
||||||
|
roles: [
|
||||||
|
{ id: 'role-dev', permissions: ['project:view'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.resolveUserPermissions(currentUser({ roleId: 'role-dev' }))).resolves.toEqual(['project:view']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no permissions for anonymous users', async () => {
|
||||||
|
await expect(service.resolveUserPermissions(null)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'User One',
|
||||||
|
username: 'user.one',
|
||||||
|
roleId: 'role-dev',
|
||||||
|
email: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
228
apps/server/src/common/auth/permission.service.ts
Normal file
228
apps/server/src/common/auth/permission.service.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
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 resolveUserPermissions(user: CurrentUser | null | undefined): Promise<string[]> {
|
||||||
|
if (!user) return [];
|
||||||
|
return this.resolveRolePermissions(user.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
|
||||||
|
if (!user) return false;
|
||||||
|
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;
|
||||||
|
const systemPermissions = await this.resolveRolePermissions(user.roleId);
|
||||||
|
if (systemPermissions.includes('*')) return true;
|
||||||
|
|
||||||
|
const hasSystemPermission = systemPermissions.includes(permission);
|
||||||
|
const hasScopedResource = Boolean(scope.projectId || scope.versionId);
|
||||||
|
if (!hasScopedResource) return hasSystemPermission;
|
||||||
|
|
||||||
|
const resolvedScope = await this.resolveScope(scope);
|
||||||
|
const projectMember = resolvedScope.projectId ? await this.prisma.projectMember.findUnique({
|
||||||
|
where: { projectId_userId: { projectId: resolvedScope.projectId, userId: user.id } },
|
||||||
|
}) : null;
|
||||||
|
|
||||||
|
if (requiresVersionWorkMembership(permission, resolvedScope)) {
|
||||||
|
return hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectMember && roleAllows(PROJECT_ROLE_PERMISSIONS[projectMember.role] ?? [], permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertCan(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<void> {
|
||||||
|
if (!user) throw new UnauthorizedException('Authentication required');
|
||||||
|
if (!(await this.can(user, permission, scope))) {
|
||||||
|
throw new ForbiddenException(`Missing permission: ${permission}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveScope(scope: ResourceScope): Promise<ResolvedScope> {
|
||||||
|
if (!scope.versionId || scope.projectId) return scope;
|
||||||
|
const version = await this.prisma.version.findUnique({
|
||||||
|
where: { id: scope.versionId },
|
||||||
|
select: { id: true, projectId: true, members: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...scope,
|
||||||
|
projectId: version?.projectId ?? scope.projectId,
|
||||||
|
versionMembers: version?.members,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRolePermissions(roleId: string): Promise<string[]> {
|
||||||
|
const configuredPermissions = await this.findConfiguredRolePermissions(roleId);
|
||||||
|
return configuredPermissions ?? SYSTEM_ROLE_PERMISSIONS[roleId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findConfiguredRolePermissions(roleId: string): Promise<string[] | null> {
|
||||||
|
const findUnique = (this.prisma as unknown as {
|
||||||
|
appData?: { findUnique?: (args: unknown) => Promise<{ value?: unknown } | null> };
|
||||||
|
}).appData?.findUnique;
|
||||||
|
if (!findUnique) return null;
|
||||||
|
|
||||||
|
const row = await findUnique({
|
||||||
|
where: { key: 'members' },
|
||||||
|
select: { value: true },
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
return readRolePermissions(row.value, roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function std4(module: string): string[] {
|
||||||
|
return [`${module}:view`, `${module}:create`, `${module}:edit`, `${module}:delete`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleAllows(permissions: string[], permission: string): boolean {
|
||||||
|
return permissions.includes('*') || permissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresVersionWorkMembership(permission: string, scope: ResourceScope): boolean {
|
||||||
|
return Boolean(scope.versionId && VERSION_WORK_MUTATION_PERMISSIONS.has(permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRolePermissions(value: unknown, roleId: string): string[] | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const roles = (value as { roles?: unknown }).roles;
|
||||||
|
if (!Array.isArray(roles)) return null;
|
||||||
|
const role = roles.find((item) => (
|
||||||
|
item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
(item as { id?: unknown }).id === roleId
|
||||||
|
));
|
||||||
|
if (!role || typeof role !== 'object') return null;
|
||||||
|
const permissions = (role as { permissions?: unknown }).permissions;
|
||||||
|
if (!Array.isArray(permissions)) return null;
|
||||||
|
return permissions.filter((item): item is string => typeof item === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVersionMember(user: CurrentUser, members: unknown): boolean {
|
||||||
|
if (!Array.isArray(members)) return false;
|
||||||
|
return members.some((member) => {
|
||||||
|
if (typeof member === 'string') return member === user.id || member === user.name;
|
||||||
|
if (!member || typeof member !== 'object') return false;
|
||||||
|
const record = member as { id?: unknown; userId?: unknown; name?: unknown };
|
||||||
|
return record.id === user.id || record.userId === user.id || record.name === user.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
9
apps/server/src/common/common-domain.module.ts
Normal file
9
apps/server/src/common/common-domain.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditService } from './audit/audit.service';
|
||||||
|
import { RbacService } from './rbac/rbac.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AuditService, RbacService],
|
||||||
|
exports: [AuditService, RbacService],
|
||||||
|
})
|
||||||
|
export class CommonDomainModule {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
import { finalize, Observable } from 'rxjs';
|
import { finalize, Observable } from 'rxjs';
|
||||||
|
import { recordSlowRequest } from '../../modules/ops/ops-runtime.store';
|
||||||
|
|
||||||
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export class ApiTimingInterceptor implements NestInterceptor {
|
|||||||
const method = request.method ?? 'UNKNOWN';
|
const method = request.method ?? 'UNKNOWN';
|
||||||
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
const url = request.originalUrl ?? request.url ?? 'unknown-url';
|
||||||
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
|
||||||
|
recordSlowRequest({ method, url, durationMs, thresholdMs: this.thresholdMs });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { RbacService } from './rbac.service';
|
||||||
|
|
||||||
|
describe('RbacService V2.7 adapter contract', () => {
|
||||||
|
const makeService = () => {
|
||||||
|
const prisma = {
|
||||||
|
projectMember: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { prisma, service: new RbacService(prisma as any) };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('allows a system admin actor without querying project membership', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
|
||||||
|
await expect(service.assertProjectRole({
|
||||||
|
actorId: 'm-admin',
|
||||||
|
projectId: 'project-1',
|
||||||
|
allowedRoles: ['owner'],
|
||||||
|
permissions: ['*'],
|
||||||
|
})).resolves.toEqual({
|
||||||
|
actorId: 'm-admin',
|
||||||
|
projectId: 'project-1',
|
||||||
|
role: 'owner',
|
||||||
|
via: 'system',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.projectMember.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows actors with an explicit global permission', async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
await expect(service.assertGlobalPermission({
|
||||||
|
actorId: 'm-pm',
|
||||||
|
permissions: ['management:view'],
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
})).resolves.toEqual({
|
||||||
|
actorId: 'm-pm',
|
||||||
|
via: 'permission',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects actors without the required global permission', async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
await expect(service.assertGlobalPermission({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: ['project:view'],
|
||||||
|
requiredPermissions: ['governance:manage'],
|
||||||
|
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows project owners to perform admin-scoped actions', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.projectMember.findUnique.mockResolvedValue({
|
||||||
|
id: 'pm-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
userId: 'm-owner',
|
||||||
|
role: 'owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.assertProjectRole({
|
||||||
|
actorId: 'm-owner',
|
||||||
|
projectId: 'project-1',
|
||||||
|
allowedRoles: ['admin'],
|
||||||
|
permissions: [],
|
||||||
|
})).resolves.toEqual({
|
||||||
|
actorId: 'm-owner',
|
||||||
|
projectId: 'project-1',
|
||||||
|
role: 'owner',
|
||||||
|
via: 'project_member',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects actors below the required project role', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.projectMember.findUnique.mockResolvedValue({
|
||||||
|
id: 'pm-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
userId: 'm-viewer',
|
||||||
|
role: 'viewer',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.assertProjectRole({
|
||||||
|
actorId: 'm-viewer',
|
||||||
|
projectId: 'project-1',
|
||||||
|
allowedRoles: ['member'],
|
||||||
|
permissions: [],
|
||||||
|
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export type ProjectGovernanceRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||||
|
|
||||||
|
export interface ProjectRoleAssertion {
|
||||||
|
actorId?: string;
|
||||||
|
projectId?: string | null;
|
||||||
|
allowedRoles: ProjectGovernanceRole[];
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalPermissionAssertion {
|
||||||
|
actorId?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
requiredPermissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectRoleDecision {
|
||||||
|
actorId: string;
|
||||||
|
projectId: string;
|
||||||
|
role: ProjectGovernanceRole;
|
||||||
|
via: 'system' | 'project_member';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalPermissionDecision {
|
||||||
|
actorId: string;
|
||||||
|
via: 'system' | 'permission';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
|
||||||
|
owner: 4,
|
||||||
|
admin: 3,
|
||||||
|
member: 2,
|
||||||
|
viewer: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RbacService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async assertGlobalPermission(input: GlobalPermissionAssertion): Promise<GlobalPermissionDecision> {
|
||||||
|
const actorId = input.actorId?.trim();
|
||||||
|
if (!actorId) {
|
||||||
|
throw new ForbiddenException('Missing actor scope');
|
||||||
|
}
|
||||||
|
if (input.permissions?.includes('*')) {
|
||||||
|
return { actorId, via: 'system' };
|
||||||
|
}
|
||||||
|
if (hasAnyPermission(input.permissions ?? [], input.requiredPermissions)) {
|
||||||
|
return { actorId, via: 'permission' };
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Insufficient global permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertProjectRole(input: ProjectRoleAssertion): Promise<ProjectRoleDecision> {
|
||||||
|
const actorId = input.actorId?.trim();
|
||||||
|
const projectId = input.projectId?.trim();
|
||||||
|
if (!actorId || !projectId) {
|
||||||
|
throw new ForbiddenException('Missing actor or project scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.permissions?.includes('*')) {
|
||||||
|
return { actorId, projectId, role: 'owner', via: 'system' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await this.prisma.projectMember.findUnique({
|
||||||
|
where: { projectId_userId: { projectId, userId: actorId } },
|
||||||
|
});
|
||||||
|
const role = normalizeProjectRole(membership?.role);
|
||||||
|
if (!role || !hasRequiredRole(role, input.allowedRoles)) {
|
||||||
|
throw new ForbiddenException('Insufficient project role');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { actorId, projectId, role, via: 'project_member' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectRole(role: string | null | undefined): ProjectGovernanceRole | null {
|
||||||
|
if (role === 'owner' || role === 'admin' || role === 'member' || role === 'viewer') return role;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: ProjectGovernanceRole[]): boolean {
|
||||||
|
if (allowedRoles.length === 0) return false;
|
||||||
|
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
|
||||||
|
return ROLE_RANK[role] >= minimumRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnyPermission(permissions: string[], requiredPermissions: string[]): boolean {
|
||||||
|
if (requiredPermissions.length === 0) return false;
|
||||||
|
const granted = new Set(permissions);
|
||||||
|
return requiredPermissions.some((permission) => granted.has(permission));
|
||||||
|
}
|
||||||
25
apps/server/src/common/user-reference.ts
Normal file
25
apps/server/src/common/user-reference.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export async function resolveUserReference(prisma: any, reference: string | null | undefined): Promise<string | null> {
|
||||||
|
const normalized = emptyToNull(reference);
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (!prisma?.user?.findFirst) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: normalized },
|
||||||
|
{ name: normalized },
|
||||||
|
{ username: normalized },
|
||||||
|
{ email: normalized },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return user?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyToNull(value: string | null | undefined): string | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (value === undefined) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal file
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { AiController } from './ai.controller';
|
||||||
|
|
||||||
|
describe('AiController', () => {
|
||||||
|
it('uses server-derived permissions for analysis instead of body permissions', async () => {
|
||||||
|
const aiService = {};
|
||||||
|
const businessAnalysisService = {
|
||||||
|
analyze: jest.fn().mockResolvedValue({ ok: false, code: 'NO_DATA', message: 'empty' }),
|
||||||
|
};
|
||||||
|
const authContext = {
|
||||||
|
resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'm-1', roleId: 'role-dev' }),
|
||||||
|
};
|
||||||
|
const permissionService = {
|
||||||
|
resolveUserPermissions: jest.fn().mockResolvedValue(['project:view']),
|
||||||
|
};
|
||||||
|
const controller = new (AiController as any)(
|
||||||
|
aiService,
|
||||||
|
businessAnalysisService,
|
||||||
|
authContext,
|
||||||
|
permissionService,
|
||||||
|
) as AiController;
|
||||||
|
const request = { headers: { 'x-ftb-user-id': 'm-1' } };
|
||||||
|
|
||||||
|
await (controller as any).analyze({ question: '分析版本风险', permissions: ['*'] }, request);
|
||||||
|
|
||||||
|
expect(authContext.resolveCurrentUser).toHaveBeenCalledWith(request);
|
||||||
|
expect(permissionService.resolveUserPermissions).toHaveBeenCalledWith({ id: 'm-1', roleId: 'role-dev' });
|
||||||
|
expect(businessAnalysisService.analyze).toHaveBeenCalledWith(
|
||||||
|
{ question: '分析版本风险', permissions: ['*'] },
|
||||||
|
{ id: 'm-1', permissions: ['project:view'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post, Req } from '@nestjs/common';
|
||||||
|
import { AuthContextService, type AuthenticatedRequest } from '../../common/auth/auth-context.service';
|
||||||
|
import { PermissionService } from '../../common/auth/permission.service';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
|
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||||||
|
import { AnalysisDto } from './dto/analysis.dto';
|
||||||
import { DecomposeDto } from './dto/decompose.dto';
|
import { DecomposeDto } from './dto/decompose.dto';
|
||||||
import { RiskInterpretDto } from './dto/risk-interpret.dto';
|
import { RiskInterpretDto } from './dto/risk-interpret.dto';
|
||||||
import type {
|
import type {
|
||||||
@@ -7,11 +11,17 @@ import type {
|
|||||||
AgentDecomposeError,
|
AgentDecomposeError,
|
||||||
AgentRiskInterpretResponse,
|
AgentRiskInterpretResponse,
|
||||||
AgentRiskInterpretError,
|
AgentRiskInterpretError,
|
||||||
|
AnalysisResponse,
|
||||||
} from '@ftb/shared';
|
} from '@ftb/shared';
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
export class AiController {
|
export class AiController {
|
||||||
constructor(private readonly aiService: AiService) {}
|
constructor(
|
||||||
|
private readonly aiService: AiService,
|
||||||
|
private readonly businessAnalysisService: BusinessAnalysisService,
|
||||||
|
private readonly authContext: AuthContextService,
|
||||||
|
private readonly permissionService: PermissionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('decompose')
|
@Post('decompose')
|
||||||
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
async decompose(@Body() dto: DecomposeDto): Promise<AgentDecomposeResponse | AgentDecomposeError> {
|
||||||
@@ -22,4 +32,17 @@ export class AiController {
|
|||||||
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
async interpretRisk(@Body() dto: RiskInterpretDto): Promise<AgentRiskInterpretResponse | AgentRiskInterpretError> {
|
||||||
return this.aiService.interpretRisk(dto);
|
return this.aiService.interpretRisk(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('analysis')
|
||||||
|
async analyze(
|
||||||
|
@Body() dto: AnalysisDto,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
): Promise<AnalysisResponse> {
|
||||||
|
const user = await this.authContext.resolveCurrentUser(request);
|
||||||
|
const permissions = await this.permissionService.resolveUserPermissions(user);
|
||||||
|
return this.businessAnalysisService.analyze(dto, {
|
||||||
|
id: user?.id ?? '',
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,24 @@ import { AiController } from './ai.controller';
|
|||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import { AiGatewayService } from './ai-gateway.service';
|
import { AiGatewayService } from './ai-gateway.service';
|
||||||
import { ConfigModule } from '../config/config.module';
|
import { ConfigModule } from '../config/config.module';
|
||||||
|
import { AuthModule } from '../../common/auth/auth.module';
|
||||||
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
|
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||||||
|
import { MetricEngine } from './analysis/metric-engine';
|
||||||
|
import { PermissionScopeResolver } from './analysis/permission-scope-resolver';
|
||||||
|
import { AnalysisReportBuilder } from './analysis/report-builder';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule, AuthModule, CommonDomainModule],
|
||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [AiService, AiGatewayService],
|
providers: [
|
||||||
|
AiService,
|
||||||
|
AiGatewayService,
|
||||||
|
BusinessAnalysisService,
|
||||||
|
PermissionScopeResolver,
|
||||||
|
MetricEngine,
|
||||||
|
AnalysisReportBuilder,
|
||||||
|
],
|
||||||
exports: [AiService],
|
exports: [AiService],
|
||||||
})
|
})
|
||||||
export class AiModule {}
|
export class AiModule {}
|
||||||
|
|||||||
@@ -271,6 +271,35 @@ describe('AiService', () => {
|
|||||||
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns a readable timeout error when decomposition provider request is aborted', async () => {
|
||||||
|
const callTool = jest.fn().mockRejectedValue(new Error('signal is aborted without reason'));
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({
|
||||||
|
callTool,
|
||||||
|
}),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
(service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0001:手机号登录');
|
||||||
|
|
||||||
|
const result = await service.decompose({
|
||||||
|
prototypeUrl: 'https://example.com/prototype',
|
||||||
|
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
|
||||||
|
members: [{ name: '张三', role: 'frontend' }],
|
||||||
|
versionId: 'version-1',
|
||||||
|
planId: 'plan-1',
|
||||||
|
target: 'dev_tasks',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'API_TIMEOUT',
|
||||||
|
error: expect.stringContaining('AI 服务调用超时'),
|
||||||
|
});
|
||||||
|
expect(callTool).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: expect.any(Number) }));
|
||||||
|
expect(result.ok ? '' : result.error).not.toContain('signal is aborted without reason');
|
||||||
|
});
|
||||||
|
|
||||||
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
||||||
const callTool = jest
|
const callTool = jest
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
} from '@ftb/shared';
|
} from '@ftb/shared';
|
||||||
|
|
||||||
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
|
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
|
||||||
|
const DECOMPOSE_AI_TIMEOUT_MS = 110000;
|
||||||
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
|
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
|
||||||
const PROTOTYPE_FETCH_ATTEMPTS = 3;
|
const PROTOTYPE_FETCH_ATTEMPTS = 3;
|
||||||
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
|
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
|
||||||
@@ -97,6 +98,7 @@ export class AiService {
|
|||||||
forceTool: true,
|
forceTool: true,
|
||||||
maxTokens: 16000,
|
maxTokens: 16000,
|
||||||
model,
|
model,
|
||||||
|
timeoutMs: DECOMPOSE_AI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -108,6 +110,15 @@ export class AiService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isProviderTimeoutError(e)) {
|
||||||
|
this.logger.warn(`AI 拆解调用超时: ${e.message}`);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `AI 服务调用超时:上游模型在${this.formatTimeout(DECOMPOSE_AI_TIMEOUT_MS)}内未返回拆解结果,请稍后重试,或在 AI 配置中切换更快/更稳定的提供商。`,
|
||||||
|
code: 'API_TIMEOUT',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.error(`AI 调用失败: ${e.message}`);
|
this.logger.error(`AI 调用失败: ${e.message}`);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -267,6 +278,17 @@ export class AiService {
|
|||||||
return message.includes('tool_call arguments') && message.includes('JSON');
|
return message.includes('tool_call arguments') && message.includes('JSON');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isProviderTimeoutError(error: any): boolean {
|
||||||
|
const name = String(error?.name || '');
|
||||||
|
const message = String(error?.message || error || '');
|
||||||
|
return /timeout|timed out|aborted|abort/i.test(`${name} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTimeout(ms: number): string {
|
||||||
|
if (ms >= 60000) return `约 ${Math.ceil(ms / 60000)} 分钟`;
|
||||||
|
return `${Math.ceil(ms / 1000)} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchPrototype(url: string): Promise<string> {
|
private async fetchPrototype(url: string): Promise<string> {
|
||||||
if (!url || !url.startsWith('http')) {
|
if (!url || !url.startsWith('http')) {
|
||||||
throw new Error('原型链接无效');
|
throw new Error('原型链接无效');
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { normalizeAnalysisPlan, validateAnalysisPlan } from './analysis-plan-processor';
|
||||||
|
|
||||||
|
describe('analysis plan processor', () => {
|
||||||
|
it('normalizes trend plans without a time range to last 30 days', () => {
|
||||||
|
const plan = normalizeAnalysisPlan(
|
||||||
|
{
|
||||||
|
metricId: 'requirement_completion_count',
|
||||||
|
analysisType: 'trend',
|
||||||
|
dimensions: ['day'],
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan.metricRef).toEqual({ metricId: 'requirement_completion_count', version: 1 });
|
||||||
|
expect(plan.timeRange).toEqual({
|
||||||
|
start: '2026-06-09T00:00:00.000Z',
|
||||||
|
end: '2026-07-08T23:59:59.999Z',
|
||||||
|
policy: 'last_30_days',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps current-state risk plans without a time range', () => {
|
||||||
|
const plan = normalizeAnalysisPlan(
|
||||||
|
{
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['version'],
|
||||||
|
scope: { type: 'managed_projects', projectIds: ['project-1'] },
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan.timeRange).toBeUndefined();
|
||||||
|
expect(plan.limit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported metric dimensions', () => {
|
||||||
|
const plan = normalizeAnalysisPlan({
|
||||||
|
metricId: 'bug_severity_count',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['department'],
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
filters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => validateAnalysisPlan(plan)).toThrow(
|
||||||
|
'Unsupported dimension department for metric bug_severity_count',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { AnalysisPlan, AnalysisType, DataScope, DimensionId, MetricId, TimePolicy } from '@ftb/shared';
|
||||||
|
import { getMetricDefinition } from './metric-catalog';
|
||||||
|
|
||||||
|
export interface AnalysisPlanDraft {
|
||||||
|
metricId: MetricId;
|
||||||
|
metricVersion?: number;
|
||||||
|
analysisType: AnalysisType;
|
||||||
|
dimensions: DimensionId[];
|
||||||
|
scope: DataScope;
|
||||||
|
filters?: Record<string, string | number | boolean | string[] | number[]>;
|
||||||
|
timeRange?: { start: string; end: string; policy?: TimePolicy };
|
||||||
|
limit?: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TOP_N = 20;
|
||||||
|
const DEFAULT_TOP_N = 10;
|
||||||
|
|
||||||
|
export function normalizeAnalysisPlan(input: AnalysisPlanDraft, now = new Date()): AnalysisPlan {
|
||||||
|
const metric = getMetricDefinition(input.metricId, input.metricVersion);
|
||||||
|
if (!metric) throw new BadRequestException(`Unknown metric ${input.metricId}`);
|
||||||
|
|
||||||
|
const needsDefaultTime = metric.defaultTimePolicy === 'last_30_days' && !input.timeRange;
|
||||||
|
const timeRange = input.timeRange
|
||||||
|
? { start: input.timeRange.start, end: input.timeRange.end, policy: input.timeRange.policy ?? 'explicit_range' as const }
|
||||||
|
: needsDefaultTime
|
||||||
|
? last30Days(now)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: { metricId: metric.metricId, version: metric.version },
|
||||||
|
analysisType: input.analysisType,
|
||||||
|
dimensions: input.dimensions.length > 0
|
||||||
|
? input.dimensions
|
||||||
|
: metric.defaultDimension
|
||||||
|
? [metric.defaultDimension]
|
||||||
|
: [],
|
||||||
|
filters: input.filters ?? {},
|
||||||
|
scope: input.scope,
|
||||||
|
...(timeRange ? { timeRange } : {}),
|
||||||
|
limit: normalizeLimit(input.limit, input.analysisType),
|
||||||
|
...(input.sort ? { sort: input.sort } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAnalysisPlan(plan: AnalysisPlan): void {
|
||||||
|
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
|
||||||
|
if (!metric || metric.status !== 'active') {
|
||||||
|
throw new BadRequestException(`Unknown metric ${plan.metricRef.metricId}`);
|
||||||
|
}
|
||||||
|
if (!metric.supportedAnalysisTypes.includes(plan.analysisType)) {
|
||||||
|
throw new BadRequestException(`Unsupported analysis type ${plan.analysisType} for metric ${plan.metricRef.metricId}`);
|
||||||
|
}
|
||||||
|
for (const dimension of plan.dimensions) {
|
||||||
|
if (!metric.supportedDimensions.includes(dimension)) {
|
||||||
|
throw new BadRequestException(`Unsupported dimension ${dimension} for metric ${plan.metricRef.metricId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (plan.limit !== undefined && (plan.limit < 1 || plan.limit > MAX_TOP_N)) {
|
||||||
|
throw new BadRequestException(`Top N limit must be between 1 and ${MAX_TOP_N}`);
|
||||||
|
}
|
||||||
|
if (plan.timeRange) {
|
||||||
|
const start = Date.parse(plan.timeRange.start);
|
||||||
|
const end = Date.parse(plan.timeRange.end);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
|
||||||
|
throw new BadRequestException('Invalid time range');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(limit: number | undefined, analysisType: AnalysisType): number | undefined {
|
||||||
|
if (!['ranking', 'breakdown', 'distribution', 'composition'].includes(analysisType)) return limit;
|
||||||
|
const value = limit ?? DEFAULT_TOP_N;
|
||||||
|
return Math.min(Math.max(1, value), MAX_TOP_N);
|
||||||
|
}
|
||||||
|
|
||||||
|
function last30Days(now: Date): NonNullable<AnalysisPlan['timeRange']> {
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setUTCHours(23, 59, 59, 999);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setUTCDate(start.getUTCDate() - 29);
|
||||||
|
start.setUTCHours(0, 0, 0, 0);
|
||||||
|
return {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
policy: 'last_30_days',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { parseSemanticIntent } from './analysis-semantic-layer';
|
||||||
|
|
||||||
|
describe('analysis semantic layer', () => {
|
||||||
|
it('maps busy wording to workload with high confidence', () => {
|
||||||
|
expect(parseSemanticIntent('最近哪个部门最忙')).toMatchObject({
|
||||||
|
concept: 'workload',
|
||||||
|
semanticConfidence: 'high',
|
||||||
|
metricId: 'department_workload',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps pressure wording to workload pressure with medium confidence', () => {
|
||||||
|
expect(parseSemanticIntent('谁压力最大')).toMatchObject({
|
||||||
|
concept: 'work_pressure',
|
||||||
|
semanticConfidence: 'medium',
|
||||||
|
metricId: 'member_pending_work',
|
||||||
|
analysisType: 'ranking',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps project risk as current-state release risk', () => {
|
||||||
|
expect(parseSemanticIntent('这个项目风险怎么样')).toMatchObject({
|
||||||
|
concept: 'release_risk',
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
timePolicy: 'current_state',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { AnalysisType, MetricId, TimePolicy } from '@ftb/shared';
|
||||||
|
|
||||||
|
export type SemanticConcept =
|
||||||
|
| 'workload'
|
||||||
|
| 'work_pressure'
|
||||||
|
| 'release_risk'
|
||||||
|
| 'delay'
|
||||||
|
| 'delivery_efficiency'
|
||||||
|
| 'quality_risk'
|
||||||
|
| 'requirement_completion'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export interface SemanticIntent {
|
||||||
|
concept: SemanticConcept;
|
||||||
|
metricId: MetricId;
|
||||||
|
analysisType: AnalysisType;
|
||||||
|
timePolicy: TimePolicy;
|
||||||
|
semanticConfidence: 'high' | 'medium' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSemanticIntent(question: string): SemanticIntent {
|
||||||
|
const text = question.trim().toLowerCase();
|
||||||
|
if (/(风险|能不能发版|能否发版|高危|延期风险)/.test(text)) {
|
||||||
|
return intent('release_risk', 'version_risk_score', 'ranking', 'current_state', 'high');
|
||||||
|
}
|
||||||
|
if (/(压力|压着|吃紧)/.test(text)) {
|
||||||
|
return intent('work_pressure', 'member_pending_work', 'ranking', 'current_state', 'medium');
|
||||||
|
}
|
||||||
|
if (/(忙|负载|待办|任务最多)/.test(text)) {
|
||||||
|
const metricId: MetricId = /(部门|产品部|研发|测试)/.test(text)
|
||||||
|
? 'department_workload'
|
||||||
|
: 'member_pending_work';
|
||||||
|
return intent('workload', metricId, 'ranking', 'current_state', 'high');
|
||||||
|
}
|
||||||
|
if (/(延期|逾期|超期)/.test(text)) {
|
||||||
|
return intent('delay', 'overdue_item_count', 'ranking', 'current_state', 'high');
|
||||||
|
}
|
||||||
|
if (/(需求).*(完成|趋势)|完成.*需求/.test(text)) {
|
||||||
|
return intent('requirement_completion', 'requirement_completion_count', 'trend', 'last_30_days', 'high');
|
||||||
|
}
|
||||||
|
if (/(bug|缺陷|质量|测试失败|通过率)/.test(text)) {
|
||||||
|
const metricId: MetricId = /(通过率)/.test(text) ? 'test_pass_rate' : 'bug_severity_count';
|
||||||
|
const analysisType: AnalysisType = metricId === 'test_pass_rate' ? 'trend' : 'distribution';
|
||||||
|
return intent(
|
||||||
|
'quality_risk',
|
||||||
|
metricId,
|
||||||
|
analysisType,
|
||||||
|
metricId === 'test_pass_rate' ? 'last_30_days' : 'current_state',
|
||||||
|
'high',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (/(加班|投入|工时)/.test(text)) {
|
||||||
|
return intent('workload', 'member_effort_hours', 'ranking', 'last_30_days', 'high');
|
||||||
|
}
|
||||||
|
return intent('unknown', 'member_pending_work', 'summary', 'current_state', 'low');
|
||||||
|
}
|
||||||
|
|
||||||
|
function intent(
|
||||||
|
concept: SemanticConcept,
|
||||||
|
metricId: MetricId,
|
||||||
|
analysisType: AnalysisType,
|
||||||
|
timePolicy: TimePolicy,
|
||||||
|
semanticConfidence: 'high' | 'medium' | 'low',
|
||||||
|
): SemanticIntent {
|
||||||
|
return { concept, metricId, analysisType, timePolicy, semanticConfidence };
|
||||||
|
}
|
||||||
53
apps/server/src/modules/ai/analysis/analysis-strategy.ts
Normal file
53
apps/server/src/modules/ai/analysis/analysis-strategy.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { AnalysisPlan, AnalysisRequest, DimensionId } from '@ftb/shared';
|
||||||
|
import { parseSemanticIntent } from './analysis-semantic-layer';
|
||||||
|
import type { SemanticIntent } from './analysis-semantic-layer';
|
||||||
|
import { normalizeAnalysisPlan } from './analysis-plan-processor';
|
||||||
|
import { getMetricDefinition } from './metric-catalog';
|
||||||
|
|
||||||
|
export function createAnalysisPlanFromQuestion(
|
||||||
|
request: AnalysisRequest,
|
||||||
|
scope: AnalysisPlan['scope'],
|
||||||
|
now = new Date(),
|
||||||
|
): { semantic: SemanticIntent; plan: AnalysisPlan } {
|
||||||
|
const semantic = parseSemanticIntent(request.question);
|
||||||
|
const dimensions = inferDimensions(request.question, semantic.metricId);
|
||||||
|
const plan = normalizeAnalysisPlan(
|
||||||
|
{
|
||||||
|
metricId: semantic.metricId,
|
||||||
|
analysisType: semantic.analysisType,
|
||||||
|
dimensions,
|
||||||
|
scope,
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return { semantic, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDimensions(
|
||||||
|
question: string,
|
||||||
|
metricId: AnalysisPlan['metricRef']['metricId'],
|
||||||
|
): DimensionId[] {
|
||||||
|
const metric = getMetricDefinition(metricId);
|
||||||
|
const preferred = inferPreferredDimension(question, metricId);
|
||||||
|
if (preferred && metric?.supportedDimensions.includes(preferred)) return [preferred];
|
||||||
|
if (metric?.defaultDimension) return [metric.defaultDimension];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPreferredDimension(
|
||||||
|
question: string,
|
||||||
|
metricId: AnalysisPlan['metricRef']['metricId'],
|
||||||
|
): DimensionId | null {
|
||||||
|
if (/部门/.test(question)) return 'department';
|
||||||
|
if (/成员|谁|负责人/.test(question)) return 'member';
|
||||||
|
if (/产品/.test(question)) return 'product';
|
||||||
|
if (/项目/.test(question)) return 'project';
|
||||||
|
if (/月|月份/.test(question)) return 'month';
|
||||||
|
if (/周/.test(question)) return 'week';
|
||||||
|
if (metricId === 'version_risk_score') return 'version';
|
||||||
|
if (metricId === 'bug_severity_count') return 'bug_severity';
|
||||||
|
if (metricId === 'requirement_status_count') return 'requirement_status';
|
||||||
|
if (metricId === 'requirement_source_count') return 'requirement_source';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { BusinessAnalysisService } from './business-analysis.service';
|
||||||
|
|
||||||
|
describe('BusinessAnalysisService', () => {
|
||||||
|
function makeService() {
|
||||||
|
const scopeResolver = {
|
||||||
|
resolveAnalysisScope: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ type: 'version', versionId: 'ver-1' }),
|
||||||
|
};
|
||||||
|
const metricEngine = {
|
||||||
|
executeMetric: jest.fn().mockResolvedValue({
|
||||||
|
metricRef: { metricId: 'version_risk_score', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
columns: [{ id: 'label', label: '版本', type: 'string' }],
|
||||||
|
rows: [{ label: 'V1', value: 88 }],
|
||||||
|
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
|
||||||
|
dataScope: { type: 'version', versionId: 'ver-1' },
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const reportBuilder = {
|
||||||
|
build: jest.fn().mockResolvedValue({
|
||||||
|
summary: 'V1 风险较高。',
|
||||||
|
keyFindings: ['风险分 88。'],
|
||||||
|
evidence: [{ label: '风险版本', value: 1, sourceDomain: 'xiaobao' }],
|
||||||
|
suggestions: ['优先处理阻塞和严重 Bug。'],
|
||||||
|
dataScope: {
|
||||||
|
timeDescription: '当前状态',
|
||||||
|
permissionDescription: '当前版本',
|
||||||
|
metricFormulaDescription: '小宝风险分',
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new BusinessAnalysisService(
|
||||||
|
scopeResolver as any,
|
||||||
|
metricEngine as any,
|
||||||
|
reportBuilder as any,
|
||||||
|
);
|
||||||
|
return { service, scopeResolver, metricEngine, reportBuilder };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns insight, chart, report, evidence, and follow-ups', async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
const result = await service.analyze(
|
||||||
|
{
|
||||||
|
question: '这个版本风险怎么样',
|
||||||
|
context: { surface: 'version_detail', versionId: 'ver-1' },
|
||||||
|
},
|
||||||
|
{ id: 'm-1', permissions: [] },
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.chart.kind).toBe('horizontal_bar');
|
||||||
|
expect(result.insight.summary).toContain('V1');
|
||||||
|
expect(result.report.summary).toContain('风险');
|
||||||
|
expect(
|
||||||
|
result.followUps.some(
|
||||||
|
(item: { type: string }) => item.type === 'question',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns NO_DATA when metric result has no rows', async () => {
|
||||||
|
const { service, metricEngine } = makeService();
|
||||||
|
metricEngine.executeMetric.mockResolvedValueOnce({
|
||||||
|
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||||||
|
analysisType: 'trend',
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
evidence: [{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }],
|
||||||
|
dataScope: { type: 'self', userId: 'm-1' },
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.analyze(
|
||||||
|
{ question: '需求完成趋势' },
|
||||||
|
{ id: 'm-1', permissions: [] },
|
||||||
|
new Date('2026-07-08T12:00:00.000Z'),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: false, code: 'NO_DATA' });
|
||||||
|
});
|
||||||
|
});
|
||||||
104
apps/server/src/modules/ai/analysis/business-analysis.service.ts
Normal file
104
apps/server/src/modules/ai/analysis/business-analysis.service.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||||||
|
import { getMetricDefinition } from './metric-catalog';
|
||||||
|
import { PermissionScopeResolver } from './permission-scope-resolver';
|
||||||
|
import { MetricEngine } from './metric-engine';
|
||||||
|
import { AnalysisReportBuilder } from './report-builder';
|
||||||
|
import { createAnalysisPlanFromQuestion } from './analysis-strategy';
|
||||||
|
import { validateAnalysisPlan } from './analysis-plan-processor';
|
||||||
|
import { buildUnifiedChartSpec } from './chart-spec-builder';
|
||||||
|
import { buildInsightCard } from './insight-engine';
|
||||||
|
import { buildFollowUps } from './follow-up-builder';
|
||||||
|
|
||||||
|
export interface AnalysisActor {
|
||||||
|
id: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BusinessAnalysisService {
|
||||||
|
constructor(
|
||||||
|
private readonly scopeResolver: PermissionScopeResolver,
|
||||||
|
private readonly metricEngine: MetricEngine,
|
||||||
|
private readonly reportBuilder: AnalysisReportBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async analyze(
|
||||||
|
request: AnalysisRequest,
|
||||||
|
actor: AnalysisActor,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<AnalysisResponse> {
|
||||||
|
const question = request.question.trim();
|
||||||
|
if (!question) {
|
||||||
|
return { ok: false, code: 'AMBIGUOUS_INTENT', message: '请输入要分析的问题。' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let scope;
|
||||||
|
try {
|
||||||
|
scope = await this.scopeResolver.resolveAnalysisScope({
|
||||||
|
actorId: actor.id,
|
||||||
|
permissions: actor.permissions,
|
||||||
|
context: request.context,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) {
|
||||||
|
return { ok: false, code: 'NO_PERMISSION', message: '当前用户没有该范围的分析权限。' };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { semantic, plan } = createAnalysisPlanFromQuestion({ ...request, question }, scope, now);
|
||||||
|
if (semantic.semanticConfidence === 'low') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'AMBIGUOUS_INTENT',
|
||||||
|
message: '这个问题有多种理解,请选择一个分析方向。',
|
||||||
|
clarificationOptions: [
|
||||||
|
{ label: '成员负载', prompt: '分析成员待办排行' },
|
||||||
|
{ label: '版本风险', prompt: '分析版本风险排行' },
|
||||||
|
],
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateAnalysisPlan(plan);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BadRequestException) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_PLAN',
|
||||||
|
message: error.message,
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = getMetricDefinition(plan.metricRef.metricId, plan.metricRef.version);
|
||||||
|
if (!metric) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'UNSUPPORTED_ANALYSIS',
|
||||||
|
message: '当前指标不在分析目录中。',
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricResult = await this.metricEngine.executeMetric(plan, now);
|
||||||
|
if (metricResult.rows.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'NO_DATA',
|
||||||
|
message: '当前范围没有可分析的数据。可以调整时间范围、切换维度或查看当前状态。',
|
||||||
|
dataScope: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
|
||||||
|
const chart = buildUnifiedChartSpec(metricResult, metric);
|
||||||
|
const report = await this.reportBuilder.build(metricResult, insight, metric, plan);
|
||||||
|
const followUps = buildFollowUps(metricResult, plan);
|
||||||
|
return { ok: true, plan, metricResult, insight, chart, report, followUps };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/server/src/modules/ai/analysis/chart-spec-builder.ts
Normal file
35
apps/server/src/modules/ai/analysis/chart-spec-builder.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { MetricDefinition, MetricResult, UnifiedChartSpec } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function buildUnifiedChartSpec(
|
||||||
|
result: MetricResult,
|
||||||
|
metric: MetricDefinition,
|
||||||
|
): UnifiedChartSpec {
|
||||||
|
const labelColumn = result.columns.find((column) => column.type === 'string');
|
||||||
|
const valueColumn = result.columns.find(
|
||||||
|
(column) => column.type === 'number' || column.type === 'percent',
|
||||||
|
);
|
||||||
|
const labelField = labelColumn?.id ?? 'label';
|
||||||
|
const valueField = valueColumn?.id ?? 'value';
|
||||||
|
const isTrend = metric.defaultChart === 'line_area';
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: metric.defaultChart,
|
||||||
|
title: metric.name,
|
||||||
|
subtitle: metric.description,
|
||||||
|
dataset: {
|
||||||
|
source: result.rows,
|
||||||
|
label: labelField,
|
||||||
|
value: valueField,
|
||||||
|
x: isTrend ? labelField : undefined,
|
||||||
|
y: isTrend ? valueField : undefined,
|
||||||
|
},
|
||||||
|
encoding: {
|
||||||
|
x: isTrend ? { field: labelField, label: labelColumn?.label ?? labelField } : undefined,
|
||||||
|
y: isTrend ? { field: valueField, label: valueColumn?.label ?? valueField } : undefined,
|
||||||
|
value: { field: valueField, label: valueColumn?.label ?? valueField },
|
||||||
|
color: { mode: result.metricRef.metricId === 'version_risk_score' ? 'risk' : 'single' },
|
||||||
|
},
|
||||||
|
annotations: [],
|
||||||
|
stylePreset: 'apple_vision_light',
|
||||||
|
};
|
||||||
|
}
|
||||||
17
apps/server/src/modules/ai/analysis/follow-up-builder.ts
Normal file
17
apps/server/src/modules/ai/analysis/follow-up-builder.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { AnalysisPlan, FollowUp, MetricResult } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function buildFollowUps(result: MetricResult, plan: AnalysisPlan): FollowUp[] {
|
||||||
|
const top = result.rows[0];
|
||||||
|
const baseQuestion = top?.label ? `为什么${String(top.label)}最高?` : '换一个维度继续分析';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ type: 'question', label: '继续分析原因', prompt: baseQuestion },
|
||||||
|
{
|
||||||
|
type: 'drilldown',
|
||||||
|
label: '查看明细',
|
||||||
|
target: plan.dimensions[0] ?? 'analysis',
|
||||||
|
filters: plan.filters,
|
||||||
|
},
|
||||||
|
{ type: 'export', label: '导出报告', format: 'pdf' },
|
||||||
|
];
|
||||||
|
}
|
||||||
23
apps/server/src/modules/ai/analysis/insight-engine.ts
Normal file
23
apps/server/src/modules/ai/analysis/insight-engine.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
|
||||||
|
|
||||||
|
export function buildInsightCard(
|
||||||
|
result: MetricResult,
|
||||||
|
metric: MetricDefinition,
|
||||||
|
semanticConfidence: InsightCard['semanticConfidence'],
|
||||||
|
): InsightCard {
|
||||||
|
const top = result.rows[0];
|
||||||
|
const topValue = top?.value ?? result.totals?.value ?? 0;
|
||||||
|
const topLabel = String(top?.label ?? metric.name);
|
||||||
|
const dataConfidence: InsightCard['dataConfidence'] =
|
||||||
|
result.rows.length === 0 ? 'insufficient' : result.rows.length < 3 ? 'partial' : 'sufficient';
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary:
|
||||||
|
result.rows.length === 0
|
||||||
|
? `${metric.name}暂无可分析数据。`
|
||||||
|
: `${topLabel}在${metric.name}中最突出。`,
|
||||||
|
primaryValue: { label: metric.name, value: topValue },
|
||||||
|
semanticConfidence,
|
||||||
|
dataConfidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
21
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
Normal file
21
apps/server/src/modules/ai/analysis/metric-catalog.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { getMetricDefinition, listMetricDefinitions } from './metric-catalog';
|
||||||
|
|
||||||
|
describe('metric catalog', () => {
|
||||||
|
it('defines versioned active metrics used by the MVP templates', () => {
|
||||||
|
const metric = getMetricDefinition('version_risk_score');
|
||||||
|
|
||||||
|
expect(metric).toMatchObject({
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
version: 1,
|
||||||
|
status: 'active',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose raw chart renderer contracts', () => {
|
||||||
|
const charts = listMetricDefinitions().map((item) => item.defaultChart);
|
||||||
|
|
||||||
|
expect(charts).toContain('horizontal_bar');
|
||||||
|
expect(charts).not.toContain('echarts_option');
|
||||||
|
});
|
||||||
|
});
|
||||||
210
apps/server/src/modules/ai/analysis/metric-catalog.ts
Normal file
210
apps/server/src/modules/ai/analysis/metric-catalog.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type { MetricDefinition, MetricId } from '@ftb/shared';
|
||||||
|
|
||||||
|
export const METRIC_CATALOG: MetricDefinition[] = [
|
||||||
|
{
|
||||||
|
metricId: 'version_risk_score',
|
||||||
|
version: 1,
|
||||||
|
name: '版本风险分',
|
||||||
|
description: '基于小宝风险摘要的当前版本风险排行。',
|
||||||
|
formula: 'xiaobao_risk_summaries.risk_score',
|
||||||
|
owner: 'xiaobao',
|
||||||
|
supportedDimensions: ['version', 'project', 'product'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'summary'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'version',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'completion_trend',
|
||||||
|
version: 1,
|
||||||
|
name: '完成趋势',
|
||||||
|
description: '按日期统计完成的计划、开发任务、测试用例、Bug 或需求数量。',
|
||||||
|
formula: 'count(completed_at or terminal status updated_at) by day',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'day',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'overdue_item_count',
|
||||||
|
version: 1,
|
||||||
|
name: '逾期事项数',
|
||||||
|
description: '当前超过计划结束时间且未完成的事项数量。',
|
||||||
|
formula: 'count(open items where due_at < now)',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['version', 'project', 'member', 'department'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'distribution', 'breakdown'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'version',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'requirement_status_count',
|
||||||
|
version: 1,
|
||||||
|
name: '需求状态分布',
|
||||||
|
description: '按需求状态统计需求数量。',
|
||||||
|
formula: 'count(requirements) by status',
|
||||||
|
owner: 'requirement',
|
||||||
|
supportedDimensions: ['requirement_status', 'product', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['composition', 'distribution', 'summary'],
|
||||||
|
defaultChart: 'donut',
|
||||||
|
defaultDimension: 'requirement_status',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'requirement_completion_count',
|
||||||
|
version: 1,
|
||||||
|
name: '需求完成数量',
|
||||||
|
description: '按时间统计进入 released 或 closed 的需求数量。',
|
||||||
|
formula: "count(requirements where status in ('released','closed')) by time bucket",
|
||||||
|
owner: 'requirement',
|
||||||
|
supportedDimensions: ['day', 'week', 'month', 'product', 'project'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'day',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'requirement_source_count',
|
||||||
|
version: 1,
|
||||||
|
name: '需求来源/类型占比',
|
||||||
|
description: '按需求来源或类型统计需求数量。',
|
||||||
|
formula: 'count(requirements) by source_type or type',
|
||||||
|
owner: 'requirement',
|
||||||
|
supportedDimensions: ['requirement_source', 'requirement_type', 'product', 'project'],
|
||||||
|
supportedAnalysisTypes: ['composition', 'distribution'],
|
||||||
|
defaultChart: 'donut',
|
||||||
|
defaultDimension: 'requirement_source',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'department_workload',
|
||||||
|
version: 1,
|
||||||
|
name: '部门负载',
|
||||||
|
description: '按部门统计当前未完成事项数量。',
|
||||||
|
formula: 'count(open work items grouped by user.department_id)',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['department'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'breakdown'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'department',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'member_pending_work',
|
||||||
|
version: 1,
|
||||||
|
name: '成员待办',
|
||||||
|
description: '按成员统计当前未完成事项数量。',
|
||||||
|
formula: 'count(open work items grouped by assignee or owner)',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['member', 'role', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'breakdown'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'member',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'member_effort_hours',
|
||||||
|
version: 1,
|
||||||
|
name: '成员投入工时',
|
||||||
|
description: '按成员统计工作活动、工时记录和加班投入。',
|
||||||
|
formula: 'sum(task_worklogs.hours + overtime_records.hours) by user',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['member', 'department', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['ranking', 'comparison'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultDimension: 'member',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'bug_severity_count',
|
||||||
|
version: 1,
|
||||||
|
name: 'Bug 严重度分布',
|
||||||
|
description: '按严重度统计未关闭 Bug 数量。',
|
||||||
|
formula: "count(bugs where status not in ('closed','rejected')) by severity",
|
||||||
|
owner: 'quality',
|
||||||
|
supportedDimensions: ['bug_severity', 'member', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['distribution', 'ranking', 'breakdown'],
|
||||||
|
defaultChart: 'stacked_horizontal_bar',
|
||||||
|
defaultDimension: 'bug_severity',
|
||||||
|
defaultTimePolicy: 'current_state',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'test_pass_rate',
|
||||||
|
version: 1,
|
||||||
|
name: '测试通过率',
|
||||||
|
description: '按时间统计测试通过用例占已执行用例比例。',
|
||||||
|
formula: "passed / count(test_cases where status in ('passed','failed','blocked'))",
|
||||||
|
owner: 'quality',
|
||||||
|
supportedDimensions: ['day', 'week', 'month', 'version', 'project'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'day',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'overtime_reason_hours',
|
||||||
|
version: 1,
|
||||||
|
name: '加班原因工时',
|
||||||
|
description: '按加班原因统计加班时长。',
|
||||||
|
formula: 'sum(overtime_records.hours) by reason',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['delay_reason', 'member', 'department', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['composition', 'ranking'],
|
||||||
|
defaultChart: 'donut',
|
||||||
|
defaultDimension: 'delay_reason',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'delay_rate',
|
||||||
|
version: 1,
|
||||||
|
name: '延期率',
|
||||||
|
description: '按范围统计逾期事项占全部计划事项比例。',
|
||||||
|
formula: 'overdue_count / planned_item_count',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['month', 'project', 'version', 'department'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'comparison'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'month',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metricId: 'delay_reason_count',
|
||||||
|
version: 1,
|
||||||
|
name: '延期原因数量',
|
||||||
|
description: '按原因统计延期相关需求变更或加班原因。',
|
||||||
|
formula: 'count(requirement.change_reason) + count(overtime.reason)',
|
||||||
|
owner: 'analysis',
|
||||||
|
supportedDimensions: ['delay_reason', 'month', 'project', 'version'],
|
||||||
|
supportedAnalysisTypes: ['trend', 'composition', 'breakdown'],
|
||||||
|
defaultChart: 'line_area',
|
||||||
|
defaultDimension: 'delay_reason',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function listMetricDefinitions(): MetricDefinition[] {
|
||||||
|
return METRIC_CATALOG.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMetricDefinition(metricId: MetricId, version?: number): MetricDefinition | null {
|
||||||
|
const candidates = METRIC_CATALOG.filter((item) => item.metricId === metricId);
|
||||||
|
if (version !== undefined) return candidates.find((item) => item.version === version) ?? null;
|
||||||
|
return candidates.find((item) => item.status === 'active') ?? null;
|
||||||
|
}
|
||||||
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal file
93
apps/server/src/modules/ai/analysis/metric-engine.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { MetricEngine } from './metric-engine';
|
||||||
|
|
||||||
|
describe('MetricEngine', () => {
|
||||||
|
function makeEngine() {
|
||||||
|
const prisma = {
|
||||||
|
xiaobaoRiskSummary: { findMany: jest.fn() },
|
||||||
|
version: { findMany: jest.fn() },
|
||||||
|
versionPlan: { findMany: jest.fn() },
|
||||||
|
devTask: { findMany: jest.fn() },
|
||||||
|
testCase: { findMany: jest.fn() },
|
||||||
|
bug: { findMany: jest.fn() },
|
||||||
|
requirement: { findMany: jest.fn() },
|
||||||
|
taskWorklog: { findMany: jest.fn() },
|
||||||
|
overtimeRecord: { findMany: jest.fn() },
|
||||||
|
user: { findMany: jest.fn() },
|
||||||
|
};
|
||||||
|
return { prisma, engine: new MetricEngine(prisma as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns version risk ranking from Xiaobao summaries', async () => {
|
||||||
|
const { prisma, engine } = makeEngine();
|
||||||
|
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
|
||||||
|
{ versionId: 'ver-1', riskLevel: 'blocked', riskScore: 92, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
|
||||||
|
{ versionId: 'ver-2', riskLevel: 'at_risk', riskScore: 71, updatedAt: new Date('2026-07-08T00:00:00.000Z') },
|
||||||
|
]);
|
||||||
|
prisma.version.findMany.mockResolvedValue([
|
||||||
|
{ id: 'ver-1', name: 'V1', projectId: 'project-1', productId: 'product-1' },
|
||||||
|
{ id: 'ver-2', name: 'V2', projectId: 'project-1', productId: 'product-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await engine.executeMetric({
|
||||||
|
metricRef: { metricId: 'version_risk_score', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['version'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'project', projectId: 'project-1' },
|
||||||
|
limit: 10,
|
||||||
|
}, new Date('2026-07-08T12:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([
|
||||||
|
{ versionId: 'ver-1', label: 'V1', value: 92, riskLevel: 'blocked' },
|
||||||
|
{ versionId: 'ver-2', label: 'V2', value: 71, riskLevel: 'at_risk' },
|
||||||
|
]);
|
||||||
|
expect(result.evidence[0]).toMatchObject({ label: '风险版本', value: 2, sourceDomain: 'xiaobao' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups member pending work across plans, tasks, cases, and bugs', async () => {
|
||||||
|
const { prisma, engine } = makeEngine();
|
||||||
|
prisma.versionPlan.findMany.mockResolvedValue([{ id: 'p1', ownerId: 'm-1', title: '产品方案', versionId: 'ver-1' }]);
|
||||||
|
prisma.devTask.findMany.mockResolvedValue([{ id: 'd1', assigneeId: 'm-1', title: '接口', versionId: 'ver-1' }]);
|
||||||
|
prisma.testCase.findMany.mockResolvedValue([{ id: 't1', assigneeId: 'm-2', title: '测试', versionId: 'ver-1' }]);
|
||||||
|
prisma.bug.findMany.mockResolvedValue([{ id: 'b1', assigneeId: 'm-1', title: '缺陷', versionId: 'ver-1' }]);
|
||||||
|
prisma.user.findMany.mockResolvedValue([
|
||||||
|
{ id: 'm-1', name: '张三', departmentId: '研发' },
|
||||||
|
{ id: 'm-2', name: '李四', departmentId: '测试' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await engine.executeMetric({
|
||||||
|
metricRef: { metricId: 'member_pending_work', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['member'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'version', versionId: 'ver-1' },
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([
|
||||||
|
{ memberId: 'm-1', label: '张三', value: 3 },
|
||||||
|
{ memberId: 'm-2', label: '李四', value: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no-data evidence for empty requirement trends', async () => {
|
||||||
|
const { prisma, engine } = makeEngine();
|
||||||
|
prisma.requirement.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await engine.executeMetric({
|
||||||
|
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||||||
|
analysisType: 'trend',
|
||||||
|
dimensions: ['day'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
timeRange: {
|
||||||
|
start: '2026-06-09T00:00:00.000Z',
|
||||||
|
end: '2026-07-08T23:59:59.999Z',
|
||||||
|
policy: 'last_30_days',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rows).toEqual([]);
|
||||||
|
expect(result.evidence).toEqual([{ label: '可统计记录', value: 0, sourceDomain: 'requirement' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal file
461
apps/server/src/modules/ai/analysis/metric-engine.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { AnalysisPlan, DataScope, EvidenceItem, MetricResult } from '@ftb/shared';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { buildScopedWhere } from './permission-scope-resolver';
|
||||||
|
|
||||||
|
type AnalysisDomain =
|
||||||
|
| 'version'
|
||||||
|
| 'versionPlan'
|
||||||
|
| 'devTask'
|
||||||
|
| 'testCase'
|
||||||
|
| 'bug'
|
||||||
|
| 'requirement'
|
||||||
|
| 'taskWorklog'
|
||||||
|
| 'overtimeRecord';
|
||||||
|
|
||||||
|
type OpenWorkItem = {
|
||||||
|
id: string;
|
||||||
|
versionId?: string | null;
|
||||||
|
title: string;
|
||||||
|
ownerId?: string | null;
|
||||||
|
assigneeId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VersionRow = { id: string; name: string; projectId?: string | null; productId?: string | null };
|
||||||
|
type RiskSummaryRow = { versionId: string; riskLevel: string; riskScore: number; updatedAt: Date };
|
||||||
|
type UserRow = { id: string; name: string; departmentId?: string | null };
|
||||||
|
type UserDepartmentRow = { id: string; departmentId?: string | null };
|
||||||
|
type StatusRow = { status: string };
|
||||||
|
type RequirementSourceRow = { sourceType: string | null; type: string | null };
|
||||||
|
type DateRow = { updatedAt: Date };
|
||||||
|
type WorkHoursRow = { userId: string | null; hours: number };
|
||||||
|
type BugSeverityRow = { severity: string };
|
||||||
|
type TestStatusRow = { status: string; updatedAt: Date };
|
||||||
|
type OvertimeReasonRow = { reason: string; hours: number };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricEngine {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async executeMetric(plan: AnalysisPlan, now = new Date()): Promise<MetricResult> {
|
||||||
|
if (plan.metricRef.metricId === 'version_risk_score') return this.versionRiskRanking(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'member_pending_work') return this.memberPendingWork(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'department_workload') return this.departmentWorkload(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'overdue_item_count') return this.overdueItemCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'requirement_status_count') return this.requirementStatusCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'requirement_completion_count') return this.requirementCompletionTrend(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'requirement_source_count') return this.requirementSourceCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'member_effort_hours') return this.memberEffortHours(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'bug_severity_count') return this.bugSeverityCount(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'test_pass_rate') return this.testPassRate(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'overtime_reason_hours') return this.overtimeReasonHours(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'completion_trend') return this.completionTrend(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'delay_rate') return this.delayRate(plan, now);
|
||||||
|
if (plan.metricRef.metricId === 'delay_reason_count') return this.delayReasonCount(plan, now);
|
||||||
|
|
||||||
|
return emptyResult(plan, now, [{ label: '可统计记录', value: 0, sourceDomain: 'project' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async versionRiskRanking(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const versions = await this.prisma.version.findMany({
|
||||||
|
where: domainWhere(plan.scope, 'version'),
|
||||||
|
select: { id: true, name: true, projectId: true, productId: true },
|
||||||
|
}) as VersionRow[];
|
||||||
|
const versionById = new Map(versions.map((version) => [version.id, version]));
|
||||||
|
const versionIds = versions.map((version) => version.id);
|
||||||
|
const rows: RiskSummaryRow[] = versionIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.xiaobaoRiskSummary.findMany({
|
||||||
|
where: { versionId: { in: versionIds } },
|
||||||
|
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||||||
|
take: plan.limit ?? 10,
|
||||||
|
}) as RiskSummaryRow[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: '版本', type: 'string' },
|
||||||
|
{ id: 'value', label: '风险分', type: 'number' },
|
||||||
|
{ id: 'riskLevel', label: '风险等级', type: 'string' },
|
||||||
|
],
|
||||||
|
rows: rows.map((row) => ({
|
||||||
|
versionId: row.versionId,
|
||||||
|
label: versionById.get(row.versionId)?.name ?? row.versionId,
|
||||||
|
value: row.riskScore,
|
||||||
|
riskLevel: row.riskLevel,
|
||||||
|
})),
|
||||||
|
evidence: [{ label: '风险版本', value: rows.length, sourceDomain: 'xiaobao' }],
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async memberPendingWork(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const [plans, devTasks, testCases, bugs] = await Promise.all([
|
||||||
|
this.prisma.versionPlan.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' } },
|
||||||
|
}),
|
||||||
|
this.prisma.devTask.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' } },
|
||||||
|
}),
|
||||||
|
this.prisma.testCase.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'testCase'), status: { notIn: ['passed', 'failed', 'blocked'] } },
|
||||||
|
}),
|
||||||
|
this.prisma.bug.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
|
||||||
|
}),
|
||||||
|
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
|
||||||
|
const openItems: OpenWorkItem[] = [
|
||||||
|
...plans.map((planItem) => ({ ...planItem, assigneeId: planItem.ownerId })),
|
||||||
|
...devTasks,
|
||||||
|
...testCases,
|
||||||
|
...bugs,
|
||||||
|
];
|
||||||
|
const counts = countByMember(openItems);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: Array.from(counts.keys()) } },
|
||||||
|
select: { id: true, name: true, departmentId: true },
|
||||||
|
}) as UserRow[];
|
||||||
|
const userById = new Map(users.map((user) => [user.id, user]));
|
||||||
|
const rows = Array.from(counts.entries())
|
||||||
|
.map(([memberId, value]) => ({ memberId, label: userById.get(memberId)?.name ?? memberId, value }))
|
||||||
|
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label))
|
||||||
|
.slice(0, plan.limit ?? 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: '成员', type: 'string' },
|
||||||
|
{ id: 'value', label: '待办数', type: 'number' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
evidence: [{ label: '未完成事项', value: sumCounts(counts), sourceDomain: 'dev_task' }],
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async departmentWorkload(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const memberResult = await this.memberPendingWork({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'member_pending_work', version: 1 },
|
||||||
|
dimensions: ['member'],
|
||||||
|
}, now);
|
||||||
|
const memberIds = memberResult.rows.map((row) => String(row.memberId ?? '')).filter(Boolean);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: memberIds } },
|
||||||
|
select: { id: true, departmentId: true },
|
||||||
|
}) as UserDepartmentRow[];
|
||||||
|
const departmentByMember = new Map(users.map((user) => [user.id, user.departmentId || '未分部门']));
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const row of memberResult.rows) {
|
||||||
|
const department = departmentByMember.get(String(row.memberId)) ?? '未分部门';
|
||||||
|
counts.set(department, (counts.get(department) ?? 0) + Number(row.value ?? 0));
|
||||||
|
}
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'部门',
|
||||||
|
'待办数',
|
||||||
|
Array.from(counts.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
'dev_task',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async overdueItemCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const [plans, devTasks, testCases, bugs] = await Promise.all([
|
||||||
|
this.prisma.versionPlan.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'versionPlan'), status: { not: 'completed' }, expectedEndAt: { lt: now } },
|
||||||
|
}),
|
||||||
|
this.prisma.devTask.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'devTask'), status: { not: 'submitted' }, expectedEndAt: { lt: now } },
|
||||||
|
}),
|
||||||
|
this.prisma.testCase.findMany({
|
||||||
|
where: {
|
||||||
|
...domainWhere(plan.scope, 'testCase'),
|
||||||
|
status: { notIn: ['passed', 'failed', 'blocked'] },
|
||||||
|
plannedEndAt: { lt: now },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.bug.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'bug'), status: { in: ['open', 'fixing', 'fixed', 'verifying'] }, plannedFixAt: { lt: now } },
|
||||||
|
}),
|
||||||
|
]) as [OpenWorkItem[], OpenWorkItem[], OpenWorkItem[], OpenWorkItem[]];
|
||||||
|
const byVersion = new Map<string, number>();
|
||||||
|
for (const item of [...plans, ...devTasks, ...testCases, ...bugs]) {
|
||||||
|
if (!item.versionId) continue;
|
||||||
|
byVersion.set(item.versionId, (byVersion.get(item.versionId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'版本',
|
||||||
|
'逾期数',
|
||||||
|
Array.from(byVersion.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
'version',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requirementStatusCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.requirement.findMany({
|
||||||
|
where: domainWhere(plan.scope, 'requirement'),
|
||||||
|
select: { status: true },
|
||||||
|
}) as StatusRow[];
|
||||||
|
return countRows(plan, now, rows.map((row) => row.status || 'unknown'), '状态', '数量', 'requirement');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requirementCompletionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const where = {
|
||||||
|
...domainWhere(plan.scope, 'requirement'),
|
||||||
|
status: { in: ['released', 'closed'] },
|
||||||
|
...timeFilter(plan, 'updatedAt'),
|
||||||
|
};
|
||||||
|
const rows = await this.prisma.requirement.findMany({ where, select: { updatedAt: true } }) as DateRow[];
|
||||||
|
return trendRows(plan, now, rows.map((row) => row.updatedAt), '完成需求', 'requirement');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requirementSourceCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const where = { ...domainWhere(plan.scope, 'requirement'), ...timeFilter(plan, 'createdAt') };
|
||||||
|
const rows = await this.prisma.requirement.findMany({ where, select: { sourceType: true, type: true } }) as RequirementSourceRow[];
|
||||||
|
const dimension = plan.dimensions.includes('requirement_type') ? 'type' : 'sourceType';
|
||||||
|
return countRows(plan, now, rows.map((row) => row[dimension] || '未填写'), '类别', '数量', 'requirement');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async memberEffortHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const [worklogs, overtime] = await Promise.all([
|
||||||
|
this.prisma.taskWorklog.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'taskWorklog'), ...timeFilter(plan, 'createdAt') },
|
||||||
|
select: { userId: true, hours: true },
|
||||||
|
}),
|
||||||
|
this.prisma.overtimeRecord.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
|
||||||
|
select: { userId: true, hours: true },
|
||||||
|
}),
|
||||||
|
]) as [WorkHoursRow[], WorkHoursRow[]];
|
||||||
|
const hours = new Map<string, number>();
|
||||||
|
for (const row of [...worklogs, ...overtime]) {
|
||||||
|
if (!row.userId) continue;
|
||||||
|
hours.set(row.userId, (hours.get(row.userId) ?? 0) + Number(row.hours ?? 0));
|
||||||
|
}
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: Array.from(hours.keys()) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}) as Array<Pick<UserRow, 'id' | 'name'>>;
|
||||||
|
const userById = new Map(users.map((user) => [user.id, user.name]));
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'成员',
|
||||||
|
'小时',
|
||||||
|
Array.from(hours.entries()).map(([memberId, value]) => ({
|
||||||
|
memberId,
|
||||||
|
label: userById.get(memberId) ?? memberId,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
'task_worklog',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bugSeverityCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.bug.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'bug'), status: { notIn: ['closed', 'rejected'] } },
|
||||||
|
select: { severity: true },
|
||||||
|
}) as BugSeverityRow[];
|
||||||
|
return countRows(plan, now, rows.map((row) => row.severity || 'normal'), '严重度', 'Bug 数', 'bug');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testPassRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.testCase.findMany({
|
||||||
|
where: {
|
||||||
|
...domainWhere(plan.scope, 'testCase'),
|
||||||
|
status: { in: ['passed', 'failed', 'blocked'] },
|
||||||
|
...timeFilter(plan, 'updatedAt'),
|
||||||
|
},
|
||||||
|
select: { status: true, updatedAt: true },
|
||||||
|
}) as TestStatusRow[];
|
||||||
|
const buckets = bucketDates(rows.map((row) => row.updatedAt));
|
||||||
|
const source = Array.from(buckets.keys()).map((label) => {
|
||||||
|
const sameDay = rows.filter((row) => dayKey(row.updatedAt) === label);
|
||||||
|
const passed = sameDay.filter((row) => row.status === 'passed').length;
|
||||||
|
return { label, value: sameDay.length === 0 ? 0 : Math.round((passed / sameDay.length) * 100) };
|
||||||
|
});
|
||||||
|
return rowsResult(plan, now, '日期', '通过率', source, 'test_case');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async overtimeReasonHours(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const rows = await this.prisma.overtimeRecord.findMany({
|
||||||
|
where: { ...domainWhere(plan.scope, 'overtimeRecord'), ...timeFilter(plan, 'createdAt') },
|
||||||
|
select: { reason: true, hours: true },
|
||||||
|
}) as OvertimeReasonRow[];
|
||||||
|
const hours = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const reason = row.reason || '未填写';
|
||||||
|
hours.set(reason, (hours.get(reason) ?? 0) + Number(row.hours ?? 0));
|
||||||
|
}
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'原因',
|
||||||
|
'小时',
|
||||||
|
Array.from(hours.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
'overtime',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completionTrend(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
return this.requirementCompletionTrend({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'requirement_completion_count', version: 1 },
|
||||||
|
dimensions: ['day'],
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delayRate(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
const overdue = await this.overdueItemCount({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'overdue_item_count', version: 1 },
|
||||||
|
dimensions: ['version'],
|
||||||
|
analysisType: 'ranking',
|
||||||
|
}, now);
|
||||||
|
const total = overdue.rows.reduce((sum, row) => sum + Number(row.value ?? 0), 0);
|
||||||
|
return {
|
||||||
|
...overdue,
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
rows: overdue.rows.map((row) => ({ ...row, value: total === 0 ? 0 : Number(row.value ?? 0) / total })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delayReasonCount(plan: AnalysisPlan, now: Date): Promise<MetricResult> {
|
||||||
|
return this.overtimeReasonHours({
|
||||||
|
...plan,
|
||||||
|
metricRef: { metricId: 'overtime_reason_hours', version: 1 },
|
||||||
|
dimensions: ['delay_reason'],
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainWhere(scope: DataScope, domain: AnalysisDomain): Record<string, unknown> {
|
||||||
|
const scopedWhere = buildScopedWhere(scope);
|
||||||
|
const base = domain === 'version' && 'versionId' in scopedWhere
|
||||||
|
? { id: scopedWhere.versionId }
|
||||||
|
: scopedWhere;
|
||||||
|
|
||||||
|
if (scope.type !== 'self') return base;
|
||||||
|
|
||||||
|
if (domain === 'version') return { id: { in: [] } };
|
||||||
|
if (domain === 'versionPlan') return { ...base, ownerId: scope.userId };
|
||||||
|
if (domain === 'devTask' || domain === 'testCase' || domain === 'bug') return { ...base, assigneeId: scope.userId };
|
||||||
|
if (domain === 'requirement') return { ...base, creatorId: scope.userId };
|
||||||
|
if (domain === 'taskWorklog' || domain === 'overtimeRecord') return { ...base, userId: scope.userId };
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeFilter(plan: AnalysisPlan, field: string): Record<string, unknown> {
|
||||||
|
if (!plan.timeRange) return {};
|
||||||
|
return { [field]: { gte: new Date(plan.timeRange.start), lte: new Date(plan.timeRange.end) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByMember(items: OpenWorkItem[]): Map<string, number> {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
const memberId = item.assigneeId ?? item.ownerId;
|
||||||
|
if (!memberId) continue;
|
||||||
|
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumCounts(counts: Map<string, number>): number {
|
||||||
|
return Array.from(counts.values()).reduce((sum, count) => sum + count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsResult(
|
||||||
|
plan: AnalysisPlan,
|
||||||
|
now: Date,
|
||||||
|
labelName: string,
|
||||||
|
valueName: string,
|
||||||
|
rows: Array<Record<string, string | number | null>>,
|
||||||
|
sourceDomain: EvidenceItem['sourceDomain'],
|
||||||
|
): MetricResult {
|
||||||
|
const sorted = rows
|
||||||
|
.sort((a, b) => Number(b.value ?? 0) - Number(a.value ?? 0))
|
||||||
|
.slice(0, plan.limit ?? rows.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: labelName, type: 'string' },
|
||||||
|
{ id: 'value', label: valueName, type: 'number' },
|
||||||
|
],
|
||||||
|
rows: sorted,
|
||||||
|
evidence: [{ label: '可统计记录', value: rows.length, sourceDomain }],
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRows(
|
||||||
|
plan: AnalysisPlan,
|
||||||
|
now: Date,
|
||||||
|
labels: string[],
|
||||||
|
labelName: string,
|
||||||
|
valueName: string,
|
||||||
|
sourceDomain: EvidenceItem['sourceDomain'],
|
||||||
|
): MetricResult {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
labelName,
|
||||||
|
valueName,
|
||||||
|
Array.from(counts.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
sourceDomain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendRows(
|
||||||
|
plan: AnalysisPlan,
|
||||||
|
now: Date,
|
||||||
|
dates: Date[],
|
||||||
|
valueLabel: string,
|
||||||
|
sourceDomain: EvidenceItem['sourceDomain'],
|
||||||
|
): MetricResult {
|
||||||
|
const buckets = bucketDates(dates);
|
||||||
|
return rowsResult(
|
||||||
|
plan,
|
||||||
|
now,
|
||||||
|
'日期',
|
||||||
|
valueLabel,
|
||||||
|
Array.from(buckets.entries()).map(([label, value]) => ({ label, value })),
|
||||||
|
sourceDomain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketDates(dates: Date[]): Map<string, number> {
|
||||||
|
const buckets = new Map<string, number>();
|
||||||
|
for (const date of dates) {
|
||||||
|
const key = dayKey(date);
|
||||||
|
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayKey(value: Date | string): string {
|
||||||
|
return new Date(value).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyResult(plan: AnalysisPlan, now: Date, evidence: EvidenceItem[]): MetricResult {
|
||||||
|
return {
|
||||||
|
metricRef: plan.metricRef,
|
||||||
|
analysisType: plan.analysisType,
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
evidence,
|
||||||
|
dataScope: plan.scope,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { buildScopedWhere, PermissionScopeResolver } from './permission-scope-resolver';
|
||||||
|
|
||||||
|
describe('PermissionScopeResolver', () => {
|
||||||
|
function makeResolver() {
|
||||||
|
const prisma = {
|
||||||
|
project: { findFirst: jest.fn() },
|
||||||
|
version: { findFirst: jest.fn() },
|
||||||
|
projectMember: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
|
};
|
||||||
|
const rbac = {
|
||||||
|
assertGlobalPermission: jest.fn(),
|
||||||
|
assertProjectRole: jest.fn(),
|
||||||
|
};
|
||||||
|
return { prisma, rbac, resolver: new PermissionScopeResolver(prisma as any, rbac as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns system scope for wildcard permissions', async () => {
|
||||||
|
const { rbac, resolver } = makeResolver();
|
||||||
|
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-8', via: 'system' });
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-8',
|
||||||
|
permissions: ['*'],
|
||||||
|
context: { surface: 'ai_assistant' },
|
||||||
|
})).resolves.toEqual({ type: 'system', reason: 'admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns managed project scope for management permission', async () => {
|
||||||
|
const { prisma, rbac, resolver } = makeResolver();
|
||||||
|
rbac.assertGlobalPermission.mockResolvedValue({ actorId: 'm-pm', via: 'permission' });
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }, { projectId: 'project-2' }]);
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-pm',
|
||||||
|
permissions: ['management:view'],
|
||||||
|
context: { surface: 'ai_assistant' },
|
||||||
|
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restricts version context to the requested version when the user has page access', async () => {
|
||||||
|
const { prisma, rbac, resolver } = makeResolver();
|
||||||
|
prisma.version.findFirst.mockResolvedValue({ id: 'version-1', projectId: 'project-1' });
|
||||||
|
rbac.assertProjectRole.mockResolvedValue({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
projectId: 'project-1',
|
||||||
|
role: 'member',
|
||||||
|
via: 'project_member',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: [],
|
||||||
|
context: { surface: 'version_detail', versionId: 'version-1' },
|
||||||
|
})).resolves.toEqual({ type: 'version', versionId: 'version-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows product context to project memberships when the actor lacks global product access', async () => {
|
||||||
|
const { prisma, rbac, resolver } = makeResolver();
|
||||||
|
rbac.assertGlobalPermission.mockRejectedValue(new ForbiddenException('No product permission'));
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([
|
||||||
|
{ projectId: 'project-1' },
|
||||||
|
{ projectId: 'project-1' },
|
||||||
|
{ projectId: 'project-2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: [],
|
||||||
|
context: { surface: 'product_detail', productId: 'product-1' },
|
||||||
|
})).resolves.toEqual({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing actor id', async () => {
|
||||||
|
const { resolver } = makeResolver();
|
||||||
|
|
||||||
|
await expect(resolver.resolveAnalysisScope({
|
||||||
|
actorId: '',
|
||||||
|
permissions: [],
|
||||||
|
context: { surface: 'ai_assistant' },
|
||||||
|
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds relation-table where filters for concrete scopes', () => {
|
||||||
|
expect(buildScopedWhere({ type: 'product', productId: 'product-1' })).toEqual({ productId: 'product-1' });
|
||||||
|
expect(buildScopedWhere({ type: 'project', projectId: 'project-1' })).toEqual({ projectId: 'project-1' });
|
||||||
|
expect(buildScopedWhere({ type: 'version', versionId: 'version-1' })).toEqual({ versionId: 'version-1' });
|
||||||
|
expect(buildScopedWhere({ type: 'managed_projects', projectIds: ['project-1', 'project-2'] })).toEqual({
|
||||||
|
projectId: { in: ['project-1', 'project-2'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal file
165
apps/server/src/modules/ai/analysis/permission-scope-resolver.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { AnalysisRequest, DataScope } from '@ftb/shared';
|
||||||
|
import { RbacService } from '../../../common/rbac/rbac.service';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface AnalysisScopeInput {
|
||||||
|
actorId?: string;
|
||||||
|
permissions: string[];
|
||||||
|
context?: AnalysisRequest['context'];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScopedWhere = {
|
||||||
|
productId?: string;
|
||||||
|
projectId?: string | { in: string[] };
|
||||||
|
versionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionScopeResolver {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly rbac: RbacService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolveAnalysisScope(input: AnalysisScopeInput): Promise<DataScope> {
|
||||||
|
const actorId = input.actorId?.trim();
|
||||||
|
if (!actorId) throw new ForbiddenException('Missing actor scope');
|
||||||
|
|
||||||
|
const permissions = input.permissions ?? [];
|
||||||
|
const context = input.context;
|
||||||
|
|
||||||
|
if (context?.surface === 'version_detail' && context.versionId) {
|
||||||
|
return this.resolveVersionScope(actorId, permissions, context.versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.surface === 'project_detail' && context.projectId) {
|
||||||
|
await this.rbac.assertProjectRole({
|
||||||
|
actorId,
|
||||||
|
projectId: context.projectId,
|
||||||
|
allowedRoles: ['viewer'],
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
return { type: 'project', projectId: context.projectId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.surface === 'product_detail' && context.productId) {
|
||||||
|
return this.resolveProductScope(actorId, permissions, context.productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('*')) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
|
return { type: 'system', reason: 'admin' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('management:view')) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
|
return this.resolveManagedProjectsScope(actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedScope = await this.resolveManagedProjectsScope(actorId);
|
||||||
|
if (managedScope.projectIds.length > 0) return managedScope;
|
||||||
|
|
||||||
|
return { type: 'self', userId: actorId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveVersionScope(
|
||||||
|
actorId: string,
|
||||||
|
permissions: string[],
|
||||||
|
versionId: string,
|
||||||
|
): Promise<DataScope> {
|
||||||
|
const version = await this.prisma.version.findFirst({
|
||||||
|
where: { id: versionId },
|
||||||
|
select: { id: true, projectId: true },
|
||||||
|
});
|
||||||
|
if (!version) throw new NotFoundException('Version not found');
|
||||||
|
|
||||||
|
if (!version.projectId) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['version:view', 'management:view'],
|
||||||
|
});
|
||||||
|
return { type: 'version', versionId: version.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rbac.assertProjectRole({
|
||||||
|
actorId,
|
||||||
|
projectId: version.projectId,
|
||||||
|
allowedRoles: ['viewer'],
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
return { type: 'version', versionId: version.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProductScope(
|
||||||
|
actorId: string,
|
||||||
|
permissions: string[],
|
||||||
|
productId: string,
|
||||||
|
): Promise<DataScope> {
|
||||||
|
if (permissions.includes('*')) {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
|
return { type: 'product', productId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.includes('management:view') || permissions.includes('product:view')) {
|
||||||
|
try {
|
||||||
|
await this.rbac.assertGlobalPermission({
|
||||||
|
actorId,
|
||||||
|
permissions,
|
||||||
|
requiredPermissions: ['management:view', 'product:view'],
|
||||||
|
});
|
||||||
|
return { type: 'product', productId };
|
||||||
|
} catch {
|
||||||
|
// Fall through to project membership narrowing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.prisma.projectMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId: actorId,
|
||||||
|
project: { productId },
|
||||||
|
},
|
||||||
|
select: { projectId: true },
|
||||||
|
});
|
||||||
|
const projectIds = uniqueProjectIds(rows);
|
||||||
|
if (projectIds.length === 0) throw new ForbiddenException('No product analysis scope');
|
||||||
|
|
||||||
|
return { type: 'managed_projects', projectIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveManagedProjectsScope(actorId: string): Promise<Extract<DataScope, { type: 'managed_projects' }>> {
|
||||||
|
const rows = await this.prisma.projectMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId: actorId,
|
||||||
|
role: { in: ['owner', 'admin'] },
|
||||||
|
},
|
||||||
|
select: { projectId: true },
|
||||||
|
});
|
||||||
|
return { type: 'managed_projects', projectIds: uniqueProjectIds(rows) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScopedWhere(scope: DataScope): ScopedWhere {
|
||||||
|
if (scope.type === 'product') return { productId: scope.productId };
|
||||||
|
if (scope.type === 'project') return { projectId: scope.projectId };
|
||||||
|
if (scope.type === 'version') return { versionId: scope.versionId };
|
||||||
|
if (scope.type === 'managed_projects') return { projectId: { in: scope.projectIds } };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueProjectIds(rows: Array<{ projectId: string | null | undefined }>): string[] {
|
||||||
|
return Array.from(new Set(rows.map((row) => row.projectId).filter((projectId): projectId is string => Boolean(projectId))));
|
||||||
|
}
|
||||||
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal file
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { AnalysisReportBuilder } from './report-builder';
|
||||||
|
import type { AnalysisPlan, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
|
||||||
|
|
||||||
|
describe('AnalysisReportBuilder', () => {
|
||||||
|
it('describes normalized time ranges from the analysis plan', async () => {
|
||||||
|
const builder = new AnalysisReportBuilder();
|
||||||
|
const metric = {
|
||||||
|
metricId: 'member_effort_hours',
|
||||||
|
version: 1,
|
||||||
|
name: '成员投入工时',
|
||||||
|
description: '成员投入',
|
||||||
|
formula: 'sum(hours)',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['member'],
|
||||||
|
supportedAnalysisTypes: ['ranking'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
} as MetricDefinition;
|
||||||
|
const result = {
|
||||||
|
metricRef: { metricId: 'member_effort_hours', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: '成员', type: 'string' },
|
||||||
|
{ id: 'value', label: '小时', type: 'number' },
|
||||||
|
],
|
||||||
|
rows: [{ label: '张三', value: 8 }],
|
||||||
|
evidence: [],
|
||||||
|
dataScope: { type: 'self', userId: 'm-1' },
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
} as MetricResult;
|
||||||
|
const insight = {
|
||||||
|
summary: '张三投入最高。',
|
||||||
|
semanticConfidence: 'high',
|
||||||
|
dataConfidence: 'partial',
|
||||||
|
} as InsightCard;
|
||||||
|
const plan = {
|
||||||
|
metricRef: { metricId: 'member_effort_hours', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['member'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
timeRange: {
|
||||||
|
start: '2026-06-09T00:00:00.000Z',
|
||||||
|
end: '2026-07-08T23:59:59.999Z',
|
||||||
|
policy: 'last_30_days',
|
||||||
|
},
|
||||||
|
} as AnalysisPlan;
|
||||||
|
|
||||||
|
const report = await (builder.build as any)(result, insight, metric, plan);
|
||||||
|
|
||||||
|
expect(report.dataScope.timeDescription).toBe('最近 30 天');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/server/src/modules/ai/analysis/report-builder.ts
Normal file
66
apps/server/src/modules/ai/analysis/report-builder.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
AnalysisPlan,
|
||||||
|
AnalysisReport,
|
||||||
|
DataScope,
|
||||||
|
InsightCard,
|
||||||
|
MetricDefinition,
|
||||||
|
MetricResult,
|
||||||
|
} from '@ftb/shared';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AnalysisReportBuilder {
|
||||||
|
async build(
|
||||||
|
result: MetricResult,
|
||||||
|
insight: InsightCard,
|
||||||
|
metric: MetricDefinition,
|
||||||
|
plan?: AnalysisPlan,
|
||||||
|
): Promise<AnalysisReport> {
|
||||||
|
return {
|
||||||
|
summary: insight.summary,
|
||||||
|
keyFindings: result.rows
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((row) => `${String(row.label ?? '对象')}:${String(row.value ?? 0)}`),
|
||||||
|
evidence: result.evidence,
|
||||||
|
suggestions:
|
||||||
|
result.rows.length > 0
|
||||||
|
? ['优先查看排名靠前的对象,并进入明细确认原因。']
|
||||||
|
: ['调整时间范围或切换分析维度。'],
|
||||||
|
dataScope: {
|
||||||
|
timeDescription: describeTimeScope(plan, result),
|
||||||
|
permissionDescription: describeScope(result.dataScope),
|
||||||
|
metricFormulaDescription: metric.formula,
|
||||||
|
generatedAt: result.generatedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultHasTime(result: MetricResult): boolean {
|
||||||
|
return result.columns.some((column) => column.type === 'date');
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTimeScope(plan: AnalysisPlan | undefined, result: MetricResult): string {
|
||||||
|
if (!plan?.timeRange) {
|
||||||
|
return resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.timeRange.policy === 'current_state') return '当前状态';
|
||||||
|
if (plan.timeRange.policy === 'last_30_days') return '最近 30 天';
|
||||||
|
if (plan.timeRange.policy === 'lifecycle') return '对象生命周期';
|
||||||
|
if (plan.timeRange.policy === 'user_required') return '用户指定时间范围';
|
||||||
|
return `${formatDate(plan.timeRange.start)} 至 ${formatDate(plan.timeRange.end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return value.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeScope(scope: DataScope): string {
|
||||||
|
if (scope.type === 'system') return '系统管理范围';
|
||||||
|
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
|
||||||
|
if (scope.type === 'self') return '与当前用户相关的数据';
|
||||||
|
if (scope.type === 'product') return `产品范围:${scope.productId}`;
|
||||||
|
if (scope.type === 'project') return `项目范围:${scope.projectId}`;
|
||||||
|
return `版本范围:${scope.versionId}`;
|
||||||
|
}
|
||||||
34
apps/server/src/modules/ai/dto/analysis.dto.ts
Normal file
34
apps/server/src/modules/ai/dto/analysis.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
class AnalysisContextDto {
|
||||||
|
@IsIn(['ai_assistant', 'product_detail', 'project_detail', 'version_detail'])
|
||||||
|
surface!: 'ai_assistant' | 'product_detail' | 'project_detail' | 'version_detail';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
versionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnalysisDto {
|
||||||
|
@IsString()
|
||||||
|
question!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => AnalysisContextDto)
|
||||||
|
context?: AnalysisContextDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
103
apps/server/src/modules/ai/prompts/analysis-plan.ts
Normal file
103
apps/server/src/modules/ai/prompts/analysis-plan.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export const ANALYSIS_PLAN_TOOL_NAME = 'propose_analysis_plan';
|
||||||
|
|
||||||
|
export const ANALYSIS_PLAN_SYSTEM_PROMPT = `
|
||||||
|
你是 FTB 项目管理系统的业务分析计划助手。
|
||||||
|
你只能提出 AnalysisPlan 草案,不能执行查询,不能编写 SQL,不能绕过权限。
|
||||||
|
所有 metricId、analysisType、dimensions、timeRange、filters 和 limit 必须来自系统给定的 Metric Catalog 与 Semantic Layer。
|
||||||
|
当问题无法映射到已暴露能力时,返回 clarificationOptions,不要编造指标。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export const ANALYSIS_PLAN_TOOL_DESCRIPTION =
|
||||||
|
'Return a normalized business analysis plan draft using only exposed metric catalog capabilities.';
|
||||||
|
|
||||||
|
export const ANALYSIS_PLAN_TOOL_INPUT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['metricId', 'analysisType', 'dimensions', 'filters'],
|
||||||
|
properties: {
|
||||||
|
metricId: {
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'version_risk_score',
|
||||||
|
'completion_trend',
|
||||||
|
'overdue_item_count',
|
||||||
|
'requirement_status_count',
|
||||||
|
'requirement_completion_count',
|
||||||
|
'requirement_source_count',
|
||||||
|
'department_workload',
|
||||||
|
'member_pending_work',
|
||||||
|
'member_effort_hours',
|
||||||
|
'bug_severity_count',
|
||||||
|
'test_pass_rate',
|
||||||
|
'overtime_reason_hours',
|
||||||
|
'delay_rate',
|
||||||
|
'delay_reason_count',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
metricVersion: { type: 'number' },
|
||||||
|
analysisType: {
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'ranking',
|
||||||
|
'trend',
|
||||||
|
'comparison',
|
||||||
|
'distribution',
|
||||||
|
'composition',
|
||||||
|
'correlation',
|
||||||
|
'breakdown',
|
||||||
|
'summary',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dimensions: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
enum: [
|
||||||
|
'product',
|
||||||
|
'project',
|
||||||
|
'version',
|
||||||
|
'requirement_status',
|
||||||
|
'requirement_type',
|
||||||
|
'requirement_source',
|
||||||
|
'department',
|
||||||
|
'member',
|
||||||
|
'role',
|
||||||
|
'month',
|
||||||
|
'week',
|
||||||
|
'day',
|
||||||
|
'bug_severity',
|
||||||
|
'bug_status',
|
||||||
|
'test_status',
|
||||||
|
'delay_reason',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filters: { type: 'object', additionalProperties: true },
|
||||||
|
timeRange: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['start', 'end'],
|
||||||
|
properties: {
|
||||||
|
start: { type: 'string' },
|
||||||
|
end: { type: 'string' },
|
||||||
|
policy: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['current_state', 'last_30_days', 'lifecycle', 'user_required', 'explicit_range'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: { type: 'number', minimum: 1, maximum: 20 },
|
||||||
|
sort: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['field', 'direction'],
|
||||||
|
properties: {
|
||||||
|
field: { type: 'string' },
|
||||||
|
direction: { type: 'string', enum: ['asc', 'desc'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
22
apps/server/src/modules/ai/prompts/analysis-report.ts
Normal file
22
apps/server/src/modules/ai/prompts/analysis-report.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const ANALYSIS_REPORT_TOOL_NAME = 'write_analysis_report';
|
||||||
|
|
||||||
|
export const ANALYSIS_REPORT_SYSTEM_PROMPT = `
|
||||||
|
你是 FTB 项目管理系统的业务分析报告助手。
|
||||||
|
你只能基于 MetricResult、InsightCard、Evidence 和 DataScope 写报告。
|
||||||
|
禁止新增数据事实,禁止推测未给出的原因,禁止扩大权限范围。
|
||||||
|
报告必须固定输出 Summary、Key Findings、Evidence、Suggestions、Data Scope。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export const ANALYSIS_REPORT_TOOL_DESCRIPTION =
|
||||||
|
'Write a structured business analysis report from deterministic metric results without inventing facts.';
|
||||||
|
|
||||||
|
export const ANALYSIS_REPORT_TOOL_INPUT_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['summary', 'keyFindings', 'suggestions'],
|
||||||
|
properties: {
|
||||||
|
summary: { type: 'string' },
|
||||||
|
keyFindings: { type: 'array', items: { type: 'string' } },
|
||||||
|
suggestions: { type: 'array', items: { type: 'string' } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -103,4 +103,48 @@ describe('AnthropicProvider', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes request timeout and disables SDK retries for tool calls', async () => {
|
||||||
|
mockCreate.mockResolvedValue({
|
||||||
|
stop_reason: 'tool_use',
|
||||||
|
model: 'claude-test',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu_test',
|
||||||
|
name: 'submit_decompose',
|
||||||
|
input: { ok: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 12,
|
||||||
|
output_tokens: 34,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = new AnthropicProvider('test-key', 'https://example.test');
|
||||||
|
|
||||||
|
await provider.callTool({
|
||||||
|
systemPrompt: '只通过工具返回',
|
||||||
|
userPrompt: '拆解这个原型',
|
||||||
|
tool: {
|
||||||
|
name: 'submit_decompose',
|
||||||
|
description: '提交拆解结果',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { ok: { type: 'boolean' } },
|
||||||
|
required: ['ok'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forceTool: true,
|
||||||
|
maxTokens: 1000,
|
||||||
|
model: 'claude-test',
|
||||||
|
timeoutMs: 110000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
{ timeout: 110000, maxRetries: 0 },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class AnthropicProvider implements AiProvider {
|
|||||||
} as any)
|
} as any)
|
||||||
: { type: 'auto' as const };
|
: { type: 'auto' as const };
|
||||||
|
|
||||||
const response = await this.client.messages.create({
|
const body = {
|
||||||
model: req.model,
|
model: req.model,
|
||||||
max_tokens: req.maxTokens,
|
max_tokens: req.maxTokens,
|
||||||
system: req.systemPrompt,
|
system: req.systemPrompt,
|
||||||
@@ -36,8 +36,11 @@ export class AnthropicProvider implements AiProvider {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tool_choice: toolChoice,
|
tool_choice: toolChoice,
|
||||||
messages: [{ role: 'user', content: req.userPrompt }],
|
messages: [{ role: 'user' as const, content: req.userPrompt }],
|
||||||
});
|
};
|
||||||
|
const response = req.timeoutMs
|
||||||
|
? await this.client.messages.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
|
||||||
|
: await this.client.messages.create(body);
|
||||||
|
|
||||||
const toolUse = response.content.find(
|
const toolUse = response.content.find(
|
||||||
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
|
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
|
||||||
|
|||||||
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal file
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { OpenAIProvider } from './openai.provider';
|
||||||
|
|
||||||
|
const mockCreate = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('openai', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn().mockImplementation(() => ({
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: mockCreate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OpenAIProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreate.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes request timeout and disables SDK retries for tool calls', async () => {
|
||||||
|
mockCreate.mockResolvedValue({
|
||||||
|
model: 'openai-test',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'submit_decompose',
|
||||||
|
arguments: JSON.stringify({ ok: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 12,
|
||||||
|
completion_tokens: 34,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = new OpenAIProvider('test-key', 'https://example.test/v1');
|
||||||
|
|
||||||
|
await provider.callTool({
|
||||||
|
systemPrompt: '只通过工具返回',
|
||||||
|
userPrompt: '拆解这个原型',
|
||||||
|
tool: {
|
||||||
|
name: 'submit_decompose',
|
||||||
|
description: '提交拆解结果',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { ok: { type: 'boolean' } },
|
||||||
|
required: ['ok'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forceTool: true,
|
||||||
|
maxTokens: 1000,
|
||||||
|
model: 'openai-test',
|
||||||
|
timeoutMs: 110000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
{ timeout: 110000, maxRetries: 0 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,16 +16,16 @@ export class OpenAIProvider implements AiProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
|
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
|
||||||
const response = await this.client.chat.completions.create({
|
const body = {
|
||||||
model: req.model,
|
model: req.model,
|
||||||
max_tokens: req.maxTokens,
|
max_tokens: req.maxTokens,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: req.systemPrompt },
|
{ role: 'system' as const, content: req.systemPrompt },
|
||||||
{ role: 'user', content: req.userPrompt },
|
{ role: 'user' as const, content: req.userPrompt },
|
||||||
],
|
],
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function' as const,
|
||||||
function: {
|
function: {
|
||||||
name: req.tool.name,
|
name: req.tool.name,
|
||||||
description: req.tool.description,
|
description: req.tool.description,
|
||||||
@@ -34,9 +34,12 @@ export class OpenAIProvider implements AiProvider {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tool_choice: req.forceTool
|
tool_choice: req.forceTool
|
||||||
? { type: 'function', function: { name: req.tool.name } }
|
? { type: 'function' as const, function: { name: req.tool.name } }
|
||||||
: 'auto',
|
: 'auto' as const,
|
||||||
});
|
};
|
||||||
|
const response = req.timeoutMs
|
||||||
|
? await this.client.chat.completions.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
|
||||||
|
: await this.client.chat.completions.create(body);
|
||||||
|
|
||||||
const choice = response.choices[0];
|
const choice = response.choices[0];
|
||||||
if (!choice?.message?.tool_calls?.length) {
|
if (!choice?.message?.tool_calls?.length) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface ToolCallRequest {
|
|||||||
forceTool: boolean;
|
forceTool: boolean;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
model: string;
|
model: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallResponse {
|
export interface ToolCallResponse {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppDataRetirementService } from './app-data-retirement.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AppDataRetirementService],
|
||||||
|
exports: [AppDataRetirementService],
|
||||||
|
})
|
||||||
|
export class AppDataRetirementModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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('keeps members AppData writable for legacy department role and password-rule config', () => {
|
||||||
|
expect(APP_DATA_RETIREMENT_CONFIG.members.state).toBe('active');
|
||||||
|
expect(() => service.assertWritable('members')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps overtime AppData writable for legacy overtime reason config', () => {
|
||||||
|
expect(APP_DATA_RETIREMENT_CONFIG.overtime.state).toBe('active');
|
||||||
|
expect(() => service.assertWritable('overtime')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats archived documents as read-only', () => {
|
||||||
|
try {
|
||||||
|
service.assertWritable('xiaobao-risk-snapshots');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.getResponse()).toMatchObject({
|
||||||
|
code: 'APP_DATA_WRITE_FROZEN',
|
||||||
|
key: 'xiaobao-risk-snapshots',
|
||||||
|
state: 'read_only_archive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import type { AppDataKey } from '../data/data-keys';
|
||||||
|
|
||||||
|
export type AppDataRetirementState = 'active' | 'write_frozen' | 'read_only_archive';
|
||||||
|
|
||||||
|
export interface AppDataRetirementEntry {
|
||||||
|
state: AppDataRetirementState;
|
||||||
|
replacement: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_DATA_RETIREMENT_CONFIG = {
|
||||||
|
'products-overview': {
|
||||||
|
state: 'write_frozen',
|
||||||
|
replacement: '/api/v1/products, /api/v1/products/:productId/projects, /api/v1/products/:productId/versions',
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
state: 'write_frozen',
|
||||||
|
replacement: '/api/v1/products/:productId/requirements',
|
||||||
|
},
|
||||||
|
'version-plans': {
|
||||||
|
state: 'write_frozen',
|
||||||
|
replacement: '/api/v1/versions/:versionId/plans',
|
||||||
|
},
|
||||||
|
'dev-tasks': {
|
||||||
|
state: 'write_frozen',
|
||||||
|
replacement: '/api/v1/versions/:versionId/dev-tasks',
|
||||||
|
},
|
||||||
|
'test-cases': {
|
||||||
|
state: 'write_frozen',
|
||||||
|
replacement: '/api/v1/versions/:versionId/test-cases',
|
||||||
|
},
|
||||||
|
bugs: {
|
||||||
|
state: 'write_frozen',
|
||||||
|
replacement: '/api/v1/versions/:versionId/bugs',
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
state: 'active',
|
||||||
|
replacement: '/api/v1/members for member identities; legacy members AppData remains temporary storage for departments, roles, and password policy.',
|
||||||
|
note: '成员身份已迁移到领域 API;部门、角色和密码策略在 V2.7 配置表承接前仍临时保留 AppData 写入。',
|
||||||
|
},
|
||||||
|
'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: 'active',
|
||||||
|
replacement: '/api/v1/overtime for overtime records; legacy overtime AppData remains temporary storage for overtime reason config.',
|
||||||
|
note: '加班记录已迁移到领域 API;加班原因配置在 V2.7 配置表承接前仍临时保留 AppData 写入。',
|
||||||
|
},
|
||||||
|
} satisfies Record<AppDataKey, AppDataRetirementEntry>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppDataRetirementService {
|
||||||
|
getEntry(key: AppDataKey): AppDataRetirementEntry {
|
||||||
|
return APP_DATA_RETIREMENT_CONFIG[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
assertWritable(key: AppDataKey) {
|
||||||
|
const entry = this.getEntry(key);
|
||||||
|
if (entry.state === 'active') return;
|
||||||
|
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'APP_DATA_WRITE_FROZEN',
|
||||||
|
message: `AppData key "${key}" is ${entry.state}; use ${entry.replacement} instead.`,
|
||||||
|
key,
|
||||||
|
state: entry.state,
|
||||||
|
replacement: entry.replacement,
|
||||||
|
note: entry.note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal file
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
|
||||||
|
describe('AuditController', () => {
|
||||||
|
it('requires audit:view for audit queries', () => {
|
||||||
|
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, AuditController.prototype.findAll);
|
||||||
|
|
||||||
|
expect(metadata).toEqual({ permission: 'audit:view' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates list query parameters to the audit service', async () => {
|
||||||
|
const service = { query: jest.fn().mockResolvedValue([{ id: 'audit-1' }]) };
|
||||||
|
const controller = new AuditController(service as any);
|
||||||
|
|
||||||
|
await expect(controller.findAll({ actorId: 'm-8' })).resolves.toEqual([{ id: 'audit-1' }]);
|
||||||
|
|
||||||
|
expect(service.query).toHaveBeenCalledWith({ actorId: 'm-8' });
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/server/src/modules/audit/audit.controller.ts
Normal file
17
apps/server/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { PermissionGuard } from '../../common/auth/permission.guard';
|
||||||
|
import { RequirePermission } from '../../common/auth/permission.decorator';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
import { QueryAuditEventsDto } from './dto/query-audit-events.dto';
|
||||||
|
|
||||||
|
@Controller('audit')
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly auditService: AuditService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(PermissionGuard)
|
||||||
|
@RequirePermission('audit:view')
|
||||||
|
findAll(@Query() query: QueryAuditEventsDto) {
|
||||||
|
return this.auditService.query(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/audit/audit.module.ts
Normal file
12
apps/server/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AuditMutationInterceptor } from '../../common/audit/audit-mutation.interceptor';
|
||||||
|
import { AuditController } from './audit.controller';
|
||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [AuditService, AuditMutationInterceptor],
|
||||||
|
exports: [AuditService, AuditMutationInterceptor],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal file
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { AuditService } from './audit.service';
|
||||||
|
|
||||||
|
describe('AuditService', () => {
|
||||||
|
const create = jest.fn();
|
||||||
|
const findMany = jest.fn();
|
||||||
|
const prisma = { auditEvent: { create, findMany } } as any;
|
||||||
|
const service = new AuditService(prisma);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes append-only audit events with sensitive fields redacted', async () => {
|
||||||
|
create.mockResolvedValue({ id: 'audit-1' });
|
||||||
|
|
||||||
|
await service.record({
|
||||||
|
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
|
||||||
|
action: 'product.update',
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: 'product-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
before: { name: 'Old', password: '123456' },
|
||||||
|
after: { name: 'New', nested: { apiKey: 'sk-test', keep: 'visible' } },
|
||||||
|
metadata: { authorization: 'Bearer token', reason: 'manual edit' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
actorId: 'm-8',
|
||||||
|
actorName: '超级管理员',
|
||||||
|
action: 'product.update',
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: 'product-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
before: { name: 'Old', password: '[REDACTED]' },
|
||||||
|
after: { name: 'New', nested: { apiKey: '[REDACTED]', keep: 'visible' } },
|
||||||
|
metadata: { authorization: '[REDACTED]', reason: 'manual edit' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries by actor, entity, scope, and date range with bounded page size', async () => {
|
||||||
|
findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.query({
|
||||||
|
actorId: 'm-8',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityId: 'bug-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
dateFrom: '2026-07-01T00:00:00.000Z',
|
||||||
|
dateTo: '2026-07-08T23:59:59.000Z',
|
||||||
|
take: '500',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
actorId: 'm-8',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityId: 'bug-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date('2026-07-01T00:00:00.000Z'),
|
||||||
|
lte: new Date('2026-07-08T23:59:59.000Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
90
apps/server/src/modules/audit/audit.service.ts
Normal file
90
apps/server/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import type { CurrentUser } from '../../common/auth/auth-context.service';
|
||||||
|
import type { QueryAuditEventsDto } from './dto/query-audit-events.dto';
|
||||||
|
|
||||||
|
export interface AuditRecordInput {
|
||||||
|
actor?: CurrentUser | null;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
scope?: unknown;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
record(input: AuditRecordInput) {
|
||||||
|
return this.prisma.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
actorId: input.actor?.id ?? null,
|
||||||
|
actorName: input.actor?.name ?? input.actor?.username ?? '',
|
||||||
|
action: input.action,
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
scope: toJson(input.scope ?? {}),
|
||||||
|
before: input.before === undefined ? undefined : toJson(redactSensitive(input.before)),
|
||||||
|
after: input.after === undefined ? undefined : toJson(redactSensitive(input.after)),
|
||||||
|
metadata: toJson(redactSensitive(input.metadata ?? {})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
query(query: QueryAuditEventsDto) {
|
||||||
|
return this.prisma.auditEvent.findMany({
|
||||||
|
where: buildWhere(query),
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: clampTake(query.take),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWhere(query: QueryAuditEventsDto) {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) {
|
||||||
|
if (query[key]) where[key] = query[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange: Record<string, Date> = {};
|
||||||
|
if (query.dateFrom) dateRange.gte = new Date(query.dateFrom);
|
||||||
|
if (query.dateTo) dateRange.lte = new Date(query.dateTo);
|
||||||
|
if (Object.keys(dateRange).length > 0) where.createdAt = dateRange;
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTake(value: string | undefined): number {
|
||||||
|
const parsed = Number(value ?? 50);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return 50;
|
||||||
|
return Math.min(100, Math.floor(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactSensitive(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => redactSensitive(item));
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
|
||||||
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||||
|
key,
|
||||||
|
isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(item),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveKey(key: string): boolean {
|
||||||
|
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
return ['password', 'token', 'secret', 'apikey', 'authorization'].some((sensitive) => normalized.includes(sensitive));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJson(value: unknown): Prisma.InputJsonValue {
|
||||||
|
return value as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal file
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class QueryAuditEventsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
actorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
entityType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
entityId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
versionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
take?: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
|
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||||
import { CreateBugDto } from './dto/create-bug.dto';
|
import { CreateBugDto } from './dto/create-bug.dto';
|
||||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||||
import { BugService } from './bug.service';
|
import { BugService } from './bug.service';
|
||||||
@@ -8,6 +9,11 @@ export class BugController {
|
|||||||
constructor(private readonly bugService: BugService) {}
|
constructor(private readonly bugService: BugService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('version.bug:create', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.create',
|
||||||
|
entityType: 'bug',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) {
|
create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) {
|
||||||
return this.bugService.create(versionId, dto);
|
return this.bugService.create(versionId, dto);
|
||||||
}
|
}
|
||||||
@@ -18,11 +24,23 @@ export class BugController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.update',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateBugDto) {
|
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateBugDto) {
|
||||||
return this.bugService.update(versionId, id, dto);
|
return this.bugService.update(versionId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
|
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.status',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -33,6 +51,12 @@ export class BugController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/transfer')
|
@Patch(':id/transfer')
|
||||||
|
@ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.transfer',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
transfer(
|
transfer(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -43,6 +67,12 @@ export class BugController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('version.bug:delete', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'bug.delete',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||||
return this.bugService.remove(versionId, id);
|
return this.bugService.remove(versionId, id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ describe('BugService domain writes', () => {
|
|||||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: createUserFindFirstMock(),
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -26,6 +29,61 @@ describe('BugService domain writes', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('resolves assignee and reporter names to user ids before relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 开发: 'dev-1', 测试: 'qa-1' });
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.bug.create.mockResolvedValue({ id: 'bug-1', versionId: 'version-1', title: '登录报错' });
|
||||||
|
|
||||||
|
await service.create('version-1', {
|
||||||
|
title: '登录报错',
|
||||||
|
assigneeId: '开发',
|
||||||
|
reportedBy: '测试',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.bug.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
reporterId: 'qa-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves transfer assignee names before updating bug foreign keys', async () => {
|
||||||
|
const { prisma, workActivity, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 李四: 'dev-2' });
|
||||||
|
prisma.bug.findFirst.mockResolvedValue({
|
||||||
|
id: 'bug-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: 'open',
|
||||||
|
title: '登录报错',
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
reporterId: 'qa-1',
|
||||||
|
});
|
||||||
|
prisma.bug.update.mockResolvedValue({
|
||||||
|
id: 'bug-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: '登录报错',
|
||||||
|
assigneeId: 'dev-2',
|
||||||
|
reporterId: 'qa-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.transfer('version-1', 'bug-1', '李四', 'qa-1');
|
||||||
|
|
||||||
|
expect(prisma.bug.update).toHaveBeenCalledWith({
|
||||||
|
where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } },
|
||||||
|
data: { assigneeId: 'dev-2' },
|
||||||
|
});
|
||||||
|
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('creates bugs directly under a version partition', async () => {
|
it('creates bugs directly under a version partition', async () => {
|
||||||
const { prisma, workActivity, service } = makeService();
|
const { prisma, workActivity, service } = makeService();
|
||||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
@@ -151,3 +209,13 @@ describe('BugService domain writes', () => {
|
|||||||
expect(prisma.bug.update).not.toHaveBeenCalled();
|
expect(prisma.bug.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||||
|
return jest.fn(({ where }: any) => {
|
||||||
|
const refs = (where?.OR ?? [])
|
||||||
|
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ref = refs[0];
|
||||||
|
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { resolveUserReference } from '../../common/user-reference';
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
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';
|
||||||
@@ -14,9 +15,10 @@ export class BugService {
|
|||||||
|
|
||||||
async create(versionId: string, dto: CreateBugDto) {
|
async create(versionId: string, dto: CreateBugDto) {
|
||||||
const version = await this.ensureVersion(versionId);
|
const version = await this.ensureVersion(versionId);
|
||||||
|
const bugData = await this.toBugData(dto);
|
||||||
const item = await this.prisma.bug.create({
|
const item = await this.prisma.bug.create({
|
||||||
data: {
|
data: {
|
||||||
...this.toBugData(dto),
|
...bugData,
|
||||||
versionId,
|
versionId,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
projectId: version.projectId,
|
projectId: version.projectId,
|
||||||
@@ -43,7 +45,7 @@ export class BugService {
|
|||||||
await this.ensureBugInVersion(versionId, id);
|
await this.ensureBugInVersion(versionId, id);
|
||||||
const item = await this.prisma.bug.update({
|
const item = await this.prisma.bug.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toBugData(dto),
|
data: await this.toBugData(dto),
|
||||||
});
|
});
|
||||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
@@ -70,16 +72,17 @@ export class BugService {
|
|||||||
|
|
||||||
async transfer(versionId: string, id: string, assigneeId: string, operator?: string) {
|
async transfer(versionId: string, id: string, assigneeId: string, operator?: string) {
|
||||||
const current = await this.ensureBugInVersion(versionId, id);
|
const current = await this.ensureBugInVersion(versionId, id);
|
||||||
|
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
|
||||||
const item = await this.prisma.bug.update({
|
const item = await this.prisma.bug.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: { assigneeId },
|
data: { assigneeId: resolvedAssigneeId },
|
||||||
});
|
});
|
||||||
const activity = await this.recordBugActivity(
|
const activity = await this.recordBugActivity(
|
||||||
item,
|
item,
|
||||||
'bug_transferred',
|
'bug_transferred',
|
||||||
'progress',
|
'progress',
|
||||||
`转交 Bug:${item.title} → ${assigneeId}`,
|
`转交 Bug:${item.title} → ${resolvedAssigneeId ?? '-'}`,
|
||||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId, operator },
|
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId, operator },
|
||||||
);
|
);
|
||||||
return { item, activities: [activity] };
|
return { item, activities: [activity] };
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@ export class BugService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toBugData(dto: Partial<CreateBugDto>) {
|
private async toBugData(dto: Partial<CreateBugDto>) {
|
||||||
return {
|
return {
|
||||||
...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }),
|
...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }),
|
||||||
...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }),
|
...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }),
|
||||||
@@ -101,8 +104,10 @@ export class BugService {
|
|||||||
...(dto.status !== undefined && { status: dto.status }),
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }),
|
...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }),
|
||||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||||
...(dto.reporterId !== undefined || dto.reportedBy !== undefined ? { reporterId: emptyToNull(dto.reporterId ?? dto.reportedBy) } : {}),
|
...(dto.reporterId !== undefined || dto.reportedBy !== undefined
|
||||||
|
? { reporterId: await resolveUserReference(this.prisma, dto.reporterId ?? dto.reportedBy) }
|
||||||
|
: {}),
|
||||||
...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }),
|
...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }),
|
||||||
...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }),
|
...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }),
|
||||||
...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }),
|
...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }),
|
||||||
|
|||||||
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { CommentService, type CommentEntityType } from './comment.service';
|
||||||
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
import { DeleteCommentDto } from './dto/delete-comment.dto';
|
||||||
|
|
||||||
|
@Controller('comments')
|
||||||
|
export class CommentController {
|
||||||
|
constructor(private readonly commentService: CommentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query('entityType') entityType: CommentEntityType, @Query('entityId') entityId: string) {
|
||||||
|
return this.commentService.list(entityType, entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateCommentDto) {
|
||||||
|
return this.commentService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string, @Body() dto: DeleteCommentDto) {
|
||||||
|
return this.commentService.remove(id, dto.actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/src/modules/comment/comment.module.ts
Normal file
13
apps/server/src/modules/comment/comment.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
import { CommentController } from './comment.controller';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CommonDomainModule, NotificationModule],
|
||||||
|
controllers: [CommentController],
|
||||||
|
providers: [CommentService],
|
||||||
|
exports: [CommentService],
|
||||||
|
})
|
||||||
|
export class CommentModule {}
|
||||||
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AuditService } from '../../common/audit/audit.service';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
|
||||||
|
describe('CommentService', () => {
|
||||||
|
const makeService = () => {
|
||||||
|
const prisma = {
|
||||||
|
comment: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
projectMember: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const notifications = {
|
||||||
|
createMany: jest.fn(),
|
||||||
|
} as unknown as NotificationService;
|
||||||
|
const audit = {
|
||||||
|
record: jest.fn(),
|
||||||
|
} as unknown as AuditService;
|
||||||
|
return {
|
||||||
|
prisma,
|
||||||
|
notifications,
|
||||||
|
audit,
|
||||||
|
service: new CommentService(prisma as any, notifications, audit),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a polymorphic comment, extracts @mentions, notifies mentioned members, and writes audit', async () => {
|
||||||
|
const { prisma, notifications, audit, service } = makeService();
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([
|
||||||
|
{ userId: 'm-alice', user: { id: 'm-alice', name: 'Alice' } },
|
||||||
|
{ userId: 'm-bob', user: { id: 'm-bob', name: 'Bob' } },
|
||||||
|
]);
|
||||||
|
prisma.comment.create.mockResolvedValue({
|
||||||
|
id: 'comment-1',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
mentionedMemberIds: ['m-alice', 'm-bob'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.create({
|
||||||
|
actorId: 'm-author',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
entityVersionId: 'ver-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'ver-1',
|
||||||
|
content: '请 @Alice 看一下接口,Bob 也同步一下',
|
||||||
|
mentionMemberIds: ['m-bob'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.id).toBe('comment-1');
|
||||||
|
expect(prisma.comment.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
authorId: 'm-author',
|
||||||
|
mentionedMemberIds: ['m-alice', 'm-bob'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect((notifications.createMany as jest.Mock)).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ recipientId: 'm-alice', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
|
||||||
|
expect.objectContaining({ recipientId: 'm-bob', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
|
||||||
|
]);
|
||||||
|
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
actorId: 'm-author',
|
||||||
|
action: 'comment.created',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: 'comment-1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft deletes a comment and writes audit', async () => {
|
||||||
|
const { prisma, audit, service } = makeService();
|
||||||
|
prisma.comment.findUnique.mockResolvedValue({
|
||||||
|
id: 'comment-1',
|
||||||
|
authorId: 'm-author',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityId: 'bug-1',
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
prisma.comment.update.mockResolvedValue({ id: 'comment-1', deletedAt: new Date('2026-07-08T08:00:00.000Z') });
|
||||||
|
|
||||||
|
await service.remove('comment-1', 'm-author');
|
||||||
|
|
||||||
|
expect(prisma.comment.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'comment-1' },
|
||||||
|
data: { deletedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
actorId: 'm-author',
|
||||||
|
action: 'comment.deleted',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: 'comment-1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported comment entity types', async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
await expect(service.create({
|
||||||
|
actorId: 'm-author',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
content: 'legacy task comment',
|
||||||
|
} as any)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
apps/server/src/modules/comment/comment.service.ts
Normal file
157
apps/server/src/modules/comment/comment.service.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AuditService } from '../../common/audit/audit.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
|
||||||
|
export const COMMENT_ENTITY_TYPES = ['dev_task', 'test_case', 'bug', 'requirement', 'version_plan'] as const;
|
||||||
|
export type CommentEntityType = (typeof COMMENT_ENTITY_TYPES)[number];
|
||||||
|
|
||||||
|
export interface CommentCreateInput {
|
||||||
|
actorId: string;
|
||||||
|
entityType: CommentEntityType;
|
||||||
|
entityId: string;
|
||||||
|
entityVersionId?: string | null;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
content: string;
|
||||||
|
mentionMemberIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(entityType: CommentEntityType, entityId: string) {
|
||||||
|
assertCommentEntityType(entityType);
|
||||||
|
return this.prisma.comment.findMany({
|
||||||
|
where: { entityType, entityId: requireText(entityId, 'entityId'), deletedAt: null },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CommentCreateInput) {
|
||||||
|
assertCommentEntityType(input.entityType);
|
||||||
|
const actorId = requireText(input.actorId, 'actorId');
|
||||||
|
const content = requireText(input.content, 'content');
|
||||||
|
const mentionedMemberIds = await this.resolveMentionMemberIds(content, input.projectId, input.mentionMemberIds);
|
||||||
|
const comment = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: requireText(input.entityId, 'entityId'),
|
||||||
|
entityVersionId: input.entityVersionId ?? null,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
authorId: actorId,
|
||||||
|
content,
|
||||||
|
mentionedMemberIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.notificationService.createMany(mentionedMemberIds
|
||||||
|
.filter((recipientId) => recipientId !== actorId)
|
||||||
|
.map((recipientId) => ({
|
||||||
|
recipientId,
|
||||||
|
actorId,
|
||||||
|
type: 'mention',
|
||||||
|
title: '你被提及了',
|
||||||
|
body: content,
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: comment.id,
|
||||||
|
resourceVersionId: input.entityVersionId ?? null,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
metadata: {
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
},
|
||||||
|
})));
|
||||||
|
|
||||||
|
await this.auditService.record({
|
||||||
|
actorId,
|
||||||
|
action: 'comment.created',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: comment.id,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
after: comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, actorId: string) {
|
||||||
|
const comment = await this.prisma.comment.findUnique({ where: { id: requireText(id, 'id') } });
|
||||||
|
if (!comment || comment.deletedAt) throw new NotFoundException('Comment not found');
|
||||||
|
|
||||||
|
const removed = await this.prisma.comment.update({
|
||||||
|
where: { id: comment.id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
await this.auditService.record({
|
||||||
|
actorId: requireText(actorId, 'actorId'),
|
||||||
|
action: 'comment.deleted',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: comment.id,
|
||||||
|
productId: comment.productId,
|
||||||
|
projectId: comment.projectId,
|
||||||
|
versionId: comment.versionId,
|
||||||
|
before: comment,
|
||||||
|
after: removed,
|
||||||
|
});
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveMentionMemberIds(content: string, projectId?: string | null, explicitIds: string[] = []): Promise<string[]> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const mentionNames = extractMentionNames(content);
|
||||||
|
if (mentionNames.length > 0 && projectId?.trim()) {
|
||||||
|
const projectMembers = await this.prisma.projectMember.findMany({
|
||||||
|
where: { projectId: projectId.trim() },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
const wanted = new Set(mentionNames.map(normalizeMentionName));
|
||||||
|
for (const member of projectMembers as Array<{ userId: string; user?: { id?: string; name?: string } }>) {
|
||||||
|
const name = normalizeMentionName(member.user?.name);
|
||||||
|
if (name && wanted.has(name)) ids.add(member.user?.id ?? member.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const explicitId of explicitIds) {
|
||||||
|
const id = explicitId.trim();
|
||||||
|
if (id) ids.add(id);
|
||||||
|
}
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMentionNames(content: string): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
const pattern = /@([\p{L}\p{N}_\-.]+)/gu;
|
||||||
|
for (const match of content.matchAll(pattern)) {
|
||||||
|
if (match[1]) names.push(match[1]);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCommentEntityType(entityType: string): asserts entityType is CommentEntityType {
|
||||||
|
if (!COMMENT_ENTITY_TYPES.includes(entityType as CommentEntityType)) {
|
||||||
|
throw new BadRequestException(`Unsupported comment entity type: ${entityType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMentionName(name?: string | null): string {
|
||||||
|
return name?.trim().toLowerCase() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(value: string | undefined | null, field: string): string {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { COMMENT_ENTITY_TYPES, type CommentEntityType } from '../comment.service';
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
actorId!: string;
|
||||||
|
|
||||||
|
@IsIn(COMMENT_ENTITY_TYPES)
|
||||||
|
entityType!: CommentEntityType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
entityId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
entityVersionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
versionId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
mentionMemberIds?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeleteCommentDto {
|
||||||
|
@IsString()
|
||||||
|
actorId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
|
||||||
|
import { ConsistencyController } from './consistency.controller';
|
||||||
|
|
||||||
|
describe('ConsistencyController', () => {
|
||||||
|
it('requires consistency:view for consistency checks', () => {
|
||||||
|
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, ConsistencyController.prototype.run);
|
||||||
|
|
||||||
|
expect(metadata).toEqual({ permission: 'consistency:view' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates consistency checks to the service', async () => {
|
||||||
|
const service = { run: jest.fn().mockResolvedValue({ status: 'pass' }) };
|
||||||
|
const controller = new ConsistencyController(service as any);
|
||||||
|
|
||||||
|
await expect(controller.run()).resolves.toEqual({ status: 'pass' });
|
||||||
|
expect(service.run).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { PermissionGuard } from '../../common/auth/permission.guard';
|
||||||
|
import { RequirePermission } from '../../common/auth/permission.decorator';
|
||||||
|
import { ConsistencyService } from './consistency.service';
|
||||||
|
|
||||||
|
@Controller('consistency')
|
||||||
|
export class ConsistencyController {
|
||||||
|
constructor(private readonly consistencyService: ConsistencyService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(PermissionGuard)
|
||||||
|
@RequirePermission('consistency:view')
|
||||||
|
run() {
|
||||||
|
return this.consistencyService.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConsistencyController } from './consistency.controller';
|
||||||
|
import { ConsistencyService } from './consistency.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ConsistencyController],
|
||||||
|
providers: [ConsistencyService],
|
||||||
|
})
|
||||||
|
export class ConsistencyModule {}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ConsistencyService } from './consistency.service';
|
||||||
|
|
||||||
|
describe('ConsistencyService', () => {
|
||||||
|
const makePrisma = () => {
|
||||||
|
const counts = {
|
||||||
|
product: 1,
|
||||||
|
project: 2,
|
||||||
|
version: 3,
|
||||||
|
requirement: 4,
|
||||||
|
versionPlan: 5,
|
||||||
|
devTask: 6,
|
||||||
|
testCase: 7,
|
||||||
|
bug: 8,
|
||||||
|
user: 9,
|
||||||
|
taskCategory: 10,
|
||||||
|
taskWorklog: 11,
|
||||||
|
overtimeRecord: 12,
|
||||||
|
workActivity: 13,
|
||||||
|
auditEvent: 14,
|
||||||
|
};
|
||||||
|
const prisma: any = {
|
||||||
|
$queryRawUnsafe: jest.fn((sql: string) => {
|
||||||
|
if (sql.includes('dev_tasks') && sql.includes("version_id = ''")) return Promise.resolve([{ count: 1n }]);
|
||||||
|
if (sql.includes('requirements') && sql.includes('missing_version')) return Promise.resolve([{ count: 2n }]);
|
||||||
|
if (sql.includes('audit_events') && sql.includes("entity_type = 'bug'")) return Promise.resolve([{ count: 0n }]);
|
||||||
|
return Promise.resolve([{ count: 0n }]);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
for (const [model, count] of Object.entries(counts)) {
|
||||||
|
prisma[model] = { count: jest.fn().mockResolvedValue(count) };
|
||||||
|
}
|
||||||
|
return prisma;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns counts plus error/warn consistency groups', async () => {
|
||||||
|
const prisma = makePrisma();
|
||||||
|
const service = new ConsistencyService(prisma);
|
||||||
|
|
||||||
|
const result = await service.run();
|
||||||
|
|
||||||
|
expect(result.status).toBe('fail');
|
||||||
|
expect(result.counts.devTasks).toBe(6);
|
||||||
|
expect(result.summary.errors).toBeGreaterThan(0);
|
||||||
|
expect(result.summary.warnings).toBeGreaterThan(0);
|
||||||
|
expect(result.checks.partitionKeys).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'dev_tasks.version_id.present',
|
||||||
|
severity: 'error',
|
||||||
|
count: 1,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(result.checks.orphanReferences).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'requirements.version_id.exists',
|
||||||
|
severity: 'error',
|
||||||
|
count: 2,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(result.checks.auditCoverage).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'audit.coverage.bug',
|
||||||
|
severity: 'warn',
|
||||||
|
count: 0,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
186
apps/server/src/modules/consistency/consistency.service.ts
Normal file
186
apps/server/src/modules/consistency/consistency.service.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export type ConsistencySeverity = 'ok' | 'warn' | 'error';
|
||||||
|
export type ConsistencyStatus = 'pass' | 'fail';
|
||||||
|
|
||||||
|
export interface ConsistencyCheckResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
severity: ConsistencySeverity;
|
||||||
|
count: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsistencyResult {
|
||||||
|
generatedAt: string;
|
||||||
|
status: ConsistencyStatus;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
checks: {
|
||||||
|
partitionKeys: ConsistencyCheckResult[];
|
||||||
|
orphanReferences: ConsistencyCheckResult[];
|
||||||
|
auditCoverage: ConsistencyCheckResult[];
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
errors: number;
|
||||||
|
warnings: number;
|
||||||
|
human: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUNT_MODELS: Array<[string, string]> = [
|
||||||
|
['products', 'product'],
|
||||||
|
['projects', 'project'],
|
||||||
|
['versions', 'version'],
|
||||||
|
['requirements', 'requirement'],
|
||||||
|
['versionPlans', 'versionPlan'],
|
||||||
|
['devTasks', 'devTask'],
|
||||||
|
['testCases', 'testCase'],
|
||||||
|
['bugs', 'bug'],
|
||||||
|
['members', 'user'],
|
||||||
|
['taskCategories', 'taskCategory'],
|
||||||
|
['taskWorklogs', 'taskWorklog'],
|
||||||
|
['overtimeRecords', 'overtimeRecord'],
|
||||||
|
['workActivities', 'workActivity'],
|
||||||
|
['auditEvents', 'auditEvent'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const PARTITION_KEY_CHECKS = [
|
||||||
|
check('requirements.product_id.present', 'requirements must keep product_id partition key', 'error', "SELECT COUNT(*) AS count FROM requirements WHERE product_id IS NULL OR product_id = ''"),
|
||||||
|
check('dev_tasks.version_id.present', 'dev_tasks must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM dev_tasks WHERE version_id IS NULL OR version_id = ''"),
|
||||||
|
check('test_cases.version_id.present', 'test_cases must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM test_cases WHERE version_id IS NULL OR version_id = ''"),
|
||||||
|
check('bugs.version_id.present', 'bugs must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM bugs WHERE version_id IS NULL OR version_id = ''"),
|
||||||
|
check('work_activities.created_at.present', 'work_activities must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM work_activities WHERE created_at IS NULL'),
|
||||||
|
check('task_worklogs.created_at.present', 'task_worklogs must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM task_worklogs WHERE created_at IS NULL'),
|
||||||
|
check('overtime_records.created_at.present', 'overtime_records must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM overtime_records WHERE created_at IS NULL'),
|
||||||
|
check('audit_events.created_at.present', 'audit_events must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM audit_events WHERE created_at IS NULL'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORPHAN_REFERENCE_CHECKS = [
|
||||||
|
check('projects.product_id.exists', 'projects.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM projects p LEFT JOIN products pr ON pr.id = p.product_id WHERE pr.id IS NULL'),
|
||||||
|
check('versions.product_id.exists', 'versions.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN products p ON p.id = v.product_id WHERE p.id IS NULL'),
|
||||||
|
check('versions.project_id.exists', 'versions.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN projects p ON p.id = v.project_id WHERE v.project_id IS NOT NULL AND p.id IS NULL'),
|
||||||
|
check('requirements.product_id.exists', 'requirements.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN products p ON p.id = r.product_id WHERE p.id IS NULL'),
|
||||||
|
check('requirements.project_id.exists', 'requirements.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN projects p ON p.id = r.project_id WHERE r.project_id IS NOT NULL AND p.id IS NULL'),
|
||||||
|
check('requirements.version_id.exists', 'requirements.version_id must reference versions.id when present', 'error', 'SELECT COUNT(*) AS count /* missing_version */ FROM requirements r LEFT JOIN versions v ON v.id = r.version_id WHERE r.version_id IS NOT NULL AND v.id IS NULL'),
|
||||||
|
check('version_plans.version_id.exists', 'version_plans.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM version_plans vp LEFT JOIN versions v ON v.id = vp.version_id WHERE v.id IS NULL'),
|
||||||
|
check('dev_tasks.version_id.exists', 'dev_tasks.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN versions v ON v.id = dt.version_id WHERE v.id IS NULL'),
|
||||||
|
check('dev_tasks.requirement.exists', 'dev_tasks requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN requirements r ON r.id = dt.requirement_id AND r.product_id = dt.requirement_product_id WHERE dt.requirement_id IS NOT NULL AND r.id IS NULL'),
|
||||||
|
check('test_cases.version_id.exists', 'test_cases.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN versions v ON v.id = tc.version_id WHERE v.id IS NULL'),
|
||||||
|
check('test_cases.requirement.exists', 'test_cases requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN requirements r ON r.id = tc.requirement_id AND r.product_id = tc.requirement_product_id WHERE tc.requirement_id IS NOT NULL AND r.id IS NULL'),
|
||||||
|
check('bugs.version_id.exists', 'bugs.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN versions v ON v.id = b.version_id WHERE v.id IS NULL'),
|
||||||
|
check('bugs.test_case.exists', 'bugs test_case composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN test_cases tc ON tc.id = b.test_case_id AND tc.version_id = b.test_case_version_id WHERE b.test_case_id IS NOT NULL AND tc.id IS NULL'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUDIT_ENTITY_TYPES = [
|
||||||
|
'product',
|
||||||
|
'project',
|
||||||
|
'version',
|
||||||
|
'requirement',
|
||||||
|
'version_plan',
|
||||||
|
'dev_task',
|
||||||
|
'test_case',
|
||||||
|
'bug',
|
||||||
|
'member',
|
||||||
|
'task_category',
|
||||||
|
'task_worklog',
|
||||||
|
'overtime',
|
||||||
|
'work_activity',
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConsistencyService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async run(): Promise<ConsistencyResult> {
|
||||||
|
const [counts, partitionKeys, orphanReferences, auditCoverage] = await Promise.all([
|
||||||
|
this.collectCounts(),
|
||||||
|
this.runChecks(PARTITION_KEY_CHECKS),
|
||||||
|
this.runChecks(ORPHAN_REFERENCE_CHECKS),
|
||||||
|
this.runAuditCoverageChecks(),
|
||||||
|
]);
|
||||||
|
const allChecks = [...partitionKeys, ...orphanReferences, ...auditCoverage];
|
||||||
|
const errors = allChecks.filter((item) => item.severity === 'error').length;
|
||||||
|
const warnings = allChecks.filter((item) => item.severity === 'warn').length;
|
||||||
|
const status: ConsistencyStatus = errors > 0 ? 'fail' : 'pass';
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
status,
|
||||||
|
counts,
|
||||||
|
checks: { partitionKeys, orphanReferences, auditCoverage },
|
||||||
|
summary: {
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
human: buildHumanSummary(status, errors, warnings, counts),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectCounts() {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
COUNT_MODELS.map(async ([label, model]) => [label, await (this.prisma as any)[model].count()] as const),
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runChecks(checks: ConsistencyCheck[]) {
|
||||||
|
return Promise.all(checks.map((item) => this.runSqlCheck(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runAuditCoverageChecks() {
|
||||||
|
return Promise.all(AUDIT_ENTITY_TYPES.map(async (entityType) => {
|
||||||
|
const count = await this.rawCount(`SELECT COUNT(*) AS count FROM audit_events WHERE entity_type = '${entityType}'`);
|
||||||
|
const label = `audit_events should contain mutation events for ${entityType}`;
|
||||||
|
return {
|
||||||
|
id: `audit.coverage.${entityType}`,
|
||||||
|
label,
|
||||||
|
severity: count === 0 ? 'warn' : 'ok',
|
||||||
|
count,
|
||||||
|
message: count === 0 ? `${label}: no events yet` : `${label}: ${count}`,
|
||||||
|
} satisfies ConsistencyCheckResult;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSqlCheck(item: ConsistencyCheck): Promise<ConsistencyCheckResult> {
|
||||||
|
const count = await this.rawCount(item.sql);
|
||||||
|
const severity = count > 0 ? item.severityWhenNonZero : 'ok';
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
severity,
|
||||||
|
count,
|
||||||
|
message: count > 0 ? `${item.label}: ${count}` : `${item.label}: ok`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rawCount(sql: string): Promise<number> {
|
||||||
|
const rows = await this.prisma.$queryRawUnsafe<Array<{ count: bigint | number | string }>>(sql);
|
||||||
|
return Number(rows[0]?.count ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsistencyCheck {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>;
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>,
|
||||||
|
sql: string,
|
||||||
|
): ConsistencyCheck {
|
||||||
|
return { id, label, severityWhenNonZero, sql };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHumanSummary(
|
||||||
|
status: ConsistencyStatus,
|
||||||
|
errors: number,
|
||||||
|
warnings: number,
|
||||||
|
counts: Record<string, number>,
|
||||||
|
) {
|
||||||
|
return `V2.5 consistency ${status}: ${errors} error(s), ${warnings} warning(s), ${counts.auditEvents ?? 0} audit event(s).`;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppDataRetirementModule } from '../app-data-retirement/app-data-retirement.module';
|
||||||
import { MigrationModule } from '../migration/migration.module';
|
import { MigrationModule } from '../migration/migration.module';
|
||||||
import { DataController } from './data.controller';
|
import { DataController } from './data.controller';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MigrationModule],
|
imports: [MigrationModule, AppDataRetirementModule],
|
||||||
controllers: [DataController],
|
controllers: [DataController],
|
||||||
providers: [DataService],
|
providers: [DataService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
describe('DataService', () => {
|
describe('DataService', () => {
|
||||||
@@ -14,10 +15,12 @@ describe('DataService', () => {
|
|||||||
const syncService = {
|
const syncService = {
|
||||||
syncAfterAppDataPut: jest.fn(),
|
syncAfterAppDataPut: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const retirementService = new AppDataRetirementService();
|
||||||
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,78 +57,99 @@ describe('DataService', () => {
|
|||||||
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upserts JSON values for allowed keys', async () => {
|
it('upserts JSON values for active config keys', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService, retirementService } = makeService();
|
||||||
const value = [{ id: 'p1', name: 'Product 1' }];
|
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
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 });
|
const writableSpy = jest.spyOn(retirementService, 'assertWritable');
|
||||||
|
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||||
|
|
||||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
await expect(service.put('members', value)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value,
|
value,
|
||||||
version: updatedAt.toISOString(),
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
||||||
where: { key: 'products-overview' },
|
where: { key: 'members' },
|
||||||
update: { value },
|
update: { value },
|
||||||
create: { key: 'products-overview', value },
|
create: { key: 'members', value },
|
||||||
});
|
});
|
||||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
expect(writableSpy).toHaveBeenCalledWith('members');
|
||||||
|
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows supporting business data keys migrated from browser storage', async () => {
|
it('rejects frozen AppData writes before touching storage or relation sync', async () => {
|
||||||
|
const { prisma, service, syncService } = makeService();
|
||||||
|
|
||||||
|
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 only active config AppData keys after business key retirement', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
const value: unknown[] = [];
|
const membersValue = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
|
const overtimeValue = { records: [], reasons: [] };
|
||||||
prisma.appData.upsert.mockImplementation(({ where }) =>
|
prisma.appData.upsert.mockImplementation(({ where }) =>
|
||||||
Promise.resolve({ key: where.key, value, updatedAt: new Date('2026-07-02T08:02:00.000Z') }),
|
Promise.resolve({
|
||||||
|
key: where.key,
|
||||||
|
value: where.key === 'members' ? membersValue : overtimeValue,
|
||||||
|
updatedAt: new Date('2026-07-02T08:02:00.000Z'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.put('task-worklogs', value)).resolves.toMatchObject({ key: 'task-worklogs', value });
|
await expect(service.put('members', membersValue)).resolves.toMatchObject({ key: 'members', value: membersValue });
|
||||||
await expect(service.put('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value });
|
await expect(service.put('overtime', overtimeValue)).resolves.toMatchObject({ key: 'overtime', value: overtimeValue });
|
||||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({
|
await expect(service.put('task-worklogs', [])).rejects.toBeInstanceOf(ConflictException);
|
||||||
key: 'overtime',
|
await expect(service.put('work-activities', [])).rejects.toBeInstanceOf(ConflictException);
|
||||||
value,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates only when the supplied version matches the stored row version', async () => {
|
it('updates only when the supplied version matches the stored row version', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
const previousVersion = '2026-07-02T08:03:00.000Z';
|
const previousVersion = '2026-07-02T08:03:00.000Z';
|
||||||
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
|
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
|
||||||
const nextValue = [{ id: 'p2', name: 'Product 2' }];
|
const nextValue = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
|
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
|
||||||
prisma.appData.findUnique.mockResolvedValue({
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
updatedAt: nextUpdatedAt,
|
updatedAt: nextUpdatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({
|
await expect(service.put('members', nextValue, previousVersion)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
version: nextUpdatedAt.toISOString(),
|
version: nextUpdatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
|
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
updatedAt: new Date(previousVersion),
|
updatedAt: new Date(previousVersion),
|
||||||
},
|
},
|
||||||
data: { value: nextValue },
|
data: { value: nextValue },
|
||||||
});
|
});
|
||||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects stale AppData versions without overwriting the current value', async () => {
|
it('rejects stale AppData versions without overwriting the current value', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
|
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
|
||||||
prisma.appData.findUnique.mockResolvedValue({
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: [{ id: 'current' }],
|
value: { departments: [], members: [], roles: [], passwordRule: null },
|
||||||
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
|
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
|
service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, '2026-07-02T08:03:00.000Z'),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||||
@@ -133,32 +157,32 @@ describe('DataService', () => {
|
|||||||
|
|
||||||
it('creates a missing row only when the client loaded a null version', async () => {
|
it('creates a missing row only when the client loaded a null version', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
const value = [{ id: 'p1' }];
|
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
|
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
|
||||||
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
prisma.appData.create.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||||
|
|
||||||
await expect(service.put('products-overview', value, null)).resolves.toEqual({
|
await expect(service.put('members', value, null)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value,
|
value,
|
||||||
version: updatedAt.toISOString(),
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(prisma.appData.create).toHaveBeenCalledWith({
|
expect(prisma.appData.create).toHaveBeenCalledWith({
|
||||||
data: { key: 'products-overview', value },
|
data: { key: 'members', value },
|
||||||
});
|
});
|
||||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects create-only writes when another client created the row first', async () => {
|
it('rejects create-only writes when another client created the row first', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
|
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
|
||||||
prisma.appData.findUnique.mockResolvedValue({
|
prisma.appData.findUnique.mockResolvedValue({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value: [{ id: 'current' }],
|
value: { departments: [], members: [], roles: [], passwordRule: null },
|
||||||
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
|
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
|
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, null)).rejects.toBeInstanceOf(
|
||||||
ConflictException,
|
ConflictException,
|
||||||
);
|
);
|
||||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||||
@@ -167,24 +191,24 @@ describe('DataService', () => {
|
|||||||
it('rejects invalid AppData versions', async () => {
|
it('rejects invalid AppData versions', async () => {
|
||||||
const { service } = makeService();
|
const { service } = makeService();
|
||||||
|
|
||||||
await expect(service.put('products-overview', [], 'not-a-date')).rejects.toBeInstanceOf(
|
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, 'not-a-date')).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the AppData response successful when relation sync fails', async () => {
|
it('keeps the AppData response successful when relation sync fails', async () => {
|
||||||
const { prisma, service, syncService } = makeService();
|
const { prisma, service, syncService } = makeService();
|
||||||
const value = [{ id: 'p1' }];
|
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||||
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
|
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
|
||||||
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
|
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
|
||||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||||
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
|
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
|
||||||
|
|
||||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
await expect(service.put('members', value)).resolves.toEqual({
|
||||||
key: 'products-overview',
|
key: 'members',
|
||||||
value,
|
value,
|
||||||
version: updatedAt.toISOString(),
|
version: updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('products-overview'));
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('members'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
|
||||||
import { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
|
import { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
|
||||||
import { isAppDataKey } from './data-keys';
|
import { type AppDataKey, isAppDataKey } from './data-keys';
|
||||||
|
|
||||||
type AppDataRow = {
|
type AppDataRow = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -17,6 +18,8 @@ export class DataService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private readonly appDataSync?: AppDataV23SyncService,
|
private readonly appDataSync?: AppDataV23SyncService,
|
||||||
|
@Optional()
|
||||||
|
private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(key: string) {
|
async get(key: string) {
|
||||||
@@ -27,6 +30,7 @@ export class DataService {
|
|||||||
|
|
||||||
async put(key: string, value: unknown, version?: string | null) {
|
async put(key: string, value: unknown, version?: string | null) {
|
||||||
this.ensureAllowedKey(key);
|
this.ensureAllowedKey(key);
|
||||||
|
this.appDataRetirement.assertWritable(key);
|
||||||
const jsonValue = value as Prisma.InputJsonValue;
|
const jsonValue = value as Prisma.InputJsonValue;
|
||||||
|
|
||||||
if (version === null) {
|
if (version === null) {
|
||||||
@@ -66,7 +70,7 @@ export class DataService {
|
|||||||
return this.toResponseAfterSync(key, row);
|
return this.toResponseAfterSync(key, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAllowedKey(key: string) {
|
private ensureAllowedKey(key: string): asserts key is AppDataKey {
|
||||||
if (!isAppDataKey(key)) {
|
if (!isAppDataKey(key)) {
|
||||||
throw new BadRequestException(`Unsupported data key: ${key}`);
|
throw new BadRequestException(`Unsupported data key: ${key}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
|
import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator';
|
||||||
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||||
import { DevTaskService } from './dev-task.service';
|
import { DevTaskService } from './dev-task.service';
|
||||||
@@ -8,6 +9,11 @@ export class DevTaskController {
|
|||||||
constructor(private readonly devTaskService: DevTaskService) {}
|
constructor(private readonly devTaskService: DevTaskService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.create',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) {
|
create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) {
|
||||||
return this.devTaskService.create(versionId, dto);
|
return this.devTaskService.create(versionId, dto);
|
||||||
}
|
}
|
||||||
@@ -18,11 +24,23 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.update',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) {
|
update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) {
|
||||||
return this.devTaskService.update(versionId, id, dto);
|
return this.devTaskService.update(versionId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.status',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -32,6 +50,12 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/block')
|
@Patch(':id/block')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.block',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
setBlocked(
|
setBlocked(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -42,6 +66,12 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/transfer')
|
@Patch(':id/transfer')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.transfer',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
transfer(
|
transfer(
|
||||||
@Param('versionId') versionId: string,
|
@Param('versionId') versionId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -51,6 +81,12 @@ export class DevTaskController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, {
|
||||||
|
action: 'dev_task.delete',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
versionIdParam: 'versionId',
|
||||||
|
})
|
||||||
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
remove(@Param('versionId') versionId: string, @Param('id') id: string) {
|
||||||
return this.devTaskService.remove(versionId, id);
|
return this.devTaskService.remove(versionId, id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ describe('DevTaskService domain writes', () => {
|
|||||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||||
};
|
};
|
||||||
const prisma = {
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findFirst: createUserFindFirstMock(),
|
||||||
|
},
|
||||||
version: {
|
version: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -26,6 +29,58 @@ describe('DevTaskService domain writes', () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('resolves assignee and creator names to user ids before relation writes', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 张三: 'dev-1', 产品经理: 'pm-1' });
|
||||||
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
|
prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' });
|
||||||
|
|
||||||
|
await service.create('version-1', {
|
||||||
|
title: '开发登录',
|
||||||
|
assigneeId: '张三',
|
||||||
|
createdBy: '产品经理',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.devTask.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
creatorId: 'pm-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves transfer assignee names before updating foreign keys', async () => {
|
||||||
|
const { prisma, workActivity, service } = makeService();
|
||||||
|
prisma.user.findFirst = createUserFindFirstMock({ 李四: 'dev-2' });
|
||||||
|
prisma.devTask.findFirst.mockResolvedValue({
|
||||||
|
id: 'task-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: '开发登录',
|
||||||
|
assigneeId: 'dev-1',
|
||||||
|
});
|
||||||
|
prisma.devTask.update.mockResolvedValue({
|
||||||
|
id: 'task-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: '开发登录',
|
||||||
|
assigneeId: 'dev-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.transfer('version-1', 'task-1', '李四');
|
||||||
|
|
||||||
|
expect(prisma.devTask.update).toHaveBeenCalledWith({
|
||||||
|
where: { id_versionId: { id: 'task-1', versionId: 'version-1' } },
|
||||||
|
data: { assigneeId: 'dev-2' },
|
||||||
|
});
|
||||||
|
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('creates dev tasks directly under a version partition', async () => {
|
it('creates dev tasks directly under a version partition', async () => {
|
||||||
const { prisma, workActivity, service } = makeService();
|
const { prisma, workActivity, service } = makeService();
|
||||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||||
@@ -151,3 +206,13 @@ describe('DevTaskService domain writes', () => {
|
|||||||
expect(prisma.devTask.update).not.toHaveBeenCalled();
|
expect(prisma.devTask.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||||
|
return jest.fn(({ where }: any) => {
|
||||||
|
const refs = (where?.OR ?? [])
|
||||||
|
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||||
|
.filter(Boolean);
|
||||||
|
const ref = refs[0];
|
||||||
|
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { resolveUserReference } from '../../common/user-reference';
|
||||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||||
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';
|
||||||
@@ -17,9 +18,10 @@ export class DevTaskService {
|
|||||||
if (!version.projectId) {
|
if (!version.projectId) {
|
||||||
throw new BadRequestException('开发任务所属版本必须归属于项目');
|
throw new BadRequestException('开发任务所属版本必须归属于项目');
|
||||||
}
|
}
|
||||||
|
const taskData = await this.toTaskData(dto);
|
||||||
const item = await this.prisma.devTask.create({
|
const item = await this.prisma.devTask.create({
|
||||||
data: {
|
data: {
|
||||||
...this.toTaskData(dto),
|
...taskData,
|
||||||
versionId,
|
versionId,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
projectId: version.projectId,
|
projectId: version.projectId,
|
||||||
@@ -44,7 +46,7 @@ export class DevTaskService {
|
|||||||
await this.ensureTaskInVersion(versionId, id);
|
await this.ensureTaskInVersion(versionId, id);
|
||||||
const item = await this.prisma.devTask.update({
|
const item = await this.prisma.devTask.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: this.toTaskData(dto),
|
data: await this.toTaskData(dto),
|
||||||
});
|
});
|
||||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||||
return { item, activities: [] };
|
return { item, activities: [] };
|
||||||
@@ -84,16 +86,17 @@ export class DevTaskService {
|
|||||||
|
|
||||||
async transfer(versionId: string, id: string, assigneeId: string) {
|
async transfer(versionId: string, id: string, assigneeId: string) {
|
||||||
const current = await this.ensureTaskInVersion(versionId, id);
|
const current = await this.ensureTaskInVersion(versionId, id);
|
||||||
|
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
|
||||||
const item = await this.prisma.devTask.update({
|
const item = await this.prisma.devTask.update({
|
||||||
where: { id_versionId: { id, versionId } },
|
where: { id_versionId: { id, versionId } },
|
||||||
data: { assigneeId },
|
data: { assigneeId: resolvedAssigneeId },
|
||||||
});
|
});
|
||||||
const activity = await this.recordTaskActivity(
|
const activity = await this.recordTaskActivity(
|
||||||
item,
|
item,
|
||||||
'dev_task_transferred',
|
'dev_task_transferred',
|
||||||
'progress',
|
'progress',
|
||||||
`转派开发任务:${item.title}`,
|
`转派开发任务:${item.title}`,
|
||||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId },
|
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId },
|
||||||
);
|
);
|
||||||
return { item, activities: [activity] };
|
return { item, activities: [activity] };
|
||||||
}
|
}
|
||||||
@@ -105,7 +108,7 @@ export class DevTaskService {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toTaskData(dto: Partial<CreateDevTaskDto>) {
|
private async toTaskData(dto: Partial<CreateDevTaskDto>) {
|
||||||
return {
|
return {
|
||||||
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
||||||
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
||||||
@@ -115,8 +118,10 @@ export class DevTaskService {
|
|||||||
...(dto.description !== undefined && { description: dto.description ?? '' }),
|
...(dto.description !== undefined && { description: dto.description ?? '' }),
|
||||||
...(dto.status !== undefined && { status: dto.status }),
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||||
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
|
...(dto.creatorId !== undefined || dto.createdBy !== undefined
|
||||||
|
? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) }
|
||||||
|
: {}),
|
||||||
...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }),
|
...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }),
|
||||||
...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }),
|
...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }),
|
||||||
...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }),
|
...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }),
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { GOVERNANCE_DICTIONARY_KINDS, type GovernanceDictionaryKind } from '../governance.service';
|
||||||
|
|
||||||
|
export class GovernanceDictionaryDto {
|
||||||
|
@IsString()
|
||||||
|
actorId!: string;
|
||||||
|
|
||||||
|
@IsIn(GOVERNANCE_DICTIONARY_KINDS)
|
||||||
|
kind!: GovernanceDictionaryKind;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
group?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scope?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoveGovernanceDictionaryDto {
|
||||||
|
@IsString()
|
||||||
|
actorId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImportGovernanceDictionariesDto {
|
||||||
|
@IsString()
|
||||||
|
actorId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => GovernanceDictionaryDto)
|
||||||
|
items!: GovernanceDictionaryDto[];
|
||||||
|
}
|
||||||
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { GovernanceDictionaryDto, ImportGovernanceDictionariesDto, RemoveGovernanceDictionaryDto } from './dto/governance-dictionary.dto';
|
||||||
|
import { GovernanceDictionaryKind, GovernanceService } from './governance.service';
|
||||||
|
|
||||||
|
@Controller('governance')
|
||||||
|
export class GovernanceController {
|
||||||
|
constructor(private readonly governanceService: GovernanceService) {}
|
||||||
|
|
||||||
|
@Get('dictionaries')
|
||||||
|
list(@Query('kind') kind: GovernanceDictionaryKind) {
|
||||||
|
return this.governanceService.list(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('dictionaries')
|
||||||
|
create(@Body() dto: GovernanceDictionaryDto) {
|
||||||
|
return this.governanceService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('dictionaries/:kind/:id')
|
||||||
|
update(
|
||||||
|
@Param('kind') kind: GovernanceDictionaryKind,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: GovernanceDictionaryDto,
|
||||||
|
) {
|
||||||
|
return this.governanceService.update({ ...dto, kind, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('dictionaries/:kind/:id')
|
||||||
|
remove(
|
||||||
|
@Param('kind') kind: GovernanceDictionaryKind,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: RemoveGovernanceDictionaryDto,
|
||||||
|
) {
|
||||||
|
return this.governanceService.remove({ actorId: dto.actorId, permissions: dto.permissions, kind, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
exportAll() {
|
||||||
|
return this.governanceService.exportAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
importAll(@Body() dto: ImportGovernanceDictionariesDto) {
|
||||||
|
return this.governanceService.importAll(dto.actorId, dto.items, dto.permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/governance/governance.module.ts
Normal file
12
apps/server/src/modules/governance/governance.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
|
import { GovernanceController } from './governance.controller';
|
||||||
|
import { GovernanceService } from './governance.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CommonDomainModule],
|
||||||
|
controllers: [GovernanceController],
|
||||||
|
providers: [GovernanceService],
|
||||||
|
exports: [GovernanceService],
|
||||||
|
})
|
||||||
|
export class GovernanceModule {}
|
||||||
146
apps/server/src/modules/governance/governance.service.spec.ts
Normal file
146
apps/server/src/modules/governance/governance.service.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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(),
|
||||||
|
},
|
||||||
|
appData: {
|
||||||
|
findUnique: 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('does not backfill requirement dictionaries from legacy requirement AppData during runtime list', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.governanceDictionary.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(service.list('requirement_type')).resolves.toEqual([]);
|
||||||
|
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.governanceDictionary.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds default task categories without reading legacy AppData on first governance list', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
const seeded = [{ id: 'cat-1', name: '前端开发', group: 'development' }];
|
||||||
|
prisma.taskCategory.findMany
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce(seeded);
|
||||||
|
prisma.taskCategory.create.mockResolvedValue(seeded[0]);
|
||||||
|
|
||||||
|
await expect(service.list('task_category')).resolves.toEqual(seeded);
|
||||||
|
|
||||||
|
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
id: 'cat-1',
|
||||||
|
name: '前端开发',
|
||||||
|
group: 'development',
|
||||||
|
code: 'frontend_development',
|
||||||
|
isSystem: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds default development and testing task categories when relation table is empty', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.taskCategory.findMany
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([{ id: 'cat-1', name: '前端开发', group: 'development' }]);
|
||||||
|
prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' });
|
||||||
|
|
||||||
|
await service.list('task_category');
|
||||||
|
|
||||||
|
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ id: 'cat-1', name: '前端开发', group: 'development' }),
|
||||||
|
});
|
||||||
|
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ id: 'cat-test-functional', name: '功能测试', group: 'testing' }),
|
||||||
|
});
|
||||||
|
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '新功能' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
275
apps/server/src/modules/governance/governance.service.ts
Normal file
275
apps/server/src/modules/governance/governance.service.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(kind: GovernanceDictionaryKind) {
|
||||||
|
assertDictionaryKind(kind);
|
||||||
|
if (kind === 'task_category') {
|
||||||
|
const rows = await this.findTaskCategories();
|
||||||
|
if (rows.length > 0) return rows;
|
||||||
|
await this.seedDefaultTaskCategories();
|
||||||
|
return this.findTaskCategories();
|
||||||
|
}
|
||||||
|
return this.findGovernanceDictionaries(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findGovernanceDictionaries(kind: GovernanceDictionaryKind) {
|
||||||
|
return this.prisma.governanceDictionary.findMany({
|
||||||
|
where: { kind, deletedAt: null },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTaskCategories() {
|
||||||
|
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { 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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedDefaultTaskCategories() {
|
||||||
|
for (const item of DEFAULT_TASK_CATEGORIES) {
|
||||||
|
await this.prisma.taskCategory.create({
|
||||||
|
data: {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
group: item.group,
|
||||||
|
code: item.code ?? null,
|
||||||
|
isSystem: item.isSystem,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TASK_CATEGORIES: Array<{ id: string; code: string; name: string; group: string; isSystem: boolean }> = [
|
||||||
|
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', isSystem: true },
|
||||||
|
{ id: 'cat-test-functional', code: 'test_functional', name: '功能测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', isSystem: true },
|
||||||
|
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', isSystem: true },
|
||||||
|
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', isSystem: true },
|
||||||
|
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', isSystem: true },
|
||||||
|
];
|
||||||
18
apps/server/src/modules/management/management.controller.ts
Normal file
18
apps/server/src/modules/management/management.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ManagementService } from './management.service';
|
||||||
|
|
||||||
|
@Controller('management')
|
||||||
|
export class ManagementController {
|
||||||
|
constructor(private readonly managementService: ManagementService) {}
|
||||||
|
|
||||||
|
@Get('overview')
|
||||||
|
getOverview(
|
||||||
|
@Query('actorId') actorId: string,
|
||||||
|
@Query('permissions') permissions?: string,
|
||||||
|
) {
|
||||||
|
return this.managementService.getOverview({
|
||||||
|
actorId,
|
||||||
|
permissions: permissions ? permissions.split(',').map((item) => item.trim()).filter(Boolean) : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/server/src/modules/management/management.module.ts
Normal file
11
apps/server/src/modules/management/management.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
|
import { ManagementController } from './management.controller';
|
||||||
|
import { ManagementService } from './management.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CommonDomainModule],
|
||||||
|
controllers: [ManagementController],
|
||||||
|
providers: [ManagementService],
|
||||||
|
})
|
||||||
|
export class ManagementModule {}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { ManagementService } from './management.service';
|
||||||
|
|
||||||
|
describe('ManagementService', () => {
|
||||||
|
const makeService = () => {
|
||||||
|
const prisma = {
|
||||||
|
appData: { findMany: jest.fn() },
|
||||||
|
projectMember: { findMany: jest.fn() },
|
||||||
|
version: { findMany: jest.fn() },
|
||||||
|
versionPlan: { findMany: jest.fn() },
|
||||||
|
devTask: { findMany: jest.fn() },
|
||||||
|
testCase: { findMany: jest.fn() },
|
||||||
|
bug: { findMany: jest.fn() },
|
||||||
|
xiaobaoRiskSummary: { findMany: jest.fn() },
|
||||||
|
};
|
||||||
|
const rbac = {
|
||||||
|
assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-manager', via: 'permission' }),
|
||||||
|
};
|
||||||
|
return { prisma, rbac, service: new ManagementService(prisma as any, rbac as any) };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('requires management view permission through the RBAC adapter', async () => {
|
||||||
|
const { rbac, service } = makeService();
|
||||||
|
rbac.assertGlobalPermission.mockRejectedValue(new Error('forbidden'));
|
||||||
|
|
||||||
|
await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).rejects.toThrow('forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty dashboard when actor has no managed projects', async () => {
|
||||||
|
const { prisma, rbac, service } = makeService();
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(service.getOverview({ actorId: 'm-dev', permissions: ['management:view'] })).resolves.toEqual({
|
||||||
|
activeVersionCount: 0,
|
||||||
|
overdueItemCount: 0,
|
||||||
|
blockedItemCount: 0,
|
||||||
|
riskCounts: {},
|
||||||
|
memberLoads: [],
|
||||||
|
activeVersions: [],
|
||||||
|
overdueItems: [],
|
||||||
|
blockedItems: [],
|
||||||
|
highRiskVersions: [],
|
||||||
|
});
|
||||||
|
expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({
|
||||||
|
actorId: 'm-dev',
|
||||||
|
permissions: ['management:view'],
|
||||||
|
requiredPermissions: ['management:view'],
|
||||||
|
});
|
||||||
|
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates active versions, overdue work, blockers, risks, and member load from relation tables only', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }]);
|
||||||
|
prisma.version.findMany.mockResolvedValue([
|
||||||
|
{ id: 'ver-1', projectId: 'project-1', name: 'V1', releaseDate: new Date('2026-07-20T00:00:00.000Z') },
|
||||||
|
]);
|
||||||
|
prisma.versionPlan.findMany.mockResolvedValue([
|
||||||
|
{ id: 'plan-1', versionId: 'ver-1', title: '产品方案', ownerId: 'm-pm', status: 'in_progress', expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||||
|
]);
|
||||||
|
prisma.devTask.findMany.mockResolvedValue([
|
||||||
|
{ id: 'task-1', versionId: 'ver-1', title: '接口开发', assigneeId: 'm-dev', status: 'in_progress', isBlocked: true, expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||||
|
]);
|
||||||
|
prisma.testCase.findMany.mockResolvedValue([
|
||||||
|
{ id: 'tc-1', versionId: 'ver-1', title: '权限测试', assigneeId: 'm-qa', status: 'running', plannedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||||
|
]);
|
||||||
|
prisma.bug.findMany.mockResolvedValue([
|
||||||
|
{ id: 'bug-1', versionId: 'ver-1', title: '线上缺陷', assigneeId: 'm-dev', status: 'open', plannedFixAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||||
|
]);
|
||||||
|
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
|
||||||
|
{ versionId: 'ver-1', riskLevel: 'likely_delayed', riskScore: 82 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getOverview({
|
||||||
|
actorId: 'm-manager',
|
||||||
|
permissions: [],
|
||||||
|
now: new Date('2026-07-08T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.activeVersionCount).toBe(1);
|
||||||
|
expect(result.overdueItemCount).toBe(4);
|
||||||
|
expect(result.blockedItemCount).toBe(1);
|
||||||
|
expect(result.riskCounts).toEqual({ likely_delayed: 1 });
|
||||||
|
expect(result.memberLoads).toEqual([
|
||||||
|
{ memberId: 'm-dev', openItemCount: 2 },
|
||||||
|
{ memberId: 'm-pm', openItemCount: 1 },
|
||||||
|
{ memberId: 'm-qa', openItemCount: 1 },
|
||||||
|
]);
|
||||||
|
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user