fix(server): 迁移超长科目备份移到截断前、keepPlus/rels 解析加固、钉钉导入失败补偿
- 迁移:超长科目原值备份移到 step 2 拆分 UPDATE 之前(原位置截断后按长度查不到原值,备份无效) - keepPlus:'+' 后紧跟公式触发符(- + = @ 等)不保留前缀,写回 Excel 不会被当公式 - workbook rels:逐元素解析任意属性顺序/引号,声明大小超限拒绝,防文件名绕过与内存放大 - create():钉钉导入失败时补偿删除刚建班级(成员外键 CASCADE),避免孤儿班级
This commit is contained in:
@@ -103,8 +103,15 @@ export function sanitizeCellText(value: string, keepPlus = false): string {
|
|||||||
let start = 0;
|
let start = 0;
|
||||||
while (start < value.length) {
|
while (start < value.length) {
|
||||||
const ch = value[start];
|
const ch = value[start];
|
||||||
if (keepPlus && ch === '+' && start === 0 && /^\+[\d\s()-]*\d[\d\s()-]*$/.test(value)) {
|
if (
|
||||||
// 整体形如国际区号(+86 138...、+86 (138) 1234-5678、+ 86 138...)时整串保留;
|
keepPlus &&
|
||||||
|
ch === '+' &&
|
||||||
|
start === 0 &&
|
||||||
|
// '+' 后不允许公式触发符(= - + @ 制表/换行),'+-86'、'+(1-2)' 这类开头仍会被
|
||||||
|
// Excel 当公式解释,回退到剥离;空格合法(+ 86 138... 也是国际区号写法)
|
||||||
|
/^\+[^=\-+@\t\r\n][\d\s()-]*\d[\d\s()-]*$/.test(value)
|
||||||
|
) {
|
||||||
|
// 整体形如国际区号(+86 138...、+86 (138) 1234-5678)时整串保留;
|
||||||
// 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离
|
// 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离
|
||||||
// (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。
|
// (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const ROSTER_IMPORT_MAX_SHEETS = 5;
|
|||||||
// 单工作表行数预算:合法花名册 ≤2000 数据行 + 表头,留出空白行余量;
|
// 单工作表行数预算:合法花名册 ≤2000 数据行 + 表头,留出空白行余量;
|
||||||
// ExcelJS 会把整表物化为对象图后才执行行数上限,此处先流式预扫描拦截超限文件
|
// ExcelJS 会把整表物化为对象图后才执行行数上限,此处先流式预扫描拦截超限文件
|
||||||
const ROSTER_IMPORT_MAX_ROWS_PER_SHEET = 2500;
|
const ROSTER_IMPORT_MAX_ROWS_PER_SHEET = 2500;
|
||||||
|
// workbook.xml.rels 正常远小于此值;超阈值视为异常(防压缩比放大后整体解压进内存)
|
||||||
|
const ROSTER_IMPORT_MAX_RELS_BYTES = 1024 * 1024;
|
||||||
const ROSTER_IMPORT_TEMPLATE_FILENAME = '班级花名册导入模板.xlsx';
|
const ROSTER_IMPORT_TEMPLATE_FILENAME = '班级花名册导入模板.xlsx';
|
||||||
|
|
||||||
/** 校验上传的 xlsx zip 结构:条目数与真实解压总大小超限即拒绝(在 ExcelJS 整体解压前执行)。 */
|
/** 校验上传的 xlsx zip 结构:条目数与真实解压总大小超限即拒绝(在 ExcelJS 整体解压前执行)。 */
|
||||||
@@ -89,10 +91,24 @@ async function assertRosterZipSafe(buffer: Buffer): Promise<void> {
|
|||||||
const sheetPaths = new Set<string>();
|
const sheetPaths = new Set<string>();
|
||||||
const relsEntry = zip.files['xl/_rels/workbook.xml.rels'];
|
const relsEntry = zip.files['xl/_rels/workbook.xml.rels'];
|
||||||
if (relsEntry && !relsEntry.dir) {
|
if (relsEntry && !relsEntry.dir) {
|
||||||
|
// rels 文件极小(正常 <10KB),但声明大小不可信:超阈值直接拒绝,避免先整体解压进内存
|
||||||
|
const declaredSize = (relsEntry as unknown as { _data?: { uncompressedSize?: number } })._data
|
||||||
|
?.uncompressedSize;
|
||||||
|
if (typeof declaredSize === 'number' && declaredSize > ROSTER_IMPORT_MAX_RELS_BYTES) {
|
||||||
|
throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件');
|
||||||
|
}
|
||||||
const relsXml = await relsEntry.async('string');
|
const relsXml = await relsEntry.async('string');
|
||||||
for (const m of relsXml.matchAll(/Type="[^"]*\/worksheet"[^>]*\bTarget="([^"]+)"/g)) {
|
// XML 属性顺序任意、单双引号皆合法,逐元素提取 Type/Target(避免仅匹配 Type 在前的写法被绕过)
|
||||||
const target = (m[1] ?? '').replace(/^\/+/, '');
|
for (const m of relsXml.matchAll(/<Relationship\b([^>]*)>/g)) {
|
||||||
if (target) sheetPaths.add(target.startsWith('xl/') ? target : `xl/${target}`);
|
const attrs = m[1] ?? '';
|
||||||
|
const type = attrs.match(/Type\s*=\s*["']([^"']+)["']/)?.[1];
|
||||||
|
const target = attrs.match(/Target\s*=\s*["']([^"']+)["']/)?.[1];
|
||||||
|
if (type?.endsWith('/worksheet') && target) {
|
||||||
|
const normalized = target.replace(/^\/+/, '');
|
||||||
|
if (normalized) {
|
||||||
|
sheetPaths.add(normalized.startsWith('xl/') ? normalized : `xl/${normalized}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const worksheetCount = entries.filter(
|
const worksheetCount = entries.filter(
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import { ClassesQueriesService } from './classes-queries.service';
|
import { ClassesQueriesService } from './classes-queries.service';
|
||||||
import { createClassRosterTemplateWorkbook, parseClassRosterWorkbook, sanitizeCellText } from './class-roster-import';
|
import {
|
||||||
|
createClassRosterTemplateWorkbook,
|
||||||
|
parseClassRosterWorkbook,
|
||||||
|
sanitizeCellText,
|
||||||
|
} from './class-roster-import';
|
||||||
import { Class, ClassStudent, Organization, Student } from '../entities';
|
import { Class, ClassStudent, Organization, Student } from '../entities';
|
||||||
|
|
||||||
/** 模拟 TypeORM QueryBuilder 链:getMany 委托给传入的 rows 获取函数;andWhere 表示带 status 过滤。 */
|
/** 模拟 TypeORM QueryBuilder 链:getMany 委托给传入的 rows 获取函数;andWhere 表示带 status 过滤。 */
|
||||||
@@ -75,9 +79,7 @@ describe('class roster import workbook', () => {
|
|||||||
];
|
];
|
||||||
ws.addRow({ name: '张三', phone: '13800138000' });
|
ws.addRow({ name: '张三', phone: '13800138000' });
|
||||||
ws.getCell(2, 2).value = { error: '#DIV/0!' } as never;
|
ws.getCell(2, 2).value = { error: '#DIV/0!' } as never;
|
||||||
expect(parseClassRosterWorkbook(workbook)).toEqual([
|
expect(parseClassRosterWorkbook(workbook)).toEqual([{ rowNumber: 2, name: '张三' }]);
|
||||||
{ rowNumber: 2, name: '张三' },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips formula-leading characters from text cells', () => {
|
it('strips formula-leading characters from text cells', () => {
|
||||||
@@ -109,7 +111,9 @@ describe('class roster import workbook', () => {
|
|||||||
const ws = workbook.addWorksheet('其他表');
|
const ws = workbook.addWorksheet('其他表');
|
||||||
ws.addRow(['字段A', '字段B']);
|
ws.addRow(['字段A', '字段B']);
|
||||||
ws.addRow(['值1', '值2']);
|
ws.addRow(['值1', '值2']);
|
||||||
expect(() => parseClassRosterWorkbook(workbook)).toThrow('未识别到班级花名册工作表,请使用下载的导入模板');
|
expect(() => parseClassRosterWorkbook(workbook)).toThrow(
|
||||||
|
'未识别到班级花名册工作表,请使用下载的导入模板',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when the required name column is missing', () => {
|
it('throws when the required name column is missing', () => {
|
||||||
@@ -135,7 +139,9 @@ describe('ClassesQueriesService.previewRosterImport', () => {
|
|||||||
const dataSource = {
|
const dataSource = {
|
||||||
getRepository: jest.fn().mockReturnValue({
|
getRepository: jest.fn().mockReturnValue({
|
||||||
...studentRepo,
|
...studentRepo,
|
||||||
createQueryBuilder: jest.fn().mockImplementation(() => makeQueryBuilder(() => studentRepo.find())),
|
createQueryBuilder: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => makeQueryBuilder(() => studentRepo.find())),
|
||||||
}),
|
}),
|
||||||
transaction: jest.fn(),
|
transaction: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -241,7 +247,14 @@ describe('ClassesQueriesService.previewRosterImport', () => {
|
|||||||
studentRepo.find
|
studentRepo.find
|
||||||
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ id: 99, name: '归档生', phone: '13600000000', idNumber: null, studentNo: null, status: 'archived' },
|
{
|
||||||
|
id: 99,
|
||||||
|
name: '归档生',
|
||||||
|
phone: '13600000000',
|
||||||
|
idNumber: null,
|
||||||
|
studentNo: null,
|
||||||
|
status: 'archived',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
const result = await queries.previewRosterImport(1, [
|
const result = await queries.previewRosterImport(1, [
|
||||||
{ rowNumber: 2, name: '归档生', phone: '13600000000' },
|
{ rowNumber: 2, name: '归档生', phone: '13600000000' },
|
||||||
@@ -396,10 +409,7 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
|||||||
it('deduplicates name-only create rows within the same batch', async () => {
|
it('deduplicates name-only create rows within the same batch', async () => {
|
||||||
const { queries } = buildManager();
|
const { queries } = buildManager();
|
||||||
const result = await queries.commitRosterImport(1, {
|
const result = await queries.commitRosterImport(1, {
|
||||||
createRows: [
|
createRows: [{ name: '甲' }, { name: '甲' }],
|
||||||
{ name: '甲' },
|
|
||||||
{ name: '甲' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.created).toBe(1);
|
expect(result.created).toBe(1);
|
||||||
@@ -445,7 +455,14 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
|||||||
// 全量状态查询(无 status 条件)返回已归档学生;在读查询返回空
|
// 全量状态查询(无 status 条件)返回已归档学生;在读查询返回空
|
||||||
if (Array.isArray(where) && where[0]?.status === undefined) {
|
if (Array.isArray(where) && where[0]?.status === undefined) {
|
||||||
return [
|
return [
|
||||||
{ id: 98, phone: '13600000000', idNumber: null, studentNo: null, name: '归档生', status: 'archived' },
|
{
|
||||||
|
id: 98,
|
||||||
|
phone: '13600000000',
|
||||||
|
idNumber: null,
|
||||||
|
studentNo: null,
|
||||||
|
name: '归档生',
|
||||||
|
status: 'archived',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -453,11 +470,30 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
|||||||
if (entity === ClassStudent) return [];
|
if (entity === ClassStudent) return [];
|
||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
create: jest.fn().mockImplementation((_entity: unknown, value: object) => ({ id: 2000, ...value })),
|
create: jest
|
||||||
save: jest.fn().mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])),
|
|
||||||
createQueryBuilder: jest
|
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(() =>
|
.mockImplementation((_entity: unknown, value: object) => ({ id: 2000, ...value })),
|
||||||
|
save: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])),
|
||||||
|
createQueryBuilder: jest.fn().mockImplementation(() =>
|
||||||
|
makeQueryBuilder((hasStatusFilter) =>
|
||||||
|
hasStatusFilter
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 98,
|
||||||
|
phone: '13600000000',
|
||||||
|
idNumber: null,
|
||||||
|
studentNo: null,
|
||||||
|
name: '归档生',
|
||||||
|
status: 'archived',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
getRepository: jest.fn().mockReturnValue({
|
||||||
|
createQueryBuilder: jest.fn().mockImplementation(() =>
|
||||||
makeQueryBuilder((hasStatusFilter) =>
|
makeQueryBuilder((hasStatusFilter) =>
|
||||||
hasStatusFilter
|
hasStatusFilter
|
||||||
? []
|
? []
|
||||||
@@ -473,25 +509,6 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
getRepository: jest.fn().mockReturnValue({
|
|
||||||
createQueryBuilder: jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() =>
|
|
||||||
makeQueryBuilder((hasStatusFilter) =>
|
|
||||||
hasStatusFilter
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: 98,
|
|
||||||
phone: '13600000000',
|
|
||||||
idNumber: null,
|
|
||||||
studentNo: null,
|
|
||||||
name: '归档生',
|
|
||||||
status: 'archived',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) };
|
const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) };
|
||||||
@@ -542,9 +559,10 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.created).toBe(1);
|
expect(result.created).toBe(1);
|
||||||
const created = manager.create.mock.calls.find(
|
const created = manager.create.mock.calls.find((call) => call[0] === Student)?.[1] as Record<
|
||||||
(call) => call[0] === Student,
|
string,
|
||||||
)?.[1] as Record<string, unknown>;
|
unknown
|
||||||
|
>;
|
||||||
expect(created.name).toBe('1+1');
|
expect(created.name).toBe('1+1');
|
||||||
expect(created.phone).toBe('HACK');
|
expect(created.phone).toBe('HACK');
|
||||||
expect(created.idNumber).toBe('ID');
|
expect(created.idNumber).toBe('ID');
|
||||||
@@ -566,7 +584,9 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
);
|
);
|
||||||
await expect(queries.commitRosterImport(1, { addStudentIds: [1] })).rejects.toThrow('班级不存在');
|
await expect(queries.commitRosterImport(1, { addStudentIds: [1] })).rejects.toThrow(
|
||||||
|
'班级不存在',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an entirely empty commit payload', async () => {
|
it('rejects an entirely empty commit payload', async () => {
|
||||||
@@ -606,10 +626,16 @@ describe('sanitizeCellText formula injection guard', () => {
|
|||||||
it('strips the + prefix when formula characters follow, keeping the remainder', () => {
|
it('strips the + prefix when formula characters follow, keeping the remainder', () => {
|
||||||
// 非纯号码前缀:回退到普通剥离(去掉前导 +),触发符不再位于首位,
|
// 非纯号码前缀:回退到普通剥离(去掉前导 +),触发符不再位于首位,
|
||||||
// 写回 Excel/CSV 时不会被解释为公式,同时不整串丢弃合法国际号码分隔符
|
// 写回 Excel/CSV 时不会被解释为公式,同时不整串丢弃合法国际号码分隔符
|
||||||
expect(sanitizeCellText('+86=HYPERLINK("http://evil")', true)).toBe('86=HYPERLINK("http://evil")');
|
expect(sanitizeCellText('+86=HYPERLINK("http://evil")', true)).toBe(
|
||||||
|
'86=HYPERLINK("http://evil")',
|
||||||
|
);
|
||||||
expect(sanitizeCellText('+86@cmd', true)).toBe('86@cmd');
|
expect(sanitizeCellText('+86@cmd', true)).toBe('86@cmd');
|
||||||
expect(sanitizeCellText('+86 138 0013 8000', true)).toBe('+86 138 0013 8000');
|
expect(sanitizeCellText('+86 138 0013 8000', true)).toBe('+86 138 0013 8000');
|
||||||
expect(sanitizeCellText('+86-138-0013-8000', true)).toBe('+86-138-0013-8000');
|
expect(sanitizeCellText('+86-138-0013-8000', true)).toBe('+86-138-0013-8000');
|
||||||
expect(sanitizeCellText('+86 138.0013.8000', true)).toBe('86 138.0013.8000');
|
expect(sanitizeCellText('+86 138.0013.8000', true)).toBe('86 138.0013.8000');
|
||||||
|
// '+' 后紧跟公式触发符(- + = @ 等)不保留前缀:'+-' 开头写回 Excel 仍会被当公式
|
||||||
|
expect(sanitizeCellText('+-86 138', true)).toBe('86 138');
|
||||||
|
expect(sanitizeCellText('++86 138', true)).toBe('86 138');
|
||||||
|
expect(sanitizeCellText('+ 86 138', true)).toBe('+ 86 138');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -295,9 +295,20 @@ export class ClassesService {
|
|||||||
return saved.id;
|
return saved.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
// batch import students by dingUserIds
|
// batch import students by dingUserIds(自带事务,事务外调用)
|
||||||
if (users?.length) {
|
if (users?.length) {
|
||||||
await this.batchImportStudents(savedClassId, users);
|
try {
|
||||||
|
await this.batchImportStudents(savedClassId, users);
|
||||||
|
} catch (error) {
|
||||||
|
// 钉钉导入失败会抛错,班级已在事务中提交——补偿删除刚创建的班级(学生/教师成员
|
||||||
|
// 外键均 CASCADE),避免留下孤儿班级且前端重试会重复建班
|
||||||
|
try {
|
||||||
|
await this.classRepo.delete(savedClassId);
|
||||||
|
} catch {
|
||||||
|
// 补偿删除失败:保留班级,优先抛原始错误
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findOne(savedClassId);
|
return this.findOne(savedClassId);
|
||||||
|
|||||||
@@ -396,6 +396,22 @@ async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promi
|
|||||||
|
|
||||||
// 2) 历史多科目值拆分:按内容判断(含分隔符或超长)而非列类型——标准升级路径
|
// 2) 历史多科目值拆分:按内容判断(含分隔符或超长)而非列类型——标准升级路径
|
||||||
// (varchar(50) NULL)下同样可能存在 '语文、数学' 这类合并值。
|
// (varchar(50) NULL)下同样可能存在 '语文、数学' 这类合并值。
|
||||||
|
// 任何 >50 字符的值(如单个超长科目)都会在拆分/兜底中截断,截断前先把原值备份到审计表
|
||||||
|
// (任课老师的超长科目不在 1b 的备份范围内,否则截断后原值永久丢失)。
|
||||||
|
// 必须在本步骤的破坏性 UPDATE 之前执行,否则截断后按 CHAR_LENGTH(subject) > 50 查不到原值。
|
||||||
|
// 备份表在 1b 才创建(收敛 schema 下 needsLegacyCleanup=false),此处无条件执行需先确保表存在。
|
||||||
|
await runner.query(`CREATE TABLE IF NOT EXISTS class_teacher_legacy_subject_backup (
|
||||||
|
id INT NOT NULL PRIMARY KEY,
|
||||||
|
class_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
role_type VARCHAR(30) NOT NULL,
|
||||||
|
subject TEXT NULL,
|
||||||
|
backed_up_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB`);
|
||||||
|
await runner.query(`INSERT IGNORE INTO class_teacher_legacy_subject_backup
|
||||||
|
(id, class_id, user_id, role_type, subject)
|
||||||
|
SELECT id, class_id, user_id, role_type, subject FROM class_teacher
|
||||||
|
WHERE CHAR_LENGTH(subject) > 50`);
|
||||||
const splitCandidates = (await runner.query(
|
const splitCandidates = (await runner.query(
|
||||||
`SELECT 1 FROM class_teacher WHERE role_type = 'subject_teacher'
|
`SELECT 1 FROM class_teacher WHERE role_type = 'subject_teacher'
|
||||||
AND (CHAR_LENGTH(subject) > 50 OR subject REGEXP '[、,,;;]') LIMIT 1`,
|
AND (CHAR_LENGTH(subject) > 50 OR subject REGEXP '[、,,;;]') LIMIT 1`,
|
||||||
@@ -435,20 +451,7 @@ async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:其余任何 >50 字符的值(如单个超长科目)先截断,避免 ALTER 报 Data too long。
|
// 兜底:其余任何 >50 字符的值(如单个超长科目)先截断,避免 ALTER 报 Data too long。
|
||||||
// 截断前先把原值备份到审计表(任课老师的超长科目不在 1b 的备份范围内,否则截断后原值永久丢失)。
|
// 原值已在上方 step 2 之前备份到 class_teacher_legacy_subject_backup。
|
||||||
// 备份表在 1b 才创建(收敛 schema 下 needsLegacyCleanup=false),此处无条件执行需先确保表存在
|
|
||||||
await runner.query(`CREATE TABLE IF NOT EXISTS class_teacher_legacy_subject_backup (
|
|
||||||
id INT NOT NULL PRIMARY KEY,
|
|
||||||
class_id INT NOT NULL,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
role_type VARCHAR(30) NOT NULL,
|
|
||||||
subject TEXT NULL,
|
|
||||||
backed_up_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB`);
|
|
||||||
await runner.query(`INSERT IGNORE INTO class_teacher_legacy_subject_backup
|
|
||||||
(id, class_id, user_id, role_type, subject)
|
|
||||||
SELECT id, class_id, user_id, role_type, subject FROM class_teacher
|
|
||||||
WHERE CHAR_LENGTH(subject) > 50`);
|
|
||||||
await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50)
|
await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50)
|
||||||
WHERE CHAR_LENGTH(subject) > 50`);
|
WHERE CHAR_LENGTH(subject) > 50`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user