import { Injectable, NotFoundException } from '@nestjs/common'; import { StudentsService } from '../../students/students.service'; import type { UpdateStudentDto } from '../../students/dto/student.dto'; import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; /** Whitelisted editable fields on a single student update. */ interface UpdateStudentInput { id: number; name?: string; studentNo?: string; phone?: string; idNumber?: string; gender?: string; ethnicity?: string; emergencyContact?: string; emergencyPhone?: string; organizationId?: number; supervisor?: string; status?: 'active' | 'graduated' | 'withdrawn'; } interface UpdateStudentsInput { updates: UpdateStudentInput[]; } type StringField = | 'name' | 'studentNo' | 'phone' | 'idNumber' | 'gender' | 'ethnicity' | 'emergencyContact' | 'emergencyPhone' | 'supervisor'; /** Forbidden input keys — if the model sends these, validation fails. */ const FORBIDDEN_INPUT_KEYS = new Set([ 'userId', 'isSuperAdmin', 'permissions', 'roles', 'ability', 'user', 'password', 'token', ]); const TOP_LEVEL_KEYS = new Set(['updates']); const ITEM_KEYS = new Set([ 'id', 'name', 'studentNo', 'phone', 'idNumber', 'gender', 'ethnicity', 'emergencyContact', 'emergencyPhone', 'organizationId', 'supervisor', 'status', ]); const STATUS_VALUES = new Set(['active', 'graduated', 'withdrawn']); const MAX_BATCH_UPDATES = 12; function isPlainRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function optionalString( value: unknown, max: number, ): { ok: true; value?: string } | { ok: false; error: string } { if (value === undefined) return { ok: true }; if (typeof value !== 'string') return { ok: false, error: '字段必须是字符串' }; const trimmed = value.trim(); if (trimmed.length > max) return { ok: false, error: `字段长度不能超过 ${max}` }; return { ok: true, value: trimmed }; } /** * Batch-updates student profiles from form-confirmed data. * * This is a write tool. It is only exposed to the model after the user * submits a rendered form (see AiChatService), and the executor still * enforces `student:edit` at execution time. */ @Injectable() export class UpdateStudentsTool implements ToolDef { readonly name = 'update_students'; readonly skillKey = 'student'; readonly requiredPermission = 'student:edit'; readonly inputSchema = { type: 'object', properties: { updates: { type: 'array', description: '待更新的学生列表(1-12 条,每条必须包含学生 id 和至少一个可编辑字段)', minItems: 1, maxItems: MAX_BATCH_UPDATES, items: { type: 'object', properties: { id: { type: 'integer', description: '学生 ID', minimum: 1 }, name: { type: 'string', description: '学生姓名', maxLength: 50 }, studentNo: { type: 'string', description: '学号', maxLength: 30 }, phone: { type: 'string', description: '手机号', maxLength: 30 }, idNumber: { type: 'string', description: '身份证号', maxLength: 30 }, gender: { type: 'string', description: '性别', maxLength: 20 }, ethnicity: { type: 'string', description: '民族', maxLength: 50 }, emergencyContact: { type: 'string', description: '紧急联系人', maxLength: 50 }, emergencyPhone: { type: 'string', description: '紧急联系电话', maxLength: 30 }, organizationId: { type: 'integer', description: '校区 ID', minimum: 1 }, supervisor: { type: 'string', description: '负责人', maxLength: 50 }, status: { type: 'string', description: '学生状态', enum: ['active', 'graduated', 'withdrawn'], }, }, required: ['id'], additionalProperties: false, }, }, }, required: ['updates'], additionalProperties: false, }; readonly description = '根据用户通过表单确认的信息批量修改学生档案(姓名、学号、手机号、身份证、性别、民族、紧急联系人、校区、负责人、状态等)。仅可在表单提交后的轮次使用,不得自行编造或修改字段。'; constructor(private readonly studentsService: StudentsService) {} validate(input: Record): ToolInputResult { for (const key of Object.keys(input)) { if (FORBIDDEN_INPUT_KEYS.has(key)) { return { ok: false, error: `不允许的输入字段: ${key}` }; } if (!TOP_LEVEL_KEYS.has(key)) { return { ok: false, error: `不允许的输入字段: ${key}` }; } } if (!Array.isArray(input.updates) || input.updates.length === 0) { return { ok: false, error: 'updates 至少需要一条记录' }; } if (input.updates.length > MAX_BATCH_UPDATES) { return { ok: false, error: `updates 不能超过 ${MAX_BATCH_UPDATES} 条` }; } const seenIds = new Set(); const updates: UpdateStudentInput[] = []; for (let index = 0; index < input.updates.length; index += 1) { const raw = input.updates[index]; if (!isPlainRecord(raw)) { return { ok: false, error: `第 ${index + 1} 条更新格式无效` }; } for (const key of Object.keys(raw)) { if (FORBIDDEN_INPUT_KEYS.has(key)) { return { ok: false, error: `不允许的输入字段: ${key}` }; } if (!ITEM_KEYS.has(key)) { return { ok: false, error: `第 ${index + 1} 条包含未知字段: ${key}` }; } } const id = Number(raw.id); if (!Number.isInteger(id) || id <= 0) { return { ok: false, error: `第 ${index + 1} 条的学生 id 必须是正整数` }; } if (seenIds.has(id)) { return { ok: false, error: `学生 id 重复: ${id}` }; } seenIds.add(id); const item: UpdateStudentInput = { id }; const stringFields: Array<[StringField, unknown, number]> = [ ['name', raw.name, 50], ['studentNo', raw.studentNo, 30], ['phone', raw.phone, 30], ['idNumber', raw.idNumber, 30], ['gender', raw.gender, 20], ['ethnicity', raw.ethnicity, 50], ['emergencyContact', raw.emergencyContact, 50], ['emergencyPhone', raw.emergencyPhone, 30], ['supervisor', raw.supervisor, 50], ]; for (const [field, value, max] of stringFields) { const parsed = optionalString(value, max); if (!parsed.ok) { return { ok: false, error: `第 ${index + 1} 条 ${String(field)}: ${parsed.error}` }; } if (parsed.value !== undefined) item[field] = parsed.value; } if (raw.organizationId !== undefined) { const organizationId = Number(raw.organizationId); if (!Number.isInteger(organizationId) || organizationId <= 0) { return { ok: false, error: `第 ${index + 1} 条的 organizationId 必须是正整数` }; } item.organizationId = organizationId; } if (raw.status !== undefined) { if (typeof raw.status !== 'string' || !STATUS_VALUES.has(raw.status)) { return { ok: false, error: `第 ${index + 1} 条的 status 无效` }; } item.status = raw.status as UpdateStudentInput['status']; } if (Object.keys(item).length === 1) { return { ok: false, error: `第 ${index + 1} 条至少需要一个可编辑字段` }; } updates.push(item); } return { ok: true, value: { updates } }; } async execute(input: UpdateStudentsInput, _context: AgentToolContext): Promise { const updated: Array<{ id: number; name: string | null }> = []; const failed: Array<{ id: number; error: string }> = []; for (const item of input.updates) { const { id: _id, ...rest } = item; const dto = rest as UpdateStudentDto; try { const student = await this.studentsService.update(item.id, dto); updated.push({ id: item.id, name: student?.name ?? null }); } catch (error) { failed.push({ id: item.id, error: error instanceof NotFoundException ? '学生不存在' : '更新失败', }); } } return { message: `成功更新 ${updated.length} 名学生,失败 ${failed.length} 条`, updated, failed, }; } }