Files
gongxue-base/apps/server/src/agent-tools/tools/update-students.tool.ts

246 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> {
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<UpdateStudentsInput> {
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<string, unknown>): ToolInputResult<UpdateStudentsInput> {
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<number>();
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<unknown> {
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,
};
}
}