From 2e595c7e72f1fa4afd07cefe5e198b85e5afb987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Thu, 9 Jul 2026 14:59:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor(data):=20=E6=94=B6=E5=8F=A3=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E8=A1=A8=E8=BF=90=E8=A1=8C=E6=97=B6=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读 - 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移 - 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试 Co-Authored-By: Codex GPT-5 --- .superpowers/sdd/progress.md | 10 + .../migration.sql | 9 + .../migration.sql | 48 +++ .../migration.sql | 2 + apps/server/prisma/schema.prisma | 8 +- apps/server/src/common/user-reference.ts | 25 ++ apps/server/src/modules/ai/ai.service.spec.ts | 29 ++ apps/server/src/modules/ai/ai.service.ts | 22 ++ .../ai/providers/anthropic.provider.spec.ts | 44 +++ .../ai/providers/anthropic.provider.ts | 9 +- .../ai/providers/openai.provider.spec.ts | 70 +++++ .../modules/ai/providers/openai.provider.ts | 17 +- .../ai/providers/provider.interface.ts | 1 + .../app-data-retirement.service.spec.ts | 10 + .../app-data-retirement.service.ts | 12 +- .../src/modules/bug/bug.service.spec.ts | 68 +++++ apps/server/src/modules/bug/bug.service.ts | 21 +- .../src/modules/data/data.service.spec.ts | 103 +++---- .../modules/dev-task/dev-task.service.spec.ts | 65 ++++ .../src/modules/dev-task/dev-task.service.ts | 19 +- .../governance/governance.service.spec.ts | 53 ++++ .../modules/governance/governance.service.ts | 52 +++- .../migration/app-data-v22.mapper.spec.ts | 30 +- .../modules/migration/app-data-v22.mapper.ts | 24 +- .../modules/product/product.service.spec.ts | 46 +++ .../src/modules/product/product.service.ts | 75 +---- .../requirement/dto/create-requirement.dto.ts | 8 + .../requirement/requirement.service.spec.ts | 74 ++++- .../requirement/requirement.service.ts | 24 +- .../test-case/test-case.service.spec.ts | 56 ++++ .../modules/test-case/test-case.service.ts | 20 +- .../v22-query/v22-query.service.spec.ts | 28 +- .../modules/v22-query/v22-query.service.ts | 87 +----- .../dto/create-version-plan.dto.ts | 3 + .../version-plan/version-plan.service.spec.ts | 124 ++++++++ .../version-plan/version-plan.service.ts | 13 +- apps/web/app/admin/governance/page.tsx | 51 +++- apps/web/app/overtime/page.tsx | 20 +- apps/web/app/products/[id]/page.tsx | 2 +- apps/web/app/projects/[id]/page.tsx | 15 +- apps/web/app/projects/page.tsx | 20 +- apps/web/app/requirements/page.tsx | 83 ++--- apps/web/app/versions/[id]/page.tsx | 87 +++--- apps/web/app/versions/page.tsx | 15 +- apps/web/app/workspace/page.tsx | 74 ++--- apps/web/app/xiaobao-warning/page.tsx | 57 +--- apps/web/components/bug/BugTab.tsx | 8 +- apps/web/components/dev-task/DevTaskTab.tsx | 4 +- .../requirement/RequirementModal.tsx | 23 -- apps/web/components/test-case/TestCaseTab.tsx | 8 +- apps/web/components/version/PlanTab.tsx | 78 ++++- apps/web/hooks/useWorkspaceWorkItems.ts | 32 +- apps/web/hooks/useXiaobaoWarningRisks.ts | 34 ++- apps/web/lib/api.test.ts | 17 ++ apps/web/lib/api.ts | 16 + apps/web/lib/domain-api.ts | 11 +- apps/web/lib/governance-source.test.ts | 21 ++ ...member-password-persistence-source.test.ts | 2 +- apps/web/lib/member-system.test.ts | 2 + apps/web/lib/member-system.ts | 8 +- .../no-broad-business-fetch-source.test.ts | 89 ++++++ ...no-business-appdata-runtime-source.test.ts | 66 ++++ .../product-store-persistence-source.test.ts | 9 +- .../requirement-domain-write-source.test.ts | 56 +++- .../lib/test-case-bug-domain-source.test.ts | 37 +++ apps/web/lib/v22-api.ts | 7 +- apps/web/lib/v245-domain-source.test.ts | 4 + apps/web/lib/version-data-scope.test.ts | 58 +++- apps/web/lib/version-data-scope.ts | 40 ++- apps/web/lib/version-members.ts | 1 + ...ersion-plan-dev-task-domain-source.test.ts | 81 +++++ .../web/lib/version-plan-owner-source.test.ts | 50 +++ apps/web/lib/version-plan.test.ts | 122 ++++++++ apps/web/lib/version-plan.ts | 161 +++++++--- apps/web/lib/workspace-v22-source.test.ts | 63 +++- apps/web/lib/workspace-v22-source.ts | 45 ++- apps/web/lib/xiaobao-v22-summary.test.ts | 14 +- apps/web/lib/xiaobao-v22-summary.ts | 2 +- apps/web/stores/useAuthStore.ts | 12 +- apps/web/stores/useBugStore.ts | 99 +++--- apps/web/stores/useDevTaskStore.ts | 114 +++---- apps/web/stores/useMemberStore.ts | 85 ++++-- apps/web/stores/useOvertimeStore.ts | 49 ++- apps/web/stores/useProductStore.ts | 145 +++------ apps/web/stores/useRequirementStore.ts | 289 +++++++++++++----- apps/web/stores/useTaskCategoryStore.ts | 47 +-- apps/web/stores/useTaskWorklogStore.ts | 35 +-- apps/web/stores/useTestCaseStore.ts | 126 +++----- apps/web/stores/useVersionPlanStore.ts | 111 +++++-- apps/web/stores/useWorkActivityStore.ts | 52 +--- apps/web/stores/useXiaobaoRiskStore.ts | 91 +----- apps/web/stores/useXiaobaoWarningReadStore.ts | 47 ++- docs/agent-spec.md | 11 +- docs/architecture.md | 37 +-- docs/decisions.md | 33 +- docs/roadmap.md | 48 +-- docs/workflow.md | 44 +-- packages/shared/src/agent.ts | 2 +- 98 files changed, 2938 insertions(+), 1441 deletions(-) create mode 100644 .superpowers/sdd/progress.md create mode 100644 apps/server/prisma/migrations/20260709000000_requirement_product_owner/migration.sql create mode 100644 apps/server/prisma/migrations/20260709010000_member_username_backfill/migration.sql create mode 100644 apps/server/prisma/migrations/20260709010000_version_plan_tasks/migration.sql create mode 100644 apps/server/src/common/user-reference.ts create mode 100644 apps/server/src/modules/ai/providers/openai.provider.spec.ts create mode 100644 apps/server/src/modules/product/product.service.spec.ts create mode 100644 apps/web/lib/governance-source.test.ts create mode 100644 apps/web/lib/no-broad-business-fetch-source.test.ts create mode 100644 apps/web/lib/no-business-appdata-runtime-source.test.ts create mode 100644 apps/web/lib/version-plan-owner-source.test.ts diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md new file mode 100644 index 0000000..91b802f --- /dev/null +++ b/.superpowers/sdd/progress.md @@ -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) diff --git a/apps/server/prisma/migrations/20260709000000_requirement_product_owner/migration.sql b/apps/server/prisma/migrations/20260709000000_requirement_product_owner/migration.sql new file mode 100644 index 0000000..bb92e5c --- /dev/null +++ b/apps/server/prisma/migrations/20260709000000_requirement_product_owner/migration.sql @@ -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); diff --git a/apps/server/prisma/migrations/20260709010000_member_username_backfill/migration.sql b/apps/server/prisma/migrations/20260709010000_member_username_backfill/migration.sql new file mode 100644 index 0000000..da47a89 --- /dev/null +++ b/apps/server/prisma/migrations/20260709010000_member_username_backfill/migration.sql @@ -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; diff --git a/apps/server/prisma/migrations/20260709010000_version_plan_tasks/migration.sql b/apps/server/prisma/migrations/20260709010000_version_plan_tasks/migration.sql new file mode 100644 index 0000000..4b91fca --- /dev/null +++ b/apps/server/prisma/migrations/20260709010000_version_plan_tasks/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "version_plans" + ADD COLUMN IF NOT EXISTS "tasks" JSONB NOT NULL DEFAULT '[]'; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 4fa5ee6..e98da90 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -24,7 +24,8 @@ model User { createdTasks Task[] @relation("TaskCreator") assignedTasks Task[] @relation("TaskAssignee") projectMembers ProjectMember[] - requirements Requirement[] + requirements Requirement[] @relation("RequirementCreator") + productOwnedRequirements Requirement[] @relation("RequirementProductOwner") watchedTasks TaskWatcher[] @@map("users") @@ -116,11 +117,13 @@ model Requirement { sourceTarget String? @map("source_target") platform String? creatorId String? @map("creator_id") + productOwnerId String? @map("product_owner_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") 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]) @@unique([productId, code]) @@ -290,6 +293,7 @@ model VersionPlan { actualStartAt DateTime? @map("actual_start_at") completedAt DateTime? @map("completed_at") resultUrl String? @map("result_url") + tasks Json @default("[]") requirementCoverage Json @default("[]") @map("requirement_coverage") logs Json @default("[]") createdAt DateTime @default(now()) @map("created_at") diff --git a/apps/server/src/common/user-reference.ts b/apps/server/src/common/user-reference.ts new file mode 100644 index 0000000..e899892 --- /dev/null +++ b/apps/server/src/common/user-reference.ts @@ -0,0 +1,25 @@ +export async function resolveUserReference(prisma: any, reference: string | null | undefined): Promise { + 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; +} diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 6dd8956..984ba77 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -271,6 +271,35 @@ describe('AiService', () => { 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 () => { const callTool = jest .fn() diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index 0c381ed..3aa9ec8 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -26,6 +26,7 @@ import type { } from '@ftb/shared'; 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_ATTEMPTS = 3; const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500; @@ -97,6 +98,7 @@ export class AiService { forceTool: true, maxTokens: 16000, model, + timeoutMs: DECOMPOSE_AI_TIMEOUT_MS, }); break; } catch (e: any) { @@ -108,6 +110,15 @@ export class AiService { 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}`); return { ok: false, @@ -267,6 +278,17 @@ export class AiService { 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 { if (!url || !url.startsWith('http')) { throw new Error('原型链接无效'); diff --git a/apps/server/src/modules/ai/providers/anthropic.provider.spec.ts b/apps/server/src/modules/ai/providers/anthropic.provider.spec.ts index b179fe3..92e3603 100644 --- a/apps/server/src/modules/ai/providers/anthropic.provider.spec.ts +++ b/apps/server/src/modules/ai/providers/anthropic.provider.spec.ts @@ -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 }, + ); + }); }); diff --git a/apps/server/src/modules/ai/providers/anthropic.provider.ts b/apps/server/src/modules/ai/providers/anthropic.provider.ts index e334171..2b9a754 100644 --- a/apps/server/src/modules/ai/providers/anthropic.provider.ts +++ b/apps/server/src/modules/ai/providers/anthropic.provider.ts @@ -24,7 +24,7 @@ export class AnthropicProvider implements AiProvider { } as any) : { type: 'auto' as const }; - const response = await this.client.messages.create({ + const body = { model: req.model, max_tokens: req.maxTokens, system: req.systemPrompt, @@ -36,8 +36,11 @@ export class AnthropicProvider implements AiProvider { }, ], 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( (c): c is Anthropic.ToolUseBlock => c.type === 'tool_use', diff --git a/apps/server/src/modules/ai/providers/openai.provider.spec.ts b/apps/server/src/modules/ai/providers/openai.provider.spec.ts new file mode 100644 index 0000000..8c3e2b1 --- /dev/null +++ b/apps/server/src/modules/ai/providers/openai.provider.spec.ts @@ -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 }, + ); + }); +}); diff --git a/apps/server/src/modules/ai/providers/openai.provider.ts b/apps/server/src/modules/ai/providers/openai.provider.ts index 8925682..14889cc 100644 --- a/apps/server/src/modules/ai/providers/openai.provider.ts +++ b/apps/server/src/modules/ai/providers/openai.provider.ts @@ -16,16 +16,16 @@ export class OpenAIProvider implements AiProvider { } async callTool(req: ToolCallRequest): Promise { - const response = await this.client.chat.completions.create({ + const body = { model: req.model, max_tokens: req.maxTokens, messages: [ - { role: 'system', content: req.systemPrompt }, - { role: 'user', content: req.userPrompt }, + { role: 'system' as const, content: req.systemPrompt }, + { role: 'user' as const, content: req.userPrompt }, ], tools: [ { - type: 'function', + type: 'function' as const, function: { name: req.tool.name, description: req.tool.description, @@ -34,9 +34,12 @@ export class OpenAIProvider implements AiProvider { }, ], tool_choice: req.forceTool - ? { type: 'function', function: { name: req.tool.name } } - : 'auto', - }); + ? { type: 'function' as const, function: { name: req.tool.name } } + : '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]; if (!choice?.message?.tool_calls?.length) { diff --git a/apps/server/src/modules/ai/providers/provider.interface.ts b/apps/server/src/modules/ai/providers/provider.interface.ts index e49a4b3..96cb12a 100644 --- a/apps/server/src/modules/ai/providers/provider.interface.ts +++ b/apps/server/src/modules/ai/providers/provider.interface.ts @@ -17,6 +17,7 @@ export interface ToolCallRequest { forceTool: boolean; maxTokens: number; model: string; + timeoutMs?: number; } export interface ToolCallResponse { diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts index 328a88d..ab2476c 100644 --- a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts @@ -27,6 +27,16 @@ describe('AppDataRetirementService', () => { } }); + 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'); diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts index 02e19be..383b77e 100644 --- a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts @@ -35,9 +35,9 @@ export const APP_DATA_RETIREMENT_CONFIG = { replacement: '/api/v1/versions/:versionId/bugs', }, members: { - state: 'write_frozen', - replacement: '/api/v1/members', - note: '成员身份已迁移;部门、角色和密码策略仍需 V2.7 配置表承接。', + 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', @@ -67,9 +67,9 @@ export const APP_DATA_RETIREMENT_CONFIG = { note: '个人已读状态等待企业协作/通知治理阶段承接。', }, overtime: { - state: 'write_frozen', - replacement: '/api/v1/overtime', - note: '加班记录已迁移;加班原因配置仍需 V2.7 配置表承接。', + 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; diff --git a/apps/server/src/modules/bug/bug.service.spec.ts b/apps/server/src/modules/bug/bug.service.spec.ts index 682afe2..e2a82e5 100644 --- a/apps/server/src/modules/bug/bug.service.spec.ts +++ b/apps/server/src/modules/bug/bug.service.spec.ts @@ -7,6 +7,9 @@ describe('BugService domain writes', () => { record: jest.fn().mockResolvedValue({ id: 'activity-1' }), }; const prisma = { + user: { + findFirst: createUserFindFirstMock(), + }, version: { 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 () => { const { prisma, workActivity, service } = makeService(); 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(); }); }); + +function createUserFindFirstMock(mapping: Record = {}) { + return jest.fn(({ where }: any) => { + const refs = (where?.OR ?? []) + .flatMap((condition: Record) => Object.values(condition)) + .filter(Boolean); + const ref = refs[0]; + return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null); + }); +} diff --git a/apps/server/src/modules/bug/bug.service.ts b/apps/server/src/modules/bug/bug.service.ts index 4663665..f84f55b 100644 --- a/apps/server/src/modules/bug/bug.service.ts +++ b/apps/server/src/modules/bug/bug.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { resolveUserReference } from '../../common/user-reference'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateBugDto } from './dto/create-bug.dto'; import { UpdateBugDto } from './dto/update-bug.dto'; @@ -14,9 +15,10 @@ export class BugService { async create(versionId: string, dto: CreateBugDto) { const version = await this.ensureVersion(versionId); + const bugData = await this.toBugData(dto); const item = await this.prisma.bug.create({ data: { - ...this.toBugData(dto), + ...bugData, versionId, productId: version.productId, projectId: version.projectId, @@ -43,7 +45,7 @@ export class BugService { await this.ensureBugInVersion(versionId, id); const item = await this.prisma.bug.update({ where: { id_versionId: { id, versionId } }, - data: this.toBugData(dto), + data: await this.toBugData(dto), }); await this.workActivity.markXiaobaoSummaryDirty(versionId); return { item, activities: [] }; @@ -70,16 +72,17 @@ export class BugService { async transfer(versionId: string, id: string, assigneeId: string, operator?: string) { const current = await this.ensureBugInVersion(versionId, id); + const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId); const item = await this.prisma.bug.update({ where: { id_versionId: { id, versionId } }, - data: { assigneeId }, + data: { assigneeId: resolvedAssigneeId }, }); const activity = await this.recordBugActivity( item, 'bug_transferred', 'progress', - `转交 Bug:${item.title} → ${assigneeId}`, - { fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId, operator }, + `转交 Bug:${item.title} → ${resolvedAssigneeId ?? '-'}`, + { fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId, operator }, ); return { item, activities: [activity] }; } @@ -91,7 +94,7 @@ export class BugService { return item; } - private toBugData(dto: Partial) { + private async toBugData(dto: Partial) { return { ...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }), ...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }), @@ -101,8 +104,10 @@ export class BugService { ...(dto.status !== undefined && { status: dto.status }), ...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }), ...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }), - ...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }), - ...(dto.reporterId !== undefined || dto.reportedBy !== undefined ? { reporterId: emptyToNull(dto.reporterId ?? dto.reportedBy) } : {}), + ...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }), + ...(dto.reporterId !== undefined || dto.reportedBy !== undefined + ? { reporterId: await resolveUserReference(this.prisma, dto.reporterId ?? dto.reportedBy) } + : {}), ...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }), ...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }), ...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }), diff --git a/apps/server/src/modules/data/data.service.spec.ts b/apps/server/src/modules/data/data.service.spec.ts index 1a667a0..6d803d4 100644 --- a/apps/server/src/modules/data/data.service.spec.ts +++ b/apps/server/src/modules/data/data.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; +import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service'; import { DataService } from './data.service'; describe('DataService', () => { @@ -14,9 +15,7 @@ describe('DataService', () => { const syncService = { syncAfterAppDataPut: jest.fn(), }; - const retirementService = { - assertWritable: jest.fn(), - }; + const retirementService = new AppDataRetirementService(); return { prisma, syncService, @@ -58,36 +57,29 @@ describe('DataService', () => { 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, 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'); - 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({ - key: 'products-overview', + await expect(service.put('members', value)).resolves.toEqual({ + key: 'members', value, version: updatedAt.toISOString(), }); expect(prisma.appData.upsert).toHaveBeenCalledWith({ - where: { key: 'products-overview' }, + where: { key: 'members' }, update: { value }, - create: { key: 'products-overview', value }, + create: { key: 'members', value }, }); - expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview'); - expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview'); + expect(writableSpy).toHaveBeenCalledWith('members'); + expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members'); }); it('rejects frozen AppData writes before touching storage or relation sync', async () => { - const { prisma, service, syncService, retirementService } = makeService(); - retirementService.assertWritable.mockImplementation(() => { - throw new ConflictException({ - code: 'APP_DATA_WRITE_FROZEN', - key: 'dev-tasks', - state: 'write_frozen', - replacement: '/api/v1/versions/:versionId/dev-tasks', - }); - }); + const { prisma, service, syncService } = makeService(); await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({ response: expect.objectContaining({ @@ -102,59 +94,62 @@ describe('DataService', () => { expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled(); }); - it('allows supporting business data keys migrated from browser storage', async () => { + it('allows only active config AppData keys after business key retirement', async () => { const { prisma, service } = makeService(); - const value: unknown[] = []; + const membersValue = { departments: [], members: [], roles: [], passwordRule: null }; + const overtimeValue = { records: [], reasons: [] }; 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('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value }); - await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({ - key: 'overtime', - value, - }); + await expect(service.put('members', membersValue)).resolves.toMatchObject({ key: 'members', value: membersValue }); + await expect(service.put('overtime', overtimeValue)).resolves.toMatchObject({ key: 'overtime', value: overtimeValue }); + await expect(service.put('task-worklogs', [])).rejects.toBeInstanceOf(ConflictException); + await expect(service.put('work-activities', [])).rejects.toBeInstanceOf(ConflictException); }); it('updates only when the supplied version matches the stored row version', async () => { const { prisma, service, syncService } = makeService(); const previousVersion = '2026-07-02T08:03: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.findUnique.mockResolvedValue({ - key: 'products-overview', + key: 'members', value: nextValue, updatedAt: nextUpdatedAt, }); - await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({ - key: 'products-overview', + await expect(service.put('members', nextValue, previousVersion)).resolves.toEqual({ + key: 'members', value: nextValue, version: nextUpdatedAt.toISOString(), }); expect(prisma.appData.updateMany).toHaveBeenCalledWith({ where: { - key: 'products-overview', + key: 'members', updatedAt: new Date(previousVersion), }, 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 () => { const { prisma, service, syncService } = makeService(); prisma.appData.updateMany.mockResolvedValue({ count: 0 }); prisma.appData.findUnique.mockResolvedValue({ - key: 'products-overview', - value: [{ id: 'current' }], + key: 'members', + value: { departments: [], members: [], roles: [], passwordRule: null }, updatedAt: new Date('2026-07-02T08:05:00.000Z'), }); 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); expect(prisma.appData.upsert).not.toHaveBeenCalled(); expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled(); @@ -162,32 +157,32 @@ describe('DataService', () => { it('creates a missing row only when the client loaded a null version', async () => { 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'); - 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({ - key: 'products-overview', + await expect(service.put('members', value, null)).resolves.toEqual({ + key: 'members', value, version: updatedAt.toISOString(), }); expect(prisma.appData.create).toHaveBeenCalledWith({ - data: { key: 'products-overview', value }, + data: { key: 'members', value }, }); 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 () => { const { prisma, service, syncService } = makeService(); prisma.appData.create.mockRejectedValue({ code: 'P2002' }); prisma.appData.findUnique.mockResolvedValue({ - key: 'products-overview', - value: [{ id: 'current' }], + key: 'members', + value: { departments: [], members: [], roles: [], passwordRule: null }, 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, ); expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled(); @@ -196,24 +191,24 @@ describe('DataService', () => { it('rejects invalid AppData versions', async () => { 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, ); }); it('keeps the AppData response successful when relation sync fails', async () => { 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 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')); - await expect(service.put('products-overview', value)).resolves.toEqual({ - key: 'products-overview', + await expect(service.put('members', value)).resolves.toEqual({ + key: 'members', value, version: updatedAt.toISOString(), }); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('products-overview')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('members')); }); }); diff --git a/apps/server/src/modules/dev-task/dev-task.service.spec.ts b/apps/server/src/modules/dev-task/dev-task.service.spec.ts index 684ac17..4e767be 100644 --- a/apps/server/src/modules/dev-task/dev-task.service.spec.ts +++ b/apps/server/src/modules/dev-task/dev-task.service.spec.ts @@ -7,6 +7,9 @@ describe('DevTaskService domain writes', () => { record: jest.fn().mockResolvedValue({ id: 'activity-1' }), }; const prisma = { + user: { + findFirst: createUserFindFirstMock(), + }, version: { 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 () => { const { prisma, workActivity, service } = makeService(); 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(); }); }); + +function createUserFindFirstMock(mapping: Record = {}) { + return jest.fn(({ where }: any) => { + const refs = (where?.OR ?? []) + .flatMap((condition: Record) => Object.values(condition)) + .filter(Boolean); + const ref = refs[0]; + return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null); + }); +} diff --git a/apps/server/src/modules/dev-task/dev-task.service.ts b/apps/server/src/modules/dev-task/dev-task.service.ts index 738d6b1..16eb25b 100644 --- a/apps/server/src/modules/dev-task/dev-task.service.ts +++ b/apps/server/src/modules/dev-task/dev-task.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { resolveUserReference } from '../../common/user-reference'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateDevTaskDto } from './dto/create-dev-task.dto'; import { UpdateDevTaskDto } from './dto/update-dev-task.dto'; @@ -17,9 +18,10 @@ export class DevTaskService { if (!version.projectId) { throw new BadRequestException('开发任务所属版本必须归属于项目'); } + const taskData = await this.toTaskData(dto); const item = await this.prisma.devTask.create({ data: { - ...this.toTaskData(dto), + ...taskData, versionId, productId: version.productId, projectId: version.projectId, @@ -44,7 +46,7 @@ export class DevTaskService { await this.ensureTaskInVersion(versionId, id); const item = await this.prisma.devTask.update({ where: { id_versionId: { id, versionId } }, - data: this.toTaskData(dto), + data: await this.toTaskData(dto), }); await this.workActivity.markXiaobaoSummaryDirty(versionId); return { item, activities: [] }; @@ -84,16 +86,17 @@ export class DevTaskService { async transfer(versionId: string, id: string, assigneeId: string) { const current = await this.ensureTaskInVersion(versionId, id); + const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId); const item = await this.prisma.devTask.update({ where: { id_versionId: { id, versionId } }, - data: { assigneeId }, + data: { assigneeId: resolvedAssigneeId }, }); const activity = await this.recordTaskActivity( item, 'dev_task_transferred', 'progress', `转派开发任务:${item.title}`, - { fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId }, + { fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId }, ); return { item, activities: [activity] }; } @@ -105,7 +108,7 @@ export class DevTaskService { return item; } - private toTaskData(dto: Partial) { + private async toTaskData(dto: Partial) { return { ...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }), ...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }), @@ -115,8 +118,10 @@ export class DevTaskService { ...(dto.description !== undefined && { description: dto.description ?? '' }), ...(dto.status !== undefined && { status: dto.status }), ...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }), - ...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }), - ...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}), + ...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }), + ...(dto.creatorId !== undefined || dto.createdBy !== undefined + ? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) } + : {}), ...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }), ...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }), ...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }), diff --git a/apps/server/src/modules/governance/governance.service.spec.ts b/apps/server/src/modules/governance/governance.service.spec.ts index 2f44aa2..fb46327 100644 --- a/apps/server/src/modules/governance/governance.service.spec.ts +++ b/apps/server/src/modules/governance/governance.service.spec.ts @@ -20,6 +20,9 @@ describe('GovernanceService', () => { update: jest.fn(), upsert: jest.fn(), }, + appData: { + findUnique: jest.fn(), + }, }; const audit = { record: jest.fn() } as unknown as AuditService; const rbac = { @@ -80,6 +83,56 @@ describe('GovernanceService', () => { })); }); + 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: '前端' }]); diff --git a/apps/server/src/modules/governance/governance.service.ts b/apps/server/src/modules/governance/governance.service.ts index 0512e4c..bfc09da 100644 --- a/apps/server/src/modules/governance/governance.service.ts +++ b/apps/server/src/modules/governance/governance.service.ts @@ -33,17 +33,28 @@ export class GovernanceService { private readonly rbacService: RbacService, ) {} - list(kind: GovernanceDictionaryKind) { + async list(kind: GovernanceDictionaryKind) { assertDictionaryKind(kind); if (kind === 'task_category') { - return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }); + 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'); @@ -212,6 +223,20 @@ export class GovernanceService { 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 { @@ -225,3 +250,26 @@ function requireText(value: string | undefined | null, field: string): string { 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 }, +]; diff --git a/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts b/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts index 6b37eff..9f196b3 100644 --- a/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts +++ b/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts @@ -50,6 +50,7 @@ function buildAppData(): Record { status: 'adopted', priority: 'P1', creator: 'Product manager', + productOwner: 'pm', createdAt: '2026-01-02T08:00:00.000Z', updatedAt: '2026-01-02T09:00:00.000Z', }, @@ -62,6 +63,11 @@ function buildAppData(): Record { name: 'Product manager', username: 'pm', email: '', + phone: '13000000001', + roleId: 'role-pm', + departmentId: 'dept-product', + password: 'Ftb12345', + isSystem: false, createdAt: '2026-01-01T00:00:00.000Z', }, { @@ -287,7 +293,17 @@ describe('mapAppDataToV22Rows', () => { }), ]); expect(result.users).toEqual([ - expect.objectContaining({ id: 'member-pm', email: 'pm@local.ftb', name: 'Product manager' }), + expect.objectContaining({ + id: 'member-pm', + email: 'pm@local.ftb', + name: 'Product manager', + username: 'pm', + phone: '13000000001', + roleId: 'role-pm', + departmentId: 'dept-product', + password: 'Ftb12345', + isSystem: false, + }), expect.objectContaining({ id: 'member-dev', email: 'dev@example.com', name: 'Developer' }), expect.objectContaining({ id: 'member-test', email: 'test@local.ftb', name: 'Tester' }), ]); @@ -302,6 +318,7 @@ describe('mapAppDataToV22Rows', () => { priority: 1, platform: 'Web,App', creatorId: 'member-pm', + productOwnerId: 'member-pm', }), ]); expect(result.devTasks).toEqual([ @@ -442,4 +459,15 @@ describe('mapAppDataToV22Rows', () => { reason: 'missing version partition context', }); }); + + it('resolves legacy person references by member username', () => { + const appData = buildAppData(); + appData.requirements.requirements[0].creator = 'pm'; + appData['version-plans'][0].owner = 'pm'; + + const result = mapAppDataToV22Rows(appData); + + expect(result.requirements[0]).toEqual(expect.objectContaining({ creatorId: 'member-pm' })); + expect(result.versionPlans[0]).toEqual(expect.objectContaining({ ownerId: 'member-pm' })); + }); }); diff --git a/apps/server/src/modules/migration/app-data-v22.mapper.ts b/apps/server/src/modules/migration/app-data-v22.mapper.ts index c05011a..cdf03d5 100644 --- a/apps/server/src/modules/migration/app-data-v22.mapper.ts +++ b/apps/server/src/modules/migration/app-data-v22.mapper.ts @@ -47,6 +47,12 @@ interface UserRow { email: string; name: string; avatar?: string; + username?: string; + departmentId?: string; + roleId?: string; + phone?: string; + password?: string; + isSystem?: boolean; createdAt?: string; updatedAt?: string; } @@ -66,6 +72,7 @@ interface RequirementRow { sourceTarget?: string; platform?: string; creatorId?: string; + productOwnerId?: string; createdAt?: string; updatedAt?: string; } @@ -94,6 +101,7 @@ interface VersionPlanRow { actualStartAt?: string; completedAt?: string; resultUrl?: string; + tasks: unknown[]; requirementCoverage: unknown[]; logs: unknown[]; createdAt?: string; @@ -383,6 +391,12 @@ export function mapAppDataToV22Rows(appData: Record): V22Mapped email: nextEmail(email), name: stringField(member, 'name') ?? username, avatar: stringField(member, 'avatar'), + username, + departmentId: stringField(member, 'departmentId'), + roleId: stringField(member, 'roleId') ?? 'member', + phone: stringField(member, 'phone') ?? '', + password: stringField(member, 'password') ?? '', + isSystem: booleanField(member, 'isSystem') ?? false, createdAt: stringField(member, 'createdAt'), updatedAt: stringField(member, 'updatedAt') ?? stringField(member, 'createdAt'), }); @@ -418,6 +432,10 @@ export function mapAppDataToV22Rows(appData: Record): V22Mapped sourceTarget: stringField(requirement, 'sourceTarget'), platform: platforms.join(',') || stringField(requirement, 'platform'), creatorId: resolveUserId(stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'), userIdByReference), + productOwnerId: resolveUserId( + stringField(requirement, 'productOwnerId') ?? stringField(requirement, 'productOwner'), + userIdByReference, + ), createdAt: stringField(requirement, 'createdAt'), updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'), }; @@ -460,6 +478,7 @@ export function mapAppDataToV22Rows(appData: Record): V22Mapped actualStartAt: stringField(plan, 'actualStartAt'), completedAt: stringField(plan, 'completedAt'), resultUrl: stringField(plan, 'resultUrl'), + tasks: unknownArray(plan.tasks), requirementCoverage: unknownArray(plan.requirementCoverage), logs: unknownArray(plan.logs), createdAt: stringField(plan, 'createdAt'), @@ -878,8 +897,9 @@ function uniqueEmailFactory() { function buildUserIdByReference(users: UserRow[]): Map { const refs = new Map(); for (const user of users) { - refs.set(user.id, user.id); - refs.set(user.name, user.id); + for (const ref of [user.id, user.name, user.username, user.email]) { + if (ref) refs.set(ref, user.id); + } } return refs; } diff --git a/apps/server/src/modules/product/product.service.spec.ts b/apps/server/src/modules/product/product.service.spec.ts new file mode 100644 index 0000000..1401557 --- /dev/null +++ b/apps/server/src/modules/product/product.service.spec.ts @@ -0,0 +1,46 @@ +import { NotFoundException } from '@nestjs/common'; +import { ProductService } from './product.service'; + +function buildPrismaMock() { + return { + product: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + appData: { + findUnique: jest.fn(), + }, + }; +} + +describe('ProductService relation source', () => { + it('does not fall back to products-overview AppData when relation products are empty', async () => { + const prisma = buildPrismaMock(); + prisma.product.findMany.mockResolvedValue([]); + prisma.appData.findUnique.mockResolvedValue({ + key: 'products-overview', + value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }], + }); + const service = new ProductService(prisma as any); + + await expect(service.findAll()).resolves.toEqual([]); + await expect(service.findAllWithChildren()).resolves.toEqual([]); + expect(prisma.appData.findUnique).not.toHaveBeenCalled(); + }); + + it('does not fall back to products-overview AppData when a relation product is missing', async () => { + const prisma = buildPrismaMock(); + prisma.product.findUnique.mockResolvedValue(null); + prisma.appData.findUnique.mockResolvedValue({ + key: 'products-overview', + value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }], + }); + const service = new ProductService(prisma as any); + + await expect(service.findOne('legacy-product')).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.appData.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/product/product.service.ts b/apps/server/src/modules/product/product.service.ts index 34f035f..65aedc7 100644 --- a/apps/server/src/modules/product/product.service.ts +++ b/apps/server/src/modules/product/product.service.ts @@ -3,32 +3,6 @@ import { PrismaService } from '../../prisma/prisma.service'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; -type ProductOverviewItem = { - id: string; - name: string; - description?: string; - createdAt?: string; - updatedAt?: string; - projects?: { id: string; name: string; description?: string; createdAt?: string }[]; - versions?: { - id: string; - productId?: string; - projectId?: string | null; - name: string; - status?: string; - currentStage?: string | null; - startDate?: string | null; - expectedReleaseDate?: string | null; - releaseDate?: string | null; - members?: unknown[]; - progress?: unknown[]; - priority?: string | number | null; - links?: unknown; - createdAt?: string; - }[]; - _count?: { requirements?: number; projects?: number; versions?: number }; -}; - @Injectable() export class ProductService { constructor(private prisma: PrismaService) {} @@ -44,15 +18,7 @@ export class ProductService { _count: { select: { requirements: true, projects: true, versions: true } }, }, }); - if (products.length > 0) return products; - - const overview = await this.getAppDataOverview(); - if (!overview) return []; - return overview.map((product) => { - const normalized = this.normalizeOverviewProduct(product); - const { projects: _projects, versions: _versions, ...rest } = normalized; - return rest; - }); + return products; } async findAllWithChildren() { @@ -87,11 +53,7 @@ export class ProductService { _count: { select: { requirements: true, projects: true, versions: true } }, }, }); - if (products.length > 0) return products.map((product) => this.normalizeRelationProduct(product)); - - const overview = await this.getAppDataOverview(); - if (!overview) return []; - return overview.map((product) => this.normalizeOverviewProduct(product)); + return products.map((product) => this.normalizeRelationProduct(product)); } async findOne(id: string) { @@ -108,14 +70,7 @@ export class ProductService { }, }); if (product) return product; - - const overview = await this.getAppDataOverview(); - const fallback = overview?.find((item) => item.id === id); - if (!fallback) throw new NotFoundException('产品不存在'); - return { - ...this.normalizeOverviewProduct(fallback), - requirements: [], - }; + throw new NotFoundException('产品不存在'); } async update(id: string, dto: UpdateProductDto) { @@ -133,30 +88,6 @@ export class ProductService { if (!exists) throw new NotFoundException('产品不存在'); } - private async getAppDataOverview(): Promise { - const row = await this.prisma.appData.findUnique({ - where: { key: 'products-overview' }, - }); - if (!Array.isArray(row?.value)) return null; - return row.value as unknown as ProductOverviewItem[]; - } - - private normalizeOverviewProduct(product: ProductOverviewItem) { - const projects = product.projects ?? []; - const versions = product.versions ?? []; - return { - ...product, - description: product.description ?? '', - projects, - versions, - _count: { - requirements: product._count?.requirements ?? 0, - projects: projects.length, - versions: versions.length, - }, - }; - } - private normalizeRelationProduct(product: any) { const projects = product.projects ?? []; const versions = (product.versions ?? []).map((version: any) => ({ diff --git a/apps/server/src/modules/requirement/dto/create-requirement.dto.ts b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts index 04eb742..af0af80 100644 --- a/apps/server/src/modules/requirement/dto/create-requirement.dto.ts +++ b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts @@ -56,4 +56,12 @@ export class CreateRequirementDto { @IsString() @IsOptional() creatorId?: string; + + @IsString() + @IsOptional() + productOwnerId?: string; + + @IsString() + @IsOptional() + productOwner?: string; } diff --git a/apps/server/src/modules/requirement/requirement.service.spec.ts b/apps/server/src/modules/requirement/requirement.service.spec.ts index 7523a77..a46a872 100644 --- a/apps/server/src/modules/requirement/requirement.service.spec.ts +++ b/apps/server/src/modules/requirement/requirement.service.spec.ts @@ -3,6 +3,9 @@ import { RequirementService } from './requirement.service'; describe('RequirementService with V2.2 composite requirement key', () => { const makeService = () => { const prisma = { + user: { + findFirst: createUserFindFirstMock(), + }, requirement: { create: jest.fn(), delete: jest.fn(), @@ -18,6 +21,44 @@ describe('RequirementService with V2.2 composite requirement key', () => { }; }; + it('resolves creator names to user ids before requirement relation writes', async () => { + const { prisma, service } = makeService(); + prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' }); + prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' }); + + await service.create('product-1', { + code: 'REQ-001', + title: 'Payment', + creatorId: '产品经理', + }); + + expect(prisma.user.findFirst).toHaveBeenCalled(); + expect(prisma.requirement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + creatorId: 'pm-1', + }), + }); + }); + + it('resolves product owner names to user ids before requirement relation writes', async () => { + const { prisma, service } = makeService(); + prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' }); + prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' }); + + await service.create('product-1', { + code: 'REQ-001', + title: 'Payment', + productOwner: '产品经理', + } as any); + + expect(prisma.user.findFirst).toHaveBeenCalled(); + expect(prisma.requirement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + productOwnerId: 'pm-1', + }), + }); + }); + it('creates requirements with a partition-key-scoped business code', async () => { const { prisma, service } = makeService(); prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' }); @@ -96,7 +137,10 @@ describe('RequirementService with V2.2 composite requirement key', () => { { title: { contains: 'login', mode: 'insensitive' } }, ], }, - include: { creator: { select: { id: true, name: true } } }, + include: { + creator: { select: { id: true, name: true } }, + productOwner: { select: { id: true, name: true } }, + }, orderBy: { createdAt: 'asc' }, take: 2, cursor: { id_productId: { id: 'req-3', productId: 'product-1' } }, @@ -132,6 +176,24 @@ describe('RequirementService with V2.2 composite requirement key', () => { }); }); + it('updates requirement product owner by id plus product id', async () => { + const { prisma, service } = makeService(); + prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' }); + prisma.requirement.findFirst.mockResolvedValue({ + id: 'req-1', + productId: 'product-1', + status: 'pending_review', + }); + prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' }); + + await service.update('product-1', 'req-1', { productOwner: '产品经理' } as any); + + expect(prisma.requirement.update).toHaveBeenCalledWith({ + where: { id_productId: { id: 'req-1', productId: 'product-1' } }, + data: { productOwnerId: 'pm-1' }, + }); + }); + it('updates frontend pool fields by id plus product id', async () => { const { prisma, service } = makeService(); prisma.requirement.findFirst.mockResolvedValue({ @@ -225,3 +287,13 @@ describe('RequirementService with V2.2 composite requirement key', () => { expect(prisma.requirement.update).not.toHaveBeenCalled(); }); }); + +function createUserFindFirstMock(mapping: Record = {}) { + return jest.fn(({ where }: any) => { + const refs = (where?.OR ?? []) + .flatMap((condition: Record) => Object.values(condition)) + .filter(Boolean); + const ref = refs[0]; + return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null); + }); +} diff --git a/apps/server/src/modules/requirement/requirement.service.ts b/apps/server/src/modules/requirement/requirement.service.ts index a072d72..0264b76 100644 --- a/apps/server/src/modules/requirement/requirement.service.ts +++ b/apps/server/src/modules/requirement/requirement.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; +import { resolveUserReference } from '../../common/user-reference'; import { RequirementStatus } from '@ftb/shared'; import { CreateRequirementDto } from './dto/create-requirement.dto'; import { UpdateRequirementDto } from './dto/update-requirement.dto'; @@ -15,6 +16,11 @@ const VALID_TRANSITIONS: Record = { [RequirementStatus.CLOSED]: [], }; +const REQUIREMENT_USER_INCLUDE = { + creator: { select: { id: true, name: true } }, + productOwner: { select: { id: true, name: true } }, +} as const; + export interface RequirementListQuery { projectId?: string; versionId?: string; @@ -38,8 +44,8 @@ function createFallbackRequirementCode() { export class RequirementService { constructor(private prisma: PrismaService) {} - create(productId: string, dto: CreateRequirementDto) { - const data = this.toRequirementData(dto); + async create(productId: string, dto: CreateRequirementDto) { + const data = await this.toRequirementData(dto); return this.prisma.requirement.create({ data: { ...data, @@ -79,7 +85,7 @@ export class RequirementService { } : {}), }, - include: { creator: { select: { id: true, name: true } } }, + include: REQUIREMENT_USER_INCLUDE, orderBy: parseRequirementSort(query.sort), take: limit + 1, ...(query.cursor @@ -100,7 +106,7 @@ export class RequirementService { async findOne(productId: string, id: string) { const req = await this.prisma.requirement.findFirst({ where: { id, productId }, - include: { creator: { select: { id: true, name: true } } }, + include: REQUIREMENT_USER_INCLUDE, }); if (!req) throw new NotFoundException('需求不存在'); return req; @@ -110,7 +116,7 @@ export class RequirementService { await this.findOne(productId, id); return this.prisma.requirement.update({ where: { id_productId: { id, productId } }, - data: this.toRequirementData(dto), + data: await this.toRequirementData(dto), }); } @@ -133,10 +139,11 @@ export class RequirementService { return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } }); } - private toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) { + private async toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) { const type = dto.type ?? dto.typeId; const platform = dto.platform ?? arrayToCsv(dto.platforms); const priority = parsePriorityValue(dto.priority); + const productOwnerReference = dto.productOwnerId ?? dto.productOwner; return { ...(dto.code !== undefined && { code: dto.code }), @@ -148,7 +155,10 @@ export class RequirementService { ...(dto.sourceType !== undefined && { sourceType: emptyToNull(dto.sourceType) }), ...(dto.sourceTarget !== undefined && { sourceTarget: emptyToNull(dto.sourceTarget) }), ...(platform !== undefined && { platform: emptyToNull(platform) }), - ...(dto.creatorId !== undefined && { creatorId: emptyToNull(dto.creatorId) }), + ...(dto.creatorId !== undefined && { creatorId: await resolveUserReference(this.prisma, dto.creatorId) }), + ...(dto.productOwnerId !== undefined || dto.productOwner !== undefined + ? { productOwnerId: await resolveUserReference(this.prisma, productOwnerReference) } + : {}), ...(priority !== undefined && { priority }), }; } diff --git a/apps/server/src/modules/test-case/test-case.service.spec.ts b/apps/server/src/modules/test-case/test-case.service.spec.ts index bffd6b4..8b143f7 100644 --- a/apps/server/src/modules/test-case/test-case.service.spec.ts +++ b/apps/server/src/modules/test-case/test-case.service.spec.ts @@ -7,6 +7,9 @@ describe('TestCaseService domain writes', () => { record: jest.fn().mockResolvedValue({ id: 'activity-1' }), }; const prisma = { + user: { + findFirst: createUserFindFirstMock(), + }, version: { findUnique: jest.fn(), }, @@ -27,6 +30,49 @@ describe('TestCaseService domain writes', () => { }; }; + it('resolves assignee and creator names to user ids before relation writes', async () => { + const { prisma, service } = makeService(); + prisma.user.findFirst = createUserFindFirstMock({ 测试: 'qa-1', 产品经理: 'pm-1' }); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.testCase.create.mockResolvedValue({ id: 'tc-1', versionId: 'version-1', title: '登录冒烟' }); + + await service.create('version-1', { + title: '登录冒烟', + assigneeId: '测试', + createdBy: '产品经理', + } as any); + + expect(prisma.user.findFirst).toHaveBeenCalledTimes(2); + expect(prisma.testCase.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + assigneeId: 'qa-1', + creatorId: 'pm-1', + }), + }); + }); + + it('resolves names while batch creating copied or AI-generated test cases', async () => { + const { prisma, service } = makeService(); + prisma.user.findFirst = createUserFindFirstMock({ 测试: 'qa-1', AI: 'system-ai' }); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.testCase.createMany.mockResolvedValue({ count: 1 }); + prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-copy-1' }]); + + await service.createMany('version-1', [ + { caseNo: 'TC-101', title: 'AI 用例', assigneeId: '测试', createdBy: 'AI' }, + ] as any); + + expect(prisma.testCase.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + assigneeId: 'qa-1', + creatorId: 'system-ai', + }), + ], + skipDuplicates: true, + }); + }); + it('creates test cases directly under a version partition', async () => { const { prisma, workActivity, service } = makeService(); prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); @@ -139,3 +185,13 @@ describe('TestCaseService domain writes', () => { expect(prisma.testCase.update).not.toHaveBeenCalled(); }); }); + +function createUserFindFirstMock(mapping: Record = {}) { + return jest.fn(({ where }: any) => { + const refs = (where?.OR ?? []) + .flatMap((condition: Record) => Object.values(condition)) + .filter(Boolean); + const ref = refs[0]; + return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null); + }); +} diff --git a/apps/server/src/modules/test-case/test-case.service.ts b/apps/server/src/modules/test-case/test-case.service.ts index bb7d63e..3320da1 100644 --- a/apps/server/src/modules/test-case/test-case.service.ts +++ b/apps/server/src/modules/test-case/test-case.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { resolveUserReference } from '../../common/user-reference'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateTestCaseDto } from './dto/create-test-case.dto'; import { UpdateTestCaseDto } from './dto/update-test-case.dto'; @@ -14,9 +15,10 @@ export class TestCaseService { async create(versionId: string, dto: CreateTestCaseDto) { const version = await this.ensureVersion(versionId); + const testCaseData = await this.toTestCaseData(dto); const item = await this.prisma.testCase.create({ data: { - ...this.toTestCaseData(dto), + ...testCaseData, versionId, productId: version.productId, projectId: version.projectId, @@ -32,15 +34,15 @@ export class TestCaseService { async createMany(versionId: string, dtos: CreateTestCaseDto[]) { if (dtos.length === 0) return { items: [], activities: [] }; const version = await this.ensureVersion(versionId); - const rows = dtos.map((dto) => ({ - ...this.toTestCaseData(dto), + const rows = await Promise.all(dtos.map(async (dto) => ({ + ...(await this.toTestCaseData(dto)), versionId, productId: version.productId, projectId: version.projectId, code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'), title: dto.title, status: dto.status ?? 'pending', - })); + }))); await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true }); const items = await this.prisma.testCase.findMany({ where: { versionId, code: { in: rows.map((row) => row.code) } }, @@ -62,7 +64,7 @@ export class TestCaseService { await this.ensureTestCaseInVersion(versionId, id); const item = await this.prisma.testCase.update({ where: { id_versionId: { id, versionId } }, - data: this.toTestCaseData(dto), + data: await this.toTestCaseData(dto), }); await this.workActivity.markXiaobaoSummaryDirty(versionId); return { item, activities: [] }; @@ -90,7 +92,7 @@ export class TestCaseService { return item; } - private toTestCaseData(dto: Partial) { + private async toTestCaseData(dto: Partial) { return { ...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }), ...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }), @@ -101,8 +103,10 @@ export class TestCaseService { ...(dto.status !== undefined && { status: dto.status }), ...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }), ...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }), - ...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }), - ...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}), + ...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }), + ...(dto.creatorId !== undefined || dto.createdBy !== undefined + ? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) } + : {}), ...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }), ...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }), ...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }), diff --git a/apps/server/src/modules/v22-query/v22-query.service.spec.ts b/apps/server/src/modules/v22-query/v22-query.service.spec.ts index 96cbab8..c9cad46 100644 --- a/apps/server/src/modules/v22-query/v22-query.service.spec.ts +++ b/apps/server/src/modules/v22-query/v22-query.service.spec.ts @@ -50,7 +50,10 @@ describe('V22QueryService', () => { expect(result.devTasks).toEqual([{ id: 'dev-1' }]); expect(prisma.requirement.findMany).toHaveBeenCalledWith({ where: { versionId: 'version-1' }, - include: { creator: { select: { id: true, name: true } } }, + include: { + creator: { select: { id: true, name: true } }, + productOwner: { select: { id: true, name: true } }, + }, orderBy: { createdAt: 'desc' }, }); expect(prisma.devTask.findMany).toHaveBeenCalledWith({ @@ -95,7 +98,10 @@ describe('V22QueryService', () => { { title: { contains: 'login', mode: 'insensitive' } }, ], }, - include: { creator: { select: { id: true, name: true } } }, + include: { + creator: { select: { id: true, name: true } }, + productOwner: { select: { id: true, name: true } }, + }, orderBy: { createdAt: 'asc' }, take: 2, cursor: { id_productId: { id: 'req-3', productId: 'product-1' } }, @@ -129,7 +135,13 @@ describe('V22QueryService', () => { }); }); - it('falls back to AppData workspace rows when relation tables are empty', async () => { + it('rejects missing workspace userId with a client error instead of a TypeError', async () => { + const service = new V22QueryService(buildPrismaMock() as any); + + await expect(service.getWorkspaceData(undefined as any)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('does not fall back to legacy AppData workspace rows when relation tables are empty', async () => { const prisma = buildPrismaMock(); prisma.versionPlan.findMany.mockResolvedValue([]); prisma.devTask.findMany.mockResolvedValue([]); @@ -170,14 +182,8 @@ describe('V22QueryService', () => { const result = await service.getWorkspaceData('m-8'); - expect(result.versionPlans).toEqual([ - expect.objectContaining({ - id: 'plan-1', - ownerId: 'm-8', - title: '案例学习 V1.3 产品方案', - }), - ]); - expect(prisma.appData.findMany).toHaveBeenCalled(); + expect(result).toEqual({ versionPlans: [], devTasks: [], testCases: [], bugs: [] }); + expect(prisma.appData.findMany).not.toHaveBeenCalled(); }); it('loads Xiaobao warning summaries either globally for managers or by user-owned version ids', async () => { diff --git a/apps/server/src/modules/v22-query/v22-query.service.ts b/apps/server/src/modules/v22-query/v22-query.service.ts index 8970b68..eb57015 100644 --- a/apps/server/src/modules/v22-query/v22-query.service.ts +++ b/apps/server/src/modules/v22-query/v22-query.service.ts @@ -1,7 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; -import { APP_DATA_KEYS } from '../data/data-keys'; -import { mapAppDataToV22Rows } from '../migration/app-data-v22.mapper'; interface RequirementQuery { productId?: string; @@ -21,8 +19,16 @@ interface XiaobaoWarningQuery { manager?: string; } +type WorkspaceRows = { + versionPlans: unknown[]; + devTasks: unknown[]; + testCases: unknown[]; + bugs: unknown[]; +}; + const REQUIREMENT_CREATOR_INCLUDE = { creator: { select: { id: true, name: true } }, + productOwner: { select: { id: true, name: true } }, } as const; @Injectable() @@ -105,8 +111,8 @@ export class V22QueryService { }; } - async getWorkspaceData(userId: string): Promise { - const normalizedUserId = userId.trim(); + async getWorkspaceData(userId?: string): Promise { + const normalizedUserId = userId?.trim(); if (!normalizedUserId) throw new BadRequestException('userId is required'); const [versionPlans, devTasks, testCases, bugs] = await Promise.all([ @@ -128,39 +134,9 @@ export class V22QueryService { }), ]); - const relationResult = { versionPlans, devTasks, testCases, bugs }; - if (hasWorkspaceRows(relationResult)) return relationResult; - - const fallback = await this.getWorkspaceDataFromAppData(normalizedUserId); - if (hasWorkspaceRows(fallback)) return fallback; - return { versionPlans, devTasks, testCases, bugs }; } - private async getWorkspaceDataFromAppData(userId: string): Promise { - const rows = await this.prisma.appData.findMany({ - where: { key: { in: [...APP_DATA_KEYS] } }, - select: { key: true, value: true }, - }); - const snapshot = Object.fromEntries(rows.map((row) => [row.key, row.value])); - const mapped = mapAppDataToV22Rows(snapshot); - - return { - versionPlans: mapped.versionPlans - .filter((plan) => plan.ownerId === userId && plan.status !== 'completed') - .sort(comparePlanRows), - devTasks: mapped.devTasks - .filter((task) => task.assigneeId === userId && task.status !== 'submitted') - .sort(comparePriorityThenUpdatedRows), - testCases: mapped.testCases - .filter((testCase) => testCase.assigneeId === userId && !['passed', 'failed', 'blocked'].includes(testCase.status)) - .sort(comparePriorityThenUpdatedRows), - bugs: mapped.bugs - .filter((bug) => bug.assigneeId === userId && ['open', 'fixing', 'fixed', 'verifying'].includes(bug.status)) - .sort(comparePriorityThenUpdatedRows), - }; - } - async getXiaobaoWarnings(query: XiaobaoWarningQuery) { if (query.manager === 'true') { const rows = await this.prisma.xiaobaoRiskSummary.findMany({ @@ -234,49 +210,6 @@ export class V22QueryService { } } -type WorkspaceRows = { - versionPlans: unknown[]; - devTasks: unknown[]; - testCases: unknown[]; - bugs: unknown[]; -}; - -type PriorityUpdatedRow = { - priority?: number; - updatedAt?: string; -}; - -type PlanSortRow = { - expectedEndAt?: string; - updatedAt?: string; -}; - -function hasWorkspaceRows(rows: WorkspaceRows): boolean { - return rows.versionPlans.length > 0 - || rows.devTasks.length > 0 - || rows.testCases.length > 0 - || rows.bugs.length > 0; -} - -function comparePlanRows(a: PlanSortRow, b: PlanSortRow): number { - const endDiff = dateSortValue(a.expectedEndAt, Number.POSITIVE_INFINITY) - - dateSortValue(b.expectedEndAt, Number.POSITIVE_INFINITY); - if (endDiff !== 0) return endDiff; - return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0); -} - -function comparePriorityThenUpdatedRows(a: PriorityUpdatedRow, b: PriorityUpdatedRow): number { - const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0); - if (priorityDiff !== 0) return priorityDiff; - return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0); -} - -function dateSortValue(value: string | undefined, fallback: number): number { - if (!value) return fallback; - const time = new Date(value).getTime(); - return Number.isFinite(time) ? time : fallback; -} - function parseLimit(raw?: string): number { const parsed = raw ? Number(raw) : 50; if (!Number.isFinite(parsed)) return 50; diff --git a/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts b/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts index b3a352c..c7edca1 100644 --- a/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts +++ b/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts @@ -47,6 +47,9 @@ export class CreateVersionPlanDto { @IsOptional() resultUrl?: string; + @IsOptional() + tasks?: unknown[]; + @IsArray() @IsOptional() linkedRequirementIds?: string[]; diff --git a/apps/server/src/modules/version-plan/version-plan.service.spec.ts b/apps/server/src/modules/version-plan/version-plan.service.spec.ts index 139bdc8..0d44c7e 100644 --- a/apps/server/src/modules/version-plan/version-plan.service.spec.ts +++ b/apps/server/src/modules/version-plan/version-plan.service.spec.ts @@ -5,8 +5,12 @@ describe('VersionPlanService domain writes', () => { const makeService = () => { const workActivity = { record: jest.fn().mockResolvedValue({ id: 'activity-1' }), + markXiaobaoSummaryDirty: jest.fn().mockResolvedValue(undefined), }; const prisma = { + user: { + findFirst: createUserFindFirstMock(), + }, version: { findUnique: jest.fn(), }, @@ -26,6 +30,58 @@ describe('VersionPlanService domain writes', () => { }; }; + it('resolves plan owner names to user ids before relation writes', async () => { + const { prisma, service } = makeService(); + prisma.user.findFirst = createUserFindFirstMock({ 张三: 'member-1' }); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.versionPlan.create.mockResolvedValue({ id: 'plan-1', versionId: 'version-1', title: '产品方案', ownerId: 'member-1' }); + + await service.create('version-1', { + type: 'product', + title: '产品方案', + owner: '张三', + } as any); + + expect(prisma.user.findFirst).toHaveBeenCalled(); + expect(prisma.versionPlan.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + ownerId: 'member-1', + }), + }); + }); + + it('clears unresolved plan owner references instead of writing invalid foreign keys', async () => { + const { prisma, service } = makeService(); + prisma.user.findFirst.mockResolvedValue(null); + prisma.versionPlan.findFirst.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '产品方案', + ownerId: 'member-1', + }); + prisma.versionPlan.update.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '产品方案', + ownerId: null, + }); + + await service.update('version-1', 'plan-1', { owner: '不存在的成员' } as any); + + expect(prisma.versionPlan.update).toHaveBeenCalledWith({ + where: { id: 'plan-1' }, + data: expect.objectContaining({ + ownerId: null, + }), + }); + }); + it('creates version plans directly under a version partition', async () => { const { prisma, workActivity, service } = makeService(); prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); @@ -61,6 +117,64 @@ describe('VersionPlanService domain writes', () => { })); }); + it('persists research direction tasks on create and update', async () => { + const { prisma, service } = makeService(); + const tasks = [{ id: 'task-1', title: '竞品分析', status: 'pending' }]; + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.versionPlan.create.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '调研方案', + ownerId: 'member-1', + tasks, + }); + prisma.versionPlan.findFirst.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '调研方案', + ownerId: 'member-1', + tasks, + }); + prisma.versionPlan.update.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '调研方案', + ownerId: 'member-1', + tasks: [{ ...tasks[0], status: 'in_progress' }], + }); + + await service.create('version-1', { + type: 'research', + title: '调研方案', + owner: 'member-1', + tasks, + } as any); + await service.update('version-1', 'plan-1', { + tasks: [{ ...tasks[0], status: 'in_progress' }], + } as any); + + expect(prisma.versionPlan.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tasks, + }), + }); + expect(prisma.versionPlan.update).toHaveBeenCalledWith({ + where: { id: 'plan-1' }, + data: expect.objectContaining({ + tasks: [{ ...tasks[0], status: 'in_progress' }], + }), + }); + }); + it('updates plan status inside version scope and records activity evidence', async () => { const { prisma, workActivity, service } = makeService(); prisma.versionPlan.findFirst.mockResolvedValue({ @@ -107,3 +221,13 @@ describe('VersionPlanService domain writes', () => { expect(prisma.versionPlan.update).not.toHaveBeenCalled(); }); }); + +function createUserFindFirstMock(mapping: Record = {}) { + return jest.fn(({ where }: any) => { + const refs = (where?.OR ?? []) + .flatMap((condition: Record) => Object.values(condition)) + .filter(Boolean); + const ref = refs[0]; + return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null); + }); +} diff --git a/apps/server/src/modules/version-plan/version-plan.service.ts b/apps/server/src/modules/version-plan/version-plan.service.ts index 647a431..941ca9f 100644 --- a/apps/server/src/modules/version-plan/version-plan.service.ts +++ b/apps/server/src/modules/version-plan/version-plan.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { resolveUserReference } from '../../common/user-reference'; import { WorkActivityService } from '../work-activity/work-activity.service'; import { CreateVersionPlanDto } from './dto/create-version-plan.dto'; import { UpdateVersionPlanDto } from './dto/update-version-plan.dto'; @@ -14,9 +15,10 @@ export class VersionPlanService { async create(versionId: string, dto: CreateVersionPlanDto) { const version = await this.ensureVersion(versionId); + const planData = await this.toPlanData(dto); const item = await this.prisma.versionPlan.create({ data: { - ...this.toPlanData(dto), + ...planData, versionId, productId: version.productId, projectId: version.projectId, @@ -38,7 +40,7 @@ export class VersionPlanService { async update(versionId: string, id: string, dto: UpdateVersionPlanDto) { const current = await this.ensurePlanInVersion(versionId, id); - const data = this.toPlanData(dto); + const data = await this.toPlanData(dto); if (dto.status === 'in_progress' && !current.actualStartAt) { data.actualStartAt = new Date(); } @@ -69,12 +71,14 @@ export class VersionPlanService { return item; } - private toPlanData(dto: Partial) { + private async toPlanData(dto: Partial) { return { ...(dto.type !== undefined && { type: dto.type }), ...(dto.title !== undefined && { title: dto.title }), ...(dto.status !== undefined && { status: dto.status }), - ...(dto.owner !== undefined || dto.ownerId !== undefined ? { ownerId: emptyToNull(dto.ownerId ?? dto.owner) } : {}), + ...(dto.owner !== undefined || dto.ownerId !== undefined + ? { ownerId: await resolveUserReference(this.prisma, dto.ownerId ?? dto.owner) } + : {}), ...(dto.startTime !== undefined || dto.expectedStartAt !== undefined ? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) } : {}), @@ -84,6 +88,7 @@ export class VersionPlanService { ...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }), ...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }), ...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }), + ...(dto.tasks !== undefined && { tasks: toJsonInput(dto.tasks) }), ...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined ? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) } : {}), diff --git a/apps/web/app/admin/governance/page.tsx b/apps/web/app/admin/governance/page.tsx index e368e15..d895162 100644 --- a/apps/web/app/admin/governance/page.tsx +++ b/apps/web/app/admin/governance/page.tsx @@ -25,13 +25,30 @@ const KIND_LABEL: Record = { requirement_source: '需求来源', }; +const TASK_CATEGORY_GROUPS = [ + { value: 'development', label: '开发' }, + { value: 'testing', label: '测试' }, + { value: 'implementation', label: '实施' }, + { value: 'other', label: '其他' }, +]; + +const SOURCE_TYPE_GROUPS = [ + { value: 'customer', label: '客户' }, + { value: 'internal', label: '内部' }, + { value: 'operation', label: '运营' }, + { value: 'aftersale', label: '售后' }, + { value: 'market', label: '市场' }, + { value: 'competitor', label: '竞品' }, + { value: 'management', label: '管理层' }, +]; + function GovernancePageInner() { const user = useAuthStore((s) => s.user); const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId)); const [kind, setKind] = useState('task_category'); const [items, setItems] = useState([]); const [name, setName] = useState(''); - const [group, setGroup] = useState('other'); + const [group, setGroup] = useState(defaultGroupForKind('task_category')); const [exportText, setExportText] = useState(''); const actorId = user?.id ?? ''; const permissions = role?.permissions ?? []; @@ -45,9 +62,13 @@ function GovernancePageInner() { void reload().catch(() => setItems([])); }, [kind]); + useEffect(() => { + setGroup(defaultGroupForKind(kind)); + }, [kind]); + const create = async () => { if (!actorId || !name.trim()) return; - await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group }); + await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group: groupForPayload(kind, group) }); setName(''); await reload(); }; @@ -99,9 +120,15 @@ function GovernancePageInner() {
-
+
setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" /> - setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" /> + {groupOptionsForKind(kind) && ( + + )} @@ -143,6 +170,22 @@ function GovernancePageInner() { ); } +function groupOptionsForKind(kind: GovernanceKind) { + if (kind === 'task_category') return TASK_CATEGORY_GROUPS; + if (kind === 'requirement_source') return SOURCE_TYPE_GROUPS; + return null; +} + +function defaultGroupForKind(kind: GovernanceKind) { + if (kind === 'task_category') return 'development'; + if (kind === 'requirement_source') return 'customer'; + return ''; +} + +function groupForPayload(kind: GovernanceKind, group: string) { + return groupOptionsForKind(kind) ? group : undefined; +} + export default function GovernancePage() { return ( diff --git a/apps/web/app/overtime/page.tsx b/apps/web/app/overtime/page.tsx index 4e90f7c..7bba457 100644 --- a/apps/web/app/overtime/page.tsx +++ b/apps/web/app/overtime/page.tsx @@ -47,7 +47,6 @@ function OvertimePageContent() { const [showReasonDrawer, setShowReasonDrawer] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRecords(); }, [fetchRecords]); useEffect(() => { fetchMembers(); }, [fetchMembers]); @@ -327,6 +326,7 @@ function OvertimePageContent() { versions={allVersions} reasons={reasons} requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))} + onVersionChange={(version) => fetchRequirements({ productId: version.productId, versionId: version.id })} onClose={() => setShowModal(false)} onSubmit={(data) => { createRecord(data as any); @@ -408,13 +408,14 @@ function DepartmentButton({ active, label, count, hours, depth = 0, icon, onClic ); } -function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: { +function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onVersionChange, onClose, onSubmit }: { defaultPerson: string; products: { id: string; name: string }[]; projects: { id: string; name: string; productId: string }[]; - versions: { id: string; name: string; projectId?: string }[]; + versions: { id: string; name: string; productId: string; projectId?: string }[]; reasons: { id: string; name: string }[]; requirements: { id: string; title: string; versionId?: string }[]; + onVersionChange: (version: { id: string; productId: string }) => void; onClose: () => void; onSubmit: (data: any) => void; }) { @@ -469,7 +470,18 @@ function OvertimeModal({ defaultPerson, products, projects, versions, reasons, r
- { + const nextVersionId = e.target.value; + setVersionId(nextVersionId); + setRequirementId(''); + const version = versions.find((item) => item.id === nextVersionId); + if (version) onVersionChange(version); + }} + required + className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" + > {filteredVersions.map((v) => )} diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index 86bd325..81731a6 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -45,7 +45,7 @@ export default function ProductDetailPage() { useEffect(() => { fetchProduct(productId); - fetchRequirements(); + fetchRequirements({ productId }); fetchRecords(); }, [productId, fetchProduct, fetchRequirements, fetchRecords]); diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index 8e2c2a5..8f7b74d 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -332,14 +332,21 @@ export default function ProjectDetailPage() { const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRecords(); }, [fetchRecords]); - useEffect(() => { fetchPlans(); }, [fetchPlans]); - useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]); + useEffect(() => { + if (!project) return; + void fetchRequirements({ productId: project.productId, projectId }); + project.versions.forEach((version) => { + void fetchPlans({ versionId: version.id }); + void fetchDevTasks({ versionId: version.id }); + void fetchTestCases({ versionId: version.id }); + void fetchBugs({ versionId: version.id }); + }); + }, [fetchBugs, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases, project, projectId]); + const user = useAuthStore((s) => s.user); const currentUserName = user?.name || ''; const { roles } = useMemberStore(); diff --git a/apps/web/app/projects/page.tsx b/apps/web/app/projects/page.tsx index ffcf40f..0fb579e 100644 --- a/apps/web/app/projects/page.tsx +++ b/apps/web/app/projects/page.tsx @@ -35,15 +35,23 @@ function ProjectsPageContent() { const [showForm, setShowForm] = useState(false); const [editingProject, setEditingProject] = useState(null); - useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); - useEffect(() => { fetchPlans(); }, [fetchPlans]); - useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); + useEffect(() => { fetchOverview(); }, [fetchOverview]); + useEffect(() => { + overview.forEach((product) => { + void fetchRequirements({ productId: product.id }); + }); + }, [fetchRequirements, overview]); + useEffect(() => { + allVersions.forEach((version) => { + void fetchPlans({ versionId: version.id }); + void fetchDevTasks({ versionId: version.id }); + void fetchTestCases({ versionId: version.id }); + }); + }, [allVersions, fetchDevTasks, fetchPlans, fetchTestCases]); + const versionProgressMap = useMemo( () => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases), [allVersions, plans, requirements, devTasks, testCases], diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx index e222fe3..38070dc 100644 --- a/apps/web/app/requirements/page.tsx +++ b/apps/web/app/requirements/page.tsx @@ -1,11 +1,10 @@ 'use client'; import { RouteGuard } from '@/components/auth/Guard'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Search, Plus, - Lightbulb, ArrowUp, ArrowDown, ChevronDown, @@ -42,7 +41,6 @@ import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-l import { Pagination } from '@/components/Pagination'; import { RequirementModal } from '@/components/requirement/RequirementModal'; import { RequirementDetail } from '@/components/requirement/RequirementDetail'; -import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer'; import { FilterSelect } from '@/components/FilterSelect'; import { buildV22RequirementQuery } from '@/lib/requirement-v22-query'; import { loadV22RequirementsPage } from '@/lib/v22-api'; @@ -199,9 +197,6 @@ function RequirementsPageContent() { const { requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement, sourceTargets, types, platforms, - addSourceTarget, updateSourceTarget, deleteSourceTarget, - addType, updateType, deleteType, - addPlatform, updatePlatform, deletePlatform, loaded: requirementsLoaded, } = useRequirementStore(); const { overview, fetchOverview } = useProductStore(); @@ -233,7 +228,6 @@ function RequirementsPageContent() { const [showModal, setShowModal] = useState(false); const [editingReq, setEditingReq] = useState(null); const [viewingReq, setViewingReq] = useState(null); - const [drawerType, setDrawerType] = useState(null); const [rejectingReq, setRejectingReq] = useState(null); const [rejectReason, setRejectReason] = useState(''); const [dateSort, setDateSort] = useState('desc'); @@ -274,6 +268,23 @@ function RequirementsPageContent() { if (selectedScope.type === 'project') return allVersions.filter((v) => v.projectId === selectedScope.projectId); return allVersions; }, [allVersions, selectedScope]); + const hydrateRequirementStoreFallback = useCallback(async () => { + const pendingLoads: Array> = []; + if (selectedScope.type === 'product') { + pendingLoads.push(fetchRequirements({ productId: selectedScope.productId })); + } else if (selectedScope.type === 'project') { + const project = allProjects.find((item) => item.id === selectedScope.projectId); + if (project) pendingLoads.push(fetchRequirements({ productId: project.productId, projectId: selectedScope.projectId })); + } else { + for (const product of overview) { + pendingLoads.push(fetchRequirements({ productId: product.id })); + } + } + for (const version of scopedVersions) { + pendingLoads.push(fetchDevTasks({ versionId: version.id })); + } + await Promise.all(pendingLoads); + }, [allProjects, fetchDevTasks, fetchRequirements, overview, scopedVersions, selectedScope]); const filterResetKey = useMemo(() => JSON.stringify({ selectedScopeKey, @@ -319,9 +330,8 @@ function RequirementsPageContent() { useEffect(() => { if (v22RequirementQuery && !v22RequirementsFailed) return; - fetchRequirements(); - fetchDevTasks(); - }, [fetchDevTasks, fetchRequirements, v22RequirementQuery, v22RequirementsFailed]); + void hydrateRequirementStoreFallback(); + }, [hydrateRequirementStoreFallback, v22RequirementQuery, v22RequirementsFailed]); useEffect(() => { setV22RequirementsFailed(false); @@ -354,8 +364,7 @@ function RequirementsPageContent() { setV22Requirements([]); setV22NextCursor(undefined); setV22RequirementsFailed(true); - fetchRequirements(); - fetchDevTasks(); + void hydrateRequirementStoreFallback(); }) .finally(() => { if (!cancelled) setV22RequirementsLoading(false); @@ -364,7 +373,7 @@ function RequirementsPageContent() { return () => { cancelled = true; }; - }, [fetchDevTasks, fetchRequirements, page, v22RequirementQuery]); + }, [hydrateRequirementStoreFallback, page, v22RequirementQuery]); const selectedScopeTitle = useMemo(() => { if (selectedScope.type === 'product') { @@ -472,7 +481,7 @@ function RequirementsPageContent() { }; const handleCreate = async () => { - await fetchRequirements(); + await hydrateRequirementStoreFallback(); setEditingReq(null); setShowModal(true); }; @@ -480,10 +489,6 @@ function RequirementsPageContent() { const handleSelectScope = (scope: RequirementScopeSelection) => { setAutoScopeSelected(true); setSelectedScope(scope); - if (scope.type === 'all') { - fetchRequirements(); - fetchDevTasks(); - } }; return ( @@ -552,13 +557,6 @@ function RequirementsPageContent() {

{selectedScopeMeta}

-