feat: 集成 AI 对话与只读查询工具

This commit is contained in:
2026-07-23 14:24:40 +08:00
parent 302dbe0621
commit f3b59935d6
52 changed files with 4942 additions and 4 deletions

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
import { CaslAction, SubjectName } from '../authorization/casl.constants';
import type { AgentToolContext } from './agent-tool.types';
@Injectable()
export class AgentBusinessScopeFactory {
constructor(private readonly abilityFactory: CaslAbilityFactory) {}
private ability(context: AgentToolContext) {
return this.abilityFactory.createForUser({
permissions: context.permissions,
isSuperAdmin: context.isSuperAdmin,
});
}
canManageAllClasses(context: AgentToolContext): boolean {
const ability = this.ability(context);
return context.isSuperAdmin || ability.can(CaslAction.Update, SubjectName.Class);
}
canManageAllAttendance(context: AgentToolContext): boolean {
const ability = this.ability(context);
return (
context.isSuperAdmin ||
ability.can(CaslAction.Manage, SubjectName.Attendance) ||
ability.can(CaslAction.Update, SubjectName.Class)
);
}
canManageAllDashboard(context: AgentToolContext): boolean {
const ability = this.ability(context);
return (
context.isSuperAdmin ||
ability.can(CaslAction.Manage, SubjectName.Dashboard) ||
ability.can(CaslAction.Update, SubjectName.Class)
);
}
}

View File

@@ -1,9 +1,21 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { StudentsModule } from '../students/students.module';
import { ClassesModule } from '../classes/classes.module';
import { AttendanceModule } from '../attendance/attendance.module';
import { RoomsModule } from '../rooms/rooms.module';
import { BillsModule } from '../bills/bills.module';
import { DashboardModule } from '../dashboard/dashboard.module';
import { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolExecutor } from './agent-tool.executor';
import { SearchStudentsTool } from './tools/search-students.tool';
import { GetStudentBasicTool } from './tools/get-student-basic.tool';
import { AgentBusinessScopeFactory } from './agent-business-scope.factory';
import { SearchClassesTool } from './tools/search-classes.tool';
import { GetAttendanceSummaryTool } from './tools/get-attendance-summary.tool';
import { SearchRoomsTool } from './tools/search-rooms.tool';
import { GetRoomOccupancySummaryTool } from './tools/get-room-occupancy-summary.tool';
import { SearchBillsTool } from './tools/search-bills.tool';
import { GetDashboardStatsTool } from './tools/get-dashboard-stats.tool';
/**
* Agent Tools feature module.
@@ -20,12 +32,19 @@ import { GetStudentBasicTool } from './tools/get-student-basic.tool';
* globally available `AuthorizationModule` and `OperationLogsModule`.
*/
@Module({
imports: [StudentsModule],
imports: [StudentsModule, ClassesModule, AttendanceModule, RoomsModule, BillsModule, DashboardModule],
providers: [
AgentToolRegistry,
AgentToolExecutor,
SearchStudentsTool,
GetStudentBasicTool,
AgentBusinessScopeFactory,
SearchClassesTool,
GetAttendanceSummaryTool,
SearchRoomsTool,
GetRoomOccupancySummaryTool,
SearchBillsTool,
GetDashboardStatsTool,
],
exports: [AgentToolExecutor],
})
@@ -34,10 +53,22 @@ export class AgentToolsModule implements OnModuleInit {
private readonly registry: AgentToolRegistry,
private readonly searchTool: SearchStudentsTool,
private readonly getTool: GetStudentBasicTool,
private readonly searchClassesTool: SearchClassesTool,
private readonly attendanceSummaryTool: GetAttendanceSummaryTool,
private readonly searchRoomsTool: SearchRoomsTool,
private readonly roomOccupancyTool: GetRoomOccupancySummaryTool,
private readonly searchBillsTool: SearchBillsTool,
private readonly dashboardStatsTool: GetDashboardStatsTool,
) {}
onModuleInit(): void {
this.registry.register(this.searchTool);
this.registry.register(this.getTool);
this.registry.register(this.searchClassesTool);
this.registry.register(this.attendanceSummaryTool);
this.registry.register(this.searchRoomsTool);
this.registry.register(this.roomOccupancyTool);
this.registry.register(this.searchBillsTool);
this.registry.register(this.dashboardStatsTool);
}
}

View File

