feat: 扩展 Agent 业务查询与学生增改工具

This commit is contained in:
2026-08-04 14:41:59 +08:00
parent 216a20ffaf
commit 3a1ed717d8
23 changed files with 1630 additions and 2 deletions

View File

@@ -31,6 +31,18 @@ export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [
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));

View File

@@ -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);
}
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,71 @@
import { CreateStudentTool } from './create-student.tool';
function createTool(overrides: Record<string, unknown> = {}) {
const studentsService = {
create: jest.fn(async (dto: Record<string, unknown>) => ({ 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 }),
);
});
});

View File

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

View File

@@ -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<Record<string, never>> {
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<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.agentGetSyncStatus();
}
}

View File

@@ -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<string | undefined> {
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<Input> {
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<string, unknown>): ToolInputResult<Input> {
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);
}
}

View File

@@ -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<Input> {
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<string, unknown>): ToolInputResult<Input> {
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);
}
}

View File

@@ -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<Input> {
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<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.agentSearchDeposits(input);
}
}

View File

@@ -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<Input> {
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<string, unknown>): ToolInputResult<Input> {
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,
);
}
}

View File

@@ -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<Input> {
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<string, unknown>): ToolInputResult<Input> {
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);
}
}

View File

@@ -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<Input> {
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<string, unknown>): ToolInputResult<Input> {
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,
);
}
}

View File

@@ -0,0 +1,124 @@
import { NotFoundException } from '@nestjs/common';
import { UpdateStudentsTool } from './update-students.tool';
function createTool(overrides: Record<string, unknown> = {}) {
const studentsService = {
update: jest.fn(async (id: number, dto: Record<string, unknown>) => ({
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: '学生不存在' }],
});
});
});

View File

@@ -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<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,
};
}
}

View File

@@ -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<Record<string, unknown>>();
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 },

View File

@@ -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<string, unknown> = { 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('教室不存在');

View File

@@ -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<Record<string, unknown>>();
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('押金记录不存在');

View File

@@ -12,5 +12,6 @@ import { ExamsService } from './exams.service';
],
controllers: [ExamsController],
providers: [ExamsService],
exports: [ExamsService],
})
export class ExamsModule {}

View File

@@ -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) => {

View File

@@ -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<Record<string, unknown>>();
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<Record<string, unknown>>();
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('费用记录不存在');

View File

@@ -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', () => {

View File

@@ -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<Record<string, unknown>>();
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 },

85
docs/agent-workflow.md Normal file
View File

@@ -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. **逐步确认**:大型导入建议分阶段确认(基础档案 → 关系数据),降低一次性确认风险。