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:
@@ -62,7 +62,8 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.31"
|
"typeorm": "^0.3.31",
|
||||||
|
"uuid": "^11.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
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 type { AiReviewColumn, AiReviewRow } from './entities/ai-review.entity';
|
||||||
import { assertKeys, isPlainRecord, requireString } from './ai-validation';
|
import { assertKeys, isPlainRecord, requireString } from './ai-validation';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, Repository } from '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 { AiForm, type AiFormField } from './entities/ai-form.entity';
|
||||||
import { isPlainRecord, requireString } from './ai-validation';
|
import { isPlainRecord, requireString } from './ai-validation';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
|
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
|
||||||
|
|
||||||
|
jest.mock('node:timers/promises', () => ({
|
||||||
|
setTimeout: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
const config: AiRuntimeConfig = {
|
const config: AiRuntimeConfig = {
|
||||||
provider: 'DEEPSEEK' as AiRuntimeConfig['provider'],
|
provider: 'DEEPSEEK' as AiRuntimeConfig['provider'],
|
||||||
baseUrl: 'https://example.test/v1',
|
baseUrl: 'https://example.test/v1',
|
||||||
@@ -57,7 +61,6 @@ describe('AiModelStreamService', () => {
|
|||||||
contentType: 'text/plain',
|
contentType: 'text/plain',
|
||||||
body: body(),
|
body: body(),
|
||||||
} as never);
|
} as never);
|
||||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
|
||||||
const consume = async () => {
|
const consume = async () => {
|
||||||
for await (const _ of service.stream(
|
for await (const _ of service.stream(
|
||||||
config,
|
config,
|
||||||
@@ -90,7 +93,6 @@ describe('AiModelStreamService', () => {
|
|||||||
body: successBody(),
|
body: successBody(),
|
||||||
} as never;
|
} 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 }> = [];
|
const events: Array<{ type: string; attempt?: number; maxRetries?: number; reason?: string }> = [];
|
||||||
for await (const event of service.stream(
|
for await (const event of service.stream(
|
||||||
config,
|
config,
|
||||||
@@ -116,7 +118,6 @@ describe('AiModelStreamService', () => {
|
|||||||
contentType: 'text/plain',
|
contentType: 'text/plain',
|
||||||
body: body(),
|
body: body(),
|
||||||
} as never);
|
} as never);
|
||||||
jest.spyOn(service as never, 'sleep' as never).mockResolvedValue(undefined as never);
|
|
||||||
const consume = async () => {
|
const consume = async () => {
|
||||||
for await (const _ of service.stream(
|
for await (const _ of service.stream(
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { lookup } from 'node:dns';
|
|||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import { isIP } from 'node:net';
|
import { isIP } from 'node:net';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
|
import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto';
|
||||||
import { AiProvider } from '../ai-config/ai-config.entity';
|
import { AiProvider } from '../ai-config/ai-config.entity';
|
||||||
import type { ModelMessage, ModelStreamEvent } from './ai-chat.types';
|
import type { ModelMessage, ModelStreamEvent } from './ai-chat.types';
|
||||||
@@ -110,7 +111,7 @@ export class AiModelStreamService {
|
|||||||
delayMs,
|
delayMs,
|
||||||
reason: error instanceof Error ? error.message : '网络连接失败',
|
reason: error instanceof Error ? error.message : '网络连接失败',
|
||||||
};
|
};
|
||||||
await this.sleep(delayMs);
|
await sleep(delayMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -127,7 +128,7 @@ export class AiModelStreamService {
|
|||||||
delayMs,
|
delayMs,
|
||||||
reason: `上游返回 ${response.status}`,
|
reason: `上游返回 ${response.status}`,
|
||||||
};
|
};
|
||||||
await this.sleep(delayMs);
|
await sleep(delayMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const body = await this.readLimitedBody(response.body);
|
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);
|
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 {
|
private safeUpstreamMessage(status: number, body: string): string {
|
||||||
if (status === 401 || status === 403) return 'AI 服务认证失败';
|
if (status === 401 || status === 403) return 'AI 服务认证失败';
|
||||||
if (status === 429) return 'AI 服务请求过于频繁';
|
if (status === 429) return 'AI 服务请求过于频繁';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import dayjs from '../common/dayjs';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { StudentProfile } from '../entities/student-profile.entity';
|
import { StudentProfile } from '../entities/student-profile.entity';
|
||||||
@@ -65,11 +66,7 @@ export class ArchiveReportService {
|
|||||||
private buildHtml(data: ReportData): string {
|
private buildHtml(data: ReportData): string {
|
||||||
const { student, profile, enrollments, exams, learnings, result, attendances } = data;
|
const { student, profile, enrollments, exams, learnings, result, attendances } = data;
|
||||||
const name = student.name;
|
const name = student.name;
|
||||||
const now = new Date().toLocaleDateString('zh-CN', {
|
const now = dayjs().format('YYYY年M月D日');
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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)}`;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { uuidV7 } from '../common/uuid-v7';
|
import { v7 as uuidV7 } from 'uuid';
|
||||||
import { withQueryRunner } from './database-migrations.runner';
|
import { withQueryRunner } from './database-migrations.runner';
|
||||||
import { stringify } from '../common/stringify';
|
import { stringify } from '../common/stringify';
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import dayjs from '../common/dayjs';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { ExpensesService } from './expenses.service';
|
import { ExpensesService } from './expenses.service';
|
||||||
import {
|
import {
|
||||||
@@ -92,10 +93,7 @@ function readCell(cell: ExcelJS.Cell | undefined): CellScalar {
|
|||||||
else return '';
|
else return '';
|
||||||
}
|
}
|
||||||
if (v instanceof Date) {
|
if (v instanceof Date) {
|
||||||
const y = v.getFullYear();
|
return dayjs(v).format('YYYY-MM-DD');
|
||||||
const m = String(v.getMonth() + 1).padStart(2, '0');
|
|
||||||
const d = String(v.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
}
|
||||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
|
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
|
||||||
return v == null ? v : '';
|
return v == null ? v : '';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import dayjs from '../common/dayjs';
|
||||||
import type { CellValue } from './imports.types';
|
import type { CellValue } from './imports.types';
|
||||||
|
|
||||||
export function parseJson<T>(raw: string | null | undefined): T | null {
|
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 {
|
export function parseDateValue(value: CellValue): string | null {
|
||||||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||||
const year = value.getFullYear();
|
return dayjs(value).format('YYYY-MM-DD');
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
}
|
||||||
const raw = textValue(value);
|
const raw = textValue(value);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* - 用户同步(自动建 Student + StudentDingMapping)
|
* - 用户同步(自动建 Student + StudentDingMapping)
|
||||||
*/
|
*/
|
||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
@@ -365,7 +366,7 @@ export class DingTalkService implements DingTalkServiceContext {
|
|||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
async rateLimit(): Promise<void> {
|
async rateLimit(): Promise<void> {
|
||||||
await this.sleep(DingTalkService.MIN_INTERVAL);
|
await sleep(DingTalkService.MIN_INTERVAL);
|
||||||
this.apiRequestCount++;
|
this.apiRequestCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +424,4 @@ export class DingTalkService implements DingTalkServiceContext {
|
|||||||
return this.schedules.queryScheduleByUsers(...args);
|
return this.schedules.queryScheduleByUsers(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import dayjs from '../common/dayjs';
|
||||||
|
|
||||||
export interface OccupancyImportRow {
|
export interface OccupancyImportRow {
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
@@ -76,10 +77,7 @@ function parseDate(cell: ExcelJS.Cell | undefined): string {
|
|||||||
const value = cell?.value;
|
const value = cell?.value;
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
const year = value.getFullYear();
|
return dayjs(value).format('YYYY-MM-DD');
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
}
|
||||||
const text = cellText(cell);
|
const text = cellText(cell);
|
||||||
const matched = text.match(/(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
|
const matched = text.match(/(\d{4})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Not, Repository } from '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 { Organization } from '../entities/organization.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -125,7 +125,8 @@
|
|||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.31"
|
"typeorm": "^0.3.31",
|
||||||
|
"uuid": "^11.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user