Files
ClassHelper/packages/server/src/modules/imports/table-import.service.ts
T
LoveChengke 8b85079d81 feat(imports): 成绩/名单表格导入 + ClassIsland 时间配置导入 + 安装版增量迁移
服务端:
- 新增 imports 模块: 模板下载(csv/xlsx)、表格解析预览、字段映射导入(append/upsert)、
  错误行号与结果统计; ClassIsland 时间配置容错解析 + replace/merge 导入 + 失败回滚(400 不写库)
- TimeLayout 模型 + 迁移; 修复 contentBase64 空值被 422 拦截, 改由 service 返回 IMPORT_EMPTY_FILE
- merge 语义改为按开始时间槽位覆盖, 同开始时间的旧节次不再被静默丢弃
- db-bootstrap 增加 _ch_migrations 账本: 覆盖安装升级也能自动补跑新增迁移(旧库实测通过)

Web 管理端:
- TableImportDialog(成绩/名单): 模板下载、预览、字段映射、重复处理模式、结果统计与错误行
- TimeLayoutImportDialog: 粘贴/选 .json、解析预览、覆盖/合并、已导入列表与删除
- 成绩录入/学生管理/课表管理接入入口(按权限矩阵显示)

验证: verify:e2e 107/107(新增导入 22 项) verify:web 18/18(新增时间配置导入弹窗真实点击)
      verify:desktop 40/40
文档: README 新增导入章节、增量迁移说明、API 表与验收对照更新
2026-09-12 15:35:01 +08:00

