feat(class-account): 学生端主体改为班级(班级码 + 班级密码)+ 数据按班级隔离
服务端:
- Class 新增 code(唯一)/passwordHash 与迁移(老数据回填 C00001 形式, 未设密码禁止登录)
- POST /auth/class-login: 班级码 + 班级密码 -> classSession 会话(sub=classId)
- PATCH /classes/:id/class-account: 管理员改班级码/重置密码(唯一性、格式校验)
- auth 中间件/socket 握手支持班级会话(sub 为 classId, 回查 Class 表, 加入 user:{classId} 房间)
- lib/session.ts: 班级设备代全班操作(通知已读/作业完成/成绩总览按全班读写), 普通学生账号行为不变
- 叫人消息同时广播到 user:{classId}, 班级设备立即上岛
- 班级列表/详情按权限返回班级码与密码状态(哈希不外泄)
客户端:
- 登录页改为班级码 + 班级密码(个人学生入口移除, /auth/login 接口保留兼容)
- 作业按钮/成绩标题按班级会话显示(全班标记完成 / 本班成绩)
- 冒烟脚本自动准备班级账号凭据(兼容全新与升级安装)
Web 管理端:
- 班级管理新增「班级账号」列与设置弹窗(改班级码/重置密码/随机生成)
- 新建班级可选自定义班级码, 默认由服务端生成
验证: verify:e2e 125/125(新增班级账号 18 项) verify:web 18/18 verify:desktop 40/40
(login=高一(1)班 classSession=true)
文档: README 新增「学生端班级账号」章节、API/迁移/验收/默认账号更新; 安装包 README 同步
This commit is contained in:
1 parent
8b85079d81
commit
1073436ad6
38 files changed
+1289
-157
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
classTeacherParamSchema,
|
||||
createClassSchema,
|
||||
listClassesQuerySchema,
|
||||
updateClassAccountSchema,
|
||||
updateClassSchema,
|
||||
type AddStudentInput,
|
||||
type CreateClassInput,
|
||||
type UpdateClassAccountInput,
|
||||
type UpdateClassInput,
|
||||
} from './classes.schemas.js';
|
||||
import * as classService from './classes.service.js';
|
||||
@@ -68,6 +70,25 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码)
|
||||
* 仅管理员:学生端「班级登录」用的就是这个班级码
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/class-account',
|
||||
requireRole('ADMIN'),
|
||||
validate({ params: idParamSchema, body: updateClassAccountSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await classService.updateClassAccount(user, id, req.body as UpdateClassAccountInput),
|
||||
'班级账号已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/classes/:id/students - 班级学生名单 */
|
||||
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -6,6 +6,12 @@ export const createClassSchema = z.object({
|
||||
grade: z.string().trim().min(1, '请输入年级').max(32),
|
||||
/** 仅管理员可指定班主任,教师创建时固定为自己 */
|
||||
teacherId: z.string().min(1).optional(),
|
||||
/** 可选:自定义班级码(学生端班级账号登录用),留空自动生成 */
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const updateClassSchema = z.object({
|
||||
@@ -13,6 +19,20 @@ export const updateClassSchema = z.object({
|
||||
grade: z.string().trim().min(1).max(32).optional(),
|
||||
});
|
||||
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||
export const updateClassAccountSchema = z
|
||||
.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
|
||||
.optional(),
|
||||
password: z.string().min(6, '班级密码至少 6 位').max(64).optional(),
|
||||
})
|
||||
.refine((value) => value.code !== undefined || value.password !== undefined, {
|
||||
message: '请至少填写班级码或班级密码中的一项',
|
||||
});
|
||||
|
||||
export const listClassesQuerySchema = z.object({
|
||||
keyword: keywordSchema,
|
||||
});
|
||||
@@ -39,4 +59,5 @@ export const classTeacherParamSchema = 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 AddStudentInput = z.infer<typeof addStudentSchema>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
|
||||
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
classScopeIdFilter,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import {
|
||||
buildClassAccountCreateData,
|
||||
updateClassAccount as updateClassAccountRecord,
|
||||
} from '../../lib/class-account.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
@@ -24,6 +28,36 @@ const countSelect = {
|
||||
notifications: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 说明:班级码 code 与班级密码哈希 passwordHash 是 Class 的标量字段,
|
||||
* 使用 include 查询时会默认返回,无需(也不能)写进 include 里。
|
||||
* 是否把它们放进 DTO 由 canSeeClassAccount() 决定,哈希永不外泄。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 是否可以看到该班级的班级账号信息:
|
||||
* 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。
|
||||
* 科任老师与学生都看不到班级码。
|
||||
*/
|
||||
function canSeeClassAccount(user: TokenPayload, item: { teacherId: string }): boolean {
|
||||
if (user.role === 'ADMIN') return true;
|
||||
return user.role === 'TEACHER' && item.teacherId === user.sub;
|
||||
}
|
||||
|
||||
/** 把班级记录转换为 DTO,并按权限附带班级码 */
|
||||
function toClassDtoWithAccount(
|
||||
user: TokenPayload,
|
||||
item: Parameters<typeof toClassDto>[0] & { code?: string; passwordHash?: string | null },
|
||||
): ClassDto {
|
||||
const dto = toClassDto(item);
|
||||
if (!canSeeClassAccount(user, item)) return dto;
|
||||
return {
|
||||
...dto,
|
||||
code: item.code,
|
||||
hasPassword: Boolean(item.passwordHash),
|
||||
};
|
||||
}
|
||||
|
||||
/** 班级列表(按权限过滤) */
|
||||
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
|
||||
const scope = await resolveClassScope(user);
|
||||
@@ -42,7 +76,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
|
||||
orderBy: [{ grade: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return classes.map(toClassDto);
|
||||
return classes.map((item) => toClassDtoWithAccount(user, item));
|
||||
}
|
||||
|
||||
/** 班级详情:学生名单 + 课程 + 协作教师 */
|
||||
@@ -68,7 +102,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi
|
||||
if (!item) throw ApiError.notFound('班级不存在');
|
||||
|
||||
return {
|
||||
...toClassDto(item),
|
||||
...toClassDtoWithAccount(user, item),
|
||||
students: item.students.map((student) => toStudentDto(student)),
|
||||
courses: item.courses.map(toCourseDto),
|
||||
teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)),
|
||||
@@ -87,15 +121,47 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
||||
teacherId = teacher.id;
|
||||
}
|
||||
|
||||
// 班级账号:自动生成班级码(也可由管理员指定)+ 默认班级密码
|
||||
const account = await buildClassAccountCreateData(env.defaultClassPassword, input.code);
|
||||
|
||||
const created = await prisma.class.create({
|
||||
data: { name: input.name, grade: input.grade, teacherId },
|
||||
data: {
|
||||
name: input.name,
|
||||
grade: input.grade,
|
||||
teacherId,
|
||||
code: account.code,
|
||||
passwordHash: account.passwordHash,
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
|
||||
return toClassDto(created);
|
||||
return toClassDtoWithAccount(user, created);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
|
||||
* 班级码即学生端"班级登录"的账号;密码留空表示不修改。
|
||||
*/
|
||||
export async function updateClassAccount(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: { code?: string; password?: string },
|
||||
): Promise<ClassDto> {
|
||||
assertCanManageClasses(user);
|
||||
await updateClassAccountRecord(classId, input);
|
||||
|
||||
const updated = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
if (!updated) throw ApiError.notFound('班级不存在');
|
||||
return toClassDtoWithAccount(user, updated);
|
||||
}
|
||||
|
||||
/** 编辑班级 */
|
||||
|
||||
Reference in new issue
Block a user