fix(ai-analysis): 收紧分析接口权限来源

This commit is contained in:
2026-07-08 22:03:37 +08:00
parent 56c8f59d13
commit c724446bbd
8 changed files with 146 additions and 12 deletions

View File

@@ -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>): CurrentUser {

View File

@@ -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<string[]> {
if (!user) return [];
return this.resolveRolePermissions(user.roleId);
}
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
if (!user) return false;
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;

View File

@@ -0,0 +1,32 @@
import { AiController } from './ai.controller';
describe('AiController', () => {
it('uses server-derived permissions for analysis instead of body permissions', async () => {
const aiService = {};
const businessAnalysisService = {
analyze: jest.fn().mockResolvedValue({ ok: false, code: 'NO_DATA', message: 'empty' }),
};
const authContext = {
resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'm-1', roleId: 'role-dev' }),
};
const permissionService = {
resolveUserPermissions: jest.fn().mockResolvedValue(['project:view']),
};
const controller = new (AiController as any)(
aiService,
businessAnalysisService,
authContext,
permissionService,
) as AiController;
const request = { headers: { 'x-ftb-user-id': 'm-1' } };
await (controller as any).analyze({ question: '分析版本风险', permissions: ['*'] }, request);
expect(authContext.resolveCurrentUser).toHaveBeenCalledWith(request);
expect(permissionService.resolveUserPermissions).toHaveBeenCalledWith({ id: 'm-1', roleId: 'role-dev' });
expect(businessAnalysisService.analyze).toHaveBeenCalledWith(
{ question: '分析版本风险', permissions: ['*'] },
{ id: 'm-1', permissions: ['project:view'] },
);
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
import { AnalysisReportBuilder } from './report-builder';
import type { AnalysisPlan, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
describe('AnalysisReportBuilder', () => {
it('describes normalized time ranges from the analysis plan', async () => {
const builder = new AnalysisReportBuilder();
const metric = {
metricId: 'member_effort_hours',
version: 1,
name: '成员投入工时',
description: '成员投入',
formula: 'sum(hours)',
owner: 'management',
supportedDimensions: ['member'],
supportedAnalysisTypes: ['ranking'],
defaultChart: 'horizontal_bar',
defaultTimePolicy: 'last_30_days',
status: 'active',
} as MetricDefinition;
const result = {
metricRef: { metricId: 'member_effort_hours', version: 1 },
analysisType: 'ranking',
columns: [
{ id: 'label', label: '成员', type: 'string' },
{ id: 'value', label: '小时', type: 'number' },
],
rows: [{ label: '张三', value: 8 }],
evidence: [],
dataScope: { type: 'self', userId: 'm-1' },
generatedAt: '2026-07-08T12:00:00.000Z',
} as MetricResult;
const insight = {
summary: '张三投入最高。',
semanticConfidence: 'high',
dataConfidence: 'partial',
} as InsightCard;
const plan = {
metricRef: { metricId: 'member_effort_hours', version: 1 },
analysisType: 'ranking',
dimensions: ['member'],
filters: {},
scope: { type: 'self', userId: 'm-1' },
timeRange: {
start: '2026-06-09T00:00:00.000Z',
end: '2026-07-08T23:59:59.999Z',
policy: 'last_30_days',
},
} as AnalysisPlan;
const report = await (builder.build as any)(result, insight, metric, plan);
expect(report.dataScope.timeDescription).toBe('最近 30 天');
});
});

View File

@@ -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<AnalysisReport> {
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} 个项目`;