From 3a1ed717d84b094eedc443d5475259c2332c58da Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 4 Aug 2026 14:41:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20Agent=20=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E6=9F=A5=E8=AF=A2=E4=B8=8E=E5=AD=A6=E7=94=9F=E5=A2=9E?= =?UTF-8?q?=E6=94=B9=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/agent-tools/agent-skill.catalog.ts | 12 + .../src/agent-tools/agent-tools.module.ts | 59 ++++- .../agent-tools/tools/business-tools.spec.ts | 73 ++++++ .../tools/create-student.tool.spec.ts | 71 +++++ .../agent-tools/tools/create-student.tool.ts | 166 ++++++++++++ .../agent-tools/tools/get-sync-status.tool.ts | 23 ++ .../tools/search-classroom-rentals.tool.ts | 62 +++++ .../tools/search-classrooms.tool.ts | 37 +++ .../agent-tools/tools/search-deposits.tool.ts | 37 +++ .../agent-tools/tools/search-exams.tool.ts | 50 ++++ .../agent-tools/tools/search-expenses.tool.ts | 52 ++++ .../tools/search-schedules.tool.ts | 60 +++++ .../tools/update-students.tool.spec.ts | 124 +++++++++ .../agent-tools/tools/update-students.tool.ts | 245 ++++++++++++++++++ .../classroom-rentals.service.ts | 65 +++++ .../src/classrooms/classrooms.service.ts | 53 +++- apps/server/src/deposits/deposits.service.ts | 59 +++++ apps/server/src/exams/exams.module.ts | 1 + apps/server/src/exams/exams.service.ts | 45 ++++ apps/server/src/expenses/expenses.service.ts | 103 ++++++++ .../src/schedules/schedules.scope.spec.ts | 57 ++++ .../server/src/schedules/schedules.service.ts | 93 +++++++ docs/agent-workflow.md | 85 ++++++ 23 files changed, 1630 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/agent-tools/tools/create-student.tool.spec.ts create mode 100644 apps/server/src/agent-tools/tools/create-student.tool.ts create mode 100644 apps/server/src/agent-tools/tools/get-sync-status.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-classroom-rentals.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-classrooms.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-deposits.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-exams.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-expenses.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-schedules.tool.ts create mode 100644 apps/server/src/agent-tools/tools/update-students.tool.spec.ts create mode 100644 apps/server/src/agent-tools/tools/update-students.tool.ts create mode 100644 docs/agent-workflow.md diff --git a/apps/server/src/agent-tools/agent-skill.catalog.ts b/apps/server/src/agent-tools/agent-skill.catalog.ts index 3d88da2..775360b 100644 --- a/apps/server/src/agent-tools/agent-skill.catalog.ts +++ b/apps/server/src/agent-tools/agent-skill.catalog.ts @@ -31,6 +31,18 @@ export const AGENT_SKILLS: readonly Omit[] = [ description: '查询账单编号、账期、金额和状态。', examples: ['查找本月未支付账单', '查询张同学最近的账单'], }, + { + key: 'classroom', + name: '教室与租用', + description: '查询教室信息、占用状态和租赁订单。', + examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?'], + }, + { + key: 'sync', + name: '同步状态', + description: '查询钉钉、企业微信等平台最近一次同步状态和排课映射进度。', + examples: ['最近一次钉钉同步是什么时候?', '同步状态正常吗?'], + }, ]; export const AGENT_SKILL_KEYS = new Set(AGENT_SKILLS.map((skill) => skill.key)); diff --git a/apps/server/src/agent-tools/agent-tools.module.ts b/apps/server/src/agent-tools/agent-tools.module.ts index 333b971..9afde4f 100644 --- a/apps/server/src/agent-tools/agent-tools.module.ts +++ b/apps/server/src/agent-tools/agent-tools.module.ts @@ -16,6 +16,22 @@ 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'; +import { CreateStudentTool } from './tools/create-student.tool'; +import { UpdateStudentsTool } from './tools/update-students.tool'; +import { SearchExamsTool } from './tools/search-exams.tool'; +import { SearchSchedulesTool } from './tools/search-schedules.tool'; +import { SearchDepositsTool } from './tools/search-deposits.tool'; +import { SearchExpensesTool } from './tools/search-expenses.tool'; +import { SearchClassroomsTool } from './tools/search-classrooms.tool'; +import { SearchClassroomRentalsTool } from './tools/search-classroom-rentals.tool'; +import { GetSyncStatusTool } from './tools/get-sync-status.tool'; +import { ExamsModule } from '../exams/exams.module'; +import { SchedulesModule } from '../schedules/schedules.module'; +import { DepositsModule } from '../deposits/deposits.module'; +import { ExpensesModule } from '../expenses/expenses.module'; +import { ClassroomsModule } from '../classrooms/classrooms.module'; +import { ClassroomRentalsModule } from '../classroom-rentals/classroom-rentals.module'; +import { SyncModule } from '../sync/sync.module'; /** * Agent Tools feature module. @@ -32,7 +48,21 @@ import { GetDashboardStatsTool } from './tools/get-dashboard-stats.tool'; * globally available `AuthorizationModule` and `OperationLogsModule`. */ @Module({ - imports: [StudentsModule, ClassesModule, AttendanceModule, RoomsModule, BillsModule, DashboardModule], + imports: [ + StudentsModule, + ClassesModule, + AttendanceModule, + RoomsModule, + BillsModule, + DashboardModule, + ExamsModule, + SchedulesModule, + DepositsModule, + ExpensesModule, + ClassroomsModule, + ClassroomRentalsModule, + SyncModule, + ], providers: [ AgentToolRegistry, AgentToolExecutor, @@ -45,6 +75,15 @@ import { GetDashboardStatsTool } from './tools/get-dashboard-stats.tool'; GetRoomOccupancySummaryTool, SearchBillsTool, GetDashboardStatsTool, + CreateStudentTool, + UpdateStudentsTool, + SearchExamsTool, + SearchSchedulesTool, + SearchDepositsTool, + SearchExpensesTool, + SearchClassroomsTool, + SearchClassroomRentalsTool, + GetSyncStatusTool, ], exports: [AgentToolExecutor], }) @@ -59,6 +98,15 @@ export class AgentToolsModule implements OnModuleInit { private readonly roomOccupancyTool: GetRoomOccupancySummaryTool, private readonly searchBillsTool: SearchBillsTool, private readonly dashboardStatsTool: GetDashboardStatsTool, + private readonly createStudentTool: CreateStudentTool, + private readonly updateStudentsTool: UpdateStudentsTool, + private readonly searchExamsTool: SearchExamsTool, + private readonly searchSchedulesTool: SearchSchedulesTool, + private readonly searchDepositsTool: SearchDepositsTool, + private readonly searchExpensesTool: SearchExpensesTool, + private readonly searchClassroomsTool: SearchClassroomsTool, + private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool, + private readonly getSyncStatusTool: GetSyncStatusTool, ) {} onModuleInit(): void { @@ -70,5 +118,14 @@ export class AgentToolsModule implements OnModuleInit { this.registry.register(this.roomOccupancyTool); this.registry.register(this.searchBillsTool); this.registry.register(this.dashboardStatsTool); + this.registry.register(this.createStudentTool); + this.registry.register(this.updateStudentsTool); + this.registry.register(this.searchExamsTool); + this.registry.register(this.searchSchedulesTool); + this.registry.register(this.searchDepositsTool); + this.registry.register(this.searchExpensesTool); + this.registry.register(this.searchClassroomsTool); + this.registry.register(this.searchClassroomRentalsTool); + this.registry.register(this.getSyncStatusTool); } } diff --git a/apps/server/src/agent-tools/tools/business-tools.spec.ts b/apps/server/src/agent-tools/tools/business-tools.spec.ts index 9f466ac..71caf20 100644 --- a/apps/server/src/agent-tools/tools/business-tools.spec.ts +++ b/apps/server/src/agent-tools/tools/business-tools.spec.ts @@ -8,6 +8,13 @@ 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'; +import { SearchExamsTool } from './search-exams.tool'; +import { SearchSchedulesTool } from './search-schedules.tool'; +import { SearchDepositsTool } from './search-deposits.tool'; +import { SearchExpensesTool } from './search-expenses.tool'; +import { SearchClassroomsTool } from './search-classrooms.tool'; +import { SearchClassroomRentalsTool } from './search-classroom-rentals.tool'; +import { GetSyncStatusTool } from './get-sync-status.tool'; function context(permissions: string[] = [], isSuperAdmin = false) { const user: AuthenticatedUser = { id: 7, username: 'teacher', permissions, isSuperAdmin, roles: [] }; @@ -76,4 +83,70 @@ describe('agent business tools', () => { expect(service.agentGetDashboardStats).toHaveBeenNthCalledWith(1, 7, false); expect(service.agentGetDashboardStats).toHaveBeenNthCalledWith(2, 7, true); }); + + it('exam tool enforces scope and rejects unknown fields', async () => { + const service = { agentSearchExams: jest.fn().mockResolvedValue([]) }; + const tool = new SearchExamsTool(service as never, scopes); + expect(tool.requiredPermission).toBe('exam:view'); + expect(tool.validate({ userId: 1 }).ok).toBe(false); + expect(tool.validate({ limit: 51 }).ok).toBe(false); + await tool.execute({ classId: 3, limit: 10 }, context(['exam:view'])); + expect(service.agentSearchExams).toHaveBeenCalledWith(7, false, { classId: 3, limit: 10 }); + await tool.execute({}, context([], true)); + expect(service.agentSearchExams).toHaveBeenLastCalledWith(7, true, {}); + }); + + it('schedule tool validates weekDay range and forwards scope', async () => { + const service = { agentSearchSchedules: jest.fn().mockResolvedValue([]) }; + const tool = new SearchSchedulesTool(service as never, scopes); + expect(tool.requiredPermission).toBe('schedule:view'); + expect(tool.validate({ weekDay: 8 }).ok).toBe(false); + expect(tool.validate({ weekDay: 0 }).ok).toBe(false); + await tool.execute({ classroomId: 2, weekDay: 3 }, context(['schedule:view'])); + expect(service.agentSearchSchedules).toHaveBeenCalledWith(7, false, { + classroomId: 2, + weekDay: 3, + }); + }); + + it('deposit tool validates and forwards safe input', async () => { + const service = { agentSearchDeposits: jest.fn().mockResolvedValue([]) }; + const tool = new SearchDepositsTool(service as never); + expect(tool.requiredPermission).toBe('deposit:view'); + expect(tool.validate({ permissions: ['deposit:view'] }).ok).toBe(false); + await tool.execute({ keyword: '张三', status: 'paid', limit: 10 }, context(['deposit:view'])); + expect(service.agentSearchDeposits).toHaveBeenCalledWith({ + keyword: '张三', + status: 'paid', + limit: 10, + }); + }); + + it('expense tool validates period range', async () => { + const tool = new SearchExpensesTool({} as never); + expect(tool.validate({ periodStart: '2026-08-01', periodEnd: '2026-07-01' }).ok).toBe(false); + expect(tool.validate({ periodStart: '2026-02-30' }).ok).toBe(false); + expect(tool.validate({ limit: 31 }).ok).toBe(false); + expect(tool.validate({ keyword: '3-301' }).ok).toBe(true); + }); + + it('classroom and rental tools validate inputs', async () => { + const classroomTool = new SearchClassroomsTool({} as never); + const rentalTool = new SearchClassroomRentalsTool({} as never); + expect(classroomTool.requiredPermission).toBe('classroom:view'); + expect(classroomTool.validate({ building: '1号楼' }).ok).toBe(true); + expect(rentalTool.requiredPermission).toBe('rental:view'); + expect(rentalTool.validate({ month: '2026-13' }).ok).toBe(false); + expect(rentalTool.validate({ month: '2026-08', includeEnded: 'yes' }).ok).toBe(false); + expect(rentalTool.validate({ month: '2026-08', includeEnded: true }).ok).toBe(true); + }); + + it('sync status tool rejects any input and forwards nothing', async () => { + const service = { agentGetSyncStatus: jest.fn().mockResolvedValue({}) }; + const tool = new GetSyncStatusTool(service as never); + expect(tool.requiredPermission).toBe('sync:read'); + expect(tool.validate({ debug: true }).ok).toBe(false); + await tool.execute({}, context(['sync:read'])); + expect(service.agentGetSyncStatus).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/server/src/agent-tools/tools/create-student.tool.spec.ts b/apps/server/src/agent-tools/tools/create-student.tool.spec.ts new file mode 100644 index 0000000..d304a23 --- /dev/null +++ b/apps/server/src/agent-tools/tools/create-student.tool.spec.ts @@ -0,0 +1,71 @@ +import { CreateStudentTool } from './create-student.tool'; + +function createTool(overrides: Record = {}) { + const studentsService = { + create: jest.fn(async (dto: Record) => ({ id: 9, ...dto })), + ...(overrides.studentsService ?? {}), + }; + const dataSource = { + getRepository: jest.fn().mockReturnValue({ + findOne: jest.fn().mockResolvedValue({ id: 1, status: 'active', isHost: true }), + }), + ...(overrides.dataSource ?? {}), + }; + const tool = new CreateStudentTool(studentsService as never, dataSource as never); + return { tool, studentsService, dataSource }; +} + +describe('CreateStudentTool', () => { + it('exposes student:create permission and student skill', () => { + const { tool } = createTool(); + expect(tool.name).toBe('create_student'); + expect(tool.skillKey).toBe('student'); + expect(tool.requiredPermission).toBe('student:create'); + }); + + it('rejects forbidden and unknown fields', () => { + const { tool } = createTool(); + expect(tool.validate({ userId: 1 }).ok).toBe(false); + expect(tool.validate({ permissions: ['student:create'] }).ok).toBe(false); + expect(tool.validate({ admin: true }).ok).toBe(false); + }); + + it('requires a valid name and phone', () => { + const { tool } = createTool(); + expect(tool.validate({}).ok).toBe(false); + expect(tool.validate({ name: '' }).ok).toBe(false); + expect(tool.validate({ name: '张三', phone: '123' }).ok).toBe(false); + expect(tool.validate({ name: '张三', phone: '13800138000', gender: 'other' }).ok).toBe(false); + }); + + it('creates student under the default host organization', async () => { + const { tool, studentsService } = createTool(); + const result = await tool.execute( + { name: '张三', phone: '13800138000', gender: 'male', studentNo: 'T001' }, + {} as never, + ); + expect(studentsService.create).toHaveBeenCalledWith({ + name: '张三', + phone: '13800138000', + gender: 'male', + studentNo: 'T001', + idNumber: undefined, + organizationId: 1, + }); + expect(result).toEqual({ + id: 9, + name: '张三', + studentNo: 'T001', + message: '学生已创建', + }); + expect(JSON.stringify(result)).not.toContain('13800138000'); + }); + + it('uses explicit organizationId when provided', async () => { + const { tool, studentsService } = createTool(); + await tool.execute({ name: '李四', organizationId: 3 }, {} as never); + expect(studentsService.create).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 3 }), + ); + }); +}); diff --git a/apps/server/src/agent-tools/tools/create-student.tool.ts b/apps/server/src/agent-tools/tools/create-student.tool.ts new file mode 100644 index 0000000..ddf79f1 --- /dev/null +++ b/apps/server/src/agent-tools/tools/create-student.tool.ts @@ -0,0 +1,166 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Organization } from '../../entities/organization.entity'; +import { StudentsService } from '../../students/students.service'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; + +/** Whitelisted input shape for create_student. */ +interface CreateStudentInput { + name: string; + phone?: string; + gender?: 'male' | 'female' | '男' | '女'; + studentNo?: string; + idNumber?: string; + organizationId?: number; +} + +/** 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 PHONE_RE = /^1[3-9]\d{9}$/; + +/** + * Creates a student archive 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:create` at execution time. + */ +@Injectable() +export class CreateStudentTool implements ToolDef { + readonly name = 'create_student'; + readonly skillKey = 'student'; + readonly inputSchema = { + type: 'object', + properties: { + name: { type: 'string', description: '学生姓名', maxLength: 50 }, + phone: { type: 'string', description: '11 位手机号', pattern: '^1[3-9]\\d{9}$' }, + gender: { + type: 'string', + description: '性别', + enum: ['male', 'female', '男', '女'], + }, + studentNo: { type: 'string', description: '学号(选填)', maxLength: 30 }, + idNumber: { type: 'string', description: '身份证号(选填)', maxLength: 30 }, + organizationId: { type: 'integer', description: '校区ID(选填,缺省用主校区)', minimum: 1 }, + }, + additionalProperties: false, + }; + readonly description = + '根据用户通过表单提交的学生信息创建学生档案。仅可在表单提交后的轮次使用,不得自行编造或修改字段。'; + readonly requiredPermission = 'student:create'; + + constructor( + private readonly studentsService: StudentsService, + private readonly dataSource: DataSource, + ) {} + + validate(input: Record): ToolInputResult { + for (const key of Object.keys(input)) { + if (FORBIDDEN_INPUT_KEYS.has(key)) { + return { ok: false, error: `不允许的输入字段: ${key}` }; + } + } + + const allowedKeys = new Set([ + 'name', + 'phone', + 'gender', + 'studentNo', + 'idNumber', + 'organizationId', + ]); + for (const key of Object.keys(input)) { + if (!allowedKeys.has(key)) { + return { ok: false, error: `不允许的输入字段: ${key}` }; + } + } + + if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 50) { + return { ok: false, error: 'name 必须是 1-50 个字符的字符串' }; + } + + const result: CreateStudentInput = { name: input.name.trim() }; + + if (input.phone !== undefined) { + if (typeof input.phone !== 'string' || !PHONE_RE.test(input.phone)) { + return { ok: false, error: 'phone 必须是 11 位手机号' }; + } + result.phone = input.phone; + } + + if (input.gender !== undefined) { + if (!['male', 'female', '男', '女'].includes(String(input.gender))) { + return { ok: false, error: 'gender 只能是 male/female/男/女' }; + } + result.gender = input.gender as CreateStudentInput['gender']; + } + + if (input.studentNo !== undefined) { + if (typeof input.studentNo !== 'string' || input.studentNo.length > 30) { + return { ok: false, error: 'studentNo 必须是长度不超过 30 的字符串' }; + } + result.studentNo = input.studentNo; + } + + if (input.idNumber !== undefined) { + if (typeof input.idNumber !== 'string' || input.idNumber.length > 30) { + return { ok: false, error: 'idNumber 必须是长度不超过 30 的字符串' }; + } + result.idNumber = input.idNumber; + } + + if (input.organizationId !== undefined) { + const id = Number(input.organizationId); + if (!Number.isInteger(id) || id <= 0) { + return { ok: false, error: 'organizationId 必须是正整数' }; + } + result.organizationId = id; + } + + return { ok: true, value: result }; + } + + async execute(input: CreateStudentInput, _context: AgentToolContext): Promise { + const organizationId = input.organizationId ?? (await this.resolveDefaultOrganizationId()); + const created = await this.studentsService.create({ + name: input.name, + phone: input.phone, + gender: input.gender, + studentNo: input.studentNo, + idNumber: input.idNumber, + organizationId, + }); + return { + id: created.id, + name: created.name, + studentNo: created.studentNo ?? null, + message: '学生已创建', + }; + } + + private async resolveDefaultOrganizationId(): Promise { + const repo = this.dataSource.getRepository(Organization); + const host = await repo.findOne({ + where: { isHost: true, status: 'active' }, + order: { id: 'ASC' }, + }); + const organization = + host ?? + (await repo.findOne({ + where: { status: 'active' }, + order: { id: 'ASC' }, + })); + if (!organization) throw new Error('未找到可用校区,无法创建学生'); + return organization.id; + } +} diff --git a/apps/server/src/agent-tools/tools/get-sync-status.tool.ts b/apps/server/src/agent-tools/tools/get-sync-status.tool.ts new file mode 100644 index 0000000..956324b --- /dev/null +++ b/apps/server/src/agent-tools/tools/get-sync-status.tool.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { SyncService } from '../../sync/sync.service'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; +import { rejectUnknownKeys } from './tool-input'; + +@Injectable() +export class GetSyncStatusTool implements ToolDef> { + readonly name = 'get_sync_status'; + readonly skillKey = 'sync'; + readonly description = '查询钉钉学生/考勤、企业微信等平台最近一次同步状态,以及排课映射进度。'; + readonly requiredPermission = 'sync:read'; + readonly inputSchema = { type: 'object', properties: {}, additionalProperties: false }; + constructor(private readonly service: SyncService) {} + + validate(raw: Record): ToolInputResult> { + const invalid = rejectUnknownKeys(raw, []); + return invalid ?? { ok: true, value: {} }; + } + + execute(_input: Record, _context: AgentToolContext) { + return this.service.agentGetSyncStatus(); + } +} diff --git a/apps/server/src/agent-tools/tools/search-classroom-rentals.tool.ts b/apps/server/src/agent-tools/tools/search-classroom-rentals.tool.ts new file mode 100644 index 0000000..cafbb50 --- /dev/null +++ b/apps/server/src/agent-tools/tools/search-classroom-rentals.tool.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { ClassroomRentalsService } from '../../classroom-rentals/classroom-rentals.service'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; +import { optionalPositiveInt, rejectUnknownKeys } from './tool-input'; + +interface Input { classroomId?: number; month?: string; includeEnded?: boolean; limit?: number } + +function optionalMonth(value: unknown): ToolInputResult { + if (value === undefined) return { ok: true, value: undefined }; + if (typeof value !== 'string' || !/^\d{4}-\d{2}$/.test(value)) { + return { ok: false, error: 'month 必须是 YYYY-MM 格式' }; + } + const [year, month] = value.split('-').map(Number); + if (month < 1 || month > 12 || year < 2000 || year > 2100) { + return { ok: false, error: 'month 不是有效月份' }; + } + return { ok: true, value }; +} + +@Injectable() +export class SearchClassroomRentalsTool implements ToolDef { + readonly name = 'search_classroom_rentals'; + readonly skillKey = 'classroom'; + readonly description = '查询教室租赁订单(教室、承租方机构、起止日期、租金、状态)。'; + readonly requiredPermission = 'rental:view'; + readonly inputSchema = { + type: 'object', + properties: { + classroomId: { type: 'integer', minimum: 1 }, + month: { type: 'string', description: 'YYYY-MM' }, + includeEnded: { type: 'boolean', description: '是否包含已结束订单' }, + limit: { type: 'integer', minimum: 1, maximum: 50 }, + }, + additionalProperties: false, + }; + constructor(private readonly service: ClassroomRentalsService) {} + + validate(raw: Record): ToolInputResult { + const invalid = rejectUnknownKeys(raw, ['classroomId', 'month', 'includeEnded', 'limit']); + if (invalid) return invalid; + const classroomId = optionalPositiveInt(raw.classroomId, 'classroomId'); + if (!classroomId.ok) return classroomId; + const month = optionalMonth(raw.month); if (!month.ok) return month; + if (raw.includeEnded !== undefined && typeof raw.includeEnded !== 'boolean') { + return { ok: false, error: 'includeEnded 必须是布尔值' }; + } + const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit; + return { + ok: true, + value: { + classroomId: classroomId.value, + month: month.value, + includeEnded: raw.includeEnded === undefined ? undefined : Boolean(raw.includeEnded), + limit: limit.value, + }, + }; + } + + execute(input: Input, _context: AgentToolContext) { + return this.service.agentSearchRentals(input); + } +} diff --git a/apps/server/src/agent-tools/tools/search-classrooms.tool.ts b/apps/server/src/agent-tools/tools/search-classrooms.tool.ts new file mode 100644 index 0000000..b2e800d --- /dev/null +++ b/apps/server/src/agent-tools/tools/search-classrooms.tool.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ClassroomsService } from '../../classrooms/classrooms.service'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; +import { optionalPositiveInt, optionalString, rejectUnknownKeys } from './tool-input'; + +interface Input { keyword?: string; building?: string; limit?: number } + +@Injectable() +export class SearchClassroomsTool implements ToolDef { + readonly name = 'search_classrooms'; + readonly skillKey = 'classroom'; + readonly description = '查询教室(名称、楼栋、容量、房型、当前占用状态),不返回排课明细。'; + readonly requiredPermission = 'classroom:view'; + readonly inputSchema = { + type: 'object', + properties: { + keyword: { type: 'string', maxLength: 100, description: '教室名称关键词' }, + building: { type: 'string', maxLength: 50, description: '楼栋' }, + limit: { type: 'integer', minimum: 1, maximum: 50 }, + }, + additionalProperties: false, + }; + constructor(private readonly service: ClassroomsService) {} + + validate(raw: Record): ToolInputResult { + const invalid = rejectUnknownKeys(raw, ['keyword', 'building', 'limit']); + if (invalid) return invalid; + const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword; + const building = optionalString(raw.building, 'building', 50); if (!building.ok) return building; + const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit; + return { ok: true, value: { keyword: keyword.value, building: building.value, limit: limit.value } }; + } + + execute(input: Input, _context: AgentToolContext) { + return this.service.agentSearchClassrooms(input); + } +} diff --git a/apps/server/src/agent-tools/tools/search-deposits.tool.ts b/apps/server/src/agent-tools/tools/search-deposits.tool.ts new file mode 100644 index 0000000..84db9e7 --- /dev/null +++ b/apps/server/src/agent-tools/tools/search-deposits.tool.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { DepositsService } from '../../deposits/deposits.service'; +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 SearchDepositsTool implements ToolDef { + readonly name = 'search_deposits'; + readonly skillKey = 'billing'; + readonly description = '查询押金记录(学生姓名/学号、金额、状态、退款信息)。'; + readonly requiredPermission = 'deposit:view'; + readonly inputSchema = { + type: 'object', + properties: { + keyword: { type: 'string', maxLength: 100, description: '学生姓名或学号' }, + status: { type: 'string', maxLength: 20, description: '押金状态' }, + limit: { type: 'integer', minimum: 1, maximum: 50 }, + }, + additionalProperties: false, + }; + constructor(private readonly service: DepositsService) {} + + validate(raw: Record): ToolInputResult { + 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.agentSearchDeposits(input); + } +} diff --git a/apps/server/src/agent-tools/tools/search-exams.tool.ts b/apps/server/src/agent-tools/tools/search-exams.tool.ts new file mode 100644 index 0000000..f14b1f7 --- /dev/null +++ b/apps/server/src/agent-tools/tools/search-exams.tool.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { ExamsService } from '../../exams/exams.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; examType?: string; classId?: number; limit?: number } + +@Injectable() +export class SearchExamsTool implements ToolDef { + readonly name = 'search_exams'; + readonly skillKey = 'student'; + readonly description = '查询当前用户有权查看的考试及成绩录入进度(考试名称、类型、日期、班级、应录/已录人数)。'; + readonly requiredPermission = 'exam:view'; + readonly inputSchema = { + type: 'object', + properties: { + keyword: { type: 'string', maxLength: 100, description: '考试名称关键词' }, + examType: { type: 'string', maxLength: 50, description: '考试类型' }, + classId: { type: 'integer', minimum: 1, description: '班级ID' }, + limit: { type: 'integer', minimum: 1, maximum: 50 }, + }, + additionalProperties: false, + }; + constructor( + private readonly service: ExamsService, + private readonly scopes: AgentBusinessScopeFactory, + ) {} + + validate(raw: Record): ToolInputResult { + const invalid = rejectUnknownKeys(raw, ['keyword', 'examType', 'classId', 'limit']); + if (invalid) return invalid; + const keyword = optionalString(raw.keyword, 'keyword', 100); if (!keyword.ok) return keyword; + const examType = optionalString(raw.examType, 'examType', 50); if (!examType.ok) return examType; + const classId = optionalPositiveInt(raw.classId, 'classId'); if (!classId.ok) return classId; + const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit; + return { + ok: true, + value: { keyword: keyword.value, examType: examType.value, classId: classId.value, limit: limit.value }, + }; + } + + execute(input: Input, context: AgentToolContext) { + return this.service.agentSearchExams( + context.userId, + this.scopes.canManageAllClasses(context), + input, + ); + } +} diff --git a/apps/server/src/agent-tools/tools/search-expenses.tool.ts b/apps/server/src/agent-tools/tools/search-expenses.tool.ts new file mode 100644 index 0000000..c03c6d2 --- /dev/null +++ b/apps/server/src/agent-tools/tools/search-expenses.tool.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { ExpensesService } from '../../expenses/expenses.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; limit?: number } + +@Injectable() +export class SearchExpensesTool implements ToolDef { + readonly name = 'search_expenses'; + readonly skillKey = 'billing'; + readonly description = '查询费用记录(宿舍水电费/杂费和个人附加费),支持按宿舍号、学生姓名/学号和账期筛选。'; + readonly requiredPermission = 'expense:view'; + readonly inputSchema = { + type: 'object', + properties: { + keyword: { type: 'string', maxLength: 100, description: '宿舍号或学生姓名/学号' }, + periodStart: { type: 'string', format: 'date' }, + periodEnd: { type: 'string', format: 'date' }, + limit: { type: 'integer', minimum: 1, maximum: 30 }, + }, + additionalProperties: false, + }; + constructor(private readonly service: ExpensesService) {} + + validate(raw: Record): ToolInputResult { + const invalid = rejectUnknownKeys(raw, ['keyword', 'periodStart', 'periodEnd', '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 limit = optionalPositiveInt(raw.limit, 'limit', 30); if (!limit.ok) return limit; + return { + ok: true, + value: { + keyword: keyword.value, + periodStart: periodStart.value, + periodEnd: periodEnd.value, + limit: limit.value, + }, + }; + } + + execute(input: Input, _context: AgentToolContext) { + return this.service.agentSearchExpenses(input); + } +} diff --git a/apps/server/src/agent-tools/tools/search-schedules.tool.ts b/apps/server/src/agent-tools/tools/search-schedules.tool.ts new file mode 100644 index 0000000..dd34082 --- /dev/null +++ b/apps/server/src/agent-tools/tools/search-schedules.tool.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { SchedulesService } from '../../schedules/schedules.service'; +import { AgentBusinessScopeFactory } from '../agent-business-scope.factory'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; +import { optionalPositiveInt, rejectUnknownKeys } from './tool-input'; + +interface Input { classId?: number; classroomId?: number; weekDay?: number; limit?: number } + +@Injectable() +export class SearchSchedulesTool implements ToolDef { + readonly name = 'search_schedules'; + readonly skillKey = 'student'; + readonly description = '查询当前用户有权查看的排课(班级、教室、星期、节次、教师、起止日期)。'; + readonly requiredPermission = 'schedule:view'; + readonly inputSchema = { + type: 'object', + properties: { + classId: { type: 'integer', minimum: 1, description: '班级ID' }, + classroomId: { type: 'integer', minimum: 1, description: '教室ID' }, + weekDay: { type: 'integer', minimum: 1, maximum: 7, description: '星期(1-7)' }, + limit: { type: 'integer', minimum: 1, maximum: 50 }, + }, + additionalProperties: false, + }; + constructor( + private readonly service: SchedulesService, + private readonly scopes: AgentBusinessScopeFactory, + ) {} + + validate(raw: Record): ToolInputResult { + const invalid = rejectUnknownKeys(raw, ['classId', 'classroomId', 'weekDay', 'limit']); + if (invalid) return invalid; + const classId = optionalPositiveInt(raw.classId, 'classId'); if (!classId.ok) return classId; + const classroomId = optionalPositiveInt(raw.classroomId, 'classroomId'); + if (!classroomId.ok) return classroomId; + const weekDay = optionalPositiveInt(raw.weekDay, 'weekDay'); + if (!weekDay.ok) return weekDay; + if (weekDay.value !== undefined && weekDay.value > 7) { + return { ok: false, error: 'weekDay 必须在 1-7 之间' }; + } + const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit; + return { + ok: true, + value: { + classId: classId.value, + classroomId: classroomId.value, + weekDay: weekDay.value, + limit: limit.value, + }, + }; + } + + execute(input: Input, context: AgentToolContext) { + return this.service.agentSearchSchedules( + context.userId, + this.scopes.canManageAllClasses(context), + input, + ); + } +} diff --git a/apps/server/src/agent-tools/tools/update-students.tool.spec.ts b/apps/server/src/agent-tools/tools/update-students.tool.spec.ts new file mode 100644 index 0000000..c03e9fb --- /dev/null +++ b/apps/server/src/agent-tools/tools/update-students.tool.spec.ts @@ -0,0 +1,124 @@ +import { NotFoundException } from '@nestjs/common'; +import { UpdateStudentsTool } from './update-students.tool'; + +function createTool(overrides: Record = {}) { + const studentsService = { + update: jest.fn(async (id: number, dto: Record) => ({ + id, + name: dto.name ?? '学生', + })), + ...(overrides.studentsService ?? {}), + }; + const tool = new UpdateStudentsTool(studentsService as never); + return { tool, studentsService }; +} + +const validInput = { + updates: [ + { id: 201, name: '於嘉丽' }, + { id: 172, name: '徐玚' }, + ], +}; + +describe('UpdateStudentsTool', () => { + it('exposes student:edit permission and student skill', () => { + const { tool } = createTool(); + expect(tool.name).toBe('update_students'); + expect(tool.skillKey).toBe('student'); + expect(tool.requiredPermission).toBe('student:edit'); + }); + + it('rejects forbidden, unknown, and empty input', () => { + const { tool } = createTool(); + expect(tool.validate({ userId: 1 }).ok).toBe(false); + expect(tool.validate({ updates: [], admin: true }).ok).toBe(false); + expect(tool.validate({}).ok).toBe(false); + expect(tool.validate({ updates: [] }).ok).toBe(false); + expect(tool.validate({ updates: [{ id: 201 }] }).ok).toBe(false); + }); + + it('rejects invalid ids, duplicate ids, invalid fields, and oversized batches', () => { + const { tool } = createTool(); + expect(tool.validate({ updates: [{ id: 0, name: 'A' }] }).ok).toBe(false); + expect(tool.validate({ updates: [{ id: 'x', name: 'A' }] }).ok).toBe(false); + expect( + tool.validate({ + updates: [ + { id: 201, name: 'A' }, + { id: 201, name: 'B' }, + ], + }).ok, + ).toBe(false); + expect(tool.validate({ updates: [{ id: 201, status: 'archived' }] }).ok).toBe(false); + expect(tool.validate({ updates: [{ id: 201, name: 'A'.repeat(51) }] }).ok).toBe(false); + expect( + tool.validate({ + updates: Array.from({ length: 13 }, (_, index) => ({ id: index + 1, name: 'A' })), + }).ok, + ).toBe(false); + }); + + it('accepts all supported editable fields', () => { + const { tool } = createTool(); + const result = tool.validate({ + updates: [ + { + id: 201, + name: '於嘉丽', + studentNo: 'S201', + phone: '13800138000', + idNumber: 'ID201', + gender: '女', + ethnicity: '汉族', + emergencyContact: '家长', + emergencyPhone: '13900139000', + organizationId: 2, + supervisor: '王老师', + status: 'active', + }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.updates[0]).toMatchObject({ + id: 201, + name: '於嘉丽', + organizationId: 2, + status: 'active', + }); + } + }); + + it('updates every student and reports a safe summary', async () => { + const { tool, studentsService } = createTool(); + const result = await tool.execute(validInput, {} as never); + expect(studentsService.update).toHaveBeenCalledTimes(2); + expect(studentsService.update).toHaveBeenCalledWith(201, { name: '於嘉丽' }); + expect(result).toEqual({ + message: '成功更新 2 名学生,失败 0 条', + updated: [ + { id: 201, name: '於嘉丽' }, + { id: 172, name: '徐玚' }, + ], + failed: [], + }); + expect(JSON.stringify(result)).not.toContain('13800138000'); + }); + + it('continues when one student cannot be updated', async () => { + const { tool } = createTool({ + studentsService: { + update: jest + .fn() + .mockRejectedValueOnce(new NotFoundException('not found')) + .mockResolvedValueOnce({ id: 172, name: '徐玚' }), + }, + }); + const result = await tool.execute(validInput, {} as never); + expect(result).toEqual({ + message: '成功更新 1 名学生,失败 1 条', + updated: [{ id: 172, name: '徐玚' }], + failed: [{ id: 201, error: '学生不存在' }], + }); + }); +}); diff --git a/apps/server/src/agent-tools/tools/update-students.tool.ts b/apps/server/src/agent-tools/tools/update-students.tool.ts new file mode 100644 index 0000000..8e2a5af --- /dev/null +++ b/apps/server/src/agent-tools/tools/update-students.tool.ts @@ -0,0 +1,245 @@ +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, + }; + } +} diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index df8f831..93add92 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -77,6 +77,71 @@ export class ClassroomRentalsService { return rentals.map((rental) => this.withEffectiveStatus(rental)); } + /** + * Agent tool: 教室租赁订单查询,返回白名单字段。 + */ + async agentSearchRentals(query?: { + classroomId?: number; + month?: string; + includeEnded?: boolean; + limit?: number; + }): Promise< + { + id: number; + classroomName: string; + lesseeOrganizationName: string | null; + startDate: string; + endDate: string; + dailyRate: number | null; + totalAmount: number | null; + status: string; + contractName: string | null; + }[] + > { + const qb = this.repo + .createQueryBuilder('r') + .leftJoin('r.classroom', 'classroom') + .leftJoin('r.lesseeOrganization', 'lesseeOrganization') + .select('r.id', 'id') + .addSelect('classroom.name', 'classroomName') + .addSelect('lesseeOrganization.name', 'lesseeOrganizationName') + .addSelect('r.startDate', 'startDate') + .addSelect('r.endDate', 'endDate') + .addSelect('r.dailyRate', 'dailyRate') + .addSelect('r.totalAmount', 'totalAmount') + .addSelect('r.status', 'status') + .addSelect('r.contractOriginalName', 'contractName'); + if (query?.classroomId) { + qb.andWhere('r.classroomId = :classroomId', { classroomId: query.classroomId }); + } + if (query?.month) { + const [y, m] = query.month.split('-').map(Number); + const first = `${y}-${String(m).padStart(2, '0')}-01`; + const lastDay = new Date(y, m, 0).getDate(); + const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }); + } + if (!query?.includeEnded) { + qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }); + } + const rows = await qb + .orderBy('r.startDate', 'DESC') + .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) + .getRawMany>(); + return rows.map((row) => ({ + id: Number(row.id), + classroomName: row.classroomName == null ? '' : String(row.classroomName), + lesseeOrganizationName: + row.lesseeOrganizationName == null ? null : String(row.lesseeOrganizationName), + startDate: String(row.startDate), + endDate: String(row.endDate), + dailyRate: row.dailyRate == null ? null : Number(row.dailyRate), + totalAmount: row.totalAmount == null ? null : Number(row.totalAmount), + status: String(row.status), + contractName: row.contractName == null ? null : String(row.contractName), + })); + } + async findOne(id: number) { const rental = await this.repo.findOne({ where: { id }, diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index 61ca5f8..e01ba17 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Not, MoreThanOrEqual } from 'typeorm'; +import { Repository, Not, MoreThanOrEqual, Like } from 'typeorm'; import { Classroom, ClassroomStatus } from '../entities/classroom.entity'; import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; @@ -24,6 +24,57 @@ export class ClassroomsService { return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id))); } + /** + * Agent tool: 教室查询,返回白名单字段和当前占用状态。 + */ + async agentSearchClassrooms(query?: { + keyword?: string; + building?: string; + limit?: number; + }): Promise< + { + id: number; + name: string; + building: string; + floor: number | null; + capacity: number; + roomType: string; + effectiveStatus: string; + currentUsage: { + type: 'schedule' | 'rental'; + title: string; + startTime: string; + endTime: string; + } | null; + }[] + > { + const where: Record = { status: Not('archived') }; + if (query?.building) where.building = query.building; + if (query?.keyword) where.name = Like(`%${query.keyword}%`); + const list = await this.repo.find({ + where, + order: { building: 'ASC', name: 'ASC' }, + take: Math.max(1, Math.min(query?.limit ?? 20, 50)), + }); + const usageMap = await this.getUsageForClassrooms(list.map((classroom) => classroom.id)); + return list.map((classroom) => { + const usage = usageMap.get(classroom.id); + return { + id: classroom.id, + name: classroom.name, + building: classroom.building ?? '', + floor: classroom.floor ?? null, + capacity: classroom.capacity, + roomType: classroom.roomType, + effectiveStatus: + classroom.status === 'archived' || classroom.status === 'maintenance' + ? classroom.status + : (usage?.state ?? 'available'), + currentUsage: usage?.currentUsage ?? null, + }; + }); + } + async findOne(id: number) { const cls = await this.repo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('教室不存在'); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 2636ee4..daeef11 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -145,6 +145,65 @@ export class DepositsService { return deposits; } + /** + * Agent tool: 押金查询,返回白名单字段(学生姓名/学号、金额、状态、退款)。 + */ + async agentSearchDeposits(query?: { + keyword?: string; + status?: string; + limit?: number; + }): Promise< + { + id: number; + studentName: string; + studentNo: string; + amount: number; + status: string; + paidDate: string; + refundAmount: number | null; + refundDate: string | null; + }[] + > { + const qb = this.repo + .createQueryBuilder('d') + .leftJoin('d.student', 'student') + .select('d.id', 'id') + .addSelect('student.name', 'studentName') + .addSelect('student.studentNo', 'studentNo') + .addSelect('d.amount', 'amount') + .addSelect('d.status', 'status') + .addSelect('d.paidDate', 'paidDate') + .addSelect('d.refundAmount', 'refundAmount') + .addSelect('d.refundDate', 'refundDate') + .where('d.status != :archived', { archived: 'archived' }); + if (query?.keyword) { + qb.andWhere( + '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', + { keyword: `%${query.keyword}%` }, + ); + } + if (query?.status && query.status !== 'archived') { + qb.andWhere('d.status = :status', { status: query.status }); + } + const rows = await qb + .orderBy('d.createdAt', 'DESC') + .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) + .getRawMany>(); + return rows.map((row) => ({ + id: Number(row.id), + studentName: row.studentName == null ? '' : String(row.studentName), + studentNo: row.studentNo == null ? '' : String(row.studentNo), + amount: money(row.amount as number | string | null | undefined), + status: String(row.status), + paidDate: row.paidDate == null ? '' : String(row.paidDate), + refundAmount: + row.refundAmount == null + ? null + : money(row.refundAmount as number | string | null | undefined), + refundDate: row.refundDate == null ? null : String(row.refundDate), + })); + } + async findOne(id: number) { const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] }); if (!deposit) throw new NotFoundException('押金记录不存在'); diff --git a/apps/server/src/exams/exams.module.ts b/apps/server/src/exams/exams.module.ts index b48ba04..0b84373 100644 --- a/apps/server/src/exams/exams.module.ts +++ b/apps/server/src/exams/exams.module.ts @@ -12,5 +12,6 @@ import { ExamsService } from './exams.service'; ], controllers: [ExamsController], providers: [ExamsService], + exports: [ExamsService], }) export class ExamsModule {} diff --git a/apps/server/src/exams/exams.service.ts b/apps/server/src/exams/exams.service.ts index 6f21b44..ff3a9ee 100644 --- a/apps/server/src/exams/exams.service.ts +++ b/apps/server/src/exams/exams.service.ts @@ -93,6 +93,51 @@ export class ExamsService { }; } + /** + * Agent tool: 查询当前用户有权查看的考试,返回白名单字段。 + * 教师范围按班级授课关系过滤,避免越权读取其他班级成绩。 + */ + async agentSearchExams( + userId: number, + canManageAll: boolean, + query?: { keyword?: string; examType?: string; classId?: number; limit?: number }, + ): Promise< + { + id: number; + examName: string; + examType: string; + examDate: string; + classId: number; + className: string | null; + totalStudents: number; + enteredScores: number; + status: string; + }[] + > { + const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); + const exams = await this.findAll( + { + keyword: query?.keyword, + examType: query?.examType, + classId: query?.classId, + isArchived: false, + }, + accessibleClassIds, + ); + const limit = Math.max(1, Math.min(query?.limit ?? 20, 50)); + return exams.slice(0, limit).map((exam) => ({ + id: exam.id, + examName: exam.examName, + examType: exam.examType, + examDate: exam.examDate, + classId: exam.classId, + className: exam.className ?? null, + totalStudents: exam.totalStudents, + enteredScores: exam.enteredScores, + status: exam.status, + })); + } + async create(dto: CreateExamDto, userId: number, canManageAll: boolean) { await this.assertClassAccess(userId, dto.classId, canManageAll); return this.dataSource.transaction(async (manager) => { diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 58bb4bf..f837a19 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -87,6 +87,109 @@ export class ExpensesService { return qb.getMany(); } + /** + * Agent tool: 费用查询(宿舍费 + 个人附加费),返回白名单字段。 + */ + async agentSearchExpenses(query?: { + keyword?: string; + periodStart?: string; + periodEnd?: string; + limit?: number; + }): Promise<{ + roomExpenses: { + id: number; + expenseType: string; + amount: number; + periodStart: string; + periodEnd: string; + roomNumber: string; + status: string; + }[]; + personalExpenses: { + id: number; + expenseType: string; + amount: number; + expenseDate: string; + studentName: string; + studentNo: string; + status: string; + }[]; + }> { + const limit = Math.max(1, Math.min(query?.limit ?? 10, 30)); + + const roomQb = this.roomExpRepo + .createQueryBuilder('e') + .leftJoin('e.room', 'room') + .select('e.id', 'id') + .addSelect('e.expenseType', 'expenseType') + .addSelect('e.amount', 'amount') + .addSelect('e.periodStart', 'periodStart') + .addSelect('e.periodEnd', 'periodEnd') + .addSelect('room.roomNumber', 'roomNumber') + .where('e.status = :status', { status: 'active' }); + if (query?.keyword) { + roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); + } + if (query?.periodStart) { + roomQb.andWhere('e.periodStart >= :periodStart', { periodStart: query.periodStart }); + } + if (query?.periodEnd) { + roomQb.andWhere('e.periodEnd <= :periodEnd', { periodEnd: query.periodEnd }); + } + const roomRows = await roomQb + .orderBy('e.createdAt', 'DESC') + .limit(limit) + .getRawMany>(); + + const personalQb = this.personalExpRepo + .createQueryBuilder('e') + .leftJoin('e.student', 'student') + .select('e.id', 'id') + .addSelect('e.expenseType', 'expenseType') + .addSelect('e.amount', 'amount') + .addSelect('e.expenseDate', 'expenseDate') + .addSelect('student.name', 'studentName') + .addSelect('student.studentNo', 'studentNo') + .where('e.status = :status', { status: 'active' }); + if (query?.keyword) { + personalQb.andWhere( + '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', + { keyword: `%${query.keyword}%` }, + ); + } + if (query?.periodStart) { + personalQb.andWhere('e.expenseDate >= :periodStart', { periodStart: query.periodStart }); + } + if (query?.periodEnd) { + personalQb.andWhere('e.expenseDate <= :periodEnd', { periodEnd: query.periodEnd }); + } + const personalRows = await personalQb + .orderBy('e.createdAt', 'DESC') + .limit(limit) + .getRawMany>(); + + return { + roomExpenses: roomRows.map((row) => ({ + id: Number(row.id), + expenseType: String(row.expenseType), + amount: Number(row.amount), + periodStart: String(row.periodStart), + periodEnd: String(row.periodEnd), + roomNumber: row.roomNumber == null ? '' : String(row.roomNumber), + status: String(row.status), + })), + personalExpenses: personalRows.map((row) => ({ + id: Number(row.id), + expenseType: String(row.expenseType), + amount: Number(row.amount), + expenseDate: String(row.expenseDate), + studentName: row.studentName == null ? '' : String(row.studentName), + studentNo: row.studentNo == null ? '' : String(row.studentNo), + status: String(row.status), + })), + }; + } + async deleteRoomExpense(id: number) { const e = await this.roomExpRepo.findOne({ where: { id } }); if (!e) throw new NotFoundException('费用记录不存在'); diff --git a/apps/server/src/schedules/schedules.scope.spec.ts b/apps/server/src/schedules/schedules.scope.spec.ts index 31d9e6e..1522cac 100644 --- a/apps/server/src/schedules/schedules.scope.spec.ts +++ b/apps/server/src/schedules/schedules.scope.spec.ts @@ -5,8 +5,32 @@ const createQb = () => ({ orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), }); +function serviceWithAssignments(assignments: number[]) { + const qb = createQb(); + const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; + const classTeacherRepo = { + find: jest + .fn() + .mockResolvedValue(assignments.map((classId) => ({ classId, userId: 7 }))), + }; + const service = new SchedulesService( + scheduleRepo as never, + {} as never, + {} as never, + {} as never, + classTeacherRepo as never, + {} as never, + ); + return { service, qb, scheduleRepo }; +} + describe('SchedulesService — teacher class scope', () => { it('filters schedule list to assigned classes when no class filter is selected', async () => { const qb = createQb(); @@ -38,6 +62,39 @@ describe('SchedulesService — teacher class scope', () => { await expect(service.findAll({}, [])).resolves.toEqual([]); expect(qb.getMany).not.toHaveBeenCalled(); }); + + it('agent search rejects classId outside the teacher scope', async () => { + const { service, scheduleRepo } = serviceWithAssignments([3, 5]); + await expect( + service.agentSearchSchedules(7, false, { classId: 9 }), + ).resolves.toEqual([]); + expect(scheduleRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('agent search always intersects teacher scope even with classroomId', async () => { + const { service, qb } = serviceWithAssignments([3, 5]); + await service.agentSearchSchedules(7, false, { classroomId: 10 }); + expect(qb.andWhere).toHaveBeenCalledWith('cs.classId IN (:...accessibleClassIds)', { + accessibleClassIds: [3, 5], + }); + }); + + it('agent search keeps scope when requested classId is accessible', async () => { + const { service, qb } = serviceWithAssignments([3, 5]); + await service.agentSearchSchedules(7, false, { classId: 3 }); + expect(qb.andWhere).toHaveBeenCalledWith('cs.classId = :classId', { classId: 3 }); + expect(qb.andWhere).toHaveBeenCalledWith('cs.classId IN (:...accessibleClassIds)', { + accessibleClassIds: [3, 5], + }); + }); + + it('agent search returns empty when teacher has no assigned classes', async () => { + const { service, scheduleRepo } = serviceWithAssignments([]); + await expect( + service.agentSearchSchedules(7, false, { classroomId: 10 }), + ).resolves.toEqual([]); + expect(scheduleRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); }); describe('SchedulesService — shared classroom occupancy visibility', () => { diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index d89a8ff..cb3d14f 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -129,6 +129,99 @@ export class SchedulesService { return qb.getMany(); } + /** + * Agent tool: 查询当前用户有权查看的排课,返回白名单字段。 + * 教师范围按班级授课关系过滤。 + */ + async agentSearchSchedules( + userId: number, + canManageAll: boolean, + query?: { classId?: number; classroomId?: number; weekDay?: number; limit?: number }, + ): Promise< + { + id: number; + classId: number | null; + className: string | null; + classroomId: number; + classroomName: string | null; + weekDay: number; + startTime: string; + endTime: string; + subject: string; + teacherName: string | null; + startDate: string; + endDate: string; + scheduleType: string; + status: string; + }[] + > { + const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); + if (query?.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) { + return []; + } + if (accessibleClassIds && accessibleClassIds.length === 0) { + return []; + } + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .leftJoin('cs.class', 'class') + .leftJoin('cs.classroom', 'classroom') + .leftJoin('cs.teacher', 'teacher') + .select([ + 'cs.id', + 'cs.classId', + 'cs.classroomId', + 'cs.weekDay', + 'cs.startTime', + 'cs.endTime', + 'cs.subject', + 'cs.teacherId', + 'cs.startDate', + 'cs.endDate', + 'cs.scheduleType', + 'cs.status', + 'class.name', + 'classroom.name', + 'teacher.name', + ]) + .where('cs.status = :active', { active: 'active' }); + + if (query?.classroomId) { + qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); + } + if (query?.classId) { + qb.andWhere('cs.classId = :classId', { classId: query.classId }); + } + if (accessibleClassIds) { + qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); + } + if (query?.weekDay) { + qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay }); + } + + const rows = await qb + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) + .getRawMany>(); + return rows.map((row) => ({ + id: Number(row.cs_id), + classId: row.cs_class_id == null ? null : Number(row.cs_class_id), + className: row.class_name == null ? null : String(row.class_name), + classroomId: Number(row.cs_classroom_id), + classroomName: row.classroom_name == null ? null : String(row.classroom_name), + weekDay: Number(row.cs_week_day), + startTime: String(row.cs_start_time), + endTime: String(row.cs_end_time), + subject: String(row.cs_subject), + teacherName: row.teacher_name == null ? null : String(row.teacher_name), + startDate: String(row.cs_start_date), + endDate: String(row.cs_end_date), + scheduleType: String(row.cs_schedule_type), + status: String(row.cs_status), + })); + } + async getClassTeachers(classId: number) { const teachers = await this.classTeacherRepo.find({ where: { classId }, diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md new file mode 100644 index 0000000..db3fa39 --- /dev/null +++ b/docs/agent-workflow.md @@ -0,0 +1,85 @@ +# Agent 业务工作流梳理 + +> 目的:梳理恭学系统当前 Agent 的能力边界与业务工作流,定位“只会机械插入 Excel、缺乏先后顺序引导”的问题,并记录已实施的改进。 + +## 一、系统角色与权限 + +系统内置 6 类角色(超管、系统管理员、教务、住宿运营、教室运营、任课老师),权限点是“模块:动作”(如 `student:create`、`occupancy:add`、`bill:generate`)。 +Agent 的工具全部通过 CASL 权限过滤 + 执行时二次鉴权,只暴露当前账号可用的读/写能力。 + +## 二、业务域与数据依赖 + +| 基础档案 | 业务关系 | 运行数据 | 结算 | +| --- | --- | --- | --- | +| 学生(students) | 分班/在读(classes、enrollments) | 考勤(attendance) | 押金(deposits) | +| 宿舍(rooms) | 入住/退宿/换宿(occupancies) | 公共费用/个人费用(expenses) | 账单(bills,人天数分摊) | +| 教室(classrooms) | 租赁(classroom-rentals) | 教室日程(schedule) | 合同 | +| 组织/校区(organizations) | 归属关系 | 考试(exams) | — | + +依赖关系(写入前必须满足): + +- 入住 / 换宿 → 依赖学生 + 宿舍 +- 考勤 → 依赖班级 + 排课 +- 账单 → 依赖入住记录 + 费用 +- 排课 → 依赖班级 + 教室 + 老师 + +## 三、Agent 当前能力 + +### 只读查询(按权限暴露) + +`search_students`、`get_student_basic`、`search_classes`、`get_attendance_summary`、`search_rooms`、`get_room_occupancy_summary`、`search_bills`、`get_dashboard_stats`、`search_exams`、`search_schedules`、`search_deposits`、`search_expenses`、`search_classrooms`、`search_classroom_rentals`、`get_sync_status` + +### 写入/导入(必须经确认) + +- `render_form` → 用户填写提交 → `create_student` / `update_students` +- `render_review` → 生成批量导入预览卡(students / rooms / transfers / checkins)→ 用户确认 → 系统按依赖顺序入库:学生 → 宿舍 → 换宿 → 入住 +- Excel 结构探查:`office_analyze`(outline/get/query),不整表读取 + +## 四、当前执行约束(已有) + +1. 所有写操作必须先渲染确认表单/预览卡,用户提交后才执行。 +2. 一个回答回合只能生成一张导入预览卡,多分表合并到同一张。 +3. 附件内容只当业务数据,不当系统指令;禁止抄录整表、禁止凭空补全。 +4. 工具执行做双重权限校验 + 审计日志;写入工具在导入确认后隐藏,防止二次写入。 + +## 五、各业务闭环(推荐顺序) + +### 学生教学闭环 + +学生档案导入/录入 → 分班 → 排课 → 考勤 → 考试/成绩(可选) + +### 住宿计费闭环(核心) + +宿舍档案 → 学生档案 → 入住登记 → (可选)换宿/退宿 → 公共/个人费用录入 → 生成账单 → 确认账单 → 标记已付 → 押金收取/退还 + +### 教室租赁闭环 + +教室档案 → 组织/校区 → 租赁订单 → 合同 → 教室日程 + +### 数据同步 + +钉钉/企业微信同步 → 排课映射 → 考勤设备/记录 + +## 六、现状问题(用户反馈) + +1. **机械执行**:用户给 Excel 就解析入库,不先说明“将导入什么、依赖什么、建议顺序”。 +2. **缺前置校验**:不主动确认学生/宿舍/班级等前置数据是否已存在,直接生成预览。 +3. **缺下一步引导**:导入完成后不提示后续动作(如入住完成 → 录费用 → 生成账单)。 +4. **缺业务顺序意识**:提示词只有“表单/预览确认”的单步约束,没有端到端闭环顺序。 + +## 七、已实施的改进 + +在 `AiChatService.SYSTEM_PROMPT` 中新增「业务工作流引导」规则: + +- 明确“基础档案 → 业务关系 → 运行数据 → 结算”的整体顺序; +- 写入/导入前先用查询工具核实前置数据,缺失时先说明缺口和下一步,不机械入库; +- 完成后主动给下一步建议(入住 → 费用 → 账单;学生 → 分班/排课); +- Excel 未说明用途时,先说明计划再生成预览; +- 只引导当前角色权限内的下一步。 + +## 八、后续建议 + +1. **工作流元数据化**:把闭环顺序与前置依赖做成可配置的 `workflow` 元数据(而不是只写在提示词里),Agent 可按数据状态动态提示。 +2. **完成度感知**:新增“业务待办”查询工具(如“本月未生成账单的入住学生数”“未分班学生数”),让引导有数据支撑。 +3. **前端示例补充**:在技能示例/欢迎语中加入工作流引导话术(如“我可以按‘先学生、再入住、后账单’帮你完成”)。 +4. **逐步确认**:大型导入建议分阶段确认(基础档案 → 关系数据),降低一次性确认风险。