NestJS 11 + Fastify 统一错误体与 trace_id,/health 真实探活 DB/Redis,OpenAPI 3.1 契约含 20 个错误码;shared 包提供错误码、权限点、ULID、分页与通用 schema;worker 完成队列进程骨架。
108 lines
3.0 KiB
TypeScript
108 lines
3.0 KiB
TypeScript
/**
|
|
* 命名工具(A0-SH-01):文件名清洗 + 同名自动加序号(PRD R-01)
|
|
* 浏览器 / Node 通用,不依赖 node:path。
|
|
*/
|
|
|
|
const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|]/g;
|
|
const RESERVED_FILENAMES = new Set([
|
|
'CON',
|
|
'PRN',
|
|
'AUX',
|
|
'NUL',
|
|
'COM1',
|
|
'COM2',
|
|
'COM3',
|
|
'COM4',
|
|
'COM5',
|
|
'COM6',
|
|
'COM7',
|
|
'COM8',
|
|
'COM9',
|
|
'LPT1',
|
|
'LPT2',
|
|
'LPT3',
|
|
'LPT4',
|
|
'LPT5',
|
|
'LPT6',
|
|
'LPT7',
|
|
'LPT8',
|
|
'LPT9',
|
|
]);
|
|
|
|
const MAX_FILENAME_LENGTH = 120;
|
|
|
|
export interface SanitizeFilenameOptions {
|
|
maxLength?: number;
|
|
replacement?: string;
|
|
fallback?: string;
|
|
}
|
|
|
|
/** 去掉控制字符(\u0000-\u001f、\u007f),保留常规空白 */
|
|
function stripControlChars(input: string): string {
|
|
let output = '';
|
|
for (const char of input) {
|
|
const code = char.codePointAt(0) ?? 0;
|
|
if (code >= 0x20 && code !== 0x7f) {
|
|
output += char;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/** 清洗文件名:去除非法字符 / 控制字符 / 首尾空格与点,中文原样保留 */
|
|
export function sanitizeFilename(input: string, options: SanitizeFilenameOptions = {}): string {
|
|
const maxLength = options.maxLength ?? MAX_FILENAME_LENGTH;
|
|
const replacement = options.replacement ?? '_';
|
|
const fallback = options.fallback ?? '未命名';
|
|
|
|
let name = stripControlChars(input.normalize('NFC'))
|
|
.replace(ILLEGAL_FILENAME_CHARS, replacement)
|
|
.trim();
|
|
name = name.replace(/^[.\s]+|[.\s]+$/g, '');
|
|
|
|
if (name.length === 0 || name.replaceAll(replacement, '').trim().length === 0) {
|
|
return fallback;
|
|
}
|
|
if (RESERVED_FILENAMES.has(name.toUpperCase())) {
|
|
name = `${name}_`;
|
|
}
|
|
if (name.length > maxLength) {
|
|
const dotIndex = name.lastIndexOf('.');
|
|
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
|
|
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
|
const keep = Math.max(1, maxLength - extension.length);
|
|
name = `${base.slice(0, keep)}${extension}`;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
const SEQUENCE_PATTERN = /^(.*)-(\d{2,})$/;
|
|
|
|
function splitExtension(name: string): { stem: string; extension: string } {
|
|
const dotIndex = name.lastIndexOf('.');
|
|
if (dotIndex <= 0 || dotIndex === name.length - 1) {
|
|
return { stem: name, extension: '' };
|
|
}
|
|
return { stem: name.slice(0, dotIndex), extension: name.slice(dotIndex) };
|
|
}
|
|
|
|
/**
|
|
* 同名自动加序号(R-01):从 base-02 开始尝试到 maxAttempts,全部占用则抛错。
|
|
* 已带序号的名字会基于其原始基名继续递增;扩展名保持不变。
|
|
*/
|
|
export function dedupeName(name: string, existing: ReadonlySet<string>, maxAttempts = 999): string {
|
|
if (!existing.has(name)) {
|
|
return name;
|
|
}
|
|
const { stem, extension } = splitExtension(name);
|
|
const matched = SEQUENCE_PATTERN.exec(stem);
|
|
const root = matched?.[1] ?? stem;
|
|
for (let sequence = 2; sequence <= maxAttempts; sequence += 1) {
|
|
const candidate = `${root}-${String(sequence).padStart(2, '0')}${extension}`;
|
|
if (!existing.has(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new RangeError(`同名文件过多,无法为「${name}」生成序号`);
|
|
}
|