Files
gongxue-base/apps/server/src/database/database-migrations.backfill.ts
wangziqi 92ba2e779f refactor(server): 用已装库替换手写工具
- uuid v7 替换手写 RFC9562 实现(uuid@11.1.1 转为直接依赖)
- node:timers/promises setTimeout 替换两处手写 sleep(钉钉限流、AI 上游重试退避)
- dayjs 替换 4 处零散 Date 格式化(imports/expenses/occupancies 导入模板、档案报告日期)
2026-08-08 17:46:31 +08:00

187 lines
6.5 KiB
TypeScript

import { Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { v7 as uuidV7 } from 'uuid';
import { withQueryRunner } from './database-migrations.runner';
import { stringify } from '../common/stringify';
/** 迁移脚本中用到的 organizations 表最小行结构。 */
interface OrganizationRow {
id: number;
}
export async function backfillOrganizations(
dataSource: DataSource,
): Promise<void> {
await withQueryRunner(dataSource, async (runner) => {
const tables = await runner.getTables([
'tenants',
'organizations',
'students',
'occupancies',
'classroom_rentals',
]);
const tableNames = new Set(tables.map((table) => table.name));
if (!tableNames.has('organizations')) return;
const organizationRows = (): Promise<OrganizationRow[]> =>
runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1') as Promise<OrganizationRow[]>;
let host = (await organizationRows())[0];
if (!host) {
await runner.query(
`INSERT INTO organizations (public_id, code, name, is_host, color, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[
uuidV7(),
'HOST',
process.env.HOST_ORGANIZATION_NAME || '本机构',
1,
'#1677ff',
'系统默认运营主体',
'active',
],
);
host = (await organizationRows())[0];
}
if (!host) return;
if (tableNames.has('tenants')) {
const legacyTenants = (await runner.query('SELECT * FROM tenants')) as Array<
Record<string, unknown>
>;
for (const legacy of legacyTenants) {
const name = stringify(legacy.name || '').trim();
if (!name) continue;
const externalRows = (await runner.query(
'SELECT * FROM organizations WHERE name = ? LIMIT 1',
[name],
)) as OrganizationRow[];
let external = externalRows[0];
if (!external) {
await runner.query(
`INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[
uuidV7(),
`ORG_${String(legacy.id)}`,
name,
0,
legacy.contact || null,
legacy.phone || null,
legacy.color || null,
legacy.notes || null,
legacy.status || 'active',
],
);
external = (
(await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])) as OrganizationRow[]
)[0];
}
if (!external) continue;
if (tableNames.has('students')) {
await runner
.query(
'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?',
[external.id, legacy.id],
)
.catch(() => undefined);
}
if (tableNames.has('occupancies')) {
await runner
.query(
'UPDATE occupancies SET responsible_organization_id = ? WHERE responsible_organization_id IS NULL AND tenant_id = ?',
[external.id, legacy.id],
)
.catch(() => undefined);
}
if (tableNames.has('classroom_rentals')) {
await runner
.query(
'UPDATE classroom_rentals SET lessee_organization_id = ?, lessor_organization_id = ? WHERE lessee_organization_id IS NULL AND tenant_id = ?',
[external.id, host.id, legacy.id],
)
.catch(() => undefined);
}
}
}
if (tableNames.has('students')) {
await runner.query(
'UPDATE students SET organization_id = ? WHERE organization_id IS NULL',
[host.id],
);
}
if (tableNames.has('occupancies')) {
await runner.query(
`UPDATE occupancies
SET responsible_organization_id = COALESCE(
(SELECT organization_id FROM students WHERE students.id = occupancies.student_id), ?
)
WHERE responsible_organization_id IS NULL`,
[host.id],
);
}
if (tableNames.has('classroom_rentals')) {
await runner.query(
'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL',
[host.id],
);
}
});
}
export async function normalizeClassDates(
dataSource: DataSource,
logger: Logger,
): Promise<void> {
let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date'];
await withQueryRunner(dataSource, async (runner) => {
const table = await runner.getTable('classes');
if (!table) return;
// Fresh MySQL schemas created by TypeORM already use native DATE columns.
// This cleanup is only for legacy schemas that stored dates as strings;
// comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE
// in strict SQL mode.
columns = columns.filter((columnName) => {
const column = table.columns.find((item) => item.name === columnName);
const type = String(column?.type ?? '').toLowerCase();
return !['date', 'datetime', 'timestamp'].includes(type);
});
});
if (columns.length === 0) return;
const columnText = (column: string) => `CAST(${column} AS CHAR)`;
const firstTenChars = (column: string) => `NULLIF(LEFT(${columnText(column)}, 10), '')`;
const normalizedDate = (column: string) => `CASE
WHEN ${column} IS NULL THEN NULL
ELSE ${firstTenChars(column)}
END`;
const lengthFunction = 'CHAR_LENGTH';
const needsNormalization = (column: string) => `(
${column} IS NOT NULL
AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10)
)`;
const assignments = columns
.map((column) => `${column} = ${normalizedDate(column)}`)
.join(',\n ');
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
interface UpdateResultLike {
changes?: number;
affectedRows?: number;
}
const result = await dataSource.transaction<UpdateResultLike | undefined>((manager) =>
manager.query(`
UPDATE classes
SET
${assignments}
WHERE
${predicates}
`),
);
const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows;
if (affected) logger.log(`已规范化 ${affected} 条班级日期数据`);
}