feat: 扩展 Agent 业务查询与学生增改工具
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
166
apps/server/src/agent-tools/tools/create-student.tool.ts
Normal file
166
apps/server/src/agent-tools/tools/create-student.tool.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
apps/server/src/agent-tools/tools/get-sync-status.tool.ts
Normal file
23
apps/server/src/agent-tools/tools/get-sync-status.tool.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
37
apps/server/src/agent-tools/tools/search-classrooms.tool.ts
Normal file
37
apps/server/src/agent-tools/tools/search-classrooms.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
apps/server/src/agent-tools/tools/search-deposits.tool.ts
Normal file
37
apps/server/src/agent-tools/tools/search-deposits.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
50
apps/server/src/agent-tools/tools/search-exams.tool.ts
Normal file
50
apps/server/src/agent-tools/tools/search-exams.tool.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/server/src/agent-tools/tools/search-expenses.tool.ts
Normal file
52
apps/server/src/agent-tools/tools/search-expenses.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
apps/server/src/agent-tools/tools/search-schedules.tool.ts
Normal file
60
apps/server/src/agent-tools/tools/search-schedules.tool.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
124
apps/server/src/agent-tools/tools/update-students.tool.spec.ts
Normal file
124
apps/server/src/agent-tools/tools/update-students.tool.spec.ts
Normal 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: '学生不存在' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
245
apps/server/src/agent-tools/tools/update-students.tool.ts
Normal file
245
apps/server/src/agent-tools/tools/update-students.tool.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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('教室不存在');
|
||||
|
||||
@@ -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('押金记录不存在');
|
||||
|
||||
@@ -12,5 +12,6 @@ import { ExamsService } from './exams.service';
|
||||
],
|
||||
controllers: [ExamsController],
|
||||
providers: [ExamsService],
|
||||
exports: [ExamsService],
|
||||
})
|
||||
export class ExamsModule {}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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('费用记录不存在');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user