fix(ai-analysis): 收紧分析接口权限来源
This commit is contained in:
@@ -114,6 +114,22 @@ describe('PermissionService', () => {
|
|||||||
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException);
|
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 {
|
function currentUser(overrides: Partial<CurrentUser>): CurrentUser {
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ const VERSION_WORK_MUTATION_PERMISSIONS = new Set([
|
|||||||
export class PermissionService {
|
export class PermissionService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
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> {
|
async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise<boolean> {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;
|
if ((SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []).includes('*')) return true;
|
||||||
|
|||||||
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal file
32
apps/server/src/modules/ai/ai.controller.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { AiController } from './ai.controller';
|
||||||
|
|
||||||
|
describe('AiController', () => {
|
||||||
|
it('uses server-derived permissions for analysis instead of body permissions', async () => {
|
||||||
|
const aiService = {};
|
||||||
|
const businessAnalysisService = {
|
||||||
|
analyze: jest.fn().mockResolvedValue({ ok: false, code: 'NO_DATA', message: 'empty' }),
|
||||||
|
};
|
||||||
|
const authContext = {
|
||||||
|
resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'm-1', roleId: 'role-dev' }),
|
||||||
|
};
|
||||||
|
const permissionService = {
|
||||||
|
resolveUserPermissions: jest.fn().mockResolvedValue(['project:view']),
|
||||||
|
};
|
||||||
|
const controller = new (AiController as any)(
|
||||||
|
aiService,
|
||||||
|
businessAnalysisService,
|
||||||
|
authContext,
|
||||||
|
permissionService,
|
||||||
|
) as AiController;
|
||||||
|
const request = { headers: { 'x-ftb-user-id': 'm-1' } };
|
||||||
|
|
||||||
|
await (controller as any).analyze({ question: '分析版本风险', permissions: ['*'] }, request);
|
||||||
|
|
||||||
|
expect(authContext.resolveCurrentUser).toHaveBeenCalledWith(request);
|
||||||
|
expect(permissionService.resolveUserPermissions).toHaveBeenCalledWith({ id: 'm-1', roleId: 'role-dev' });
|
||||||
|
expect(businessAnalysisService.analyze).toHaveBeenCalledWith(
|
||||||
|
{ question: '分析版本风险', permissions: ['*'] },
|
||||||
|
{ id: 'm-1', permissions: ['project:view'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Headers, Post } from '@nestjs/common';
|
import { Body, Controller, Post, Req } from '@nestjs/common';
|
||||||
import { CurrentUser } from '../../common/auth/current-user.decorator';
|
import { AuthContextService, type AuthenticatedRequest } from '../../common/auth/auth-context.service';
|
||||||
import type { CurrentUser as ResolvedCurrentUser } from '../../common/auth/auth-context.service';
|
import { PermissionService } from '../../common/auth/permission.service';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||||||
import { AnalysisDto } from './dto/analysis.dto';
|
import { AnalysisDto } from './dto/analysis.dto';
|
||||||
@@ -19,6 +19,8 @@ export class AiController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly aiService: AiService,
|
private readonly aiService: AiService,
|
||||||
private readonly businessAnalysisService: BusinessAnalysisService,
|
private readonly businessAnalysisService: BusinessAnalysisService,
|
||||||
|
private readonly authContext: AuthContextService,
|
||||||
|
private readonly permissionService: PermissionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('decompose')
|
@Post('decompose')
|
||||||
@@ -34,13 +36,13 @@ export class AiController {
|
|||||||
@Post('analysis')
|
@Post('analysis')
|
||||||
async analyze(
|
async analyze(
|
||||||
@Body() dto: AnalysisDto,
|
@Body() dto: AnalysisDto,
|
||||||
@CurrentUser() user: ResolvedCurrentUser | null,
|
@Req() request: AuthenticatedRequest,
|
||||||
@Headers('x-ftb-user-id') headerUserId?: string,
|
|
||||||
@Headers('x-user-id') legacyHeaderUserId?: string,
|
|
||||||
): Promise<AnalysisResponse> {
|
): Promise<AnalysisResponse> {
|
||||||
|
const user = await this.authContext.resolveCurrentUser(request);
|
||||||
|
const permissions = await this.permissionService.resolveUserPermissions(user);
|
||||||
return this.businessAnalysisService.analyze(dto, {
|
return this.businessAnalysisService.analyze(dto, {
|
||||||
id: user?.id ?? headerUserId ?? legacyHeaderUserId ?? '',
|
id: user?.id ?? '',
|
||||||
permissions: dto.permissions ?? [],
|
permissions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AiController } from './ai.controller';
|
|||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import { AiGatewayService } from './ai-gateway.service';
|
import { AiGatewayService } from './ai-gateway.service';
|
||||||
import { ConfigModule } from '../config/config.module';
|
import { ConfigModule } from '../config/config.module';
|
||||||
|
import { AuthModule } from '../../common/auth/auth.module';
|
||||||
import { CommonDomainModule } from '../../common/common-domain.module';
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
import { BusinessAnalysisService } from './analysis/business-analysis.service';
|
||||||
import { MetricEngine } from './analysis/metric-engine';
|
import { MetricEngine } from './analysis/metric-engine';
|
||||||
@@ -10,7 +11,7 @@ import { PermissionScopeResolver } from './analysis/permission-scope-resolver';
|
|||||||
import { AnalysisReportBuilder } from './analysis/report-builder';
|
import { AnalysisReportBuilder } from './analysis/report-builder';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, CommonDomainModule],
|
imports: [ConfigModule, AuthModule, CommonDomainModule],
|
||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [
|
providers: [
|
||||||
AiService,
|
AiService,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export class BusinessAnalysisService {
|
|||||||
|
|
||||||
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
|
const insight = buildInsightCard(metricResult, metric, semantic.semanticConfidence);
|
||||||
const chart = buildUnifiedChartSpec(metricResult, metric);
|
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);
|
const followUps = buildFollowUps(metricResult, plan);
|
||||||
return { ok: true, plan, metricResult, insight, chart, report, followUps };
|
return { ok: true, plan, metricResult, insight, chart, report, followUps };
|
||||||
}
|
}
|
||||||
|
|||||||
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal file
54
apps/server/src/modules/ai/analysis/report-builder.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { AnalysisReportBuilder } from './report-builder';
|
||||||
|
import type { AnalysisPlan, InsightCard, MetricDefinition, MetricResult } from '@ftb/shared';
|
||||||
|
|
||||||
|
describe('AnalysisReportBuilder', () => {
|
||||||
|
it('describes normalized time ranges from the analysis plan', async () => {
|
||||||
|
const builder = new AnalysisReportBuilder();
|
||||||
|
const metric = {
|
||||||
|
metricId: 'member_effort_hours',
|
||||||
|
version: 1,
|
||||||
|
name: '成员投入工时',
|
||||||
|
description: '成员投入',
|
||||||
|
formula: 'sum(hours)',
|
||||||
|
owner: 'management',
|
||||||
|
supportedDimensions: ['member'],
|
||||||
|
supportedAnalysisTypes: ['ranking'],
|
||||||
|
defaultChart: 'horizontal_bar',
|
||||||
|
defaultTimePolicy: 'last_30_days',
|
||||||
|
status: 'active',
|
||||||
|
} as MetricDefinition;
|
||||||
|
const result = {
|
||||||
|
metricRef: { metricId: 'member_effort_hours', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
columns: [
|
||||||
|
{ id: 'label', label: '成员', type: 'string' },
|
||||||
|
{ id: 'value', label: '小时', type: 'number' },
|
||||||
|
],
|
||||||
|
rows: [{ label: '张三', value: 8 }],
|
||||||
|
evidence: [],
|
||||||
|
dataScope: { type: 'self', userId: 'm-1' },
|
||||||
|
generatedAt: '2026-07-08T12:00:00.000Z',
|
||||||
|
} as MetricResult;
|
||||||
|
const insight = {
|
||||||
|
summary: '张三投入最高。',
|
||||||
|
semanticConfidence: 'high',
|
||||||
|
dataConfidence: 'partial',
|
||||||
|
} as InsightCard;
|
||||||
|
const plan = {
|
||||||
|
metricRef: { metricId: 'member_effort_hours', version: 1 },
|
||||||
|
analysisType: 'ranking',
|
||||||
|
dimensions: ['member'],
|
||||||
|
filters: {},
|
||||||
|
scope: { type: 'self', userId: 'm-1' },
|
||||||
|
timeRange: {
|
||||||
|
start: '2026-06-09T00:00:00.000Z',
|
||||||
|
end: '2026-07-08T23:59:59.999Z',
|
||||||
|
policy: 'last_30_days',
|
||||||
|
},
|
||||||
|
} as AnalysisPlan;
|
||||||
|
|
||||||
|
const report = await (builder.build as any)(result, insight, metric, plan);
|
||||||
|
|
||||||
|
expect(report.dataScope.timeDescription).toBe('最近 30 天');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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()
|
@Injectable()
|
||||||
export class AnalysisReportBuilder {
|
export class AnalysisReportBuilder {
|
||||||
@@ -7,6 +14,7 @@ export class AnalysisReportBuilder {
|
|||||||
result: MetricResult,
|
result: MetricResult,
|
||||||
insight: InsightCard,
|
insight: InsightCard,
|
||||||
metric: MetricDefinition,
|
metric: MetricDefinition,
|
||||||
|
plan?: AnalysisPlan,
|
||||||
): Promise<AnalysisReport> {
|
): Promise<AnalysisReport> {
|
||||||
return {
|
return {
|
||||||
summary: insight.summary,
|
summary: insight.summary,
|
||||||
@@ -19,7 +27,7 @@ export class AnalysisReportBuilder {
|
|||||||
? ['优先查看排名靠前的对象,并进入明细确认原因。']
|
? ['优先查看排名靠前的对象,并进入明细确认原因。']
|
||||||
: ['调整时间范围或切换分析维度。'],
|
: ['调整时间范围或切换分析维度。'],
|
||||||
dataScope: {
|
dataScope: {
|
||||||
timeDescription: resultHasTime(result) ? '按分析计划时间范围统计' : '当前状态',
|
timeDescription: describeTimeScope(plan, result),
|
||||||
permissionDescription: describeScope(result.dataScope),
|
permissionDescription: describeScope(result.dataScope),
|
||||||
metricFormulaDescription: metric.formula,
|
metricFormulaDescription: metric.formula,
|
||||||
generatedAt: result.generatedAt,
|
generatedAt: result.generatedAt,
|
||||||
@@ -32,6 +40,22 @@ function resultHasTime(result: MetricResult): boolean {
|
|||||||
return result.columns.some((column) => column.type === 'date');
|
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 {
|
function describeScope(scope: DataScope): string {
|
||||||
if (scope.type === 'system') return '系统管理范围';
|
if (scope.type === 'system') return '系统管理范围';
|
||||||
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
|
if (scope.type === 'managed_projects') return `管理项目范围:${scope.projectIds.length} 个项目`;
|
||||||
|
|||||||
Reference in New Issue
Block a user