feat: 集成 AI 对话与只读查询工具
This commit is contained in:
39
apps/server/src/agent-tools/agent-business-scope.factory.ts
Normal file
39
apps/server/src/agent-tools/agent-business-scope.factory.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
79
apps/server/src/agent-tools/tools/business-tools.spec.ts
Normal file
79
apps/server/src/agent-tools/tools/business-tools.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
24
apps/server/src/agent-tools/tools/search-bills.tool.ts
Normal file
24
apps/server/src/agent-tools/tools/search-bills.tool.ts
Normal 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); }
|
||||
}
|
||||
29
apps/server/src/agent-tools/tools/search-classes.tool.ts
Normal file
29
apps/server/src/agent-tools/tools/search-classes.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
apps/server/src/agent-tools/tools/search-rooms.tool.ts
Normal file
22
apps/server/src/agent-tools/tools/search-rooms.tool.ts
Normal 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); }
|
||||
}
|
||||
55
apps/server/src/agent-tools/tools/tool-input.ts
Normal file
55
apps/server/src/agent-tools/tools/tool-input.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user