功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
368 lines
15 KiB
TypeScript
368 lines
15 KiB
TypeScript
import { dayKeyLocal, type AiActionKind, type AiAuditDto, type AiInterpretResult, type AiSettingsDto } from '@classhelper/shared';
|
||
import { z } from 'zod';
|
||
import { getAccessibleClassIds } from '../../lib/access.js';
|
||
import { prisma } from '../../lib/db.js';
|
||
import { ApiError } from '../../lib/http.js';
|
||
import type { TokenPayload } from '../../lib/jwt.js';
|
||
import { logger } from '../../lib/logger.js';
|
||
import { decryptSecret, encryptSecret } from '../../lib/secrets.js';
|
||
import { SETTING_KEYS, getSetting, getSettings, setSetting } from '../../lib/settings.js';
|
||
import { createHomeworkSchema } from '../homeworks/homeworks.schemas.js';
|
||
import { createNotificationSchema } from '../notifications/notifications.schemas.js';
|
||
import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
|
||
|
||
/**
|
||
* 大模型助手:**只解析、不落库**。
|
||
*
|
||
* 设计边界(照 approved 计划):
|
||
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
|
||
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
|
||
* 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用(AGENTS.md §5 第 5/14 条);
|
||
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
|
||
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
|
||
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
|
||
*/
|
||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||
|
||
/** 与手动入口同源的参数校验(助手不得放宽任何字段约束) */
|
||
const homeworkParamsSchema = createHomeworkSchema.pick({
|
||
classId: true,
|
||
courseId: true,
|
||
title: true,
|
||
content: true,
|
||
assignDate: true,
|
||
});
|
||
const notificationParamsSchema = createNotificationSchema.pick({
|
||
classId: true,
|
||
title: true,
|
||
content: true,
|
||
priority: true,
|
||
});
|
||
const callParamsSchema = z.object({
|
||
classId: z.string().min(1),
|
||
studentId: z.string().min(1).optional(),
|
||
studentName: z.string().trim().max(40).optional(),
|
||
message: z.string().trim().max(200).optional(),
|
||
});
|
||
|
||
const modelReplySchema = z.object({
|
||
action: z.enum(['create_homework', 'create_notification', 'call_student', 'unknown']),
|
||
params: z.record(z.string(), z.unknown()).default({}),
|
||
confidence: z.number().min(0).max(1).optional(),
|
||
reason: z.string().max(200).optional(),
|
||
});
|
||
|
||
export async function getAiSettings(): Promise<AiSettingsDto> {
|
||
const values = await getSettings([
|
||
SETTING_KEYS.aiEnabled,
|
||
SETTING_KEYS.aiProvider,
|
||
SETTING_KEYS.aiBaseUrl,
|
||
SETTING_KEYS.aiModel,
|
||
SETTING_KEYS.aiApiKey,
|
||
SETTING_KEYS.aiSystemPromptExtra,
|
||
]);
|
||
const apiKey = decryptSecret(values[SETTING_KEYS.aiApiKey] ?? '');
|
||
return {
|
||
enabled: values[SETTING_KEYS.aiEnabled] === 'true',
|
||
provider: values[SETTING_KEYS.aiProvider] === 'mock' ? 'mock' : 'openai',
|
||
baseUrl: values[SETTING_KEYS.aiBaseUrl] ?? '',
|
||
model: values[SETTING_KEYS.aiModel] ?? '',
|
||
hasApiKey: apiKey.length > 0,
|
||
keyHint: apiKey.length >= 4 ? apiKey.slice(-4) : '',
|
||
systemPromptExtra: values[SETTING_KEYS.aiSystemPromptExtra] ?? '',
|
||
};
|
||
}
|
||
|
||
export async function updateAiSettings(
|
||
user: TokenPayload,
|
||
input: UpdateAiSettingsInput,
|
||
): Promise<AiSettingsDto> {
|
||
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
|
||
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
|
||
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
|
||
if (input.model !== undefined) await setSetting(SETTING_KEYS.aiModel, input.model.trim());
|
||
if (input.systemPromptExtra !== undefined) {
|
||
await setSetting(SETTING_KEYS.aiSystemPromptExtra, input.systemPromptExtra.trim());
|
||
}
|
||
if (input.apiKey !== undefined) {
|
||
// 传 null = 清除;传字符串 = 换成新密钥(空串按清除处理)
|
||
await setSetting(SETTING_KEYS.aiApiKey, input.apiKey ? encryptSecret(input.apiKey.trim()) : '');
|
||
}
|
||
logger.info(`大模型设置已更新(操作人=${user.username},密钥${input.apiKey ? '已更新' : '未改'})`);
|
||
return getAiSettings();
|
||
}
|
||
|
||
interface AssistantContext {
|
||
classes: { id: string; name: string }[];
|
||
courses: { id: string; name: string }[];
|
||
}
|
||
|
||
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
|
||
const classIds = await getAccessibleClassIds(user);
|
||
const classes = await prisma.class.findMany({
|
||
where: { id: { in: classIds } },
|
||
select: { id: true, name: true },
|
||
orderBy: { name: 'asc' },
|
||
take: 80,
|
||
});
|
||
const courses = classId
|
||
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
|
||
: [];
|
||
return { classes, courses };
|
||
}
|
||
|
||
function buildSystemPrompt(context: AssistantContext, extra: string): string {
|
||
return [
|
||
'你是班级管理系统的指令解析器。把老师的一句中文转成严格 JSON,不要输出任何解释。',
|
||
'可选动作(action):create_homework(布置作业)、create_notification(发通知)、call_student(叫学生来)、unknown(看不懂)。',
|
||
'输出格式:{"action":"…","params":{…},"confidence":0~1}。',
|
||
'create_homework 的 params:{classId, courseId?, title, content, assignDate?(YYYY-MM-DD)}。',
|
||
'create_notification 的 params:{classId, title, content, priority?(LOW|NORMAL|HIGH|URGENT)}。',
|
||
'call_student 的 params:{classId, studentName, message?}。',
|
||
`今天日期:${dayKeyLocal(new Date())}。`,
|
||
`可用班级:${context.classes.map((item) => `${item.name}(${item.id})`).join('、') || '无'}。`,
|
||
context.courses.length
|
||
? `候选班级的科目:${context.courses.map((item) => `${item.name}(${item.id})`).join('、')}。`
|
||
: '未指定班级时 courseId 留空。',
|
||
'信息不足时把 confidence 压低并在 reason 里说明缺什么;不要编造不存在于上面的班级/科目 id。',
|
||
extra ? `学校额外要求:${extra}` : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
}
|
||
|
||
/** 离线确定性解析(provider=mock):只做关键词判断,供自动化与"没配模型时试跑"用 */
|
||
function mockInterpret(text: string, context: AssistantContext): z.infer<typeof modelReplySchema> {
|
||
// 优先用句子里**真正提到的**班级(「给2026级1班布置作业」);都没提到才退回按名称升序的第一个。
|
||
// (曾经只取第一个,结果本机取到了已归档的 2023级1班,与句子里说的班级无关。)
|
||
const mentioned = context.classes.find((item) => text.includes(item.name));
|
||
const classId = mentioned?.id ?? context.classes[0]?.id ?? '';
|
||
if (!classId) return { action: 'unknown', params: {}, confidence: 0.1, reason: '当前账号没有可发布的班级' };
|
||
const course = context.courses.find((item) => text.includes(item.name));
|
||
|
||
if (/作业|布置|卷子|练习|预习|背诵/.test(text)) {
|
||
return {
|
||
action: 'create_homework',
|
||
params: {
|
||
classId,
|
||
courseId: course?.id ?? null,
|
||
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
|
||
content: text,
|
||
assignDate: dayKeyLocal(new Date()),
|
||
},
|
||
confidence: 0.55,
|
||
};
|
||
}
|
||
if (/通知|告知|提醒|传达/.test(text)) {
|
||
return {
|
||
action: 'create_notification',
|
||
params: {
|
||
classId,
|
||
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
|
||
content: text,
|
||
priority: /紧急|立即|马上/.test(text) ? 'URGENT' : 'NORMAL',
|
||
},
|
||
confidence: 0.55,
|
||
};
|
||
}
|
||
if (/叫|找|来一趟|办公室/.test(text)) {
|
||
const matched = /\S{2,4}(?=同学|来|到)/.exec(text);
|
||
return {
|
||
action: 'call_student',
|
||
params: { classId, studentName: matched?.[0] ?? '', message: text },
|
||
confidence: 0.4,
|
||
reason: '离线示例解析只按关键词猜测学生姓名',
|
||
};
|
||
}
|
||
return { action: 'unknown', params: {}, confidence: 0.2, reason: '没听懂要做什么' };
|
||
}
|
||
|
||
async function callOpenAi(
|
||
input: AiInterpretInput,
|
||
extension: { baseUrl: string; model: string; apiKey: string; prompt: string },
|
||
): Promise<z.infer<typeof modelReplySchema>> {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(`${extension.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
authorization: `Bearer ${extension.apiKey}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: extension.model || 'gpt-4o-mini',
|
||
temperature: 0.2,
|
||
response_format: { type: 'json_object' },
|
||
messages: [
|
||
{ role: 'system', content: extension.prompt },
|
||
{ role: 'user', content: input.text },
|
||
],
|
||
}),
|
||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||
});
|
||
} catch (error) {
|
||
throw new ApiError(504, 'AI_TIMEOUT', `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw ApiError.badRequest(`大模型服务返回 HTTP ${response.status}(请检查服务地址、模型名与密钥)`);
|
||
}
|
||
|
||
const body = (await response.json()) as { choices?: { message?: { content?: string } }[] };
|
||
const content = body.choices?.[0]?.message?.content ?? '';
|
||
try {
|
||
return modelReplySchema.parse(JSON.parse(content));
|
||
} catch {
|
||
logger.warn(`大模型返回无法解析:${content.slice(0, 200)}`);
|
||
throw new ApiError(502, 'AI_INVALID_OUTPUT', '大模型返回的内容不是可用的指令,请换一种说法');
|
||
}
|
||
}
|
||
|
||
/** 把校验通过的参数解析成"给人看的预览"(班级/科目/学生名都换成中文) */
|
||
async function buildPreview(
|
||
action: AiActionKind,
|
||
params: Record<string, unknown>,
|
||
): Promise<AiInterpretResult['preview']> {
|
||
const warnings: string[] = [];
|
||
const classId = typeof params.classId === 'string' ? params.classId : '';
|
||
const classRecord = classId
|
||
? await prisma.class.findUnique({ where: { id: classId }, select: { name: true } })
|
||
: null;
|
||
|
||
if (action === 'create_homework') {
|
||
const courseId = typeof params.courseId === 'string' ? params.courseId : '';
|
||
const course = courseId
|
||
? await prisma.course.findUnique({ where: { id: courseId }, select: { name: true } })
|
||
: null;
|
||
if (!course) warnings.push('没有指定科目:只有班主任/管理员能发不指定科目的作业');
|
||
return {
|
||
summary: `给${classRecord?.name ?? '未知班级'}布置作业:${String(params.title ?? '')}`,
|
||
className: classRecord?.name ?? null,
|
||
courseName: course?.name ?? null,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
if (action === 'create_notification') {
|
||
return {
|
||
summary: `给${classRecord?.name ?? '未知班级'}发通知:${String(params.title ?? '')}`,
|
||
className: classRecord?.name ?? null,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
if (action === 'call_student') {
|
||
const studentName = typeof params.studentName === 'string' ? params.studentName : '';
|
||
const student = studentName && classId
|
||
? await prisma.student.findFirst({
|
||
where: { classId, name: studentName },
|
||
select: { id: true, name: true },
|
||
})
|
||
: null;
|
||
if (!student) warnings.push(studentName ? `没找到学生「${studentName}」` : '没识别出学生姓名');
|
||
return {
|
||
summary: `叫${student?.name ?? (studentName || '某位同学')}到老师这里`,
|
||
className: classRecord?.name ?? null,
|
||
studentName: student?.name ?? (studentName || null),
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
return { summary: '没听懂要做什么,请换一种说法', warnings: ['助手没有解析出可用动作'] };
|
||
}
|
||
|
||
/**
|
||
* POST /api/ai/interpret - 解析一句中文,返回**待确认**的结构化动作。
|
||
* 绝不写业务表;只写一条 `AiAudit`(谁在什么时候让助手做了什么)。
|
||
*/
|
||
export async function interpret(user: TokenPayload, input: AiInterpretInput): Promise<AiInterpretResult> {
|
||
const settings = await getAiSettings();
|
||
if (!settings.enabled) {
|
||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
|
||
}
|
||
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
|
||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写');
|
||
}
|
||
|
||
const context = await resolveContext(user, input.classId);
|
||
const reply =
|
||
settings.provider === 'mock'
|
||
? mockInterpret(input.text, context)
|
||
: await callOpenAi(input, {
|
||
baseUrl: settings.baseUrl,
|
||
model: settings.model,
|
||
apiKey: decryptSecret(await getSetting(SETTING_KEYS.aiApiKey)),
|
||
prompt: buildSystemPrompt(context, settings.systemPromptExtra),
|
||
});
|
||
|
||
// 用与手动入口同一份 zod 再校验一次:模型给的东西**不可信**
|
||
let action: AiActionKind = 'unknown';
|
||
let params: Record<string, unknown> = {};
|
||
const warnings: string[] = [];
|
||
try {
|
||
if (reply.action === 'create_homework') {
|
||
const parsed = homeworkParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
|
||
action = 'create_homework';
|
||
params = parsed as unknown as Record<string, unknown>;
|
||
} else if (reply.action === 'create_notification') {
|
||
const parsed = notificationParamsSchema.parse({
|
||
...reply.params,
|
||
classId: reply.params.classId ?? input.classId,
|
||
});
|
||
action = 'create_notification';
|
||
params = parsed as unknown as Record<string, unknown>;
|
||
} else if (reply.action === 'call_student') {
|
||
const parsed = callParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
|
||
action = 'call_student';
|
||
params = parsed as unknown as Record<string, unknown>;
|
||
}
|
||
} catch (error) {
|
||
warnings.push('助手给出的参数不完整,请手动补全后再发布');
|
||
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
|
||
const preview = await buildPreview(action, params);
|
||
preview.warnings = [...preview.warnings, ...warnings];
|
||
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
|
||
|
||
await prisma.aiAudit.create({
|
||
data: {
|
||
userId: user.sub,
|
||
prompt: input.text,
|
||
intent: action,
|
||
status: 'pending',
|
||
message: preview.summary,
|
||
rawReply: JSON.stringify(reply).slice(0, 2000),
|
||
},
|
||
});
|
||
|
||
return {
|
||
action,
|
||
params,
|
||
preview,
|
||
confidence: reply.confidence ?? 0.5,
|
||
needConfirm: true,
|
||
rawReply: null,
|
||
};
|
||
}
|
||
|
||
/** GET /api/ai/audit - 助手使用审计(最近 N 条) */
|
||
export async function listAudit(limit = 50): Promise<AiAuditDto[]> {
|
||
const rows = await prisma.aiAudit.findMany({ orderBy: { createdAt: 'desc' }, take: limit });
|
||
// `AiAudit.userId` 是普通字符串列(schema 里没有建 User 关系),所以姓名另外查一次
|
||
const userIds = [...new Set(rows.map((row) => row.userId))];
|
||
const users = userIds.length
|
||
? await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
|
||
: [];
|
||
return rows.map((row) => ({
|
||
id: row.id,
|
||
userId: row.userId,
|
||
userName: users.find((user) => user.id === row.userId)?.name ?? '',
|
||
prompt: row.prompt,
|
||
intent: row.intent,
|
||
status: row.status,
|
||
message: row.message,
|
||
createdAt: row.createdAt.toISOString(),
|
||
}));
|
||
}
|