chore(release): 1.3.3 发布并退役旧联动
This commit is contained in:
1 parent
b7cdc7c9ea
commit
c0ebf88138
184 files changed
+5450
-11705
No files matched your search
@@ -7,6 +7,7 @@ import { env } from './config/env.js';
|
||||
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
||||
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { backfillNotificationDates } from './lib/backfill-notification-dates.js';
|
||||
import { refreshDeveloperMode } from './lib/settings.js';
|
||||
import { resolveWebDistDir } from './lib/web-static.js';
|
||||
import { initRealtime } from './realtime/socket.js';
|
||||
@@ -51,6 +52,8 @@ async function prepareDatabase(): Promise<void> {
|
||||
}
|
||||
|
||||
await prepareDatabase();
|
||||
const backfilledDates = await backfillNotificationDates();
|
||||
if (backfilledDates) logger.info(`已补齐 ${backfilledDates} 条历史通知的所属日期`);
|
||||
|
||||
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
|
||||
await refreshDeveloperMode();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { dayKeyLocal } from '@classhelper/shared';
|
||||
import { prisma } from './db.js';
|
||||
|
||||
/** 旧叫人通知没有所属日期;用服务进程的本地日期口径幂等回填,兼容 SQLite 与 MySQL。 */
|
||||
export async function backfillNotificationDates(): Promise<number> {
|
||||
let updated = 0;
|
||||
for (;;) {
|
||||
const rows = await prisma.notification.findMany({
|
||||
where: { assignDate: '' },
|
||||
select: { id: true, createdAt: true },
|
||||
take: 200,
|
||||
});
|
||||
if (!rows.length) return updated;
|
||||
await prisma.$transaction(
|
||||
rows.map((row) =>
|
||||
prisma.notification.update({
|
||||
where: { id: row.id },
|
||||
data: { assignDate: dayKeyLocal(row.createdAt) },
|
||||
}),
|
||||
),
|
||||
);
|
||||
updated += rows.length;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,23 @@ async function loadAppliedMigrations(client: {
|
||||
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
|
||||
// 开发库/源码部署由 Prisma 管理迁移;覆盖安装时接纳其已完成记录,
|
||||
// 不能重跑已被后续迁移改过的旧 SQL(例如已移除的 User.classId 索引)。
|
||||
const prismaLedger = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_prisma_migrations'`,
|
||||
);
|
||||
if (prismaLedger.length > 0) {
|
||||
const completed = await prisma.$queryRawUnsafe<Array<{ migration_name: string }>>(
|
||||
`SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
|
||||
);
|
||||
for (const row of completed) {
|
||||
await client.execute({
|
||||
sql: `INSERT OR IGNORE INTO "${MIGRATION_LEDGER}" ("name") VALUES (?)`,
|
||||
args: [row.migration_name],
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
|
||||
`SELECT name FROM "${MIGRATION_LEDGER}"`,
|
||||
);
|
||||
|
||||
@@ -2,8 +2,14 @@ import type { Response } from 'express';
|
||||
import type { ApiResponse } from '@classhelper/shared';
|
||||
|
||||
/** 成功响应:{ success: true, data, message } */
|
||||
export function sendOk<T>(res: Response, data: T, message = 'ok', status = 200): void {
|
||||
const body: ApiResponse<T> = { success: true, data, message };
|
||||
export function sendOk<T>(
|
||||
res: Response,
|
||||
data: T,
|
||||
message = 'ok',
|
||||
status = 200,
|
||||
legacyFields: object = {},
|
||||
): void {
|
||||
const body: ApiResponse<T> = { ...legacyFields, success: true, data, message };
|
||||
res.status(status).json(body);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
ArchivedClassDto,
|
||||
ArchivedYearDto,
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
ClassTeacherBrief,
|
||||
CourseBrief,
|
||||
CourseDto,
|
||||
@@ -191,8 +190,6 @@ export interface ClassLike {
|
||||
teacherId: string;
|
||||
/** 本学期教学周数(班主任可调,默认 20) */
|
||||
termWeeks?: number;
|
||||
/** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
|
||||
notificationChannel?: string | null;
|
||||
/** 班级端按学号查成绩的开关 */
|
||||
studentGradeQueryEnabled?: boolean | null;
|
||||
/** 毕业归档所属届别;null = 在读 */
|
||||
@@ -209,11 +206,6 @@ export interface ClassLike {
|
||||
};
|
||||
}
|
||||
|
||||
/** 通知显示位置:库里存字符串,出参收敛成联合类型(非法值一律按默认 both 处理) */
|
||||
export function toNotificationChannel(value: string | null | undefined): ClassIslandNotificationChannel {
|
||||
return value === 'client' || value === 'classisland' ? value : 'both';
|
||||
}
|
||||
|
||||
export function toClassDto(item: ClassLike): ClassDto {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -223,7 +215,6 @@ export function toClassDto(item: ClassLike): ClassDto {
|
||||
classIndex: item.classIndex ?? null,
|
||||
teacherId: item.teacherId,
|
||||
termWeeks: item.termWeeks ?? 20,
|
||||
notificationChannel: toNotificationChannel(item.notificationChannel),
|
||||
// 默认开启:老库补列时的默认值也是 true,两边保持一致
|
||||
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
|
||||
archivedYearId: item.archivedYearId ?? null,
|
||||
|
||||
@@ -12,13 +12,13 @@ import type { TokenPayload } from './jwt.js';
|
||||
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
|
||||
*
|
||||
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
|
||||
* - `ADMIN`:全集。另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
|
||||
* - `ADMIN`:按权限组计算,另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
|
||||
* 把自己(或最后一个管理员)锁在权限页之外;
|
||||
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
|
||||
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
|
||||
* 等)决定,给某个班级端"分配权限组"没有意义;
|
||||
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
|
||||
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 `requireRole`)。
|
||||
* **没有任何分组**时使用数据库中可编辑的角色默认组(逐条镜像权限组上线前各路由的 `requireRole`)。
|
||||
*
|
||||
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
|
||||
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
|
||||
@@ -28,10 +28,7 @@ export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<
|
||||
|
||||
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
for (const key of PERMISSION_KEYS) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
await ensureDefaultGroups();
|
||||
|
||||
const assignments = await prisma.userPermissionGroup.findMany({
|
||||
where: { userId: user.sub },
|
||||
@@ -39,7 +36,14 @@ export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
for (const key of defaultPermissionsForRole(user.role)) permissions.add(key);
|
||||
const group = await prisma.permissionGroup.findUnique({
|
||||
where: { defaultRole: user.role },
|
||||
include: { items: true },
|
||||
});
|
||||
for (const key of group ? group.items.map((item) => item.key) : defaultPermissionsForRole(user.role)) {
|
||||
if ((PERMISSION_KEYS as readonly string[]).includes(key)) permissions.add(key as PermissionKey);
|
||||
}
|
||||
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
@@ -50,17 +54,48 @@ export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<
|
||||
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
|
||||
}
|
||||
}
|
||||
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */
|
||||
let defaultGroupsReady: Promise<void> | null = null;
|
||||
export function ensureDefaultGroups(): Promise<void> {
|
||||
if (!defaultGroupsReady)
|
||||
defaultGroupsReady = createDefaultGroups().finally(() => {
|
||||
defaultGroupsReady = null;
|
||||
});
|
||||
return defaultGroupsReady;
|
||||
}
|
||||
async function createDefaultGroups(): Promise<void> {
|
||||
for (const role of ['ADMIN', 'TEACHER'] as const) {
|
||||
if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
|
||||
continue;
|
||||
const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`;
|
||||
let name = baseName;
|
||||
let suffix = 1;
|
||||
// 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。
|
||||
while (await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } })) {
|
||||
name = `${baseName}(系统 ${suffix++})`;
|
||||
}
|
||||
await prisma.permissionGroup.upsert({
|
||||
where: { defaultRole: role },
|
||||
update: {},
|
||||
create: {
|
||||
id: `default-permission-${role.toLowerCase()}`,
|
||||
defaultRole: role,
|
||||
name,
|
||||
builtin: true,
|
||||
description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。',
|
||||
items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理员不可被剥夺的权限键(防锁死) */
|
||||
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
|
||||
|
||||
/** 角色默认模板(供权限页展示"没分组时会是什么") */
|
||||
export function roleDefaultPermissions(role: TokenPayload['role']): readonly PermissionKey[] {
|
||||
return ROLE_DEFAULT_PERMISSIONS[role];
|
||||
}
|
||||
|
||||
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
|
||||
export function permissionLabel(key: PermissionKey): string {
|
||||
return PERMISSION_LABELS[key];
|
||||
|
||||
@@ -46,7 +46,7 @@ export const attachmentUrlSchema = z
|
||||
.max(500)
|
||||
.refine(
|
||||
(value) =>
|
||||
value === '' || value.startsWith('/') || /^https?:\/\//i.test(value),
|
||||
value === '' || (/^\/(?![\/\\])/.test(value) && !value.includes('\\')) || /^https?:\/\//i.test(value),
|
||||
{ message: '附件链接需为 http(s):// 地址或站内相对路径' },
|
||||
)
|
||||
.nullish();
|
||||
|
||||
@@ -50,8 +50,6 @@ export const SNAPSHOT_TABLES = [
|
||||
'Notification',
|
||||
'NotificationRead',
|
||||
'Grade',
|
||||
'IntegrationDevice',
|
||||
'ClassIslandPush',
|
||||
'ClientDevice',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
@@ -132,7 +130,11 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
|
||||
}
|
||||
}
|
||||
|
||||
async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise<void> {
|
||||
async function setForeignKeys(
|
||||
prisma: PrismaClient,
|
||||
provider: DatabaseProvider,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const sql =
|
||||
provider === 'mysql'
|
||||
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
|
||||
@@ -164,7 +166,12 @@ export async function restoreSnapshot(
|
||||
}
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = snapshot.data[table];
|
||||
const rows = snapshot.data[table].map((row) => {
|
||||
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
|
||||
const copy = { ...(row as Record<string, unknown>) };
|
||||
delete copy.notificationChannel;
|
||||
return copy;
|
||||
});
|
||||
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
|
||||
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
|
||||
}
|
||||
|
||||
@@ -104,6 +104,15 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */
|
||||
export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler {
|
||||
const guard = requirePermission(...keys);
|
||||
return (req, res, next) => {
|
||||
if (req.auth?.user.role === 'CLASS_DEVICE') return next();
|
||||
return guard(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限守卫(任一满足):只要拥有其中**一个**权限键就放行。
|
||||
*
|
||||
@@ -119,7 +128,9 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
|
||||
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
|
||||
if (keys.length > 0 && !keys.some((key) => permissions.has(key))) {
|
||||
const labels = keys.map((key) => `「${PERMISSION_LABELS[key]}」`).join('、');
|
||||
next(ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`));
|
||||
next(
|
||||
ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { dayKeyLocal, type AiActionKind, type AiAuditDto, type AiInterpretResult, type AiSettingsDto } from '@classhelper/shared';
|
||||
import {
|
||||
dayKeyLocal,
|
||||
type AiActionKind,
|
||||
type AiAuditDto,
|
||||
type AiInterpretResult,
|
||||
type AiSettingsDto,
|
||||
} from '@classhelper/shared';
|
||||
import { z } from 'zod';
|
||||
import { getAccessibleClassIds } from '../../lib/access.js';
|
||||
import { getAccessibleClassIds, assertClassAccess } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
@@ -17,7 +23,7 @@ import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
|
||||
* 设计边界(照 approved 计划):
|
||||
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
|
||||
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
|
||||
* 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用(AGENTS.md §5 第 5/14 条);
|
||||
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md §5 第 5/14 条);
|
||||
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
|
||||
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
|
||||
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
|
||||
@@ -98,6 +104,7 @@ interface AssistantContext {
|
||||
}
|
||||
|
||||
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
const classIds = await getAccessibleClassIds(user);
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { id: { in: classIds } },
|
||||
@@ -201,7 +208,11 @@ async function callOpenAi(
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiError(504, 'AI_TIMEOUT', `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`);
|
||||
throw new ApiError(
|
||||
504,
|
||||
'AI_TIMEOUT',
|
||||
`无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -253,12 +264,13 @@ async function buildPreview(
|
||||
|
||||
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;
|
||||
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 || '某位同学')}到老师这里`,
|
||||
@@ -281,7 +293,11 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
|
||||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
|
||||
}
|
||||
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
|
||||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写');
|
||||
throw new ApiError(
|
||||
400,
|
||||
'AI_NOT_CONFIGURED',
|
||||
'还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写',
|
||||
);
|
||||
}
|
||||
|
||||
const context = await resolveContext(user, input.classId);
|
||||
@@ -301,7 +317,10 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
if (reply.action === 'create_homework') {
|
||||
const parsed = homeworkParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
|
||||
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') {
|
||||
@@ -312,7 +331,10 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
|
||||
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 });
|
||||
const parsed = callParamsSchema.parse({
|
||||
...reply.params,
|
||||
classId: reply.params.classId ?? input.classId,
|
||||
});
|
||||
action = 'call_student';
|
||||
params = parsed as unknown as Record<string, unknown>;
|
||||
}
|
||||
@@ -321,6 +343,16 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
|
||||
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (typeof params.classId === 'string') {
|
||||
await assertClassAccess(user, params.classId);
|
||||
if (typeof params.courseId === 'string') {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { id: params.courseId, classId: params.classId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!course) throw ApiError.badRequest('科目不属于所选班级');
|
||||
}
|
||||
}
|
||||
const preview = await buildPreview(action, params);
|
||||
preview.warnings = [...preview.warnings, ...warnings];
|
||||
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
|
||||
@@ -330,7 +362,8 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
|
||||
userId: user.sub,
|
||||
prompt: input.text,
|
||||
intent: action,
|
||||
status: 'pending',
|
||||
// 审计只记录解析,不代表前端确认后的业务操作已执行。
|
||||
status: action === 'unknown' ? 'unrecognized' : 'interpreted',
|
||||
message: preview.summary,
|
||||
rawReply: JSON.stringify(reply).slice(0, 2000),
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import * as authService from './auth.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** POST /api/auth/login - 登录(公开):教师 / 管理员 / 个人学生账号 */
|
||||
/** POST /api/auth/login - 登录(公开):教师 / 管理员 */
|
||||
router.post('/login', validate({ body: loginSchema }), async (req, res) => {
|
||||
const result: LoginResponse = await authService.login(req.body as LoginInput);
|
||||
sendOk(res, result, '登录成功');
|
||||
@@ -26,7 +26,7 @@ router.post('/login', validate({ body: loginSchema }), async (req, res) => {
|
||||
* POST /api/auth/class-login - 班级账号登录(公开,ClassHelper 班级端主入口)
|
||||
*
|
||||
* 班级码 + 班级密码 → 以「班级」为主体的 JWT(个人数据按全班读写)。
|
||||
* 个人学生账号仍可用 /auth/login 登录(向后兼容,客户端已不再展示该入口)。
|
||||
* 学生仅为名单记录,没有个人登录账号。
|
||||
*/
|
||||
router.post('/class-login', validate({ body: classLoginSchema }), async (req, res) => {
|
||||
const result: LoginResponse = await authService.classLogin(req.body as ClassLoginInput);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
|
||||
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
|
||||
import { assertCanPublishNotification } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
@@ -6,7 +6,6 @@ import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass, emitToSession } from '../../realtime/bus.js';
|
||||
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
|
||||
import type { CreateCallInput } from './calls.schemas.js';
|
||||
|
||||
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
@@ -55,6 +54,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
|
||||
priority: input.urgent ? 'URGENT' : 'HIGH',
|
||||
createdBy: user.sub,
|
||||
assignDate: dayKeyLocal(new Date()),
|
||||
},
|
||||
include: { creator: creatorSelect, reads: true },
|
||||
});
|
||||
@@ -65,26 +65,6 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
// 定向广播给教室机器:会话房间以**班级 id** 为键,叫人消息因此立即展开
|
||||
emitToSession(created.classId, SOCKET_EVENTS.callNew, dto);
|
||||
|
||||
// 叫人同样推到教室的 ClassIsland:老师点名时,教室大屏也弹一条(学生姓名在标题里)。
|
||||
// 显示位置由班级端决定;推送失败不影响叫人本身。
|
||||
try {
|
||||
if (await shouldPushToClassIsland(input.classId)) {
|
||||
await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: created.title,
|
||||
content: created.content,
|
||||
urgent: input.urgent === true,
|
||||
// 叫人属于「主动通知」:教室的 ClassIsland 上课时段也要立刻弹
|
||||
kind: 'call',
|
||||
createdBy: user.sub,
|
||||
notificationId: created.id,
|
||||
teacherName,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`叫人推送 ClassIsland 失败(叫人本身已发出):${String(error)}`);
|
||||
}
|
||||
|
||||
logger.info(`${input.urgent ? '紧急叫人' : '叫人'}:${teacherName} → ${studentName}(${message})`);
|
||||
return dto;
|
||||
}
|
||||
@@ -2,12 +2,7 @@ import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import {
|
||||
validate,
|
||||
validatedBody,
|
||||
validatedParams,
|
||||
validatedQuery,
|
||||
} from '../../middleware/validate.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
addStudentSchema,
|
||||
@@ -19,14 +14,12 @@ import {
|
||||
promoteClassesSchema,
|
||||
updateClassAccountSchema,
|
||||
updateClassSchema,
|
||||
updateNotificationChannelSchema,
|
||||
updateStudentGradeQuerySchema,
|
||||
type AddStudentInput,
|
||||
type AssignSubjectTeachersInput,
|
||||
type CreateClassInput,
|
||||
type UpdateClassAccountInput,
|
||||
type UpdateClassInput,
|
||||
type UpdateNotificationChannelInput,
|
||||
type UpdateStudentGradeQueryInput,
|
||||
} from './classes.schemas.js';
|
||||
import * as classService from './classes.service.js';
|
||||
@@ -73,10 +66,15 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||
});
|
||||
|
||||
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
|
||||
router.post('/', requirePermission('class:manage'), validate({ body: createClassSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
|
||||
});
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: createClassSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
|
||||
router.patch(
|
||||
@@ -160,55 +158,9 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/classisland-status - 本班 ClassHelper 班级端 / ClassIsland 联动状态
|
||||
*
|
||||
* 教室机器(ClassHelper 班级端)也能读:客户端「设置 → ClassIsland 联动」据此显示状态。
|
||||
*/
|
||||
router.get('/:id/classisland-status', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.getClassIslandStatus(user, id), '获取联动状态成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端"
|
||||
*
|
||||
* 教室的 ClassHelper 班级端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
|
||||
* 教师/管理员也能读(Web 端展示用)。
|
||||
*/
|
||||
router.get('/:id/notification-channel', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.getNotificationChannel(user, id), '获取通知显示位置成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/classes/:id/notification-channel - 设置"通知显示到哪个端"
|
||||
*
|
||||
* **由教室的 ClassHelper 班级端自己选**,教师/管理员也可改。
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/notification-channel',
|
||||
validate({ params: idParamSchema, body: updateNotificationChannelSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await classService.updateNotificationChannel(
|
||||
user,
|
||||
id,
|
||||
validatedBody<UpdateNotificationChannelInput>(req),
|
||||
),
|
||||
'通知显示位置已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细
|
||||
* PATCH 同路径 - 开关它(班级端自己可改,教师/管理员也可改)
|
||||
* PATCH 同路径 - 教师/管理员按班级管理权限设置,班级端只读
|
||||
*/
|
||||
router.get('/:id/student-grade-query', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -224,11 +176,7 @@ router.patch(
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await classService.updateStudentGradeQuery(
|
||||
user,
|
||||
id,
|
||||
validatedBody<UpdateStudentGradeQueryInput>(req),
|
||||
),
|
||||
await classService.updateStudentGradeQuery(user, id, validatedBody<UpdateStudentGradeQueryInput>(req)),
|
||||
'成绩查询开关已更新',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared';
|
||||
import { MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared';
|
||||
import {
|
||||
classIndexSchema,
|
||||
enrollmentYearSchema,
|
||||
@@ -99,10 +99,7 @@ export const assignSubjectTeachersSchema = z.object({
|
||||
.trim()
|
||||
.min(1, '请选择或填写科目')
|
||||
.max(32)
|
||||
.refine(
|
||||
(value) => value.length > 0,
|
||||
{ message: '科目名不能为空' },
|
||||
),
|
||||
.refine((value) => value.length > 0, { message: '科目名不能为空' }),
|
||||
teacherId: z.string().min(1).nullish(),
|
||||
}),
|
||||
)
|
||||
@@ -114,17 +111,6 @@ export const assignHeadTeacherSchema = z.object({
|
||||
teacherId: z.string().min(1, '请选择班主任'),
|
||||
});
|
||||
|
||||
/**
|
||||
* 通知显示位置(both / client / classisland)。
|
||||
* 由 **ClassHelper 班级端**在设置页里改,教师/管理员也可以在 Web 端改。
|
||||
*/
|
||||
export const updateNotificationChannelSchema = z.object({
|
||||
notificationChannel: z.enum(
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS as unknown as [string, ...string[]],
|
||||
{ message: '显示位置只能是 both / client / classisland' },
|
||||
),
|
||||
});
|
||||
|
||||
/** 班级端按学号查询成绩明细的开关 */
|
||||
export const updateStudentGradeQuerySchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -138,7 +124,6 @@ export const classStudentParamSchema = z.object({
|
||||
export type CreateClassInput = z.infer<typeof createClassSchema>;
|
||||
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
|
||||
export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
|
||||
export type UpdateNotificationChannelInput = z.infer<typeof updateNotificationChannelSchema>;
|
||||
export type UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>;
|
||||
export type AddStudentInput = z.infer<typeof addStudentSchema>;
|
||||
export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>;
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
ClassDetailDto,
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
StudentDto,
|
||||
SubjectTeacherDto,
|
||||
} from '@classhelper/shared';
|
||||
import type { ClassDetailDto, ClassDto, StudentDto, SubjectTeacherDto } from '@classhelper/shared';
|
||||
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import {
|
||||
@@ -13,7 +7,6 @@ import {
|
||||
assertCanManageRoster,
|
||||
assertClassAccess,
|
||||
classScopeIdFilter,
|
||||
isClassDevice,
|
||||
isStaff,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
@@ -24,23 +17,15 @@ import {
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import {
|
||||
toClassDto,
|
||||
toCourseDto,
|
||||
toNotificationChannel,
|
||||
toStudentDto,
|
||||
toTeacherBrief,
|
||||
} from '../../lib/mappers.js';
|
||||
import { toClassDto, toCourseDto, toStudentDto, toTeacherBrief } from '../../lib/mappers.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type {
|
||||
AddStudentInput,
|
||||
AssignSubjectTeachersInput,
|
||||
CreateClassInput,
|
||||
UpdateClassInput,
|
||||
UpdateNotificationChannelInput,
|
||||
UpdateStudentGradeQueryInput,
|
||||
} from './classes.schemas.js';
|
||||
import { attachIntegrationFlag } from '../integrations/integrations.service.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
|
||||
const countSelect = {
|
||||
@@ -106,8 +91,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
|
||||
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }, { grade: 'asc' }],
|
||||
});
|
||||
|
||||
// 附上「是否已接入 ClassIsland」徽标:Web 端班级列表/课表页据此提示
|
||||
return attachIntegrationFlag(classes.map((item) => toClassDtoWithAccount(user, item)));
|
||||
return classes.map((item) => toClassDtoWithAccount(user, item));
|
||||
}
|
||||
|
||||
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
|
||||
@@ -261,7 +245,10 @@ export async function assignSubjectTeachers(
|
||||
.teacherId;
|
||||
|
||||
const existing = assignment.courseId
|
||||
? await prisma.course.findUnique({ where: { id: assignment.courseId }, select: { id: true, classId: true } })
|
||||
? await prisma.course.findUnique({
|
||||
where: { id: assignment.courseId },
|
||||
select: { id: true, classId: true },
|
||||
})
|
||||
: await prisma.course.findUnique({
|
||||
where: { classId_name: { classId, name: assignment.subjectName } },
|
||||
select: { id: true, classId: true },
|
||||
@@ -481,98 +468,6 @@ export async function removeStudent(user: TokenPayload, classId: string, student
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- ClassHelper 班级端状态 */
|
||||
|
||||
/**
|
||||
* 本班的 ClassHelper 联动状态(教室机器上的「联动」面板展示)。
|
||||
*
|
||||
* 为什么单独开一个接口:设备列表(/integrations/devices)是管理面,只对教师/管理员开放;
|
||||
* 而教室机器(ClassHelper 班级端)需要知道"本班接没接 ClassIsland、最后什么时候上报的",
|
||||
* 才能给出准确的提示(例如没接就别选"只在 ClassIsland 上弹")。
|
||||
*/
|
||||
export async function getClassIslandStatus(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
onlineCount: number;
|
||||
lastSeenAt: string | null;
|
||||
lastHeartbeatAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
}> {
|
||||
await assertClassAccess(user, classId);
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId, enabled: true, mode: 'plugin' },
|
||||
select: {
|
||||
name: true,
|
||||
lastSeenAt: true,
|
||||
lastHeartbeatAt: true,
|
||||
pluginVersion: true,
|
||||
classIslandVersion: true,
|
||||
},
|
||||
orderBy: { lastHeartbeatAt: 'desc' },
|
||||
});
|
||||
|
||||
const latest = devices[0];
|
||||
const window = 60_000;
|
||||
return {
|
||||
connected: devices.length > 0,
|
||||
deviceName: latest?.name ?? null,
|
||||
deviceCount: devices.length,
|
||||
onlineCount: devices.filter(
|
||||
(item) => item.lastHeartbeatAt && Date.now() - item.lastHeartbeatAt.getTime() < window,
|
||||
).length,
|
||||
lastSeenAt: latest?.lastSeenAt ? latest.lastSeenAt.toISOString() : null,
|
||||
lastHeartbeatAt: latest?.lastHeartbeatAt ? latest.lastHeartbeatAt.toISOString() : null,
|
||||
pluginVersion: latest?.pluginVersion ?? null,
|
||||
classIslandVersion: latest?.classIslandVersion ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 班级端设置 */
|
||||
|
||||
/**
|
||||
* 读取班级的通知显示位置(both / client / classisland)。
|
||||
* 这是**教室机器**要读的值:登录后拉一次,把自己的弹窗行为对齐到同一个选择。
|
||||
*/
|
||||
export async function getNotificationChannel(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
|
||||
await assertClassAccess(user, classId);
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { notificationChannel: true },
|
||||
});
|
||||
return { notificationChannel: toNotificationChannel(record?.notificationChannel) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置班级的通知显示位置。
|
||||
*
|
||||
* 允许两类人改:**该班的 ClassHelper 班级端**(需求就是"由教室机器自己选")
|
||||
* 与教师 / 管理员(Web 端也能改,便于老师统一要求)。
|
||||
*/
|
||||
export async function updateNotificationChannel(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: UpdateNotificationChannelInput,
|
||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (!isClassDevice(user) && !isStaff(user)) {
|
||||
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改通知显示位置');
|
||||
}
|
||||
|
||||
const channel = toNotificationChannel(input.notificationChannel);
|
||||
await prisma.class.update({ where: { id: classId }, data: { notificationChannel: channel } });
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
logger.info(`班级通知显示位置已更新:classId=${classId} channel=${channel} by=${user.sub}`);
|
||||
return { notificationChannel: channel };
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
|
||||
* 班级端设置页用它渲染开关的当前状态。
|
||||
@@ -595,10 +490,7 @@ export async function getStudentGradeQuery(
|
||||
* 设置「是否允许 ClassHelper 班级端查看本班成绩与排名」。
|
||||
*
|
||||
* **只有教师 / 管理员能改**(需求:「成绩查询由服务端控制,客户端只读」)。
|
||||
* 原先班级端自己也能改这个开关 —— 教室机器能给自己开成绩查询,等于把
|
||||
* "学校说不给看"变成了一台机器说了算;而与它同一套模式的「通知显示位置」
|
||||
* (`Class.notificationChannel`)**仍然由班级端自己改**,那个确实是现场偏好,
|
||||
* 两者别再按"同一个模式"去理解。关闭后班级端查成绩一律 403,教师端不受影响。
|
||||
* 关闭后班级端查成绩一律 403,教师端不受影响。
|
||||
*/
|
||||
export async function updateStudentGradeQuery(
|
||||
user: TokenPayload,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -30,7 +30,9 @@ router.use(authenticate());
|
||||
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<ClientHeartbeatInput>(req);
|
||||
sendOk(res, await clientService.heartbeat(user, input, req.ip ?? ''), '心跳已上报');
|
||||
const result = await clientService.heartbeat(user, input, req.ip ?? '');
|
||||
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
|
||||
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
|
||||
});
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
|
||||
@@ -56,18 +58,26 @@ router.post(
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<CreateClientCommandInput>(req);
|
||||
sendCreated(res, await clientService.createCommands(user, input), '命令已下发,将在这台电脑下次心跳时送达');
|
||||
sendCreated(
|
||||
res,
|
||||
await clientService.createCommands(user, input),
|
||||
'命令已下发,将在这台电脑下次心跳时送达',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
|
||||
router.get(
|
||||
'/devices',
|
||||
requirePermission('client:manage'),
|
||||
requireAnyPermission('client:read', 'client:manage', 'remote:control', 'client:update-push'),
|
||||
validate({ query: listClientDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const query = validatedQuery<ListClientDevicesQuery>(req);
|
||||
sendOk(res, await clientService.listDevices({ classId: query.classId }), '获取设备列表成功');
|
||||
sendOk(
|
||||
res,
|
||||
await clientService.listDevices(getAuthUser(req), { classId: query.classId }),
|
||||
'获取设备列表成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -90,7 +100,7 @@ router.delete(
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await clientService.deleteDevice(id), '设备已移除');
|
||||
sendOk(res, await clientService.deleteDevice(getAuthUser(req), id), '设备已移除');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80
|
||||
|
||||
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
||||
export const clientHeartbeatSchema = z.object({
|
||||
agentProtocol: z.number().int().min(1).max(2).optional(),
|
||||
deviceKey: deviceKeySchema,
|
||||
name: z.string().trim().max(60).optional(),
|
||||
clientVersion: z.string().trim().max(40).optional(),
|
||||
platform: z.string().trim().max(40).optional(),
|
||||
autoUpdate: z.boolean().optional(),
|
||||
pendingVersion: z.string().trim().max(40).nullable().optional(),
|
||||
stateNote: z.string().trim().max(120).optional(),
|
||||
});
|
||||
@@ -53,6 +55,11 @@ export const pushClientUpdateSchema = z.object({
|
||||
all: z.boolean().optional(),
|
||||
version: z.string().trim().max(40).nullable().optional(),
|
||||
clear: z.boolean().optional(),
|
||||
mode: z.enum(['idle', 'immediate', 'after-school']).default('idle'),
|
||||
afterSchoolTime: z
|
||||
.string()
|
||||
.regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, '放学时间应为 HH:mm')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ClientHeartbeatInput = z.infer<typeof clientHeartbeatSchema>;
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
CLIENT_COMMAND_EXPIRE_MS,
|
||||
CLIENT_COMMAND_LABELS,
|
||||
CLIENT_ONLINE_WINDOW_MS,
|
||||
weekParityOf,
|
||||
type ClientUpdateMode,
|
||||
type ClientCommandAckRequest,
|
||||
type ClientCommandDto,
|
||||
type ClientCommandKind,
|
||||
@@ -10,18 +12,23 @@ import {
|
||||
type ClientPushResult,
|
||||
type ClientTargetResult,
|
||||
} from '@classhelper/shared';
|
||||
import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
|
||||
import { resolveClassScope, classScopeWhere } 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 * as clientReleases from '../update/client-releases.service.js';
|
||||
import type { ClientHeartbeatInput, CreateClientCommandInput, PushClientUpdateInput } from './clients.schemas.js';
|
||||
import type {
|
||||
ClientHeartbeatInput,
|
||||
CreateClientCommandInput,
|
||||
PushClientUpdateInput,
|
||||
} from './clients.schemas.js';
|
||||
|
||||
/**
|
||||
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
|
||||
*
|
||||
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 与 `IntegrationDevice.requestScheduleReport` 同一套路,不新增长连接握手。
|
||||
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
|
||||
* 由客户端下一次心跳(30 秒内)带走。
|
||||
*/
|
||||
@@ -35,6 +42,8 @@ type DeviceRow = {
|
||||
platform: string;
|
||||
autoUpdate: boolean;
|
||||
updateTargetVersion: string;
|
||||
updateMode: ClientUpdateMode;
|
||||
updateNotBefore: Date | null;
|
||||
pendingVersion: string;
|
||||
lastHeartbeatAt: Date | null;
|
||||
lastIp: string;
|
||||
@@ -131,6 +140,7 @@ export async function heartbeat(
|
||||
name: input.name?.trim() || '未命名设备',
|
||||
clientVersion: input.clientVersion?.trim() ?? '',
|
||||
platform: input.platform?.trim() ?? '',
|
||||
autoUpdate: input.autoUpdate !== false,
|
||||
pendingVersion: input.pendingVersion?.trim() ?? '',
|
||||
stateNote: input.stateNote?.trim() ?? '',
|
||||
lastHeartbeatAt: now,
|
||||
@@ -153,7 +163,16 @@ export async function heartbeat(
|
||||
deviceId: device.id,
|
||||
serverTime: now.toISOString(),
|
||||
updateTargetVersion: targetVersion || null,
|
||||
update,
|
||||
updateMode: device.updateMode,
|
||||
updateNotBefore: device.updateNotBefore?.toISOString() ?? null,
|
||||
// 旧客户端没有时机判断:到放学时间前不提供包,避免它提前安装。
|
||||
update:
|
||||
input.agentProtocol !== 2 &&
|
||||
device.updateMode === 'after-school' &&
|
||||
device.updateNotBefore &&
|
||||
now < device.updateNotBefore
|
||||
? null
|
||||
: update,
|
||||
command: command ? toCommandDto(command) : null,
|
||||
};
|
||||
}
|
||||
@@ -187,9 +206,13 @@ export async function ackCommand(
|
||||
}
|
||||
|
||||
/** GET /api/clients/devices - Web 端「客户端设备」列表(含待执行命令) */
|
||||
export async function listDevices(options: { classId?: string } = {}): Promise<ClientDeviceDto[]> {
|
||||
export async function listDevices(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string } = {},
|
||||
): Promise<ClientDeviceDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: options.classId ? { classId: options.classId } : {},
|
||||
where: classScopeWhere(scope),
|
||||
include: { class: { select: { id: true, name: true } } },
|
||||
orderBy: [{ lastHeartbeatAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})) as unknown as DeviceRow[];
|
||||
@@ -235,6 +258,8 @@ export async function listDevices(options: { classId?: string } = {}): Promise<C
|
||||
platform: row.platform,
|
||||
autoUpdate: row.autoUpdate,
|
||||
updateTargetVersion: row.updateTargetVersion,
|
||||
updateMode: row.updateMode,
|
||||
updateNotBefore: row.updateNotBefore?.toISOString() ?? null,
|
||||
pendingVersion: row.pendingVersion,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
lastIp: row.lastIp,
|
||||
@@ -249,10 +274,11 @@ export async function listDevices(options: { classId?: string } = {}): Promise<C
|
||||
}
|
||||
|
||||
/** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */
|
||||
async function findTargetDevices(deviceIds: string[]): Promise<Map<string, DeviceRow>> {
|
||||
async function findTargetDevices(user: TokenPayload, deviceIds: string[]): Promise<Map<string, DeviceRow>> {
|
||||
const wanted = [...new Set(deviceIds)];
|
||||
const scope = classScopeWhere(await resolveClassScope(user));
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: wanted } },
|
||||
where: { AND: [scope, { id: { in: wanted } }] },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
@@ -268,7 +294,7 @@ export async function createCommands(
|
||||
user: TokenPayload,
|
||||
input: CreateClientCommandInput,
|
||||
): Promise<ClientPushResult> {
|
||||
const targets = await findTargetDevices(input.deviceIds);
|
||||
const targets = await findTargetDevices(user, input.deviceIds);
|
||||
const expiresAt = new Date(Date.now() + CLIENT_COMMAND_EXPIRE_MS);
|
||||
const note = input.note?.trim() ?? '';
|
||||
const results: ClientTargetResult[] = [];
|
||||
@@ -304,12 +330,13 @@ export async function createCommands(
|
||||
}
|
||||
|
||||
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
|
||||
async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<DeviceRow[]> {
|
||||
async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> {
|
||||
const scope = classScopeWhere(await resolveClassScope(user));
|
||||
const deviceIds = [...new Set(input.deviceIds ?? [])];
|
||||
if (deviceIds.length > 0) {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: deviceIds } },
|
||||
select: { id: true, name: true },
|
||||
where: { AND: [scope, { id: { in: deviceIds } }] },
|
||||
select: { id: true, name: true, classId: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return rows;
|
||||
}
|
||||
@@ -317,13 +344,16 @@ async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<Devic
|
||||
const classIds = [...new Set(input.classIds ?? [])];
|
||||
if (classIds.length > 0) {
|
||||
return (await prisma.clientDevice.findMany({
|
||||
where: { classId: { in: classIds } },
|
||||
select: { id: true, name: true },
|
||||
where: { AND: [scope, { classId: { in: classIds } }] },
|
||||
select: { id: true, name: true, classId: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
if (input.all) {
|
||||
return (await prisma.clientDevice.findMany({ select: { id: true, name: true } })) as unknown as DeviceRow[];
|
||||
return (await prisma.clientDevice.findMany({
|
||||
where: scope,
|
||||
select: { id: true, name: true, classId: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
throw ApiError.badRequest('请选择要推送的设备、班级,或选择"全部设备"');
|
||||
@@ -333,8 +363,7 @@ async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<Devic
|
||||
* POST /api/clients/updates - 统一下发客户端更新
|
||||
*
|
||||
* `version` 省略时用服务端缓存里最新的那个版本;`clear: true` 取消下发。
|
||||
* 只改 `updateTargetVersion`,真正的下载与安装由客户端按自己的空闲判据决定
|
||||
* (上课时段 / 窗口可见都不装)。
|
||||
* 保存目标版本和安装策略,下载、安装由客户端在心跳后执行。
|
||||
*/
|
||||
export async function pushUpdate(
|
||||
user: TokenPayload,
|
||||
@@ -352,14 +381,21 @@ export async function pushUpdate(
|
||||
if (!info) throw ApiError.badRequest(`服务端缺少 v${target} 的安装包文件,请重新下载或登记`);
|
||||
}
|
||||
|
||||
const devices = await resolveUpdateTargets(input);
|
||||
const devices = await resolveUpdateTargets(user, input);
|
||||
if (devices.length === 0) throw ApiError.badRequest('没有匹配到任何客户端设备');
|
||||
|
||||
const results: ClientTargetResult[] = [];
|
||||
for (const device of devices) {
|
||||
await prisma.clientDevice.update({
|
||||
where: { id: device.id },
|
||||
data: { updateTargetVersion: target },
|
||||
data: {
|
||||
updateTargetVersion: target,
|
||||
updateMode: clear ? 'idle' : input.mode,
|
||||
updateNotBefore:
|
||||
!clear && input.mode === 'after-school'
|
||||
? await afterSchoolAt(device.classId, input.afterSchoolTime)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
results.push({ deviceId: device.id, deviceName: device.name, ok: true });
|
||||
}
|
||||
@@ -373,10 +409,36 @@ export async function pushUpdate(
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** 按当前教学周、单双周与星期计算今天放学时间;未绑定班级使用明确的兜底时间。 */
|
||||
async function afterSchoolAt(classId: string | null, fallback = '17:00'): Promise<Date> {
|
||||
const now = new Date();
|
||||
let end = fallback;
|
||||
if (classId) {
|
||||
const week = resolveCurrentWeek(now, await loadTermContext(classId));
|
||||
const last = await prisma.schedule.findFirst({
|
||||
where: {
|
||||
classId,
|
||||
dayOfWeek: now.getDay() || 7,
|
||||
weekStart: { lte: week },
|
||||
weekEnd: { gte: week },
|
||||
weekParity: { in: ['ALL', weekParityOf(week)] },
|
||||
},
|
||||
orderBy: { endTime: 'desc' },
|
||||
select: { endTime: true },
|
||||
});
|
||||
if (last && last.endTime > end) end = last.endTime;
|
||||
}
|
||||
const [hours, minutes] = end.split(':').map(Number);
|
||||
const at = new Date(now);
|
||||
at.setHours(hours!, minutes!, 0, 0);
|
||||
return at;
|
||||
}
|
||||
|
||||
/** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */
|
||||
export async function deleteDevice(deviceId: string): Promise<{ id: string }> {
|
||||
const device = await prisma.clientDevice.findUnique({
|
||||
where: { id: deviceId },
|
||||
export async function deleteDevice(user: TokenPayload, deviceId: string): Promise<{ id: string }> {
|
||||
const scope = classScopeWhere(await resolveClassScope(user));
|
||||
const device = await prisma.clientDevice.findFirst({
|
||||
where: { AND: [scope, { id: deviceId }] },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
});
|
||||
if (!device) throw ApiError.notFound('设备不存在');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { resolveCurrentWeek } from '../../lib/term.js';
|
||||
import { authenticate, getAuthUser } from '../../middleware/auth.js';
|
||||
import { resolveCurrentWeek, loadTermContext } from '../../lib/term.js';
|
||||
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as dashboardService from './dashboard.service.js';
|
||||
|
||||
@@ -9,7 +9,7 @@ const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */
|
||||
router.get('/summary', async (req, res) => {
|
||||
router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
|
||||
});
|
||||
@@ -21,11 +21,13 @@ router.get('/summary', async (req, res) => {
|
||||
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
|
||||
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
|
||||
*/
|
||||
router.get('/term', async (req, res) => {
|
||||
router.get('/term', requireStaffPermission('term:read'), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined;
|
||||
const maxWeek = await dashboardService.resolveTermWeeks(user, classId);
|
||||
sendOk(res, { currentWeek: resolveCurrentWeek(), maxWeek }, '获取学期信息成功');
|
||||
const targetClassId = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : classId;
|
||||
const context = await loadTermContext(targetClassId);
|
||||
sendOk(res, { currentWeek: resolveCurrentWeek(new Date(), context), maxWeek }, '获取学期信息成功');
|
||||
});
|
||||
|
||||
export const dashboardModule = defineModule({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requirePermission,
|
||||
requireStaffPermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -43,16 +48,13 @@ router.get('/my', async (req, res) => {
|
||||
*/
|
||||
router.get(
|
||||
'/student/:studentNo',
|
||||
requireStaffPermission('student:grade-query'),
|
||||
validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { studentNo } = validatedParams<{ studentNo: string }>(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await gradeService.getStudentGradeDetail(user, studentNo, classId),
|
||||
'获取学生成绩明细成功',
|
||||
);
|
||||
sendOk(res, await gradeService.getStudentGradeDetail(user, studentNo, classId), '获取学生成绩明细成功');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -74,15 +76,11 @@ router.get(
|
||||
* **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集,
|
||||
* 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。
|
||||
*/
|
||||
router.get(
|
||||
'/exams',
|
||||
validate({ query: examListQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
|
||||
},
|
||||
);
|
||||
router.get('/exams', validate({ query: examListQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
|
||||
});
|
||||
|
||||
/** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */
|
||||
router.put(
|
||||
@@ -106,15 +104,11 @@ router.put(
|
||||
* 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用
|
||||
* `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。
|
||||
*/
|
||||
router.get(
|
||||
'/ranking',
|
||||
validate({ query: examRankingQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
|
||||
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
|
||||
},
|
||||
);
|
||||
router.get('/ranking', validate({ query: examRankingQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
|
||||
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
|
||||
});
|
||||
|
||||
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
|
||||
router.get(
|
||||
@@ -134,14 +128,19 @@ router.get(
|
||||
);
|
||||
|
||||
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */
|
||||
router.post('/', requirePermission('grade:write'), validate({ body: createGradeSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
res,
|
||||
await gradeService.createGrade(user, req.body as CreateGradeInput),
|
||||
'成绩录入成功,已实时推送',
|
||||
);
|
||||
});
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('grade:write'),
|
||||
validate({ body: createGradeSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
res,
|
||||
await gradeService.createGrade(user, req.body as CreateGradeInput),
|
||||
'成绩录入成功,已实时推送',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/grades/levels - 批量修改等级
|
||||
|
||||
@@ -287,13 +287,16 @@ export async function updateGradeLevels(
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
input.items.map((item) =>
|
||||
prisma.grade.update({ where: { id: item.id }, data: { level: item.level } }),
|
||||
),
|
||||
input.items.map((item) => prisma.grade.update({ where: { id: item.id }, data: { level: item.level } })),
|
||||
);
|
||||
|
||||
const sample = byId.get(input.items[0]!.id);
|
||||
if (sample) emitToClass(sample.classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: input.items.length });
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of input.items) {
|
||||
const classId = byId.get(item.id)!.classId;
|
||||
counts.set(classId, (counts.get(classId) ?? 0) + 1);
|
||||
}
|
||||
for (const [classId, count] of counts)
|
||||
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count });
|
||||
return { updated: input.items.length };
|
||||
}
|
||||
|
||||
@@ -301,6 +304,7 @@ export async function updateGradeLevels(
|
||||
const classId = input.classId!;
|
||||
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级');
|
||||
const levelType = input.levelType!;
|
||||
if (levelType === 'custom') throw ApiError.badRequest('自定义等级不能由分数推算,请逐条填写等级');
|
||||
const records = await prisma.grade.findMany({
|
||||
where: {
|
||||
classId,
|
||||
@@ -316,10 +320,7 @@ export async function updateGradeLevels(
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
levelType,
|
||||
level:
|
||||
levelType === 'percent'
|
||||
? gradeLevel(gradePercent(item.score, item.totalScore))
|
||||
: item.level,
|
||||
level: gradeLevel(gradePercent(item.score, item.totalScore)),
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -97,7 +97,7 @@ function normalizeTime(value: unknown): string | null {
|
||||
|
||||
/**
|
||||
* `WeekCountDiv` / `WeekCountDivTotal` → 我们课表的 weekParity。
|
||||
* 口径集中在 `@classhelper/shared` 的 `weekParityFromDiv`(插件上报走同一份实现),
|
||||
* 口径集中在 `@classhelper/shared` 的 `weekParityFromDiv`,
|
||||
* 这里只是本模块内的别名,保证"手动导入 JSON"与"插件实时上报"落到库里完全一致。
|
||||
*/
|
||||
const resolveParity = weekParityFromDiv;
|
||||
@@ -294,13 +294,14 @@ export async function importClassPlan(
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, teacherId: true },
|
||||
select: { id: true, teacherId: true, termWeeks: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
// 时间配置(导入的时间表)里如果只有部分节次,weekEnd 用默认 20 周
|
||||
// 导入范围与本班配置的学期长度一致。
|
||||
const weekStart = input.termStartWeek ?? 1;
|
||||
const weekEnd = 20;
|
||||
const weekEnd = classRecord.termWeeks;
|
||||
if (weekStart > weekEnd) throw ApiError.badRequest('导入起始周不能超出本班学期周数');
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1) 补建缺失课程(用班主任作为课程任课老师)
|
||||
|
||||
@@ -48,23 +48,33 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
|
||||
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
|
||||
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
|
||||
*/
|
||||
router.get('/template', requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), (req, res) => {
|
||||
const kind = resolveImportKind(req.query.kind);
|
||||
assertImportKindAllowed(kind, getAuthUser(req).role);
|
||||
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
|
||||
if (format === 'xlsx') {
|
||||
const base64 = buildTemplateXlsx(kind);
|
||||
res.setHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('content-disposition', `attachment; filename="template-${kind}.xlsx"`);
|
||||
res.send(Buffer.from(base64, 'base64'));
|
||||
return;
|
||||
}
|
||||
sendOk(
|
||||
res,
|
||||
{ kind, format, fileName: `template-${kind}.csv`, content: buildTemplateCsv(kind) },
|
||||
'模板生成成功',
|
||||
);
|
||||
});
|
||||
router.get(
|
||||
'/template',
|
||||
requireAnyPermission(
|
||||
'import:students',
|
||||
'grade:import',
|
||||
'import:teachers',
|
||||
'import:schedule',
|
||||
'import:class-teachers',
|
||||
),
|
||||
(req, res) => {
|
||||
const kind = resolveImportKind(req.query.kind);
|
||||
assertImportKindAllowed(kind, getAuthUser(req).role);
|
||||
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
|
||||
if (format === 'xlsx') {
|
||||
const base64 = buildTemplateXlsx(kind);
|
||||
res.setHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('content-disposition', `attachment; filename="template-${kind}.xlsx"`);
|
||||
res.send(Buffer.from(base64, 'base64'));
|
||||
return;
|
||||
}
|
||||
sendOk(
|
||||
res,
|
||||
{ kind, format, fileName: `template-${kind}.csv`, content: buildTemplateCsv(kind) },
|
||||
'模板生成成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
|
||||
@@ -72,7 +82,13 @@ router.get('/template', requireAnyPermission('import:students', 'grade:import',
|
||||
*/
|
||||
router.post(
|
||||
'/table/preview',
|
||||
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
|
||||
requireAnyPermission(
|
||||
'import:students',
|
||||
'grade:import',
|
||||
'import:teachers',
|
||||
'import:schedule',
|
||||
'import:class-teachers',
|
||||
),
|
||||
validate({ body: tableFileSchema }),
|
||||
(req, res) => {
|
||||
const input = validatedBody<TableFileInput>(req);
|
||||
@@ -89,13 +105,19 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/table/commit',
|
||||
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
|
||||
requireAnyPermission(
|
||||
'import:students',
|
||||
'grade:import',
|
||||
'import:teachers',
|
||||
'import:schedule',
|
||||
'import:class-teachers',
|
||||
),
|
||||
validate({ body: tableCommitSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<TableCommitInput>(req);
|
||||
assertImportKindAllowed(input.kind, getAuthUser(req).role);
|
||||
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
|
||||
if (input.kind !== 'teachers' && !input.classId) {
|
||||
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
|
||||
throw ApiError.badRequest('请先选择要导入的班级');
|
||||
}
|
||||
const result = await commitTable(getAuthUser(req), input);
|
||||
|
||||
@@ -334,7 +334,16 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
});
|
||||
// 学号在库里已归一化为大写,比对前统一一次
|
||||
const byStudentNo = new Map(students.map((item) => [normalizeStudentNo(item.studentNo), item]));
|
||||
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const item of students) {
|
||||
const key = item.name.trim().toLowerCase();
|
||||
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const byName = new Map(
|
||||
students
|
||||
.filter((item) => nameCounts.get(item.name.trim().toLowerCase()) === 1)
|
||||
.map((item) => [item.name.trim().toLowerCase(), item]),
|
||||
);
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
@@ -370,9 +379,19 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
errors.push({ row: rowNumber, message: '缺少学生标识(学号与姓名至少填写一个)' });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!byStudentNo.has(normalizeStudentNo(studentNo)) &&
|
||||
(nameCounts.get(name.trim().toLowerCase()) ?? 0) > 1
|
||||
) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `该班有 ${nameCounts.get(name.trim().toLowerCase())} 个同名同学「${name}」,请填写正确学号`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const student =
|
||||
(studentNo ? byStudentNo.get(normalizeStudentNo(studentNo)) : undefined) ??
|
||||
(name ? byName.get(name.toLowerCase()) : undefined);
|
||||
(name ? byName.get(name.trim().toLowerCase()) : undefined);
|
||||
if (!student) {
|
||||
errors.push({ row: rowNumber, message: `找不到该班学生:${studentNo || name}` });
|
||||
continue;
|
||||
@@ -492,7 +511,8 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
const classByName = new Map<string, { id: string; name: string }>();
|
||||
for (const item of allClasses) {
|
||||
const key = item.name.toLowerCase();
|
||||
if (classByName.has(key)) classByName.delete(key); // 重名 → 标记为不可用
|
||||
if (classByName.has(key))
|
||||
classByName.delete(key); // 重名 → 标记为不可用
|
||||
else classByName.set(key, item);
|
||||
}
|
||||
|
||||
@@ -555,10 +575,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
}
|
||||
if (existing.classId !== classId) {
|
||||
// 换班要留痕,与手动调班同一种历史
|
||||
const [from, to] = await Promise.all([
|
||||
classNameOf(existing.classId),
|
||||
classNameOf(classId),
|
||||
]);
|
||||
const [from, to] = await Promise.all([classNameOf(existing.classId), classNameOf(classId)]);
|
||||
await prisma.studentClassTransfer.create({
|
||||
data: {
|
||||
studentId: existing.id,
|
||||
@@ -681,7 +698,9 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`);
|
||||
logger.info(
|
||||
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -698,7 +717,10 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
|
||||
* 后面几行指向的还是同一个账号(`useTeacherNo` 打开时按工号匹配,关闭时按姓名唯一匹配),
|
||||
* 因此"班主任同时是科任老师"只会有一条教师记录,权限在 `resolveClassRole` 里合并。
|
||||
*/
|
||||
export async function commitClassTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
export async function commitClassTeachers(
|
||||
user: TokenPayload,
|
||||
input: TableCommitInput,
|
||||
): Promise<ImportResult> {
|
||||
await assertImportAllowed(user, undefined, 'classTeachers');
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
@@ -891,12 +913,13 @@ async function classNameOf(classId: string | null): Promise<string> {
|
||||
return record?.name ?? '';
|
||||
}
|
||||
|
||||
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
|
||||
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
|
||||
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
|
||||
const value = cell.trim().toUpperCase();
|
||||
if (!value) return null;
|
||||
if (value === 'ADMIN' || cell.includes('管理员') || value === '管理员') return 'ADMIN';
|
||||
return 'TEACHER';
|
||||
if (value === 'TEACHER' || value === '教师' || value === '老师') return 'TEACHER';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 班级任课老师的角色列:班主任 / 科任 */
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
|
||||
/**
|
||||
* 联动设备的令牌工具。
|
||||
*
|
||||
* 为什么不用 JWT:
|
||||
* - 令牌是「设备级长期凭证」,插件装在教室机器上,长时间无人值守,
|
||||
* 需要一个"管理员在 Web 端点了重置才失效"的凭证,而不是有固定过期时间的会话;
|
||||
* - 明文只在创建/重置接口返回一次,服务端只存 sha256 —— 库被读走也无法反推出可用令牌。
|
||||
*/
|
||||
export function generateDeviceToken(): string {
|
||||
// 32 字节随机 -> 64 位十六进制,前缀便于在日志/设置页里一眼认出
|
||||
return `chci_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
|
||||
export function hashDeviceToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/** 令牌前缀提示(chci_ + 前 8 位),用于设备列表里人眼识别 */
|
||||
export function tokenHintOf(token: string): string {
|
||||
return `${token.slice(0, 13)}…`;
|
||||
}
|
||||
|
||||
/** 从 \`X-ClassIsland-Token\` 或 \`Authorization: Bearer\` 中取出设备令牌 */
|
||||
export function extractDeviceToken(headerValue: string | undefined, custom: unknown): string | null {
|
||||
if (typeof custom === 'string' && custom.trim()) return custom.trim();
|
||||
if (!headerValue) return null;
|
||||
const [scheme, value] = headerValue.split(' ');
|
||||
if (!value || scheme.toLowerCase() !== 'bearer') return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/** 设备上下文(与 Express.Request.device 的结构一致) */
|
||||
export interface DeviceContext {
|
||||
deviceId: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
/** 是否有一次待办的「下发课表到教室」(Web 端点按钮时置位,拉取成功即清除) */
|
||||
mirrorRequested: boolean;
|
||||
}
|
||||
|
||||
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
|
||||
export function authenticateDevice(): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const token = extractDeviceToken(req.headers.authorization, req.headers['x-classisland-token']);
|
||||
if (!token) throw ApiError.unauthorized('缺少设备令牌(X-ClassIsland-Token)');
|
||||
|
||||
const record = await prisma.integrationDevice.findFirst({
|
||||
where: { tokenHash: hashDeviceToken(token) },
|
||||
select: {
|
||||
id: true,
|
||||
classId: true,
|
||||
name: true,
|
||||
enabled: true,
|
||||
syncScheduleToServer: true,
|
||||
mirrorScheduleToClassIsland: true,
|
||||
requestMirrorSchedule: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
|
||||
|
||||
const context: DeviceContext = {
|
||||
deviceId: record.id,
|
||||
classId: record.classId,
|
||||
name: record.name,
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
mirrorRequested: record.requestMirrorSchedule === true,
|
||||
};
|
||||
req.device = context;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
/** 取出当前设备上下文,未鉴权时抛 401 */
|
||||
export function getDevice(req: Request): DeviceContext {
|
||||
const device = req.device;
|
||||
if (!device) throw ApiError.unauthorized('缺少设备上下文');
|
||||
return device;
|
||||
}
|
||||
|
||||
/** 设备被管理员停用:插件上报与下发一律拒绝(403 而不是 401,便于插件提示"请管理员开启") */
|
||||
export function assertDeviceEnabled(device: DeviceContext): void {
|
||||
if (!device.enabled) {
|
||||
throw ApiError.forbidden('该设备已在「ClassIsland 联动」中被停用,请联系管理员开启');
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成的令牌字符串的字面量前缀,供测试与文档引用 */
|
||||
export const DEVICE_TOKEN_PREFIX = 'chci_';
|
||||
@@ -1,255 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as gradeService from '../grades/grades.service.js';
|
||||
import { studentGradeQuerySchema } from '../grades/grades.schemas.js';
|
||||
import { authenticateDevice, assertDeviceEnabled, getDevice } from './device-auth.js';
|
||||
import {
|
||||
ackNotificationSchema,
|
||||
classIslandReportSchema,
|
||||
createDeviceSchema,
|
||||
deviceIdParamSchema,
|
||||
listDevicesQuerySchema,
|
||||
sendNotificationSchema,
|
||||
updateDeviceSchema,
|
||||
type ClassIslandReportInput,
|
||||
type CreateDeviceInput,
|
||||
type SendNotificationInput,
|
||||
type UpdateDeviceInput,
|
||||
} from './integrations.schemas.js';
|
||||
import * as integrationService from './integrations.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/* ================================================================== 插件侧
|
||||
* 用**设备令牌**鉴权(X-ClassIsland-Token / Authorization: Bearer),不是 JWT 会话。
|
||||
* 路由放在文件最前面,避免被下面的 authenticate() 拦住;每个路由单独挂 authenticateDevice()。
|
||||
*/
|
||||
|
||||
/** POST /api/integrations/classisland/report - 插件上报当前状态 +(可选)课表 */
|
||||
router.post(
|
||||
'/classisland/report',
|
||||
authenticateDevice(),
|
||||
validate({ body: classIslandReportSchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
const result = await integrationService.applyReport(device, validatedBody<ClassIslandReportInput>(req));
|
||||
sendOk(res, result, '上报成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/integrations/classisland/pending - 插件拉取尚未确认的下发提醒(离线补齐) */
|
||||
router.get('/classisland/pending', authenticateDevice(), async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
sendOk(res, await integrationService.listPendingNotifications(device), '获取待提醒成功');
|
||||
});
|
||||
|
||||
/** POST /api/integrations/classisland/ack - 插件确认提醒已弹出(之后不再补发) */
|
||||
router.post(
|
||||
'/classisland/ack',
|
||||
authenticateDevice(),
|
||||
validate({ body: ackNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
await integrationService.ackNotification(device, validatedBody<{ id: string }>(req).id);
|
||||
sendOk(res, { acked: true }, '已确认');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/integrations/classisland/class-plan - 插件拉取本班课表(镜像回 ClassIsland) */
|
||||
router.get('/classisland/class-plan', authenticateDevice(), async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
if (!device.mirrorScheduleToClassIsland) {
|
||||
sendOk(res, null, '该设备未开启「把班级课表镜像到 ClassIsland」');
|
||||
return;
|
||||
}
|
||||
sendOk(res, await integrationService.pullClassPlanForDevice(device), '获取课表成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/integrations/classisland/student-grades?studentNo= - 班级端按学号查成绩明细
|
||||
*
|
||||
* 给"插件形态的 ClassHelper 班级端"留的通道:走设备令牌而不是 JWT。
|
||||
* 受**同一个班级开关**约束(`Class.studentGradeQueryEnabled`),与桌面客户端走的是同一条 service。
|
||||
*/
|
||||
router.get(
|
||||
'/classisland/student-grades',
|
||||
authenticateDevice(),
|
||||
validate({ query: studentGradeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
const { studentNo } = validatedQuery<{ studentNo?: string }>(req);
|
||||
if (!studentNo) throw ApiError.badRequest('请提供学号(studentNo)');
|
||||
// 设备令牌没有"账号",构造一个等价于 ClassHelper 班级端的会话载荷
|
||||
const session = {
|
||||
sub: device.classId,
|
||||
username: '',
|
||||
name: '',
|
||||
role: 'CLASS_DEVICE' as const,
|
||||
classId: device.classId,
|
||||
classSession: true,
|
||||
};
|
||||
sendOk(
|
||||
res,
|
||||
await gradeService.getStudentGradeDetail(session, studentNo, device.classId),
|
||||
'获取学生成绩明细成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* ================================================================== Web 管理端
|
||||
* 用 JWT 鉴权;权限与"课表管理 / 发通知"保持一致,由 service 内二次校验班级维度角色。
|
||||
*/
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* GET /api/integrations/devices?classId= - 联动设备列表(含最后一次上报的状态快照)
|
||||
*
|
||||
* 仅管理员/教师可见:设备列表属于管理面,学生也不需要知道教室机器接了哪台设备;
|
||||
* 教师只能看到自己可访问班级的设备(service 内按 classScope 过滤)。
|
||||
*/
|
||||
router.get(
|
||||
'/devices',
|
||||
requirePermission('device:manage'),
|
||||
validate({ query: listDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await integrationService.listDevices(getAuthUser(req), classId), '获取设备列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/integrations/classisland/status - **ClassHelper 班级端在线状态**(教师网页)
|
||||
*
|
||||
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
|
||||
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
|
||||
*/
|
||||
router.get('/classisland/status', requirePermission('device:manage'), async (req, res) => {
|
||||
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
|
||||
});
|
||||
|
||||
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
|
||||
router.post(
|
||||
'/devices',
|
||||
requirePermission('device:manage'),
|
||||
validate({ body: createDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.createDevice(
|
||||
getAuthUser(req),
|
||||
validatedBody<CreateDeviceInput>(req),
|
||||
);
|
||||
sendCreated(res, result, '设备已创建,请把令牌填入插件设置(只显示这一次)');
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
|
||||
router.patch(
|
||||
'/devices/:id',
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await integrationService.updateDevice(getAuthUser(req), id, validatedBody<UpdateDeviceInput>(req)),
|
||||
'设备已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/integrations/devices/:id/request-schedule - 「从教室机器获取一次课表」
|
||||
*
|
||||
* **这是现在取教室课表的唯一入口**(原来的自动回传已默认关闭,见
|
||||
* `IntegrationDevice.syncScheduleToServer`)。Web 端点击前会弹警告对话框,
|
||||
* 说明它会用教室机器的课表覆盖本班现有课表。
|
||||
* 置一个待办标记,插件在下一次心跳看到就立刻把课表推上来。
|
||||
*/
|
||||
router.post(
|
||||
'/devices/:id/request-schedule',
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const result = await integrationService.requestScheduleReport(getAuthUser(req), id);
|
||||
sendOk(res, result, result.nextHeartbeatHint);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/integrations/devices/:id/mirror-schedule - 「下发一次课表到教室」
|
||||
*
|
||||
* 与「获取课表」反方向、同样是一次性动作。插件在下一次心跳看到待办就去拉本班课表,
|
||||
* 并**强制替换**写进 ClassIsland(覆盖老师手排的冲突课表)。
|
||||
*
|
||||
* 为什么要做成按钮而不是后台一直同步:插件原先每 15 秒上报一次、每次上报完还顺手镜像一遍,
|
||||
* 教室课表被反复重写,服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的是
|
||||
* "课表被反复删改"(2026-10-11 实测)。
|
||||
*/
|
||||
router.post(
|
||||
'/devices/:id/mirror-schedule',
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const result = await integrationService.requestMirrorSchedule(getAuthUser(req), id);
|
||||
sendOk(res, result, result.nextHeartbeatHint);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */router.post(
|
||||
'/devices/:id/token',
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await integrationService.resetDeviceToken(getAuthUser(req), id), '令牌已重置');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/integrations/devices/:id - 删除设备 */
|
||||
router.delete(
|
||||
'/devices/:id',
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await integrationService.deleteDevice(getAuthUser(req), id);
|
||||
sendOk(res, { id }, '设备已删除');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
|
||||
router.post(
|
||||
'/classisland/notify',
|
||||
requirePermission('classisland:notify'),
|
||||
validate({ body: sendNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.sendNotification(
|
||||
getAuthUser(req),
|
||||
validatedBody<SendNotificationInput>(req),
|
||||
);
|
||||
sendOk(
|
||||
res,
|
||||
result,
|
||||
result.skipped === 'channel-client'
|
||||
? '该班教室已设置为「只在 ClassHelper 客户端显示」,本次未推送到 ClassIsland'
|
||||
: result.targetCount === 0
|
||||
? '已保存提醒,但该班级还没有已接入的 ClassIsland 设备'
|
||||
: `已下发到 ${result.delivered} 台 ClassIsland 设备`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const integrationsModule = defineModule({
|
||||
name: 'integrations',
|
||||
basePath: '/integrations',
|
||||
router,
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
WEEK_PARITY_VALUES,
|
||||
CLASSISLAND_NOTIFICATION_MAX_DURATION,
|
||||
CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
} from '@classhelper/shared';
|
||||
import { booleanFlagSchema, timeSchema } from '../../lib/schemas.js';
|
||||
|
||||
/** 1=周一 … 7=周日 */
|
||||
const dayOfWeekSchema = z.coerce.number().int().min(1).max(7);
|
||||
|
||||
export const integrationModeSchema = z.enum(['plugin', 'import']);
|
||||
export const weekParitySchema = z.enum(WEEK_PARITY_VALUES as unknown as [string, ...string[]]);
|
||||
|
||||
/* ------------------------------------------------------------------ Web 管理端 */
|
||||
|
||||
export const listDevicesQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const createDeviceSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
name: z.string().trim().max(40).optional(),
|
||||
mode: integrationModeSchema.optional(),
|
||||
/**
|
||||
* 是否允许插件把 ClassIsland 的课表**自动**回传到本服务。
|
||||
* 默认 **false**(课表以服务端为准);要取教室的课表用「从教室机器获取课表」这个手动动作。
|
||||
*/
|
||||
syncScheduleToServer: z.boolean().optional(),
|
||||
/** 是否允许把本班课表镜像回 ClassIsland。默认 **true** —— 这是现在课表同步的主方向。 */
|
||||
mirrorScheduleToClassIsland: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateDeviceSchema = z.object({
|
||||
name: z.string().trim().max(40).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
mode: integrationModeSchema.optional(),
|
||||
syncScheduleToServer: z.boolean().optional(),
|
||||
mirrorScheduleToClassIsland: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const deviceIdParamSchema = z.object({ id: z.string().min(1, '缺少设备 id') });
|
||||
|
||||
export const sendNotificationSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
title: z.string().trim().min(1, '请输入提醒标题').max(120),
|
||||
content: z.string().trim().min(1, '请输入提醒内容').max(2000),
|
||||
durationSeconds: z.coerce.number().int().min(1).max(CLASSISLAND_NOTIFICATION_MAX_DURATION).optional(),
|
||||
speech: booleanFlagSchema,
|
||||
speechContent: z.string().trim().max(500).optional(),
|
||||
saveToNotifications: z.boolean().optional(),
|
||||
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).optional(),
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ 插件侧(设备令牌鉴权) */
|
||||
|
||||
export const reportScheduleEntrySchema = z.object({
|
||||
dayOfWeek: dayOfWeekSchema,
|
||||
startTime: timeSchema,
|
||||
endTime: timeSchema,
|
||||
subject: z.string().trim().min(1, '科目不能为空').max(40),
|
||||
teacherName: z.string().trim().max(40).nullish(),
|
||||
weekParity: weekParitySchema.optional(),
|
||||
weekCountDiv: z.coerce.number().int().min(0).max(30).optional(),
|
||||
weekCountDivTotal: z.coerce.number().int().min(0).max(30).optional(),
|
||||
planName: z.string().trim().max(60).optional(),
|
||||
});
|
||||
|
||||
export const reportTimeLayoutItemSchema = z.object({
|
||||
index: z.coerce.number().int().min(0).max(200).optional(),
|
||||
name: z.string().trim().max(40).optional(),
|
||||
startTime: timeSchema,
|
||||
endTime: timeSchema,
|
||||
type: z.enum(['class', 'break', 'divider', 'action']).optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const classIslandReportSchema = z.object({
|
||||
pluginVersion: z.string().trim().max(40).optional(),
|
||||
classIslandVersion: z.string().trim().max(40).optional(),
|
||||
/**
|
||||
* 插件生成的机器码。设备创建时服务端还不知道它是谁,只能用 `pending-…` 占位,
|
||||
* 因此插件每次上报都带上它,服务端据此回填,Web 端设备列表才能与教室机器对上
|
||||
* (见 integrations.service 的 syncDeviceKey)。
|
||||
*/
|
||||
deviceKey: z.string().trim().max(64).optional(),
|
||||
state: z
|
||||
.object({
|
||||
inClass: z.boolean().optional(),
|
||||
subject: z.string().trim().max(40).nullish(),
|
||||
nextSubject: z.string().trim().max(40).nullish(),
|
||||
timeState: z.string().trim().max(40).nullish(),
|
||||
periodStart: timeSchema.nullish(),
|
||||
periodEnd: timeSchema.nullish(),
|
||||
week: z.coerce.number().int().min(1).max(60).nullish(),
|
||||
classPlanLoaded: z.boolean().optional(),
|
||||
clientTime: z.string().trim().max(40).optional(),
|
||||
})
|
||||
.optional(),
|
||||
schedule: z
|
||||
.object({
|
||||
mode: z.enum(['replace', 'merge']).optional(),
|
||||
entries: z.array(reportScheduleEntrySchema).max(500, '单次上报的课程条目过多'),
|
||||
})
|
||||
.nullish(),
|
||||
timeLayout: z
|
||||
.object({
|
||||
name: z.string().trim().max(60).optional(),
|
||||
mode: z.enum(['replace', 'merge']).optional(),
|
||||
items: z.array(reportTimeLayoutItemSchema).max(200, '单次上报的节次过多'),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
/** 插件确认某条提醒已弹出(避免重连后重复补发) */
|
||||
export const ackNotificationSchema = z.object({
|
||||
id: z.string().min(1, '缺少提醒 id'),
|
||||
});
|
||||
|
||||
/** 插件上报的默认值集中在这里,避免散落在 service 里 */
|
||||
export const REPORT_DEFAULTS = {
|
||||
scheduleMode: 'merge',
|
||||
timeLayoutMode: 'merge',
|
||||
timeLayoutName: 'ClassIsland 时间表',
|
||||
notificationDuration: CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
} as const;
|
||||
|
||||
export type CreateDeviceInput = z.infer<typeof createDeviceSchema>;
|
||||
export type UpdateDeviceInput = z.infer<typeof updateDeviceSchema>;
|
||||
export type SendNotificationInput = z.infer<typeof sendNotificationSchema>;
|
||||
export type ClassIslandReportInput = z.infer<typeof classIslandReportSchema>;
|
||||
export type ReportScheduleEntry = z.infer<typeof reportScheduleEntrySchema>;
|
||||
export type ReportTimeLayoutItem = z.infer<typeof reportTimeLayoutItemSchema>;
|
||||
@@ -1,957 +0,0 @@
|
||||
import {
|
||||
CLASS_HELPER_ONLINE_WINDOW_MS,
|
||||
SOCKET_EVENTS,
|
||||
weekDivFromParity,
|
||||
type ClassDto,
|
||||
type ClassHelperDeviceStatusDto,
|
||||
type ClassHelperStatusListDto,
|
||||
type ClassIslandPendingResult,
|
||||
type ClassIslandPushNotification,
|
||||
type ClassIslandReportResult,
|
||||
type ClassIslandScheduleEntry,
|
||||
type IntegrationDeviceDto,
|
||||
type IntegrationDeviceTokenDto,
|
||||
type SendClassIslandNotificationResult,
|
||||
type TimeLayoutEntry,
|
||||
type NotificationDto,
|
||||
} from '@classhelper/shared';
|
||||
import { assertCanManageSchedule, assertCanPublishNotification, resolveClassScope } from '../../lib/access.js';
|
||||
import { loadTermContext, resolveCurrentWeek } from '../../lib/term.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 { toNotificationChannel, toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass, emitToRooms } from '../../realtime/bus.js';
|
||||
import { SOCKET_ROOMS } from '@classhelper/shared';
|
||||
import type { DeviceContext } from './device-auth.js';
|
||||
import { generateDeviceToken, hashDeviceToken, tokenHintOf } from './device-auth.js';
|
||||
import type {
|
||||
ClassIslandReportInput,
|
||||
CreateDeviceInput,
|
||||
SendNotificationInput,
|
||||
UpdateDeviceInput,
|
||||
} from './integrations.schemas.js';
|
||||
import { REPORT_DEFAULTS } from './integrations.schemas.js';
|
||||
import {
|
||||
applyReportedSchedule,
|
||||
applyReportedTimeLayout,
|
||||
toClassIslandWeekDay,
|
||||
type MirrorClassPlan,
|
||||
} from './write-back.service.js';
|
||||
|
||||
/**
|
||||
* ClassIsland 联动服务。
|
||||
*
|
||||
* 两条链路:
|
||||
* 1) **ClassHelper → ClassIsland**:教师发通知 → 落库 + Socket.IO 广播 →
|
||||
* 教室机器上的插件弹出全屏提醒(离线时先落库,插件重连后拉取补发);
|
||||
* 2) **ClassIsland → ClassHelper**:插件把"当前上什么课 + 全量课表"上报 →
|
||||
* 本服务按设备令牌解析出所属班级并写库 → 广播 `classisland:state` 给 Web 端实时展示。
|
||||
*
|
||||
* 权限口径:
|
||||
* - 设备管理(创建/重置令牌/启停):与"课表管理"一致(管理员或本班班主任);
|
||||
* - 下发提醒:与"发通知"一致(管理员 / 班主任 / 科任老师)。
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ 设备管理(Web 端,JWT 鉴权) */
|
||||
|
||||
/**
|
||||
* 设备记录 -> DTO(tokenHash 永不出库)。
|
||||
*
|
||||
* `online` 由**最后一次心跳**(`lastHeartbeatAt`)与共享的
|
||||
* `CLASS_HELPER_ONLINE_WINDOW_MS`(60 秒)算出,而不是最后一次带状态的上报 ——
|
||||
* 插件每 60 秒打一次心跳,因此"最近一分钟内有心跳"才是最贴合的口径。
|
||||
*/
|
||||
function toDeviceDto(record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
deviceKey: string;
|
||||
mode: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
requestScheduleReport: boolean;
|
||||
classIslandVersion: string | null;
|
||||
pluginVersion: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
lastHeartbeatAt: Date | null;
|
||||
classPlanLoaded: boolean;
|
||||
currentSubject: string | null;
|
||||
currentTimeState: string | null;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
nextSubject: string | null;
|
||||
tokenHint: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
class?: { name: string } | null;
|
||||
}): IntegrationDeviceDto {
|
||||
const heartbeat = record.lastHeartbeatAt ?? record.lastSeenAt;
|
||||
return {
|
||||
id: record.id,
|
||||
classId: record.classId,
|
||||
className: record.class?.name ?? null,
|
||||
name: record.name,
|
||||
deviceKey: record.deviceKey,
|
||||
mode: record.mode === 'import' ? 'import' : 'plugin',
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
requestScheduleReport: record.requestScheduleReport,
|
||||
classIslandVersion: record.classIslandVersion,
|
||||
pluginVersion: record.pluginVersion,
|
||||
lastSeenAt: record.lastSeenAt ? record.lastSeenAt.toISOString() : null,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
online: Boolean(record.enabled && heartbeat && Date.now() - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
|
||||
classPlanLoaded: record.classPlanLoaded,
|
||||
currentSubject: record.currentSubject,
|
||||
currentTimeState: record.currentTimeState,
|
||||
currentPeriodStart: record.currentPeriodStart,
|
||||
currentPeriodEnd: record.currentPeriodEnd,
|
||||
nextSubject: record.nextSubject,
|
||||
tokenHint: record.tokenHint,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/integrations/classisland/status —— **ClassHelper 班级端在线状态**(教师网页)。
|
||||
*
|
||||
* 范围:管理员看全部班级,普通教师看自己担任班主任的班级 ∪ 任课(有 Course)的班级。
|
||||
* 这是「教师网页可查看班级 ClassHelper 在线状态」这条需求的落点。
|
||||
*/
|
||||
export async function listClassHelperStatus(user: TokenPayload): Promise<ClassHelperStatusListDto> {
|
||||
const scope = await resolveClassScope(user);
|
||||
const classes = await prisma.class.findMany({
|
||||
where: {
|
||||
...(scope.mode === 'all' ? {} : { id: { in: scope.classIds } }),
|
||||
// 归档班级的班级端已经停用,不列出来
|
||||
archivedYearId: null,
|
||||
},
|
||||
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
studentGradeQueryEnabled: true,
|
||||
integrationDevices: {
|
||||
where: { mode: 'plugin' },
|
||||
orderBy: [{ lastHeartbeatAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
deviceKey: true,
|
||||
enabled: true,
|
||||
lastSeenAt: true,
|
||||
lastHeartbeatAt: true,
|
||||
pluginVersion: true,
|
||||
classIslandVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
serverTime: new Date().toISOString(),
|
||||
onlineWindowMs: CLASS_HELPER_ONLINE_WINDOW_MS,
|
||||
classes: classes.map((item) => {
|
||||
const devices: ClassHelperDeviceStatusDto[] = item.integrationDevices.map((device) => {
|
||||
const heartbeat = device.lastHeartbeatAt ?? device.lastSeenAt;
|
||||
return {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
deviceKey: device.deviceKey,
|
||||
enabled: device.enabled,
|
||||
online: Boolean(device.enabled && heartbeat && now - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
|
||||
lastSeenAt: device.lastSeenAt ? device.lastSeenAt.toISOString() : null,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
pluginVersion: device.pluginVersion,
|
||||
classIslandVersion: device.classIslandVersion,
|
||||
};
|
||||
});
|
||||
return {
|
||||
classId: item.id,
|
||||
className: item.name,
|
||||
grade: item.grade,
|
||||
devices,
|
||||
online: devices.some((device) => device.online),
|
||||
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDevices(user: TokenPayload, classId?: string): Promise<IntegrationDeviceDto[]> {
|
||||
// 管理员不传 classId 时看全部;教师只看自己可访问的班级
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
const records = await prisma.integrationDevice.findMany({
|
||||
where: scope.mode === 'all' ? {} : { classId: { in: scope.classIds } },
|
||||
include: { class: { select: { name: true } } },
|
||||
orderBy: [{ classId: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return records.map(toDeviceDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建设备:一次性返回明文令牌。
|
||||
* deviceKey 由插件上报的机器码决定,但创建时还不知道机器码,
|
||||
* 因此这里用随机值占位,插件首次上报时按 token 找到记录并回填真实机器码。
|
||||
*/
|
||||
export async function createDevice(
|
||||
user: TokenPayload,
|
||||
input: CreateDeviceInput,
|
||||
): Promise<IntegrationDeviceTokenDto> {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const token = generateDeviceToken();
|
||||
const created = await prisma.integrationDevice.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
name: input.name?.trim() || `${classRecord.name} ClassHelper 班级端`,
|
||||
deviceKey: `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
mode: input.mode ?? 'plugin',
|
||||
tokenHash: hashDeviceToken(token),
|
||||
tokenHint: tokenHintOf(token),
|
||||
// 同步方向(2026-10-06 起):课表以服务端为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上来」改成 Web 端的一次性人工动作,所以默认关掉自动回传。
|
||||
syncScheduleToServer: input.syncScheduleToServer === true,
|
||||
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland !== false,
|
||||
},
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
|
||||
logger.info(`ClassIsland 联动设备已创建:班级=${classRecord.name} 设备=${created.name}`);
|
||||
return { device: toDeviceDto(created), token };
|
||||
}
|
||||
|
||||
async function findDeviceOrFail(id: string): Promise<{
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
/** 「下发课表到教室」要按它判断这台设备接不接受下发(见 requestMirrorSchedule) */
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
}> {
|
||||
const record = await prisma.integrationDevice.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, name: true, enabled: true, mirrorScheduleToClassIsland: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('设备不存在或已被删除');
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function updateDevice(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
input: UpdateDeviceInput,
|
||||
): Promise<IntegrationDeviceDto> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
|
||||
const updated = await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() || current.name } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(input.mode !== undefined ? { mode: input.mode } : {}),
|
||||
...(input.syncScheduleToServer !== undefined
|
||||
? { syncScheduleToServer: input.syncScheduleToServer }
|
||||
: {}),
|
||||
...(input.mirrorScheduleToClassIsland !== undefined
|
||||
? { mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland }
|
||||
: {}),
|
||||
},
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
logger.info(`ClassIsland 联动设备已更新:${updated.name} enabled=${updated.enabled}`);
|
||||
return toDeviceDto(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「**从教室机器获取一次课表**」的待办(Web 端手动动作)。
|
||||
*
|
||||
* 为什么是"待办"而不是直接拉:课表由**插件主动上报**,服务端没有反向通道去催它。
|
||||
* 所以这里只置一个标记,插件在下一次上报(默认 60 秒一次的心跳)看到
|
||||
* `settings.scheduleRequested = true` 就立刻把当前课表推上来并完成待办。
|
||||
*
|
||||
* 它独立于 `syncScheduleToServer`:后者是"允许自动回传",现在默认**关闭**
|
||||
* (自动回传会在老师手排课之后被教室的旧课表悄悄覆盖);
|
||||
* 管理员显式点"获取课表"时仍然应当能取到一次。
|
||||
*/
|
||||
export async function requestScheduleReport(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
if (!current.enabled) throw ApiError.badRequest('该设备已停用,请先启用后再获取课表');
|
||||
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { requestScheduleReport: true },
|
||||
});
|
||||
logger.info(`已请求设备上报课表:${current.name}(classId=${current.classId})由 ${user.name} 发起`);
|
||||
return {
|
||||
requested: true,
|
||||
deviceName: current.name,
|
||||
nextHeartbeatHint: '教室机器会在下一次心跳(约 1 分钟内)把当前课表上报上来',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「**下发一次课表到教室**」的待办(Web 端手动动作)。
|
||||
*
|
||||
* 与 `requestScheduleReport` 对称:服务端没有反向通道去催插件,所以只置一个标记,
|
||||
* 插件在下一次心跳看到 `settings.mirrorRequested = true` 就去拉本班课表、
|
||||
* **强制替换**写进 ClassIsland(见插件侧 `ClassPlanWriter` 的 force 语义)。
|
||||
*
|
||||
* 为什么要改成手动(2026-10-11):插件原先每 15 秒上报一次、每次上报完还顺手镜像一遍
|
||||
* (插件侧的 `MirrorSchedule` 开关),教室课表被反复重写、服务端也每 15 秒 merge + 清理
|
||||
* 一次幽灵行 —— 老师看到的是"课表被反复删改"。两个方向现在都只由点击触发。
|
||||
*/
|
||||
export async function requestMirrorSchedule(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
if (!current.enabled) throw ApiError.badRequest('该设备已停用,请先启用后再下发课表');
|
||||
if (!current.mirrorScheduleToClassIsland) {
|
||||
throw ApiError.badRequest('这台设备没有开启「允许下发课表」,请先在设备设置里打开');
|
||||
}
|
||||
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { requestMirrorSchedule: true },
|
||||
});
|
||||
logger.info(`已请求向设备下发课表:${current.name}(classId=${current.classId})由 ${user.name} 发起`);
|
||||
return {
|
||||
requested: true,
|
||||
deviceName: current.name,
|
||||
nextHeartbeatHint: '教室机器会在下一次心跳(约 1 分钟内)拉取并强制替换教室课表',
|
||||
};
|
||||
}
|
||||
|
||||
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
|
||||
export async function resetDeviceToken(
|
||||
user: TokenPayload, deviceId: string,
|
||||
): Promise<IntegrationDeviceTokenDto> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
|
||||
const token = generateDeviceToken();
|
||||
const updated = await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { tokenHash: hashDeviceToken(token), tokenHint: tokenHintOf(token) },
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
logger.warn(`ClassIsland 联动设备令牌已重置:${updated.name}(旧令牌立即失效)`);
|
||||
return { device: toDeviceDto(updated), token };
|
||||
}
|
||||
|
||||
export async function deleteDevice(user: TokenPayload, deviceId: string): Promise<void> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
await prisma.integrationDevice.delete({ where: { id: deviceId } });
|
||||
logger.info(`ClassIsland 联动设备已删除:${current.name}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 提醒下发(Web 端 → ClassIsland) */
|
||||
|
||||
function toPushDto(
|
||||
record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
durationSeconds: number;
|
||||
speechContent: string | null;
|
||||
urgent: boolean;
|
||||
kind?: string | null;
|
||||
createdAt: Date;
|
||||
},
|
||||
extras: { className?: string | null; teacherName?: string | null } = {},
|
||||
): ClassIslandPushNotification {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
content: record.content,
|
||||
durationSeconds: record.durationSeconds,
|
||||
speechContent: record.speechContent,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
urgent: record.urgent,
|
||||
// 老数据(迁移前)kind 为空:按普通通知处理
|
||||
kind: record.kind === 'call' ? 'call' : 'notification',
|
||||
classId: record.classId,
|
||||
className: extras.className ?? null,
|
||||
teacherName: extras.teacherName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发一条提醒到该班级的 ClassIsland 设备。
|
||||
*
|
||||
* - 落库(ClassIslandPush)→ 在线设备通过 Socket.IO 立即收到;离线设备重连后走
|
||||
* `GET /api/integrations/classisland/pending` 补齐,保证"老师按了发送就一定送到";
|
||||
* - `saveToNotifications` 默认 true:同时写一条班级通知,ClassHelper 班级端通知中心/灵动岛也能看到,
|
||||
* 避免"只在 ClassIsland 上弹了一下,事后查无此事"。
|
||||
*/
|
||||
export async function sendNotification(
|
||||
user: TokenPayload,
|
||||
input: SendNotificationInput,
|
||||
): Promise<SendClassIslandNotificationResult> {
|
||||
await assertCanPublishNotification(user, input.classId);
|
||||
|
||||
const teacher = await prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
const teacherName = teacher?.name ?? teacher?.username ?? null;
|
||||
|
||||
const priority = input.priority ?? 'NORMAL';
|
||||
let notificationId: string | null = null;
|
||||
|
||||
// 教室在客户端里选了"只在 ClassHelper 客户端显示"时,**任何入口**都不再推 ClassIsland
|
||||
// (包括这个专门发给 ClassIsland 的入口)—— 规则只有一条,老师不用记"哪个页面有例外"。
|
||||
// 仍然把通知落库,并把跳过原因回给调用方,由 Web 端提示"不是没送达,是教室没要"。
|
||||
const allowed = await shouldPushToClassIsland(input.classId);
|
||||
|
||||
if (input.saveToNotifications !== false) {
|
||||
const created = await prisma.notification.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
},
|
||||
include: { creator: { select: { id: true, name: true, username: true } }, reads: true },
|
||||
});
|
||||
notificationId = created.id;
|
||||
const dto: NotificationDto = toNotificationDto(created, { withStatus: true });
|
||||
// ClassHelper 班级端照旧收到通知中心的实时推送
|
||||
emitToClass(input.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
logger.info(`ClassIsland 提醒被跳过:班级=${input.classId} 已选择"只在 ClassHelper 客户端显示"`);
|
||||
return { delivered: 0, targetCount: 0, notificationId, skipped: 'channel-client' };
|
||||
}
|
||||
|
||||
const result = await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
durationSeconds: input.durationSeconds,
|
||||
speechContent: input.speech
|
||||
? input.speechContent?.trim() || `${input.title}。${input.content}`
|
||||
: null,
|
||||
urgent: priority === 'URGENT',
|
||||
createdBy: user.sub,
|
||||
notificationId,
|
||||
teacherName,
|
||||
});
|
||||
|
||||
return { ...result, notificationId, skipped: null };
|
||||
}
|
||||
|
||||
/** 推送所需的入参:三个发布入口(联动页 / 通知发布 / 叫人)共用同一套语义 */
|
||||
export interface PushToClassIslandInput {
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
/** 显示时长(秒),留空用默认值 */
|
||||
durationSeconds?: number;
|
||||
/** 语音朗读内容;null = 不朗读 */
|
||||
speechContent?: string | null;
|
||||
urgent?: boolean;
|
||||
createdBy?: string | null;
|
||||
/** 若这次提醒也落了通知中心的库,把通知 id 带上,便于事后追溯 */
|
||||
notificationId?: string | null;
|
||||
/** 覆盖落库/广播时附带的教师名(三个入口的取法不同) */
|
||||
teacherName?: string | null;
|
||||
/** 提醒类型:notification(默认)/ call(叫人,算「主动通知」) */
|
||||
kind?: 'notification' | 'call';
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条提醒推给某个班的 ClassIsland 设备(落库 + 广播)。
|
||||
*
|
||||
* 这是**三个发布入口的唯一共用实现**:
|
||||
* 1. 联动页的「下发提醒」(sendNotification);
|
||||
* 2. 通知发布页的 POST /notifications;
|
||||
* 3. 叫人 POST /calls。
|
||||
* 因此"换个页面发通知就联动不到 ClassIsland"这类不一致不会再出现。
|
||||
*
|
||||
* 调用方负责权限校验与(必要时)显示位置开关判断。
|
||||
*/
|
||||
export async function pushToClassIsland(
|
||||
input: PushToClassIslandInput,
|
||||
): Promise<{ delivered: number; targetCount: number }> {
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId: input.classId, enabled: true, mode: 'plugin' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// 一个已接入的设备都没有时**不落推送记录**:
|
||||
// 否则任何"发通知"的动作(通知发布 / 叫人 / 冒烟脚本)都会给该班堆一条待提醒,
|
||||
// 等这台教室真的装上插件时被一次性补发一堆过期内容。
|
||||
// 教室机器只是离线(设备存在但没上报)的情况不受影响 —— 那正是"离线补齐"要覆盖的场景。
|
||||
if (devices.length === 0) {
|
||||
logger.info(`跳过 ClassIsland 推送:班级=${classRecord.name} 还没有已接入的设备`);
|
||||
return { delivered: 0, targetCount: 0 };
|
||||
}
|
||||
|
||||
const push = await prisma.classIslandPush.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
durationSeconds: input.durationSeconds ?? REPORT_DEFAULTS.notificationDuration,
|
||||
speechContent: input.speechContent ?? null,
|
||||
urgent: input.urgent === true,
|
||||
kind: input.kind ?? 'notification',
|
||||
createdBy: input.createdBy ?? null,
|
||||
notificationId: input.notificationId ?? null,
|
||||
// 24 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
emitToClass(input.classId, SOCKET_EVENTS.classislandNotification, {
|
||||
...toPushDto(push, { className: classRecord.name, teacherName: input.teacherName ?? null }),
|
||||
targetCount: devices.length,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`ClassIsland 提醒下发:班级=${classRecord.name} 设备数=${devices.length} 标题="${input.title}"`,
|
||||
);
|
||||
return { delivered: devices.length, targetCount: devices.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级的通知显示位置(由教室的班级客户端在设置页里选)。
|
||||
*
|
||||
* - `client`:教室里选了"只在 ClassHelper 客户端弹",因此**不要**推 ClassIsland;
|
||||
* - 其余(both / classisland / 非法值)都推 —— 非法值按默认 both 处理,宁可多送一次也不要静默丢提醒。
|
||||
*/
|
||||
export async function shouldPushToClassIsland(classId: string): Promise<boolean> {
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { notificationChannel: true },
|
||||
});
|
||||
return toNotificationChannel(record?.notificationChannel) !== 'client';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 插件侧(设备令牌鉴权) */
|
||||
|
||||
/**
|
||||
* 插件上报:状态 + 可选的全量课表/节次时间。
|
||||
*
|
||||
* 关键点:**班级由设备令牌决定**,插件无法指定 classId,
|
||||
* 因此一台被绑定到 A 班的机器不可能把课表写进 B 班。
|
||||
*/
|
||||
export async function applyReport(
|
||||
device: DeviceContext,
|
||||
input: ClassIslandReportInput,
|
||||
): Promise<ClassIslandReportResult> {
|
||||
const now = new Date();
|
||||
// 周次口径按**本班**算(管理员可以给每个班单独配开学日期与逐周区间)
|
||||
const week = resolveCurrentWeek(now, await loadTermContext(device.classId));
|
||||
|
||||
// 0) 机器码回填(设备创建时只能用 pending-… 占位,首次上报才知道它到底是哪台机器)
|
||||
if (input.deviceKey) await syncDeviceKey(device, input.deviceKey);
|
||||
|
||||
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
||||
//
|
||||
// 心跳与"带状态的上报"分开记:`lastHeartbeatAt` 每次上报都刷(在线判定看它),
|
||||
// `lastSeenAt` 只在确实带了运行状态时刷 —— 这样"连得上但一直没上报状态"的设备
|
||||
// 不会被误判成一切正常。
|
||||
const state = input.state;
|
||||
const currentDevice = await prisma.integrationDevice.findUnique({
|
||||
where: { id: device.deviceId },
|
||||
select: { requestScheduleReport: true },
|
||||
});
|
||||
const scheduleRequested = currentDevice?.requestScheduleReport === true;
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: device.deviceId },
|
||||
data: {
|
||||
lastHeartbeatAt: now,
|
||||
// 这次真的带了课表上来 → 待办完成
|
||||
...(input.schedule ? { requestScheduleReport: false } : {}),
|
||||
...(state ? { lastSeenAt: now } : {}),
|
||||
...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}),
|
||||
...(input.classIslandVersion ? { classIslandVersion: input.classIslandVersion } : {}),
|
||||
...(state
|
||||
? {
|
||||
classPlanLoaded: state.classPlanLoaded === true,
|
||||
currentSubject: state.subject ?? null,
|
||||
currentTimeState: state.timeState ?? null,
|
||||
currentPeriodStart: state.periodStart ?? null,
|
||||
currentPeriodEnd: state.periodEnd ?? null,
|
||||
nextSubject: state.nextSubject ?? null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (state) {
|
||||
emitToRooms([SOCKET_ROOMS.class(device.classId), SOCKET_ROOMS.teachers], SOCKET_EVENTS.classislandState, {
|
||||
classId: device.classId,
|
||||
deviceId: device.deviceId,
|
||||
deviceName: device.name,
|
||||
inClass: state.inClass === true,
|
||||
subject: state.subject ?? null,
|
||||
nextSubject: state.nextSubject ?? null,
|
||||
timeState: state.timeState ?? null,
|
||||
periodStart: state.periodStart ?? null,
|
||||
periodEnd: state.periodEnd ?? null,
|
||||
week: state.week ?? week,
|
||||
classPlanLoaded: state.classPlanLoaded === true,
|
||||
at: now.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2) 课表写入(受设备开关与班级设置双重约束)
|
||||
let scheduleApplied = false;
|
||||
let scheduleCreated = 0;
|
||||
let scheduleUpdated = 0;
|
||||
let courses: string[] = [];
|
||||
if (input.schedule && input.schedule.entries.length > 0) {
|
||||
if (!device.syncScheduleToServer) {
|
||||
logger.warn(`ClassIsland 上报课表被忽略:设备「${device.name}」的"回传课表到服务端"开关已关闭`);
|
||||
} else {
|
||||
const result = await applyReportedSchedule(
|
||||
device.classId,
|
||||
input.schedule.mode ?? REPORT_DEFAULTS.scheduleMode,
|
||||
input.schedule.entries,
|
||||
);
|
||||
scheduleApplied = result.applied;
|
||||
scheduleCreated = result.created;
|
||||
scheduleUpdated = result.updated;
|
||||
courses = result.courses;
|
||||
if (result.applied) {
|
||||
emitToClass(device.classId, SOCKET_EVENTS.scheduleUpdated, {
|
||||
classId: device.classId,
|
||||
action: 'imported',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 节次时间表写入
|
||||
let timeLayoutApplied = false;
|
||||
if (input.timeLayout && input.timeLayout.items.length > 0) {
|
||||
const result = await applyReportedTimeLayout(
|
||||
device.classId,
|
||||
input.timeLayout.name?.trim() || REPORT_DEFAULTS.timeLayoutName,
|
||||
input.timeLayout.items,
|
||||
);
|
||||
timeLayoutApplied = result.applied;
|
||||
}
|
||||
|
||||
// 4) 取出尚未确认的下发提醒(插件刚启动时把漏掉的提醒立刻弹出来)
|
||||
const pending = await pendingNotificationFor(device.deviceId, device.classId);
|
||||
|
||||
return {
|
||||
scheduleApplied,
|
||||
scheduleCreated,
|
||||
scheduleUpdated,
|
||||
courses,
|
||||
timeLayoutApplied,
|
||||
week,
|
||||
serverTime: now.toISOString(),
|
||||
settings: {
|
||||
mirrorScheduleToClassIsland: device.mirrorScheduleToClassIsland,
|
||||
enabled: device.enabled,
|
||||
// 「从教室机器取一次课表」的待办:Web 端点过之后置位,插件看到就立刻推一次课表上来。
|
||||
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
|
||||
// 但管理员显式点"获取课表"时仍然应当能取到。
|
||||
scheduleRequested: scheduleRequested,
|
||||
// 「下发一次课表到教室」的待办(反方向)。插件看到就去拉本班课表并强制替换,
|
||||
// 拉取成功时由 `pullClassPlanForDevice` 清除标记 —— 与上面那条对称。
|
||||
mirrorRequested: device.mirrorRequested,
|
||||
},
|
||||
pendingNotification: pending,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把插件上报的机器码回填到设备记录。
|
||||
*
|
||||
* `IntegrationDevice` 上有 `@@unique([classId, deviceKey])`,因此要避开两个坑:
|
||||
* 1) 同一台机器在这个班已有另一条设备记录(例如管理员重复建过设备)时直接写会撞唯一约束 → 500,
|
||||
* 这里改为记 warning 并保留原值;
|
||||
* 2) 值没变时不必白写一次库。
|
||||
*/
|
||||
async function syncDeviceKey(device: DeviceContext, reportedKey: string): Promise<void> {
|
||||
const key = reportedKey.trim();
|
||||
if (!key) return;
|
||||
|
||||
const current = await prisma.integrationDevice.findUnique({
|
||||
where: { id: device.deviceId },
|
||||
select: { deviceKey: true },
|
||||
});
|
||||
if (!current || current.deviceKey === key) return;
|
||||
|
||||
const taken = await prisma.integrationDevice.findFirst({
|
||||
where: { classId: device.classId, deviceKey: key, id: { not: device.deviceId } },
|
||||
select: { name: true },
|
||||
});
|
||||
if (taken) {
|
||||
logger.warn(
|
||||
`ClassIsland 上报的机器码「${key}」已被本班设备「${taken.name}」占用,本次不回填(设备=${device.name})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.integrationDevice.update({ where: { id: device.deviceId }, data: { deviceKey: key } });
|
||||
logger.info(`ClassIsland 设备机器码已回填:${device.name} -> ${key}`);
|
||||
}
|
||||
|
||||
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
||||
async function pendingNotificationFor(
|
||||
deviceId: string,
|
||||
classId: string,
|
||||
): Promise<ClassIslandPushNotification | null> {
|
||||
const record = await prisma.classIslandPush.findFirst({
|
||||
where: {
|
||||
classId,
|
||||
ackedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
OR: [{ deviceId: null }, { deviceId }],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!record) return null;
|
||||
return toPushDto(record);
|
||||
}
|
||||
|
||||
/** 插件重连/定时轮询:拉取本班所有未确认的提醒 */
|
||||
export async function listPendingNotifications(device: DeviceContext): Promise<ClassIslandPendingResult> {
|
||||
const now = new Date();
|
||||
const records = await prisma.classIslandPush.findMany({
|
||||
where: {
|
||||
classId: device.classId,
|
||||
ackedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
OR: [{ deviceId: null }, { deviceId: device.deviceId }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 20,
|
||||
});
|
||||
return {
|
||||
notifications: records.map((item) => toPushDto(item)),
|
||||
serverTime: now.toISOString(),
|
||||
week: resolveCurrentWeek(now, await loadTermContext(device.classId)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 插件确认提醒已弹出(之后不再补发) */
|
||||
export async function ackNotification(device: DeviceContext, id: string): Promise<void> {
|
||||
const record = await prisma.classIslandPush.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, deviceId: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('提醒不存在');
|
||||
if (record.classId !== device.classId) throw ApiError.forbidden('该提醒不属于本设备所在班级');
|
||||
await prisma.classIslandPush.update({
|
||||
where: { id },
|
||||
data: { ackedAt: new Date(), ...(record.deviceId ? {} : { deviceId: device.deviceId }) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件拉取本班课表(mirrorScheduleToClassIsland 打开时,
|
||||
* 插件把它转换后覆盖写入 ClassIsland,实现"教师在 Web 端排课 → 教室课表跟着变")。
|
||||
*/
|
||||
export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
||||
classPlan: MirrorClassPlan;
|
||||
week: number;
|
||||
/** 学期周次区间(管理员逐周配置的);空数组 = 按学期开始日期线性推算 */
|
||||
weekRanges: Array<{ weekNumber: number; startDate: string; endDate: string }>;
|
||||
serverTime: string;
|
||||
}> {
|
||||
const now = new Date();
|
||||
// 周次口径按**本班**算:管理员可以给每个班单独配开学日期与逐周区间(调休、错峰开学)
|
||||
const termContext = await loadTermContext(device.classId);
|
||||
const week = resolveCurrentWeek(now, termContext);
|
||||
|
||||
// 「下发一次课表」的待办到这里就算完成:插件已经来拉了,拿到就会写进 ClassIsland。
|
||||
// **必须清掉** —— 不清的话每次心跳都会重新下发一遍,又变回"一直下发",
|
||||
// 而这正是老师反映"课表被反复删改"的源头。
|
||||
if (device.mirrorRequested) {
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: device.deviceId },
|
||||
data: { requestMirrorSchedule: false },
|
||||
});
|
||||
logger.info(`课表下发待办已完成:${device.name}(classId=${device.classId})`);
|
||||
}
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: device.classId },
|
||||
select: { id: true, name: true, termWeeks: true, termStartDate: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const [schedules, timeLayouts] = await Promise.all([
|
||||
prisma.schedule.findMany({
|
||||
where: { classId: device.classId },
|
||||
include: { course: { select: { id: true, name: true } } },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
}),
|
||||
prisma.timeLayout.findMany({
|
||||
where: { classId: device.classId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 节次时间表:优先用班级里已导入的 ClassIsland 时间表,其次按课表的开始时间合成
|
||||
const layoutId = `ch-${device.classId}`;
|
||||
const layoutName = timeLayouts[0]?.name ?? 'ClassHelper时间表';
|
||||
let layouts: { startTime: string; endTime: string; timeType: number }[] = [];
|
||||
if (timeLayouts.length > 0 && timeLayouts[0]) {
|
||||
layouts = parseStoredTimeLayout(timeLayouts[0].items).map((item) => ({
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
// 本系统的 class->0(上课)、break->1(课间)、divider->2(分割线)
|
||||
timeType: item.type === 'break' ? 1 : item.type === 'divider' ? 2 : 0,
|
||||
}));
|
||||
} else {
|
||||
layouts = synthesizeTimeLayout(schedules);
|
||||
}
|
||||
|
||||
const entries = schedules.map((item) => {
|
||||
const parity = weekDivFromParity((item.weekParity as 'ALL' | 'ODD' | 'EVEN' | null) ?? 'ALL');
|
||||
return {
|
||||
weekDay: toClassIslandWeekDay(item.dayOfWeek),
|
||||
weekCountDiv: parity.weekCountDiv,
|
||||
weekCountDivTotal: parity.weekCountDivTotal,
|
||||
subject: item.course?.name ?? '未命名科目',
|
||||
teacherName: null,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
timeLayoutId: layoutId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
classPlan: {
|
||||
entries,
|
||||
timeLayouts: [{ id: layoutId, name: layoutName, layouts }],
|
||||
profileName: `ClassHelper-${classRecord.name}`,
|
||||
// 下发本班实际生效的开学日期(管理员可以为某个班单独配,优先级高于全局 .env)
|
||||
termStartDate: termContext.termStartDate,
|
||||
},
|
||||
week,
|
||||
weekRanges: termContext.weeks,
|
||||
serverTime: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「课表/周次变了,请重新镜像」的轻量通知。
|
||||
*
|
||||
* 插件是**轮询**镜像的(不订阅 Socket.IO),所以这里只做两件事:
|
||||
* 广播给 Web 端让页面刷新,以及把该班的设备标记为"有新版本"。
|
||||
* 真正的下发发生在插件下一次拉取课表时 —— 它会把新的开学日期与周次区间一并带走。
|
||||
*/
|
||||
export async function notifyScheduleChanged(classId: string): Promise<void> {
|
||||
emitToRooms([SOCKET_ROOMS.class(classId), SOCKET_ROOMS.teachers], SOCKET_EVENTS.scheduleUpdated, {
|
||||
classId,
|
||||
action: 'imported',
|
||||
});
|
||||
logger.info(`课表/周次已变更,等待教室机器下次镜像时下发:classId=${classId}`);
|
||||
}
|
||||
|
||||
/** 解析库里存的节次 JSON(容错:坏数据不抛异常,退化为空) */
|
||||
function parseStoredTimeLayout(raw: string): { startTime: string; endTime: string; type: string }[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item) => item as Partial<TimeLayoutEntry>)
|
||||
.filter(
|
||||
(item): item is Partial<TimeLayoutEntry> & { startTime: string; endTime: string } =>
|
||||
typeof item.startTime === 'string' && typeof item.endTime === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
type: typeof item.type === 'string' ? item.type : 'class',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有导入节次时间表时,用课表里出现过的起止时间合成一份(去重、按时间排序)。
|
||||
*
|
||||
* **每节课之间补一个课间**(timeType=1:上一节下课 → 下一节上课),最后一节之后不补(放学)。
|
||||
* ClassHelper这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
|
||||
*/
|
||||
function synthesizeTimeLayout(
|
||||
schedules: { startTime: string; endTime: string }[],
|
||||
): { startTime: string; endTime: string; timeType: number }[] {
|
||||
const seen = new Map<string, string>();
|
||||
for (const item of schedules) {
|
||||
if (!seen.has(item.startTime)) seen.set(item.startTime, item.endTime);
|
||||
}
|
||||
|
||||
const classPoints = [...seen.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([startTime, endTime]) => ({ startTime, endTime, timeType: 0 }));
|
||||
|
||||
const items: { startTime: string; endTime: string; timeType: number }[] = [];
|
||||
for (let index = 0; index < classPoints.length; index += 1) {
|
||||
const point = classPoints[index]!;
|
||||
items.push(point);
|
||||
const next = classPoints[index + 1];
|
||||
if (!next) continue; // 最后一节之后是放学,不插课间
|
||||
if (next.startTime > point.endTime) {
|
||||
items.push({ startTime: point.endTime, endTime: next.startTime, timeType: 1 });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 供仪表盘/统计使用 */
|
||||
|
||||
/** 某个班级当前是否已有在线设备(用于 Web 端提示"该班尚未接入 ClassIsland") */
|
||||
export async function countEnabledDevices(classIds: string[]): Promise<number> {
|
||||
if (classIds.length === 0) return 0;
|
||||
return prisma.integrationDevice.count({
|
||||
where: { classId: { in: classIds }, enabled: true, mode: 'plugin' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 供 ClassDto 附加"是否已接入 ClassIsland"(Web 端班级列表里显示徽标) */
|
||||
export async function attachIntegrationFlag(classes: ClassDto[]): Promise<ClassDto[]> {
|
||||
if (classes.length === 0) return classes;
|
||||
const ids = classes.map((item) => item.id);
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId: { in: ids }, enabled: true },
|
||||
select: { classId: true },
|
||||
});
|
||||
const connected = new Set(devices.map((item) => item.classId));
|
||||
return classes.map((item) => ({ ...item, classIslandConnected: connected.has(item.id) }));
|
||||
}
|
||||
|
||||
export type { ClassIslandScheduleEntry };
|
||||
@@ -1,271 +0,0 @@
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { weekParityFromDiv } from '@classhelper/shared';
|
||||
import type { ReportScheduleEntry, ReportTimeLayoutItem } from './integrations.schemas.js';
|
||||
|
||||
/**
|
||||
* 插件上报的课表 / 节次时间 → 写入本服务。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1) **复用导入模块的口径**:与 `modules/imports/class-plan.service.ts`、
|
||||
* `time-layout.service.ts` 完全一致 —— 缺失的科目自动补建课程(任课老师取班主任)、
|
||||
* merge 按 `星期 + 开始时间 + 单双周` 去重、replace 先清空再写入。
|
||||
* 这样"插件实时上报"和"手动导入 JSON"落到库里的结果是一样的,不会出现两套语义。
|
||||
* 2) **幂等**:插件每次上报(启动、课表变化、定时)都会带全量课表,
|
||||
* 因此 merge 必须是"存在则更新、不存在则新增",重复上报不会产生重复行。
|
||||
* 3) 整个写入放在一个事务里:解析/校验失败的批次不会留下半截数据。
|
||||
*/
|
||||
|
||||
export interface ScheduleWriteResult {
|
||||
applied: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
/** 被清理掉的旧同步行(老师已在 ClassIsland 里删除的课) */
|
||||
removed: number;
|
||||
courses: string[];
|
||||
weekStart: number;
|
||||
weekEnd: number;
|
||||
}
|
||||
|
||||
/** 单双周口径:优先用插件显式给的 weekParity,否则从 WeekCountDiv/Total 推导 */
|
||||
function resolveParity(entry: ReportScheduleEntry): 'ALL' | 'ODD' | 'EVEN' {
|
||||
if (entry.weekParity === 'ALL' || entry.weekParity === 'ODD' || entry.weekParity === 'EVEN') {
|
||||
return entry.weekParity;
|
||||
}
|
||||
const { weekCountDiv = 0, weekCountDivTotal = 0 } = entry;
|
||||
return weekParityFromDiv(weekCountDiv, weekCountDivTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入课表。classId 由**设备令牌**决定(不接受插件指定),因此插件无法越权写别的班级。
|
||||
*/
|
||||
export async function applyReportedSchedule(
|
||||
classId: string,
|
||||
mode: 'replace' | 'merge',
|
||||
entries: ReportScheduleEntry[],
|
||||
): Promise<ScheduleWriteResult> {
|
||||
const empty: ScheduleWriteResult = {
|
||||
applied: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
removed: 0,
|
||||
courses: [],
|
||||
weekStart: 1,
|
||||
weekEnd: 20,
|
||||
};
|
||||
if (entries.length === 0) return empty;
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, teacherId: true, termWeeks: true },
|
||||
});
|
||||
if (!classRecord) {
|
||||
logger.warn(`ClassIsland 上报:班级不存在,已丢弃本批课表(classId=${classId})`);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 起止周次:与导入模块保持一致(weekStart 由班级进度决定,weekEnd 取班级教学周数)
|
||||
const weekStart = 1;
|
||||
const weekEnd = classRecord.termWeeks > 0 ? classRecord.termWeeks : 20;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1) 补齐缺失的课程(任课老师先用班主任占位,老师可在课表管理里改)
|
||||
const existingCourses = await tx.course.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id]));
|
||||
const createdCourses: string[] = [];
|
||||
for (const name of new Set(entries.map((item) => item.subject))) {
|
||||
if (courseIdByName.has(name)) continue;
|
||||
const created = await tx.course.create({
|
||||
data: { classId, name, teacherId: classRecord.teacherId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
courseIdByName.set(created.name, created.id);
|
||||
createdCourses.push(created.name);
|
||||
}
|
||||
|
||||
// 2) replace:先清空该班课表(与导入模块的 replace 语义一致)
|
||||
if (mode === 'replace') {
|
||||
await tx.schedule.deleteMany({ where: { classId } });
|
||||
}
|
||||
|
||||
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
/** 本轮上报覆盖到的行 id:不在其中的 classisland 来源行会被清理(见第 4 步) */
|
||||
const keptIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const courseId = courseIdByName.get(entry.subject);
|
||||
if (!courseId) continue;
|
||||
const weekParity = resolveParity(entry);
|
||||
const existing = await tx.schedule.findFirst({
|
||||
where: { classId, dayOfWeek: entry.dayOfWeek, startTime: entry.startTime, weekParity },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await tx.schedule.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
courseId,
|
||||
endTime: entry.endTime,
|
||||
// 不覆盖 weekStart / weekEnd:插件上报契约里没有周次范围,
|
||||
// 硬写"整学期"会把老师在 Web 端为选修/短期课设的「第 1~10 周」悄悄抹掉。
|
||||
// 只在新建时给默认值(见下面 create)。
|
||||
//
|
||||
// 同样**不要**把 source 改成 'classisland':merge 的去重键是「星期 + 开始时间 + 单双周」,
|
||||
// 老师在 Web 端手排的同一时间槽会被这里"收养"。一旦收养时改写来源,
|
||||
// 后续那次"清理插件侧已删除的课"就会把这行老师手排的课一起删掉
|
||||
// (真机上发生过:种子课表 93 行被删成 91 行,`verify:e2e` 的上课状态用例随之失败)。
|
||||
// 因此来源只在 create 时确定,update 永远保持原值。
|
||||
},
|
||||
});
|
||||
keptIds.add(existing.id);
|
||||
updated += 1;
|
||||
} else {
|
||||
await tx.schedule.create({
|
||||
data: {
|
||||
classId,
|
||||
courseId,
|
||||
dayOfWeek: entry.dayOfWeek,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
location: null,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
weekParity,
|
||||
source: 'classisland',
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 清理"老师已在 ClassIsland 里删掉"的行。
|
||||
//
|
||||
// merge 模式原先只 upsert、从不删除:老师在 ClassIsland 删掉一节,或把 8:00 的课挪到 8:05,
|
||||
// 服务端那行会永远留着(幽灵课 / 同一天两节重复课)。但也不能照 replace 那样整表清空 ——
|
||||
// 那会连老师在 Web 端手排的课一起删。
|
||||
// 因此只清理 `source='classisland'`(即由上报同步产生)且本轮未再出现的行。
|
||||
let removed = 0;
|
||||
if (mode === 'merge') {
|
||||
const synced = await tx.schedule.findMany({
|
||||
where: { classId, source: 'classisland' },
|
||||
select: { id: true, dayOfWeek: true, startTime: true, weekParity: true },
|
||||
});
|
||||
const stale = synced.filter(
|
||||
(row) =>
|
||||
!keptIds.has(row.id) &&
|
||||
!entries.some(
|
||||
(entry) =>
|
||||
entry.dayOfWeek === row.dayOfWeek &&
|
||||
entry.startTime === row.startTime &&
|
||||
resolveParity(entry) === row.weekParity,
|
||||
),
|
||||
);
|
||||
if (stale.length > 0) {
|
||||
await tx.schedule.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } });
|
||||
removed = stale.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, removed, createdCourses, allCourses: [...courseIdByName.keys()] };
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||
`清理=${result.removed} 补建科目=[${result.createdCourses.join(',')}]`,
|
||||
);
|
||||
return {
|
||||
applied: true,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
courses: result.allCourses,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入节次时间表(TimeLayout)。
|
||||
* 与 `modules/imports/time-layout.service.ts` 的口径保持一致:
|
||||
* 同一班级同名配置整体替换;没有则新建。
|
||||
* 注意:`action`(ClassIsland TimeType=3,行动)在本系统里没有对应节次类型,
|
||||
* 写入时降级为 `divider`(不占课时,只做视觉分隔),避免类型校验失败。
|
||||
*/
|
||||
export async function applyReportedTimeLayout(
|
||||
classId: string,
|
||||
name: string,
|
||||
items: ReportTimeLayoutItem[],
|
||||
): Promise<{ applied: boolean; count: number }> {
|
||||
if (items.length === 0) return { applied: false, count: 0 };
|
||||
|
||||
const normalized = items.map((item, index) => ({
|
||||
index: item.index && item.index > 0 ? item.index : index + 1,
|
||||
name: item.name?.trim() || `第 ${index + 1} 节`,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
type: item.type === 'action' ? 'divider' : (item.type ?? 'class'),
|
||||
skipped: item.skipped === true,
|
||||
}));
|
||||
|
||||
const current = await prisma.timeLayout.findFirst({
|
||||
where: { classId, name },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (current) {
|
||||
await prisma.timeLayout.update({
|
||||
where: { id: current.id },
|
||||
data: { items: JSON.stringify(normalized), source: 'classisland' },
|
||||
});
|
||||
} else {
|
||||
await prisma.timeLayout.create({
|
||||
data: { classId, name, source: 'classisland', items: JSON.stringify(normalized) },
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`ClassIsland 上报节次时间表:班级=${classId} 名称=${name} 共 ${normalized.length} 节`);
|
||||
return { applied: true, count: normalized.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本服务的课表转换成 ClassIsland 的档案口径(供插件拉回去镜像)。
|
||||
*
|
||||
* 关键换算:
|
||||
* - 星期:本系统 1=周一…7=周日 → ClassIsland 0=周日、1=周一…6=周六;
|
||||
* - 节次:ClassIsland 的 Classes[i] 对应 Layouts 里第 i 个 TimeType=0(上课)的点,
|
||||
* 因此这里必须**按节次顺序**生成 layouts,并把每个 Schedule 归到"开始时间最接近且不晚于它"的节次上;
|
||||
* - 单双周:ALL→(0,0);ODD→(1,2);EVEN→(2,2),与导入模块的解析互逆。
|
||||
*/
|
||||
export interface MirrorClassPlan {
|
||||
entries: {
|
||||
weekDay: number;
|
||||
weekCountDiv: number;
|
||||
weekCountDivTotal: number;
|
||||
subject: string;
|
||||
teacherName: string | null;
|
||||
/**
|
||||
* 该课目的起止时间(HH:mm)。插件靠它重建 ClassIsland 的时间表:
|
||||
* `ClassPlan.Classes[i]` 必须与时间表里第 i 个上课点一一对应,缺了时间就整份错位。
|
||||
*/
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
timeLayoutId: string;
|
||||
}[];
|
||||
timeLayouts: {
|
||||
id: string;
|
||||
name: string;
|
||||
layouts: { startTime: string; endTime: string; timeType: number }[];
|
||||
}[];
|
||||
profileName: string;
|
||||
termStartDate: string;
|
||||
}
|
||||
|
||||
/** 本系统 dayOfWeek(1=周一…7=周日)→ ClassIsland WeekDay(0=周日…6=周六) */
|
||||
export function toClassIslandWeekDay(dayOfWeek: number): number {
|
||||
return dayOfWeek % 7;
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import { booleanFlagSchema } from '../../lib/schemas.js';
|
||||
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
|
||||
|
||||
/** 本地日期(YYYY-MM-DD):按天归档/筛选用它,不要用 createdAt 的 UTC 切片 */
|
||||
const dayKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
|
||||
const dayKeySchema = z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
|
||||
|
||||
export const listNotificationsQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
@@ -52,14 +49,11 @@ export const notificationScopeQuerySchema = z.object({
|
||||
/**
|
||||
* 一次给多个班级发同一条通知(批量发布)。
|
||||
*
|
||||
* 上限 50 个班是刻意设的:批量发布是逐班串行落库 + 逐班推 ClassIsland,
|
||||
* 上限 50 个班是刻意设的:批量发布是逐班串行落库与广播,
|
||||
* 一次塞几百个班会长时间占住请求,也会把教室大屏刷屏。
|
||||
*/
|
||||
export const bulkCreateNotificationSchema = z.object({
|
||||
classIds: z
|
||||
.array(z.string().min(1))
|
||||
.min(1, '请至少选择一个班级')
|
||||
.max(50, '一次最多发布 50 个班级'),
|
||||
classIds: z.array(z.string().min(1)).min(1, '请至少选择一个班级').max(50, '一次最多发布 50 个班级'),
|
||||
title: z.string().trim().min(1, '请输入通知标题').max(120),
|
||||
content: z.string().min(1, '请输入通知内容').max(5000),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
|
||||
@@ -23,7 +23,6 @@ import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
|
||||
import { computeClassStatus } from '../schedules/schedules.service.js';
|
||||
import type { CreateNotificationInput } from './notifications.schemas.js';
|
||||
|
||||
@@ -94,7 +93,9 @@ export async function listNotifications(
|
||||
take: 200,
|
||||
});
|
||||
|
||||
return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }));
|
||||
return notifications.map((item) =>
|
||||
toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +158,7 @@ export async function listNotificationCreators(
|
||||
/**
|
||||
* 批量发布通知:一次给多个班级发同一条内容。
|
||||
*
|
||||
* 逐班串行调用 `createNotification`(复用同一套权限断言 + 上课保护 + 实时广播 + ClassIsland 推送),
|
||||
* 逐班串行调用 `createNotification`(复用同一套权限断言 + 上课保护 + 实时广播),
|
||||
* 某个班失败只记进结果,不影响其他班 —— 前端拿到逐班结果后自己决定怎么展示与重试。
|
||||
*/
|
||||
export async function bulkCreateNotifications(
|
||||
@@ -235,61 +236,18 @@ export async function createNotification(
|
||||
const dto = toNotificationDto(created, { withStatus: true });
|
||||
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
|
||||
// 同一条通知也推到教室的 ClassIsland 上("老师发通知 → 教室大屏弹出"不该只在联动页生效)。
|
||||
// 是否真的推送由**教室客户端**在设置页里选的显示位置决定(见 Class.notificationChannel):
|
||||
// 选了"只弹 ClassHelper 客户端"就不打扰 ClassIsland。
|
||||
await pushToClassIslandIfEnabled({
|
||||
classId: created.classId,
|
||||
title: created.title,
|
||||
content: created.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
notificationId: created.id,
|
||||
teacherName: created.creator?.name ?? created.creator?.username ?? null,
|
||||
});
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知类发布入口的公共尾巴:按班级的显示位置决定要不要推 ClassIsland。
|
||||
*
|
||||
* 推失败**不能**影响通知本身已经发布成功这件事 —— 教室机器离线、插件没装都不该让老师收到 500,
|
||||
* 因此这里把异常收敛成日志告警。
|
||||
*/
|
||||
async function pushToClassIslandIfEnabled(input: {
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: string;
|
||||
createdBy: string;
|
||||
notificationId: string;
|
||||
teacherName: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!(await shouldPushToClassIsland(input.classId))) {
|
||||
logger.info(`通知未推送 ClassIsland:班级=${input.classId} 已选择"只在 ClassHelper 客户端显示"`);
|
||||
return;
|
||||
}
|
||||
await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
urgent: input.priority === 'URGENT',
|
||||
createdBy: input.createdBy,
|
||||
notificationId: input.notificationId,
|
||||
teacherName: input.teacherName,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`通知推送 ClassIsland 失败(通知本身已发布):${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
|
||||
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!current) throw ApiError.notFound('通知不存在');
|
||||
await assertCanPublishNotification(user, current.classId);
|
||||
await prisma.notification.delete({ where: { id: notificationId } });
|
||||
emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, {
|
||||
id: notificationId,
|
||||
classId: current.classId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@ router.get('/me', async (req, res) => {
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
|
||||
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, permissionService.permissionCatalog(), '获取权限目录成功');
|
||||
sendOk(res, await permissionService.permissionCatalog(), '获取权限目录成功');
|
||||
});
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表 */
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
PERMISSION_CATEGORIES,
|
||||
PERMISSION_DEFAULT_ROLES,
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_LABELS,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
@@ -18,7 +17,11 @@ 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 { ADMIN_UNREMOVABLE_PERMISSIONS, loadEffectivePermissions } from '../../lib/permissions.js';
|
||||
import {
|
||||
ADMIN_UNREMOVABLE_PERMISSIONS,
|
||||
ensureDefaultGroups,
|
||||
loadEffectivePermissions,
|
||||
} from '../../lib/permissions.js';
|
||||
|
||||
/**
|
||||
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
|
||||
@@ -32,6 +35,7 @@ const GROUP_SELECT = {
|
||||
name: true,
|
||||
description: true,
|
||||
builtin: true,
|
||||
defaultRole: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
items: { select: { key: true } },
|
||||
@@ -43,6 +47,7 @@ type GroupRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
builtin: boolean;
|
||||
defaultRole: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items: { key: string }[];
|
||||
@@ -89,7 +94,11 @@ function toGroupDto(row: GroupRow): PermissionGroupDto {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
builtin: row.builtin,
|
||||
keys: row.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
defaultRole: row.defaultRole as UserRole | null,
|
||||
keys: row.items
|
||||
.map((item) => item.key)
|
||||
.filter(knownKey)
|
||||
.sort(byCatalogOrder),
|
||||
memberCount: row._count.users,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
@@ -97,8 +106,19 @@ function toGroupDto(row: GroupRow): PermissionGroupDto {
|
||||
}
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
|
||||
export function permissionCatalog(): PermissionCatalogDto {
|
||||
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
|
||||
await ensureDefaultGroups();
|
||||
const defaults = await prisma.permissionGroup.findMany({
|
||||
where: { defaultRole: { not: null } },
|
||||
include: { items: true },
|
||||
});
|
||||
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
|
||||
const keysForRole = (role: UserRole): PermissionKey[] => {
|
||||
const keys =
|
||||
defaults.find((group) => group.defaultRole === role)?.items.map((item) => item.key) ??
|
||||
ROLE_DEFAULT_PERMISSIONS[role];
|
||||
return normalizeKeys(role === 'ADMIN' ? [...keys, ...ADMIN_UNREMOVABLE_PERMISSIONS] : keys);
|
||||
};
|
||||
return {
|
||||
keys: [...PERMISSION_KEYS],
|
||||
labels: { ...PERMISSION_LABELS },
|
||||
@@ -107,17 +127,19 @@ export function permissionCatalog(): PermissionCatalogDto {
|
||||
keys: [...category.keys],
|
||||
})),
|
||||
defaultRoles: Object.fromEntries(
|
||||
PERMISSION_KEYS.map((key) => [key, [...PERMISSION_DEFAULT_ROLES[key]]]),
|
||||
PERMISSION_KEYS.map((key) => [key, roles.filter((role) => keysForRole(role).includes(key))]),
|
||||
) as Record<PermissionKey, UserRole[]>,
|
||||
roleDefaults: Object.fromEntries(
|
||||
roles.map((role) => [role, [...ROLE_DEFAULT_PERMISSIONS[role]]]),
|
||||
) as Record<UserRole, PermissionKey[]>,
|
||||
roleDefaults: Object.fromEntries(roles.map((role) => [role, keysForRole(role)])) as Record<
|
||||
UserRole,
|
||||
PermissionKey[]
|
||||
>,
|
||||
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
|
||||
export async function listGroups(): Promise<PermissionGroupDto[]> {
|
||||
await ensureDefaultGroups();
|
||||
const rows = await prisma.permissionGroup.findMany({
|
||||
select: GROUP_SELECT,
|
||||
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
|
||||
@@ -201,17 +223,12 @@ export async function deleteGroup(groupId: string): Promise<{ id: string }> {
|
||||
// 键与成员关系都是 onDelete: Cascade,直接删组即可;
|
||||
// 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。
|
||||
await prisma.permissionGroup.delete({ where: { id: groupId } });
|
||||
logger.info(
|
||||
`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`,
|
||||
);
|
||||
logger.info(`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`);
|
||||
return { id: groupId };
|
||||
}
|
||||
|
||||
/** 把一行 User(含分组关联)转成权限页用的成员 DTO */
|
||||
function toMemberDto(
|
||||
row: MemberRow,
|
||||
keysOfGroup: Map<string, PermissionKey[]>,
|
||||
): PermissionMemberDto {
|
||||
function toMemberDto(row: MemberRow, keysOfGroup: Map<string, PermissionKey[]>): PermissionMemberDto {
|
||||
const groupIds = row.permissionGroups.map((link) => link.groupId);
|
||||
const union = new Set<PermissionKey>();
|
||||
for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key);
|
||||
@@ -231,7 +248,7 @@ function toMemberDto(
|
||||
role,
|
||||
groupIds,
|
||||
permissionCount,
|
||||
fixed: role === 'ADMIN',
|
||||
fixed: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,11 +266,27 @@ export async function listMembers(): Promise<PermissionMemberDto[]> {
|
||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
||||
groups.map((group) => [
|
||||
group.id,
|
||||
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
group.items
|
||||
.map((item) => item.key)
|
||||
.filter(knownKey)
|
||||
.sort(byCatalogOrder),
|
||||
]),
|
||||
);
|
||||
|
||||
return (rows as MemberRow[]).map((row) => toMemberDto(row, keysOfGroup));
|
||||
return Promise.all(
|
||||
(rows as MemberRow[]).map(async (row) => ({
|
||||
...toMemberDto(row, keysOfGroup),
|
||||
permissionCount: (
|
||||
await loadEffectivePermissions({
|
||||
sub: row.id,
|
||||
role: row.role as UserRole,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
classId: null,
|
||||
})
|
||||
).size,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
|
||||
@@ -265,9 +298,6 @@ export async function assignUserGroups(
|
||||
if (!user) throw ApiError.notFound('账号不存在');
|
||||
|
||||
const row = user as MemberRow;
|
||||
if (row.role === 'ADMIN') {
|
||||
throw ApiError.badRequest('管理员固定拥有全部权限,不需要(也不能)分配权限组');
|
||||
}
|
||||
if (row.role === 'CLASS_DEVICE') {
|
||||
throw ApiError.badRequest(
|
||||
'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配',
|
||||
@@ -299,10 +329,24 @@ export async function assignUserGroups(
|
||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
||||
groups.map((group) => [
|
||||
group.id,
|
||||
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
group.items
|
||||
.map((item) => item.key)
|
||||
.filter(knownKey)
|
||||
.sort(byCatalogOrder),
|
||||
]),
|
||||
);
|
||||
return toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup);
|
||||
return {
|
||||
...toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup),
|
||||
permissionCount: (
|
||||
await loadEffectivePermissions({
|
||||
sub: row.id,
|
||||
role: row.role as UserRole,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
classId: null,
|
||||
})
|
||||
).size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,7 +358,7 @@ export async function assignUserGroups(
|
||||
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
|
||||
const permissions = await loadEffectivePermissions(user);
|
||||
const fromGroups =
|
||||
user.classSession || user.role === 'CLASS_DEVICE' || user.role === 'ADMIN'
|
||||
user.classSession || user.role === 'CLASS_DEVICE'
|
||||
? false
|
||||
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { gradesModule } from './grades/grades.module.js';
|
||||
import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||
import { importsModule } from './imports/imports.module.js';
|
||||
import { integrationsModule } from './integrations/integrations.module.js';
|
||||
import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { permissionsModule } from './permissions/permissions.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
@@ -40,7 +39,6 @@ export const apiModules: ApiModule[] = [
|
||||
permissionsModule,
|
||||
callsModule,
|
||||
importsModule,
|
||||
integrationsModule,
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
systemModule,
|
||||
|
||||
@@ -47,7 +47,7 @@ export const createScheduleSchema = z
|
||||
message: '结束时间必须晚于开始时间',
|
||||
path: ['endTime'],
|
||||
})
|
||||
.refine((data) => (data.weekStart ?? 1) <= (data.weekEnd ?? 20), {
|
||||
.refine((data) => data.weekEnd === undefined || (data.weekStart ?? 1) <= data.weekEnd, {
|
||||
message: '起始周次不能大于结束周次',
|
||||
path: ['weekEnd'],
|
||||
});
|
||||
|
||||
@@ -115,6 +115,10 @@ export async function createSchedule(user: TokenPayload, input: CreateScheduleIn
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const weekStart = input.weekStart ?? 1;
|
||||
const weekEnd = input.weekEnd ?? termWeeks;
|
||||
if (weekStart > weekEnd) throw ApiError.badRequest('起始周次不能大于结束周次');
|
||||
|
||||
const created = await prisma.schedule.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
@@ -123,8 +127,8 @@ export async function createSchedule(user: TokenPayload, input: CreateScheduleIn
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
location: input.location ?? null,
|
||||
weekStart: input.weekStart ?? 1,
|
||||
weekEnd: input.weekEnd ?? termWeeks,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
// 单双周:默认 ALL(每周),与旧数据行为一致
|
||||
weekParity: input.weekParity ?? 'ALL',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -33,7 +33,7 @@ router.use(authenticate());
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('roster:manage'),
|
||||
requireAnyPermission('roster:read', 'roster:manage'),
|
||||
validate({ query: listStudentsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -45,7 +45,7 @@ router.get(
|
||||
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
|
||||
router.get(
|
||||
'/transfers',
|
||||
requirePermission('roster:manage'),
|
||||
requireAnyPermission('roster:read', 'roster:manage'),
|
||||
validate({ query: listTransfersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PaginatedResult, StudentDto, StudentTransferDto } from '@classhelp
|
||||
import { normalizeStudentNo } from '@classhelper/shared';
|
||||
import {
|
||||
assertCanManageRoster,
|
||||
assertClassAccess,
|
||||
classScopeWhere,
|
||||
isAdmin,
|
||||
resolveClassScope,
|
||||
@@ -33,10 +34,7 @@ import type {
|
||||
const withClass = { class: { select: { name: true, grade: true } } } as const;
|
||||
|
||||
/** 学生名单(按班级权限过滤) */
|
||||
export async function listStudents(
|
||||
user: TokenPayload,
|
||||
options: ListStudentsQuery,
|
||||
): Promise<StudentDto[]> {
|
||||
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
|
||||
const students = await prisma.student.findMany({
|
||||
@@ -178,7 +176,10 @@ export async function transferStudents(
|
||||
// 注意传的是**调班前**的 movable(classId 还是原班级):历史里的"原班级"要按每条自己的来
|
||||
const transfers = await recordTransfer(user, movable, null, input.toClassId, mode, input.note ?? '');
|
||||
|
||||
notifyClassChange(movable.map((item) => item.classId), input.toClassId);
|
||||
notifyClassChange(
|
||||
movable.map((item) => item.classId),
|
||||
input.toClassId,
|
||||
);
|
||||
return { moved: movable.length, transfers };
|
||||
}
|
||||
|
||||
@@ -229,7 +230,10 @@ export async function transferOutStudents(
|
||||
})),
|
||||
});
|
||||
|
||||
notifyClassChange(pending.map((item) => item.classId), null);
|
||||
notifyClassChange(
|
||||
pending.map((item) => item.classId),
|
||||
null,
|
||||
);
|
||||
|
||||
const created = await prisma.studentClassTransfer.findMany({
|
||||
where: { studentId: { in: pending.map((item) => item.id) }, mode: 'transfer-out' },
|
||||
@@ -248,11 +252,20 @@ export async function listTransfers(
|
||||
throw ApiError.forbidden('查看全部调班历史需要管理员权限');
|
||||
}
|
||||
|
||||
if (options.classId) await assertClassAccess(user, options.classId);
|
||||
if (options.studentId) {
|
||||
const student = await prisma.student.findUnique({
|
||||
where: { id: options.studentId },
|
||||
select: { classId: true },
|
||||
});
|
||||
if (!student) throw ApiError.notFound('学生不存在');
|
||||
if (student.classId) await assertClassAccess(user, student.classId);
|
||||
else if (!isAdmin(user)) throw ApiError.forbidden('无权查看未分班学生的调班历史');
|
||||
}
|
||||
|
||||
const where = {
|
||||
...(options.studentId ? { studentId: options.studentId } : {}),
|
||||
...(options.classId
|
||||
? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] }
|
||||
: {}),
|
||||
...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}),
|
||||
};
|
||||
|
||||
const page = options.page ?? 1;
|
||||
@@ -320,7 +333,10 @@ async function recordTransfer(
|
||||
async function loadClassNames(classIds: (string | null)[]): Promise<Map<string, string>> {
|
||||
const ids = [...new Set(classIds.filter((id): id is string => Boolean(id)))];
|
||||
if (ids.length === 0) return new Map();
|
||||
const classes = await prisma.class.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } });
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return new Map(classes.map((item) => [item.id, item.name]));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, requirePermission } from '../../middleware/auth.js';
|
||||
import { authenticate, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ router.use(authenticate());
|
||||
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('teacher:manage'),
|
||||
requireAnyPermission('teacher:read', 'teacher:manage'),
|
||||
validate({ query: listTeachersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||
|
||||
@@ -14,7 +14,6 @@ import { MAX_TERM_WEEK, loadTermContext, resolveCurrentWeek } from '../../lib/te
|
||||
import { SETTING_KEYS, setSetting } from '../../lib/settings.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import { notifyScheduleChanged } from '../integrations/integrations.service.js';
|
||||
import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js';
|
||||
|
||||
/**
|
||||
@@ -28,7 +27,7 @@ import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js
|
||||
* 3. 也可以直接改某个班的开学日期(`Class.termStartDate`)。
|
||||
*
|
||||
* `classId = ''` 表示**全校默认**;某个班有自己的配置时优先用它。
|
||||
* 改完之后会把新的课表口径(含周次区间)**下发给该班所有在线的 ClassIsland**。
|
||||
* 改完之后广播课表更新,客户端重新读取当前周次。
|
||||
*/
|
||||
|
||||
/** 把 null/undefined 归一成空串(空串 = 全校默认) */
|
||||
@@ -70,7 +69,10 @@ export async function getTermWeeks(user: TokenPayload, classId?: string | null):
|
||||
* 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。
|
||||
* 保存后把新的周次口径下发给该班所有在线设备。
|
||||
*/
|
||||
export async function updateTermWeeks(user: TokenPayload, input: UpdateTermWeeksInput): Promise<TermWeeksDto> {
|
||||
export async function updateTermWeeks(
|
||||
user: TokenPayload,
|
||||
input: UpdateTermWeeksInput,
|
||||
): Promise<TermWeeksDto> {
|
||||
assertCanManageClasses(user);
|
||||
const scope = scopeOf(input.classId);
|
||||
|
||||
@@ -118,9 +120,7 @@ export async function updateTermWeeks(user: TokenPayload, input: UpdateTermWeeks
|
||||
await setSetting(SETTING_KEYS.termStartDate, input.termStartDate);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`,
|
||||
);
|
||||
logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`);
|
||||
await publishTermChange(scope);
|
||||
return getTermWeeks(user, scope || null);
|
||||
}
|
||||
@@ -219,19 +219,11 @@ export async function suggestHolidays(year?: number): Promise<HolidaySuggestionD
|
||||
|
||||
/* ------------------------------------------------------------------ 内部工具 */
|
||||
|
||||
/**
|
||||
* 周次变了要把新的口径**下发给教室的 ClassIsland**(需求:「对所有 ClassIsland 下发修改」)。
|
||||
*
|
||||
* 逐周区间没法直接塞进 ClassIsland 的模型(它按"教学周序号 + 单双周"组织课表),
|
||||
* 因此实际下发的是**学期开始日期**与**各周次的起止**,随镜像课表一起走:
|
||||
* 插件据此校正"现在是第几周",并让课表的周次口径与教室里的实际日历对齐。
|
||||
*/
|
||||
/** 周次变更后通知班级客户端刷新课表。 */
|
||||
async function publishTermChange(scope: string): Promise<void> {
|
||||
if (scope) {
|
||||
emitToClass(scope, SOCKET_EVENTS.classUpdated, { classId: scope, action: 'updated' });
|
||||
await notifyScheduleChanged(scope).catch((error) => {
|
||||
logger.warn(`周次变更后下发课表失败(数据已保存):${String(error)}`);
|
||||
});
|
||||
emitToClass(scope, SOCKET_EVENTS.scheduleUpdated, { classId: scope, action: 'imported' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,6 +234,6 @@ async function publishTermChange(scope: string): Promise<void> {
|
||||
});
|
||||
for (const item of classes) {
|
||||
emitToClass(item.id, SOCKET_EVENTS.classUpdated, { classId: item.id, action: 'updated' });
|
||||
await notifyScheduleChanged(item.id).catch(() => undefined);
|
||||
emitToClass(item.id, SOCKET_EVENTS.scheduleUpdated, { classId: item.id, action: 'imported' });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
hasAuthPermission,
|
||||
requireAnyPermission,
|
||||
requirePermission,
|
||||
requireStaffPermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as clientReleases from './client-releases.service.js';
|
||||
@@ -28,9 +35,8 @@ router.use(authenticate());
|
||||
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
|
||||
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
|
||||
*/
|
||||
router.get('/check', async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const force = req.query.force === '1' && user.role === 'ADMIN';
|
||||
router.get('/check', requireStaffPermission('update:check'), async (req, res) => {
|
||||
const force = req.query.force === '1' && hasAuthPermission(req, 'update:force');
|
||||
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
|
||||
});
|
||||
|
||||
@@ -42,9 +48,13 @@ router.get('/check', async (req, res) => {
|
||||
* 教室机器没有外网,所以"统一推送更新"必须有这一层中继:服务端替它们把安装包拉下来,
|
||||
* 客户端再从 `/client/download/:version` 取。
|
||||
*/
|
||||
router.get('/client/releases', requirePermission('client:manage'), async (_req, res) => {
|
||||
sendOk(res, await clientReleases.listReleases(), '获取安装包列表成功');
|
||||
});
|
||||
router.get(
|
||||
'/client/releases',
|
||||
requireAnyPermission('client:read', 'client:manage', 'client:update-push'),
|
||||
async (_req, res) => {
|
||||
sendOk(res, await clientReleases.listReleases(), '获取安装包列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/update/client/fetch - 让服务端从 GitHub 下载某个版本的客户端安装包并登记
|
||||
|
||||
@@ -21,6 +21,7 @@ interface SocketData {
|
||||
name: string;
|
||||
role: UserRole;
|
||||
classId: string | null;
|
||||
classSession: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +61,7 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
classId: payload.classId,
|
||||
classSession: payload.classSession === true,
|
||||
};
|
||||
next();
|
||||
} catch {
|
||||
@@ -79,6 +81,7 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
classId: user.classId,
|
||||
classSession: user.classSession,
|
||||
});
|
||||
for (const classId of classIds) {
|
||||
await socket.join(SOCKET_ROOMS.class(classId));
|
||||
@@ -107,6 +110,7 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
classId: user.classId,
|
||||
classSession: user.classSession,
|
||||
});
|
||||
if (!allowed.includes(classId)) {
|
||||
ack?.({ ok: false, message: '无权订阅该班级' });
|
||||
@@ -140,6 +144,6 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
};
|
||||
registerEmitter(emit);
|
||||
|
||||
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / user:{id})');
|
||||
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / session:{id})');
|
||||
return io;
|
||||
}
|
||||
-10
@@ -1,13 +1,11 @@
|
||||
import type { PermissionKey } from '@classhelper/shared';
|
||||
import type { TokenPayload } from '../lib/jwt.js';
|
||||
import type { DeviceContext } from '../modules/integrations/device-auth.js';
|
||||
|
||||
/**
|
||||
* 扩展 Express 的 Request 类型。
|
||||
* - auth:认证中间件解析出的当前用户 + 生效权限集合
|
||||
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
|
||||
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
|
||||
* - device:ClassIsland 插件上报时解析出的设备上下文
|
||||
*/
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -20,14 +18,6 @@ declare global {
|
||||
};
|
||||
validatedQuery?: unknown;
|
||||
validatedParams?: unknown;
|
||||
/**
|
||||
* ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts)。
|
||||
*
|
||||
* **引用那个接口本身,不要在这里再抄一遍字段** —— 原先这里是手抄的结构,
|
||||
* 往 `DeviceContext` 加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;`
|
||||
* 上("Property 'x' is missing … but required"),看不出真正原因是这处重复声明。
|
||||
*/
|
||||
device?: DeviceContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user