From c724446bbd4580a531506ef930a1cabcce992d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 22:03:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai-analysis):=20=E6=94=B6=E7=B4=A7=E5=88=86?= =?UTF-8?q?=E6=9E=90=E6=8E=A5=E5=8F=A3=E6=9D=83=E9=99=90=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/permission.service.spec.ts | 16 ++++++ .../src/common/auth/permission.service.ts | 5 ++ .../src/modules/ai/ai.controller.spec.ts | 32 +++++++++++ apps/server/src/modules/ai/ai.controller.ts | 18 ++++--- apps/server/src/modules/ai/ai.module.ts | 3 +- .../ai/analysis/business-analysis.service.ts | 2 +- .../ai/analysis/report-builder.spec.ts | 54 +++++++++++++++++++ .../src/modules/ai/analysis/report-builder.ts | 28 +++++++++- 8 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/modules/ai/ai.controller.spec.ts create mode 100644 apps/server/src/modules/ai/analysis/report-builder.spec.ts diff --git a/apps/server/src/common/auth/permission.service.spec.ts b/apps/server/src/common/auth/permission.service.spec.ts index 38dcd4e..edda20f 100644 --- a/apps/server/src/common/auth/permission.service.spec.ts +++ b/apps/server/src/common/auth/permission.service.spec.ts @@ -114,6 +114,22 @@ describe('PermissionService', () => { await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException); await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException); }); + + it('resolves configured role permissions for the current user', async () => { + appDataFindUnique.mockResolvedValue({ + value: { + roles: [ + { id: 'role-dev', permissions: ['project:view'] }, + ], + }, + }); + + await expect(service.resolveUserPermissions(currentUser({ roleId: 'role-dev' }))).resolves.toEqual(['project:view']); + }); + + it('returns no permissions for anonymous users', async () => { + await expect(service.resolveUserPermissions(null)).resolves.toEqual([]); + }); }); function currentUser(overrides: Partial): CurrentUser { diff --git a/apps/server/src/common/auth/permission.service.ts b/apps/server/src/common/auth/permission.service.ts index 90b7050..b462521 100644 --- a/apps/server/src/common/auth/permission.service.ts +++ b/apps/server/src/common/auth/permission.service.ts @@ -117,6 +117,11 @@ const VERSION_WORK_MUTATION_PERMISSIONS = new Set([ export class PermissionService { constructor(private readonly prisma: PrismaService) {} + async resolveUserPermissions(user: CurrentUser | null | undefined): Promise { + if (!user) return []; + return this.resolveRolePermissions(user.roleId); + } + async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise { if (!user) return false; if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true; diff --git a/apps/server/src/modules/ai/ai.controller.spec.ts b/apps/server/src/modules/ai/ai.controller.spec.ts new file mode 100644 index 0000000..8309d01 --- /dev/null +++ b/apps/server/src/modules/ai/ai.controller.spec.ts @@ -0,0 +1,32 @@ +import { AiController } from './ai.controller'; + +describe('AiController', () => { + it('uses server-derived permissions for analysis instead of body permissions', async () => { + const aiService = {}; + const businessAnalysisService = { + analyze: jest.fn().mockResolvedValue({ ok: false, code: 'NO_DATA', message: 'empty' }), + }; + const authContext = { + resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'm-1', roleId: 'role-dev' }), + }; + const permissionService = { + resolveUserPermissions: jest.fn().mockResolvedValue(['project:view']), + }; + const controller = new (AiController as any)( + aiService, + businessAnalysisService, + authContext, + permissionService, + ) as AiController; + const request = { headers: { 'x-ftb-user-id': 'm-1' } }; + + await (controller as any).analyze({ question: '分析版本风险', permissions: ['*'] }, request); + + expect(authContext.resolveCurrentUser).toHaveBeenCalledWith(request); + expect(permissionService.resolveUserPermissions).toHaveBeenCalledWith({ id: 'm-1', roleId: 'role-dev' }); + expect(businessAnalysisService.analyze).toHaveBeenCalledWith( + { question: '分析版本风险', permissions: ['*'] }, + { id: 'm-1', permissions: ['project:view'] }, + ); + }); +}); diff --git a/apps/server/src/modules/ai/ai.controller.ts b/apps/server/src/modules/ai/ai.controller.ts index 336f115..37a70f4 100644 --- a/apps/server/src/modules/ai/ai.controller.ts +++ b/apps/server/src/modules/ai/ai.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Headers, Post } from '@nestjs/common'; -import { CurrentUser } from '../../common/auth/current-user.decorator'; -import type { CurrentUser as ResolvedCurrentUser } from '../../common/auth/auth-context.service'; +import { Body, Controller, Post, Req } from '@nestjs/common'; +import { AuthContextService, type AuthenticatedRequest } from '../../common/auth/auth-context.service'; +import { PermissionService } from '../../common/auth/permission.service'; import { AiService } from './ai.service'; import { BusinessAnalysisService } from './analysis/business-analysis.service'; import { AnalysisDto } from './dto/analysis.dto'; @@ -19,6 +19,8 @@ export class AiController { constructor( private readonly aiService: AiService, private readonly businessAnalysisService: BusinessAnalysisService, + private readonly authContext: AuthContextService, + private readonly permissionService: PermissionService, ) {} @Post('decompose') @@ -34,13 +36,13 @@ export class AiController { @Post('analysis') async analyze( @Body() dto: AnalysisDto, - @CurrentUser() user: ResolvedCurrentUser | null, - @Headers('x-ftb-user-id') headerUserId?: string, - @Headers('x-user-id') legacyHeaderUserId?: string, + @Req() request: AuthenticatedRequest, ): Promise { + const user = await this.authContext.resolveCurrentUser(request); + const permissions = await this.permissionService.resolveUserPermissions(user); return this.businessAnalysisService.analyze(dto, { - id: user?.id ?? headerUserId ?? legacyHeaderUserId ?? '', - permissions: dto.permissions ?? [], + id: user?.id ?? '', + permissions, }); } } diff --git a/apps/server/src/modules/ai/ai.module.ts b/apps/server/src/modules/ai/ai.module.ts index b0e333f..1427726 100644 --- a/apps/server/src/modules/ai/ai.module.ts +++ b/apps/server/src/modules/ai/ai.module.ts @@ -3,6 +3,7 @@ import { AiController } from './ai.controller'; import { AiService } from './ai.service'; import { AiGatewayService } from './ai-gateway.service'; import { ConfigModule } from '../config/config.module'; +import { AuthModule } from '../../common/auth/auth.module'; import { CommonDomainModule } from '../../common/common-domain.module'; import { BusinessAnalysisService } from './analysis/business-analysis.service'; import { MetricEngine } from './analysis/metric-engine'; @@ -10,7 +11,7 @@ import { PermissionScopeResolver } from './analysis/permission-scope-resolver'; import { AnalysisReportBuilder } from './analysis/report-builder'; @Module({ - imports: [ConfigModule, CommonDomainModule], + imports: [ConfigModule, AuthModule, CommonDomainModule], controllers: [AiController], providers: [ AiService, diff --git a/apps/server/src/modules/ai/analysis/business-analysis.service.ts b/apps/server/src/modules/ai/analysis/business-analysis.service.ts index c37d36b..77b3bcb 100644 --- a/apps/server/src/modules/ai/analysis/business-analysis.service.ts +++ b/apps/server/src/modules/ai/analysis/business-analysis.service.ts @@ -97,7 +97,7 @@ export class BusinessAnalysisService { const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence); const chart = buildUnifiedChartSpec(metricResult, metric); - const report = await this.reportBuilder.build(metricResult, insight, metric); + const report = await this.reportBuilder.build(metricResult, insight, metric, plan); const followUps = buildFollowUps(metricResult, plan); return { ok: true, plan, metricResult, insight, chart, report, followUps }; } diff --git a/apps/server/src/modules/ai/analysis/report-builder.spec.ts b/apps/server/src/modules/ai/analysis/report-builder.spec.ts new file mode 100644 index 0000000..7adb235 --- /dev/null +++ b/apps/server/src/modules/ai/analysis/report-builder.spec.ts @@ -0,0 +1,54 @@ +import { AnalysisReportBuilder } from './report-builder'; +import type { AnalysisPlan, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared'; + +describe('AnalysisReportBuilder', () => { + it('describes normalized time ranges from the analysis plan', async () => { + const builder = new AnalysisReportBuilder(); + const metric = { + metricId: 'member_effort_hours', + version: 1, + name: '成员投入工时', + description: '成员投入', + formula: 'sum(hours)', + owner: 'management', + supportedDimensions: ['member'], + supportedAnalysisTypes: ['ranking'], + defaultChart: 'horizontal_bar', + defaultTimePolicy: 'last_30_days', + status: 'active', + } as MetricDefinition; + const result = { + metricRef: { metricId: 'member_effort_hours', version: 1 }, + analysisType: 'ranking', + columns: [ + { id: 'label', label: '成员', type: 'string' }, + { id: 'value', label: '小时', type: 'number' }, + ], + rows: [{ label: '张三', value: 8 }], + evidence: [], + dataScope: { type: 'self', userId: 'm-1' }, + generatedAt: '2026-07-08T12:00:00.000Z', + } as MetricResult; + const insight = { + summary: '张三投入最高。', + semanticConfidence: 'high', + dataConfidence: 'partial', + } as InsightCard; + const plan = { + metricRef: { metricId: 'member_effort_hours', version: 1 }, + analysisType: 'ranking', + dimensions: ['member'], + filters: {}, + scope: { type: 'self', userId: 'm-1' }, + timeRange: { + start: '2026-06-09T00:00:00.000Z', + end: '2026-07-08T23:59:59.999Z', + policy: 'last_30_days', + }, + } as AnalysisPlan; + + const report = await (builder.build as any)(result, insight, metric, plan); + + expect(report.dataScope.timeDescription).toBe('最近 30 天'); + }); +}); diff --git a/apps/server/src/modules/ai/analysis/report-builder.ts b/apps/server/src/modules/ai/analysis/report-builder.ts index 8a28e1f..300b62b 100644 --- a/apps/server/src/modules/ai/analysis/report-builder.ts +++ b/apps/server/src/modules/ai/analysis/report-builder.ts @@ -1,5 +1,12 @@ import { Injectable } from '@nestjs/common'; -import type { AnalysisReport, DataScope, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared'; +import type { + AnalysisPlan, + AnalysisReport, + DataScope, + InsightCard, + MetricDefinition, + MetricResult, +} from '@ftb/shared'; @Injectable() export class AnalysisReportBuilder { @@ -7,6 +14,7 @@ export class AnalysisReportBuilder { result: MetricResult, insight: InsightCard, metric: MetricDefinition, + plan?: AnalysisPlan, ): Promise { return { summary: insight.summary, @@ -19,7 +27,7 @@ export class AnalysisReportBuilder { ? ['优先查看排名靠前的对象,并进入明细确认原因。'] : ['调整时间范围或切换分析维度。'], dataScope: { - timeDescription: resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态', + timeDescription: describeTimeScope(plan, result), permissionDescription: describeScope(result.dataScope), metricFormulaDescription: metric.formula, generatedAt: result.generatedAt, @@ -32,6 +40,22 @@ function resultHasTime(result: MetricResult): boolean { return result.columns.some((column) => column.type === 'date'); } +function describeTimeScope(plan: AnalysisPlan | undefined, result: MetricResult): string { + if (!plan?.timeRange) { + return resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态'; + } + + if (plan.timeRange.policy === 'current_state') return '当前状态'; + if (plan.timeRange.policy === 'last_30_days') return '最近 30 天'; + if (plan.timeRange.policy === 'lifecycle') return '对象生命周期'; + if (plan.timeRange.policy === 'user_required') return '用户指定时间范围'; + return `${formatDate(plan.timeRange.start)} 至 ${formatDate(plan.timeRange.end)}`; +} + +function formatDate(value: string): string { + return value.slice(0, 10); +} + function describeScope(scope: DataScope): string { if (scope.type === 'system') return '系统管理范围'; if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;