@@ -0,0 +1,79 @@
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import type { AuthenticatedUser } from '../../authorization';
import { AgentBusinessScopeFactory } from '../agent-business-scope.factory';
import { AgentToolContextFactory } from '../agent-tool.types';
import { SearchClassesTool } from './search-classes.tool';
import { GetAttendanceSummaryTool } from './get-attendance-summary.tool';
import { SearchRoomsTool } from './search-rooms.tool';
import { GetRoomOccupancySummaryTool } from './get-room-occupancy-summary.tool';
import { SearchBillsTool } from './search-bills.tool';
import { GetDashboardStatsTool } from './get-dashboard-stats.tool';
function context(permissions: string[] = [], isSuperAdmin = false) {
const user: AuthenticatedUser = { id: 7, username: 'teacher', permissions, isSuperAdmin, roles: [] };
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
const scopes = new AgentBusinessScopeFactory(new CaslAbilityFactory());
describe('agent business tools', () => {
it('search_classes rejects unknown fields and enforces teacher scope', async () => {
const service = { agentSearchClasses: jest.fn().mockResolvedValue([]) };
const tool = new SearchClassesTool(service as never, scopes);
expect(tool.validate({ userId: 1 }).ok).toBe(false);
expect(tool.validate({ limit: 51 }).ok).toBe(false);
await tool.execute({ keyword: '一班' }, context(['class:view']));
expect(service.agentSearchClasses).toHaveBeenCalledWith(7, false, { keyword: '一班' });
});
it('class:edit grants full class and attendance scope', async () => {
const classService = { agentSearchClasses: jest.fn().mockResolvedValue([]) };
const attendanceService = { agentGetAttendanceSummary: jest.fn().mockResolvedValue([]) };
const ctx = context(['class:view', 'class:edit', 'attendance:view']);
await new SearchClassesTool(classService as never, scopes).execute({}, ctx);
await new GetAttendanceSummaryTool(attendanceService as never, scopes).execute({}, ctx);
expect(classService.agentSearchClasses).toHaveBeenCalledWith(7, true, {});
expect(attendanceService.agentGetAttendanceSummary).toHaveBeenCalledWith(7, true, {});
});
it('attendance validates date range and limit', () => {
const tool = new GetAttendanceSummaryTool({} as never, scopes);
expect(tool.validate({ dateFrom: '2026-07-23', dateTo: '2026-07-22' }).ok).toBe(false);
expect(tool.validate({ dateFrom: '2026-02-30' }).ok).toBe(false);
expect(tool.validate({ limit: 50 }).ok).toBe(true);
});
it('room tools reject sensitive/unknown fields and forward safe input', async () => {
const service = {
agentSearchRooms: jest.fn().mockResolvedValue([]),
agentGetRoomOccupancySummary: jest.fn().mockResolvedValue([]),
};
const search = new SearchRoomsTool(service as never);
const summary = new GetRoomOccupancySummaryTool(service as never);
expect(search.validate({ studentName: '张三' }).ok).toBe(false);
expect(summary.validate({ permissions: ['room:view'] }).ok).toBe(false);
await search.execute({ building: '1号楼', limit: 10 }, context(['room:view']));
await summary.execute({ date: '2026-07-23' }, context(['room:view']));
expect(service.agentSearchRooms).toHaveBeenCalledWith({ building: '1号楼', limit: 10 });
expect(service.agentGetRoomOccupancySummary).toHaveBeenCalledWith({ date: '2026-07-23' });
});
it('bill tool exposes read permission and validates ranges', async () => {
const service = { agentSearchBills: jest.fn().mockResolvedValue([]) };
const tool = new SearchBillsTool(service as never);
expect(tool.requiredPermission).toBe('bill:view');
expect(tool.validate({ periodStart: '2026-07-31', periodEnd: '2026-07-01' }).ok).toBe(false);
await tool.execute({ status: 'unpaid', limit: 20 }, context(['bill:view']));
expect(service.agentSearchBills).toHaveBeenCalledWith({ status: 'unpaid', limit: 20 });
});
it('dashboard uses teacher scope unless super admin', async () => {
const service = { agentGetDashboardStats: jest.fn().mockResolvedValue({}) };
const tool = new GetDashboardStatsTool(service as never, scopes);
expect(tool.validate({ debug: true }).ok).toBe(false);
await tool.execute({}, context(['dashboard:view']));
await tool.execute({}, context([], true));
expect(service.agentGetDashboardStats).toHaveBeenNthCalledWith(1, 7, false);
expect(service.agentGetDashboardStats).toHaveBeenNthCalledWith(2, 7, true);
});
});

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { AttendanceService } from '../../attendance/attendance.service';
import { AgentBusinessScopeFactory } from '../agent-business-scope.factory';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
import { optionalDate, optionalPositiveInt, rejectUnknownKeys } from './tool-input';
interface Input { classId?: number; dateFrom?: string; dateTo?: string; limit?: number }
@Injectable()
export class GetAttendanceSummaryTool implements ToolDef<Input> {
readonly name = 'get_attendance_summary';
readonly description = '按日期和班级汇总当前用户有权查看的考勤数据。';
readonly requiredPermission = 'attendance:view';
readonly inputSchema = { type: 'object', properties: {
classId: { type: 'integer', minimum: 1 }, dateFrom: { type: 'string', format: 'date' },
dateTo: { type: 'string', format: 'date' }, limit: { type: 'integer', minimum: 1, maximum: 50 },
}, additionalProperties: false };
constructor(private readonly service: AttendanceService, private readonly scopes: AgentBusinessScopeFactory) {}
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
const invalid = rejectUnknownKeys(raw, ['classId', 'dateFrom', 'dateTo', 'limit']); if (invalid) return invalid;
const classId = optionalPositiveInt(raw.classId, 'classId'); if (!classId.ok) return classId;
const dateFrom = optionalDate(raw.dateFrom, 'dateFrom'); if (!dateFrom.ok) return dateFrom;
const dateTo = optionalDate(raw.dateTo, 'dateTo'); if (!dateTo.ok) return dateTo;
if (dateFrom.value && dateTo.value && dateFrom.value > dateTo.value) return { ok: false, error: 'dateTo 不能早于 dateFrom' };
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
return { ok: true, value: { classId: classId.value, dateFrom: dateFrom.value, dateTo: dateTo.value, limit: limit.value } };
}
execute(input: Input, context: AgentToolContext) {
return this.service.agentGetAttendanceSummary(context.userId, this.scopes.canManageAllAttendance(context), input);
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { DashboardService } from '../../dashboard/dashboard.service';
import { AgentBusinessScopeFactory } from '../agent-business-scope.factory';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
import { rejectUnknownKeys } from './tool-input';
@Injectable()
export class GetDashboardStatsTool implements ToolDef<Record<string, never>> {
readonly name = 'get_dashboard_stats'; readonly requiredPermission = 'dashboard:view';
readonly description = '获取当前用户数据范围内的学生、班级和今日考勤概览。';
readonly inputSchema = { type: 'object', properties: {}, additionalProperties: false };
constructor(private readonly service: DashboardService, private readonly scopes: AgentBusinessScopeFactory) {}
validate(raw: Record<string, unknown>): ToolInputResult<Record<string, never>> {
const invalid = rejectUnknownKeys(raw, []); return invalid ?? { ok: true, value: {} };
}
execute(_input: Record<string, never>, context: AgentToolContext) {
return this.service.agentGetDashboardStats(context.userId, this.scopes.canManageAllDashboard(context));
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { RoomsService } from '../../rooms/rooms.service';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
import { optionalDate, optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
interface Input { date?: string; building?: string; limit?: number }
@Injectable()
export class GetRoomOccupancySummaryTool implements ToolDef<Input> {
readonly name = 'get_room_occupancy_summary'; readonly requiredPermission = 'room:view';
readonly description = '按日期汇总宿舍入住数量和空余床位,不返回住户资料。';
readonly inputSchema = { type: 'object', properties: { date: { type: 'string', format: 'date' }, building: { type: 'string', maxLength: 50 }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, additionalProperties: false };
constructor(private readonly service: RoomsService) {}
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
const invalid = rejectUnknownKeys(raw, ['date', 'building', 'limit']); if (invalid) return invalid;
const date = optionalDate(raw.date, 'date'); if (!date.ok) return date;
const building = optionalString(raw.building, 'building', 50); if (!building.ok) return building;
const limit = optionalPositiveInt(raw.limit, 'limit', 100); if (!limit.ok) return limit;
return { ok: true, value: { date: date.value, building: building.value, limit: limit.value } };
}
execute(input: Input, _context: AgentToolContext) { return this.service.agentGetRoomOccupancySummary(input); }
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { BillsService } from '../../bills/bills.service';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
import { optionalDate, optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
interface Input { keyword?: string; periodStart?: string; periodEnd?: string; status?: string; limit?: number }
@Injectable()
export class SearchBillsTool implements ToolDef<Input> {
readonly name = 'search_bills'; readonly requiredPermission = 'bill:view';
readonly description = '查询账单编号、学生显示名、账期、金额和状态。';
readonly inputSchema = { type: 'object', properties: { keyword: { type: 'string', maxLength: 100 }, periodStart: { type: 'string', format: 'date' }, periodEnd: { type: 'string', format: 'date' }, status: { type: 'string', maxLength: 20 }, limit: { type: 'integer', minimum: 1, maximum: 50 } }, additionalProperties: false };
constructor(private readonly service: BillsService) {}
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
const invalid = rejectUnknownKeys(raw, ['keyword', 'periodStart', 'periodEnd', 'status', 'limit']); if (invalid) return invalid;
const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword;
const periodStart = optionalDate(raw.periodStart, 'periodStart'); if (!periodStart.ok) return periodStart;
const periodEnd = optionalDate(raw.periodEnd, 'periodEnd'); if (!periodEnd.ok) return periodEnd;
if (periodStart.value && periodEnd.value && periodStart.value > periodEnd.value) return { ok: false, error: 'periodEnd 不能早于 periodStart' };
const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
return { ok: true, value: { keyword: keyword.value, periodStart: periodStart.value, periodEnd: periodEnd.value, status: status.value, limit: limit.value } };
}
execute(input: Input, _context: AgentToolContext) { return this.service.agentSearchBills(input); }
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { ClassesService } from '../../classes/classes.service';
import { AgentBusinessScopeFactory } from '../agent-business-scope.factory';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
import { optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
interface Input { keyword?: string; status?: string; limit?: number }
@Injectable()
export class SearchClassesTool implements ToolDef<Input> {
readonly name = 'search_classes';
readonly description = '查询当前用户有权查看的班级,仅返回班级基础字段和在读人数。';
readonly requiredPermission = 'class:view';
readonly inputSchema = { type: 'object', properties: {
keyword: { type: 'string', maxLength: 100 }, status: { type: 'string', maxLength: 20 },
limit: { type: 'integer', minimum: 1, maximum: 50 },
}, additionalProperties: false };
constructor(private readonly service: ClassesService, private readonly scopes: AgentBusinessScopeFactory) {}
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
const invalid = rejectUnknownKeys(raw, ['keyword', 'status', 'limit']); if (invalid) return invalid;
const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword;
const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
return { ok: true, value: { keyword: keyword.value, status: status.value, limit: limit.value } };
}
execute(input: Input, context: AgentToolContext) {
return this.service.agentSearchClasses(context.userId, this.scopes.canManageAllClasses(context), input);
}
}

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { RoomsService } from '../../rooms/rooms.service';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
import { optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input';
interface Input { keyword?: string; building?: string; status?: string; limit?: number }
@Injectable()
export class SearchRoomsTool implements ToolDef<Input> {
readonly name = 'search_rooms'; readonly requiredPermission = 'room:view';
readonly description = '查询宿舍及床位占用数量,不返回住户资料。';
readonly inputSchema = { type: 'object', properties: { keyword: { type: 'string', maxLength: 50 }, building: { type: 'string', maxLength: 50 }, status: { type: 'string', maxLength: 20 }, limit: { type: 'integer', minimum: 1, maximum: 50 } }, additionalProperties: false };
constructor(private readonly service: RoomsService) {}
validate(raw: Record<string, unknown>): ToolInputResult<Input> {
const invalid = rejectUnknownKeys(raw, ['keyword', 'building', 'status', 'limit']); if (invalid) return invalid;
const keyword = optionalString(raw.keyword, 'keyword', 50); if (!keyword.ok) return keyword;
const building = optionalString(raw.building, 'building', 50); if (!building.ok) return building;
const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;
const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;
return { ok: true, value: { keyword: keyword.value, building: building.value, status: status.value, limit: limit.value } };
}
execute(input: Input, _context: AgentToolContext) { return this.service.agentSearchRooms(input); }
}

View File

@@ -0,0 +1,55 @@
import type { ToolInputResult } from '../agent-tool.types';
const FORBIDDEN_KEYS = new Set([
'userId', 'isSuperAdmin', 'permissions', 'roles', 'ability', 'user', 'password', 'token',
]);
export function rejectUnknownKeys(
input: Record<string, unknown>,
allowed: readonly string[],
): ToolInputResult<never> | undefined {
const allowedSet = new Set(allowed);
for (const key of Object.keys(input)) {
if (FORBIDDEN_KEYS.has(key) || !allowedSet.has(key)) {
return { ok: false, error: `不允许的输入字段: ${key}` };
}
}
return undefined;
}
export function optionalString(
value: unknown,
field: string,
maxLength: number,
): ToolInputResult<string | undefined> {
if (value === undefined) return { ok: true, value: undefined };
if (typeof value !== 'string' || value.length > maxLength) {
return { ok: false, error: `${field} 必须是长度不超过${maxLength}的字符串` };
}
return { ok: true, value: value.trim() || undefined };
}
export function optionalPositiveInt(
value: unknown,
field: string,
maximum?: number,
): ToolInputResult<number | undefined> {
if (value === undefined) return { ok: true, value: undefined };
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0 || (maximum !== undefined && parsed > maximum)) {
return { ok: false, error: `${field} 必须是正整数${maximum ? `且不超过${maximum}` : ''}` };
}
return { ok: true, value: parsed };
}
export function optionalDate(value: unknown, field: string): ToolInputResult<string | undefined> {
if (value === undefined) return { ok: true, value: undefined };
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return { ok: false, error: `${field} 必须是 YYYY-MM-DD 日期` };
}
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
return { ok: false, error: `${field} 不是有效日期` };
}
return { ok: true, value };
}