forked from wangziqi/gongxue-base
246 lines
8.4 KiB
TypeScript
246 lines
8.4 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|