410 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import bcrypt from 'bcryptjs';
import * as XLSX from 'xlsx';
import { prisma } from '../../lib/db.js';
import { env } from '../../config/env.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
* 表格导入(xlsx / xls / csv)。
*
* 流程:上传(base64) → 解析首个工作表 → 预览(列名/前 N 行/校验问题/建议映射)
* → 前端确认字段映射与写入模式 → 提交(逐行校验,重复按模式处理)→ 结果统计。
*
* 异常处理:空文件、超大文件、格式错误、缺列、非法数值、重复数据、未知学生等
* 都会给出明确原因与行号,绝不静默失败。
*/
const MAX_IMPORT_BYTES = 8 * 1024 * 1024; // 8MB
const PREVIEW_ROWS = 20;
export type ImportKind = TableFileInput['kind'];
/** 规范字段定义(预览、模板、映射提示共用) */
export const IMPORT_FIELDS: Record<
ImportKind,
{ key: string; label: string; required: boolean; synonyms: string[] }[]
> = {
grades: [
{
key: 'username',
label: '学生用户名',
required: false,
synonyms: ['用户名', 'username', '学号', '账号'],
},
{ key: 'name', label: '学生姓名', required: false, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{
key: 'examName',
label: '考试名称',
required: true,
synonyms: ['考试', '考试名称', 'exam', 'examName', '测验'],
},
{ key: 'score', label: '分数', required: true, synonyms: ['分数', 'score', '成绩', '得分'] },
{ key: 'totalScore', label: '总分', required: false, synonyms: ['总分', 'totalScore', '满分'] },
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
],
students: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
],
};
export interface TablePreview {
kind: ImportKind;
columns: string[];
rows: string[][];
totalRows: number;
suggestedMapping: Record<string, string>;
errors: string[];
warnings: string[];
templateCsv: string;
}
interface ParsedTable {
columns: string[];
rows: string[][];
}
function decodeBase64(contentBase64: string): Buffer {
const cleaned = contentBase64.includes(',')
? contentBase64.slice(contentBase64.indexOf(',') + 1)
: contentBase64;
const buffer = Buffer.from(cleaned, 'base64');
if (buffer.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '文件内容为空,请确认导出后重新上传');
if (buffer.length > MAX_IMPORT_BYTES) {
throw new ApiError(
413,
'IMPORT_TOO_LARGE',
`文件过大(${(buffer.length / 1024 / 1024).toFixed(1)}MB),请拆分后分批导入(上限 ${MAX_IMPORT_BYTES / 1024 / 1024}MB)`,
);
}
return buffer;
}
/** 解析表格:xlsx/xls 走 SheetJS,csv/tsv 也走 SheetJS(自动识别分隔符) */
function parseTable(buffer: Buffer, fileName: string): ParsedTable {
let workbook: XLSX.WorkBook;
try {
workbook = XLSX.read(buffer, { type: 'buffer', raw: false, cellDates: false });
} catch (error) {
throw new ApiError(
400,
'IMPORT_FORMAT_INVALID',
`文件解析失败:${(error as Error).message}(支持 .xlsx/.xls/.csv)`,
{
fileName,
},
);
}
const sheetName = workbook.SheetNames[0];
const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
if (!sheet) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格里没有工作表,请确认文件内容');
const matrix = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
blankrows: false,
defval: '',
raw: false,
});
const cleaned = matrix
.map((row) => (Array.isArray(row) ? row.map((cell) => String(cell ?? '').trim()) : []))
.filter((row) => row.some((cell) => cell !== ''));
if (cleaned.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格是空的(没有任何数据行)');
const headerRow = cleaned[0] ?? [];
const columns = headerRow.map((cell, index) => (cell === '' ? `第 ${index + 1} 列` : cell));
const rows = cleaned.slice(1).map((row) => {
const values = [...row];
while (values.length < columns.length) values.push('');
return values.slice(0, columns.length);
});
if (rows.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格只有表头,没有数据行');
return { columns, rows };
}
/** 生成模板 CSV(同时用于预览弹窗的"下载模板") */
export function buildTemplateCsv(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label).join(',');
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', '92', '100', '数学']
: ['student01', '王小明', 'student123'];
const sampleLine = fields
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
.join(',');
return `\ufeff${header}\n${sampleLine}\n`;
}
/** 生成模板 XLSX(Base64),让老师可以直接下载后填写 */
export function buildTemplateXlsx(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label);
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', 92, 100, '数学']
: ['student01', '王小明', 'student123'];
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, kind === 'grades' ? '成绩' : '学生名单');
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
}
/** 按同义词自动建议列映射 */
function suggestMapping(kind: ImportKind, columns: string[]): Record<string, string> {
const mapping: Record<string, string> = {};
for (const field of IMPORT_FIELDS[kind]) {
const match = columns.find(
(column) =>
field.synonyms.some((synonym) => column.toLowerCase() === synonym.toLowerCase()) ||
field.synonyms.some((synonym) => column.toLowerCase().includes(synonym.toLowerCase())),
);
if (match) mapping[field.key] = match;
}
return mapping;
}
/** 预览:解析 + 必填列校验 + 建议映射(不写库) */
export function previewTable(input: TableFileInput): TablePreview {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: string[] = [];
const warnings: string[] = [];
const suggestedMapping = suggestMapping(input.kind, parsed.columns);
for (const field of IMPORT_FIELDS[input.kind]) {
if (field.required && !suggestedMapping[field.key]) {
errors.push(`缺少必填列「${field.label}」:请在表格里补上该列,或在下一步手动映射到某一列`);
}
}
if (parsed.rows.length > 2000) warnings.push(`数据行较多(${parsed.rows.length} 行),导入可能需要几秒钟`);
return {
kind: input.kind,
columns: parsed.columns,
rows: parsed.rows.slice(0, PREVIEW_ROWS),
totalRows: parsed.rows.length,
suggestedMapping,
errors,
warnings,
templateCsv: buildTemplateCsv(input.kind),
};
}
export interface ImportRowError {
row: number;
message: string;
}
export interface ImportResult {
kind: ImportKind;
total: number;
inserted: number;
updated: number;
skipped: number;
failed: number;
errors: ImportRowError[];
warnings: string[];
}
function cellValue(row: string[], columns: string[], mapping: Record<string, string>, key: string): string {
const column = mapping[key];
if (!column) return '';
const index = columns.indexOf(column);
if (index < 0) return '';
return (row[index] ?? '').trim();
}
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageGrades(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const students = await prisma.user.findMany({
where: { classId: input.classId, role: 'STUDENT' },
select: { id: true, username: true, name: true },
});
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId: input.classId },
select: { id: true, name: true },
});
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2; // 含表头,从 Excel 视角的行号
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const examName = cellValue(row, parsed.columns, input.mapping, 'examName');
const scoreText = cellValue(row, parsed.columns, input.mapping, 'score');
const totalText = cellValue(row, parsed.columns, input.mapping, 'totalScore');
const courseName = cellValue(row, parsed.columns, input.mapping, 'courseName');
if (!username && !name) {
errors.push({ row: rowNumber, message: '缺少学生标识(用户名与姓名至少填写一个)' });
continue;
}
const student =
(username ? byUsername.get(username.toLowerCase()) : undefined) ??
(name ? byName.get(name.toLowerCase()) : undefined);
if (!student) {
errors.push({ row: rowNumber, message: `找不到该班学生:${username || name}` });
continue;
}
if (!examName) {
errors.push({ row: rowNumber, message: '缺少考试名称' });
continue;
}
const score = Number(scoreText);
if (!Number.isFinite(score)) {
errors.push({ row: rowNumber, message: `分数「${scoreText}」不是数字` });
continue;
}
const totalScore = totalText ? Number(totalText) : 100;
if (!Number.isFinite(totalScore) || totalScore <= 0) {
errors.push({ row: rowNumber, message: `总分「${totalText || '100'}」不合法` });
continue;
}
if (score < 0 || score > totalScore) {
errors.push({ row: rowNumber, message: `分数 ${score} 超出 0~${totalScore} 范围` });
continue;
}
const course = courseName ? courses.find((item) => item.name === courseName) : null;
if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
const existing = await prisma.grade.findFirst({
where: { classId: input.classId, userId: student.id, examName, courseId: course?.id ?? null },
select: { id: true },
});
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
await prisma.grade.update({
where: { id: existing.id },
data: { score, totalScore, publishedAt: new Date() },
});
updated += 1;
} else {
await prisma.grade.create({
data: {
classId: input.classId,
userId: student.id,
courseId: course?.id ?? null,
examName,
score,
totalScore,
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'grades',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`成绩导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const cls = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
if (!cls) throw ApiError.notFound('班级不存在');
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role !== 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被教师/管理员账号占用` });
continue;
}
if (input.mode === 'append') {
skipped += 1;
continue;
}
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: input.classId } });
updated += 1;
} else {
const initialPassword = password || env.defaultStudentPassword;
await prisma.user.create({
data: {
username,
name,
role: 'STUDENT',
classId: input.classId,
passwordHash: await bcrypt.hash(initialPassword, 10),
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'students',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`学生名单导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input);
}