refactor(server): 用已装库替换手写工具

- uuid v7 替换手写 RFC9562 实现(uuid@11.1.1 转为直接依赖)
- node:timers/promises setTimeout 替换两处手写 sleep(钉钉限流、AI 上游重试退避)
- dayjs 替换 4 处零散 Date 格式化(imports/expenses/occupancies 导入模板、档案报告日期)
This commit is contained in:
2026-08-08 17:46:31 +08:00
parent 8ddc3ea690
commit 92ba2e779f
15 changed files with 25 additions and 68 deletions

View File

@@ -62,7 +62,8 @@
"pino": "^10.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.31"
"typeorm": "^0.3.31",
"uuid": "^11.1.1"
},
"devDependencies": {
"@eslint/js": "^9.18.0",

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { uuidV7 } from '../common/uuid-v7';
import { v7 as uuidV7 } from 'uuid';
import type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity';
import { assertKeys, isPlainRecord, requireString } from './ai-validation';

View File

@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { v7 as uuidV7 } from 'uuid';
import { AiForm, type AiFormField } from './entities/ai-form.entity';
import { isPlainRecord, requireString } from './ai-validation';

View File

@@ -1,6 +1,10 @@
import { AiModelStreamService } from './ai-model-stream.service';
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
jest.mock('node:timers/promises', () => ({
setTimeout: jest.fn().mockResolvedValue(undefined),
}));
const config: AiRuntimeConfig = {
provider: 'DEEPSEEK' as AiRuntimeConfig['provider'],
baseUrl: 'https://example.test/v1',
@@ -57,7 +61,6 @@ describe('AiModelStreamService', () => {
contentType: 'text/plain',
body: body(),
} as never);
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
const consume = async () => {
for await (const _ of service.stream(
config,
@@ -90,7 +93,6 @@ describe('AiModelStreamService', () => {
body: successBody(),
} as never;
});
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
const events: Array<{ type: string; attempt?: number; maxRetries?: number; reason?: string }> = [];
for await (const event of service.stream(
config,
@@ -116,7 +118,6 @@ describe('AiModelStreamService', () => {
contentType: 'text/plain',
body: body(),
} as never);
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
const consume = async () => {
for await (const _ of service.stream(
config,

View File

@@ -3,6 +3,7 @@ import { lookup } from 'node:dns';
import * as http from 'node:http';
import * as https from 'node:https';
import { isIP } from 'node:net';
import { setTimeout as sleep } from 'node:timers/promises';
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
import { AiProvider } from '../ai-config/ai-config.entity';
import type { ModelMessage, ModelStreamEvent } from './ai-chat.types';
@@ -110,7 +111,7 @@ export class AiModelStreamService {
delayMs,
reason: error instanceof Error ? error.message : '网络连接失败',
};
await this.sleep(delayMs);
await sleep(delayMs);
continue;
}
throw error;
@@ -127,7 +128,7 @@ export class AiModelStreamService {
delayMs,
reason: `上游返回 ${response.status}`,
};
await this.sleep(delayMs);
await sleep(delayMs);
continue;
}
const body = await this.readLimitedBody(response.body);
@@ -220,10 +221,6 @@ export class AiModelStreamService {
return /socket hang up|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(error.message);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private safeUpstreamMessage(status: number, body: string): string {
if (status === 401 || status === 403) return 'AI 服务认证失败';
if (status === 429) return 'AI 服务请求过于频繁';

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import dayjs from '../common/dayjs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StudentProfile } from '../entities/student-profile.entity';
@@ -65,11 +66,7 @@ export class ArchiveReportService {
private buildHtml(data: ReportData): string {
const { student, profile, enrollments, exams, learnings, result, attendances } = data;
const name = student.name;
const now = new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const now = dayjs().format('YYYY年M月D日');
const sections = [
{

View File

@@ -1,11 +0,0 @@
import { uuidV7 } from './uuid-v7';
describe('uuidV7', () => {
it('creates an RFC 9562 version 7 UUID with time-sortable prefixes', () => {
const first = uuidV7(1_700_000_000_000);
const second = uuidV7(1_700_000_000_001);
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(first < second).toBe(true);
});
});

View File

@@ -1,21 +0,0 @@
import { randomBytes } from 'node:crypto';
/** Generates an RFC 9562 UUIDv7 using the current Unix timestamp and cryptographic randomness. */
export function uuidV7(now = Date.now()): string {
const bytes = Buffer.alloc(16);
const random = randomBytes(10);
let timestamp = BigInt(now);
for (let index = 5; index >= 0; index -= 1) {
bytes[index] = Number(timestamp & 0xffn);
timestamp >>= 8n;
}
bytes[6] = 0x70 | (random[0] & 0x0f);
bytes[7] = random[1];
bytes[8] = 0x80 | (random[2] & 0x3f);
random.copy(bytes, 9, 3, 10);
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

View File

@@ -1,6 +1,6 @@
import { Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { v7 as uuidV7 } from 'uuid';
import { withQueryRunner } from './database-migrations.runner';
import { stringify } from '../common/stringify';

View File

@@ -18,6 +18,7 @@ import {
ValidationPipe,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import dayjs from '../common/dayjs';
import type { Response } from 'express';
import { ExpensesService } from './expenses.service';
import {
@@ -92,10 +93,7 @@ function readCell(cell: ExcelJS.Cell | undefined): CellScalar {
else return '';
}
if (v instanceof Date) {
const y = v.getFullYear();
const m = String(v.getMonth() + 1).padStart(2, '0');
const d = String(v.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
return dayjs(v).format('YYYY-MM-DD');
}
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
return v == null ? v : '';

View File

@@ -1,4 +1,5 @@
import * as ExcelJS from 'exceljs';
import dayjs from '../common/dayjs';
import type { CellValue } from './imports.types';
export function parseJson<T>(raw: string | null | undefined): T | null {
@@ -52,10 +53,7 @@ export function cellValue(cell: ExcelJS.Cell | undefined): CellValue {
export function parseDateValue(value: CellValue): string | null {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
return dayjs(value).format('YYYY-MM-DD');
}
const raw = textValue(value);
if (!raw) return null;

View File

@@ -7,6 +7,7 @@
* - 用户同步(自动建 Student + StudentDingMapping
*/
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { setTimeout as sleep } from 'node:timers/promises';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
@@ -365,7 +366,7 @@ export class DingTalkService implements DingTalkServiceContext {
// ═══════════════════════════════════════════
async rateLimit(): Promise<void> {
await this.sleep(DingTalkService.MIN_INTERVAL);
await sleep(DingTalkService.MIN_INTERVAL);
this.apiRequestCount++;
}
@@ -423,7 +424,4 @@ export class DingTalkService implements DingTalkServiceContext {
return this.schedules.queryScheduleByUsers(...args);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -1,4 +1,5 @@
import * as ExcelJS from 'exceljs';
import dayjs from '../common/dayjs';
export interface OccupancyImportRow {
roomNumber: string;
@@ -76,10 +77,7 @@ function parseDate(cell: ExcelJS.Cell | undefined): string {
const value = cell?.value;
if (!value) return '';
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
return dayjs(value).format('YYYY-MM-DD');
}
const text = cellText(cell);
const matched = text.match(/(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})/);

View File

@@ -1,7 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { v7 as uuidV7 } from 'uuid';
import { Organization } from '../entities/organization.entity';
import { Student } from '../entities/student.entity';
import { Occupancy } from '../entities/occupancy.entity';

3
package-lock.json generated
View File

@@ -125,7 +125,8 @@
"pino": "^10.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.31"
"typeorm": "^0.3.31",
"uuid": "^11.1.1"
},
"devDependencies": {
"@eslint/js": "^9.18.0",