kt-template-online-api/src/common/services/tool.service.ts

750 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha';
import { normalizeVbenErrorText } from '../response/vben-response';
import type {
KtDictOption,
KtPage,
KtResponse,
NapcatLoginStatusLike,
QrcodeLookupOptions,
} from '../types';
@Injectable()
export class ToolsService {
/**
* 执行 当前模块流程。
* @param size - 数量限制;影响 captche 的返回值。
*/
async captche(size = 4) {
const captcha = svgCaptcha.create({
size,
fontSize: 50,
width: 100,
height: 34,
background: '#ffffff',
});
return captcha;
}
/**
* 执行 当前模块流程。
* @param code - 响应状态码;决定 公共基础设施条件分支。
* @param msg - 响应提示文案;生成统一错误文案。
* @param data - 响应数据;承载 公共基础设施新增、更新、导入或执行字段。
* @returns 当前模块产出的 KtResponse。
*/
res(code: number, msg: string, data: any): KtResponse {
if (code === 200) {
return {
code,
msg,
data,
};
}
return {
code,
msg,
err: normalizeVbenErrorText(data, msg),
};
}
/**
* 获取分页数据。
* @param list - 公共基础设施列表;影响 page 的返回值。
* @param total - 总记录数;影响 page 的返回值。
* @returns 当前模块产出的 KtPage<T>。
*/
page<T = any>(list: T[], total: number): KtPage<T> {
const retn = {
list,
total,
};
return retn;
}
/**
* 查询 当前模块数据。
* @param alias - SQL 表别名;限定 公共基础设施查询范围。
* @param key - 键名;生成规范化文本。
*/
getWhereStr(alias: string, key) {
return `${alias}.${key.toString()} = :${key.toString()}`;
}
/**
* 查询 当前模块数据。
* @param alias - SQL 表别名;限定 公共基础设施查询范围。
* @param key - 键名;生成规范化文本。
*/
getLikeStr(alias: string, key) {
return `${alias}.${key.toString()} like :${key.toString()}`;
}
/**
* 查询 当前模块数据。
* @param alias - SQL 表别名;驱动 `conditions.push()` 的 公共基础设施步骤。
* @param wheres - 精确匹配字段列表;遍历并累积 公共基础设施结果。
* @param likes - 模糊匹配字段列表;遍历并累积 公共基础设施结果。
* @param values - 配置值字典;限定 公共基础设施查询范围。
* @param operator - SQL 条件连接符;限定 公共基础设施查询范围。
* @returns 当前模块查询结果。
*/
getLikeWhere<T = object>(
alias: string,
wheres: Array<keyof T>,
likes: Array<keyof T>,
values: Partial<T>,
operator: 'AND' | 'OR' = 'AND',
): [string, Record<string, unknown>] {
/**
* 判断 公共基础设施条件。
* @param value - 待转换值;计算 公共基础设施判断结果。
*/
const hasValue = (value: unknown) =>
value !== undefined && value !== null && value !== '';
const conditions: string[] = [];
const params: Record<string, unknown> = {};
wheres.forEach((key) => {
const value = values[key];
if (!hasValue(value)) return;
const paramKey = key.toString();
conditions.push(this.getWhereStr(alias, key));
params[paramKey] = value;
});
likes.forEach((key) => {
const value = values[key];
if (!hasValue(value)) return;
const paramKey = key.toString();
conditions.push(this.getLikeStr(alias, key));
params[paramKey] = `%${value}%`;
});
return [conditions.join(` ${operator} `), params];
}
/**
* 执行 当前模块流程。
* @param label - 字典展示文本;影响 dictFormat 的返回值。
* @param value - 待转换时间值;影响 dictFormat 的返回值。
* @param other - 字典附加字段;影响 dictFormat 的返回值。
* @returns 当前模块产出的 KtDictOption<T>。
*/
dictFormat<T = object>(
label: string,
value: any,
other: Partial<T>,
): KtDictOption<T> {
const options = {
label,
value,
...other,
};
return options;
}
/**
* 查询 当前模块数据。
* @param err - 异常或失败对象;提取状态码、错误体、堆栈或失败原因。
* @param fallback - 兜底值;限定 公共基础设施查询范围。
*/
getErrorMessage(err: unknown, fallback = '') {
const response = (err as any)?.getResponse?.();
if (typeof response?.msg === 'string') return response.msg;
if (typeof response?.message === 'string') return response.message;
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (err === undefined || err === null) return fallback;
return `${err}`;
}
/**
* 执行 当前模块流程。
* @param ms - 等待毫秒数;驱动 `setTimeout()` 的 公共基础设施步骤。
*/
sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;影响 toTrimmedString 的返回值。
*/
toTrimmedString(value: unknown) {
return `${value ?? ''}`.trim();
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;决定 公共基础设施条件分支。
*/
toSecretText(value: unknown) {
if (value === undefined || value === null) return '';
const text = `${value}`;
return text.trim() ? text : '';
}
/**
* 转换 当前模块输入。
* @param value - 待转文本值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
normalizeWhitespaceText(value: unknown) {
return this.toTrimmedString(value).replace(/\s+/g, ' ');
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param maxLength - 最大文本长度;驱动 `text.slice()` 的 公共基础设施步骤。
*/
toStoredMessageText(value: unknown, maxLength = 4000) {
const text = this.toTrimmedString(value).replace(
/\[CQ:image,file=base64:\/\/([A-Za-z0-9+/=]+)\]/g,
(_match, payload: string) =>
`[CQ:image,file=base64://<${payload.length} chars>]`,
);
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength)}...<truncated ${text.length - maxLength} chars>`;
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param maxLength - 最大文本长度;驱动 `text.slice()` 的 公共基础设施步骤。
*/
toColumnText(value: unknown, maxLength: number) {
const text = this.toTrimmedString(value);
if (maxLength <= 0) return '';
if (text.length <= maxLength) return text;
if (maxLength <= 3) return text.slice(0, maxLength);
return `${text.slice(0, maxLength - 3)}...`;
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param maxLength - 最大文本长度;驱动 `text.slice()` 的 公共基础设施步骤。
*/
toStableColumnText(value: unknown, maxLength: number) {
const text = this.toTrimmedString(value);
if (maxLength <= 0) return '';
if (text.length <= maxLength) return text;
const suffix = `...#${createHash('sha1').update(text).digest('hex').slice(0, 12)}`;
if (maxLength <= suffix.length) return text.slice(0, maxLength);
return `${text.slice(0, maxLength - suffix.length)}${suffix}`;
}
/**
* 执行 当前模块流程。
* @param value - 待转换值;影响 toStringId 的返回值。
*/
toStringId(value: number | string | undefined | null) {
return value === undefined || value === null ? '' : `${value}`;
}
/**
* 转换 当前模块输入。
* @param value - 待转文本值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
normalizeSlugText(value: unknown) {
const text = this.toTrimmedString(value);
if (!text) return '';
try {
return decodeURIComponent(text).toLowerCase().replace(/\s+/g, '-');
} catch {
return text.toLowerCase().replace(/\s+/g, '-');
}
}
/**
* 执行 当前模块流程。
* @param value - 待转换值;驱动 `Number()` 的 公共基础设施步骤。
* @param fallback - 兜底值;驱动 `Number.isFinite()` 的 公共基础设施步骤。
*/
toPositiveNumber(
value: number | string | undefined | null,
fallback: number,
) {
const nextValue = Number(value);
return Number.isFinite(nextValue) && nextValue > 0 ? nextValue : fallback;
}
/**
* 查询 当前模块数据。
* @param query - 查询参数 DTO限定 公共基础设施分页、搜索或详情查询条件。
* @param defaultPageNo - defaultPageNo 输入;驱动 `this.toPositiveNumber()` 的 公共基础设施步骤。
* @param defaultPageSize - defaultPageSize 输入;驱动 `this.toPositiveNumber()` 的 公共基础设施步骤。
*/
getPageParams(
query: { pageNo?: number | string; pageSize?: number | string } = {},
defaultPageNo = 1,
defaultPageSize = 10,
) {
const pageNo = this.toPositiveNumber(query.pageNo, defaultPageNo);
const pageSize = this.toPositiveNumber(query.pageSize, defaultPageSize);
return {
pageNo,
pageSize,
skip: (pageNo - 1) * pageSize,
};
}
/**
* 转换 当前模块输入。
* @param value - 待转换值;决定 公共基础设施条件分支。
* @param fallback - 兜底值;影响 normalizeBoolean 的返回值。
*/
normalizeBoolean(value: unknown, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
return ['1', 'true', 'yes'].includes(`${value}`.toLowerCase());
}
/**
* 转换 当前模块输入。
* @param value - 待转换值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
normalizeNullableString(value: unknown) {
if (value === undefined || value === null) return null;
const nextValue = this.toTrimmedString(value);
return nextValue ? nextValue : null;
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;驱动 `this.toSecretText()` 的 公共基础设施步骤。
* @param secret - secret 输入;驱动 `this.deriveSecretKey()` 的 公共基础设施步骤。
*/
encryptSecretText(value: unknown, secret: unknown) {
const text = this.toSecretText(value);
if (!text) return null;
const key = this.deriveSecretKey(secret);
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([
cipher.update(text, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return [
'ktv1',
iv.toString('base64url'),
tag.toString('base64url'),
encrypted.toString('base64url'),
].join(':');
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param secret - secret 输入;驱动 `createDecipheriv()` 的 公共基础设施步骤。
*/
decryptSecretText(value: unknown, secret: unknown) {
const text = this.toTrimmedString(value);
if (!text) return '';
const [version, ivText, tagText, encryptedText] = text.split(':');
if (version !== 'ktv1' || !ivText || !tagText || !encryptedText) {
throw new Error('密文格式不正确');
}
const decipher = createDecipheriv(
'aes-256-gcm',
this.deriveSecretKey(secret),
Buffer.from(ivText, 'base64url'),
);
decipher.setAuthTag(Buffer.from(tagText, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(encryptedText, 'base64url')),
decipher.final(),
]).toString('utf8');
}
/**
* 执行 当前模块流程。
* @param values - 配置值字典;驱动 `for()` 的 公共基础设施步骤。
*/
pickFirstText(...values: unknown[]) {
for (const value of values) {
const text = this.toTrimmedString(value);
if (text) return text;
}
return '';
}
/**
* 执行 当前模块流程。
* @param value - 待转换值;影响 includesAny 的返回值。
* @param keywords - 公共基础设施列表;计算 公共基础设施布尔判断。
*/
includesAny(value: unknown, keywords: string[]) {
const text = `${value ?? ''}`;
return keywords.some((keyword) => text.includes(keyword));
}
/**
* 执行 当前模块流程。
* @param value - 待转文本值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param keyword - keyword 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
includesText(value: unknown, keyword: unknown) {
const normalizedKeyword = this.toTrimmedString(keyword);
if (!normalizedKeyword) return true;
return this.toTrimmedString(value)
.toLowerCase()
.includes(normalizedKeyword.toLowerCase());
}
/**
* 判断 当前模块条件。
* @param left - left 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param right - right 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
isSameText(left: unknown, right?: unknown) {
const rightText = this.toTrimmedString(right);
return !!rightText && this.toTrimmedString(left) === rightText;
}
/**
* 执行 当前模块流程。
* @param payload - payload 输入;驱动 `Object.entries()` 的 公共基础设施步骤。
*/
pickDefined<T extends Record<string, unknown>>(payload: T) {
return Object.entries(payload).reduce<Partial<T>>((acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key as keyof T] = value as T[keyof T];
}
return acc;
}, {});
}
/**
* 读取 当前模块资源。
* @param request - 当前 HTTP 请求;提供路由、用户、请求体或查询参数。
* @param name - 名称文本;执行 `name.toLowerCase()` 对应的 公共基础设施步骤。
*/
readHeader(
request: { headers?: Record<string, any> } | undefined,
name: string,
) {
const value = request?.headers?.[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
}
/**
* 查询 当前模块数据。
* @param request - 当前 HTTP 请求;提供路由、用户、请求体或查询参数。
*/
getRequestId(request: { headers?: Record<string, any>; id?: unknown }) {
return this.pickFirstText(
request?.id,
this.readHeader(request, 'x-request-id'),
this.readHeader(request, 'x-correlation-id'),
);
}
/**
* 查询 当前模块数据。
* @param request - 当前 HTTP 请求;提供路由、用户、请求体或查询参数。
*/
getRequestPath(
request:
| {
originalUrl?: unknown;
path?: unknown;
url?: unknown;
}
| undefined,
) {
return this.normalizeRequestPathValue(
this.pickFirstText(request?.path, request?.originalUrl, request?.url),
);
}
/**
* 转换 当前模块输入。
* @param value - 待转换值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
normalizeRequestPathValue(value: unknown) {
const text = this.toTrimmedString(value);
if (!text) return '';
try {
return new URL(text, 'http://localhost').pathname;
} catch {
return text.split('?')[0] || text;
}
}
/**
* 读取 当前模块资源。
* @param request - 当前 HTTP 请求;提供路由、用户、请求体或查询参数。
* @param cookieName - cookieName 输入;影响 readCookie 的返回值。
*/
readCookie(
request: { headers?: Record<string, any> } | undefined,
cookieName: string,
) {
const cookieHeader = request?.headers?.cookie || '';
const cookie = `${cookieHeader}`.split(';').find((item) => {
const [key] = item.trim().split('=');
return key === cookieName;
});
if (!cookie) return undefined;
const [, ...value] = cookie.trim().split('=');
const joined = value.join('=');
try {
return decodeURIComponent(joined);
} catch {
return joined;
}
}
/**
* 读取 当前模块资源。
* @param authHeader - authHeader 输入;生成规范化文本。
*/
readBearerToken(authHeader?: string) {
if (!authHeader?.startsWith('Bearer ')) return null;
return authHeader.split(' ')[1] || null;
}
/**
* 执行 当前模块流程。
* @param data - 业务数据;承载 公共基础设施新增、更新、导入或执行字段。
*/
pickQrcode(data?: Record<string, any> | null) {
if (!data) return '';
return this.pickFirstText(data.qrcode, data.qrcodeurl, data.url);
}
/**
* 确保Fresh Qrcode。
* @param qrcode - qrcode 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param options - 公共基础设施列表;使用 `requireFresh`、`staleQrcode` 字段生成结果。
*/
ensureFreshQrcode(qrcode: unknown, options: QrcodeLookupOptions = {}) {
const normalized = this.toTrimmedString(qrcode);
if (options.requireFresh && !normalized) {
throw new Error('NapCat 二维码仍未刷新');
}
if (
normalized &&
options.requireFresh &&
this.isSameText(normalized, options.staleQrcode)
) {
throw new Error('NapCat 二维码仍未刷新');
}
return normalized;
}
/**
* 执行 当前模块流程。
* @param info - info 输入;使用 `uin`、`self_id`、`selfId` 字段生成结果。
*/
pickNapcatSelfId(info: Record<string, any>) {
return this.pickFirstText(info.uin, info.self_id, info.selfId);
}
/**
* 执行 当前模块流程。
* @param info - info 输入;使用 `nick`、`nickname`、`name` 字段生成结果。
*/
pickNapcatNickname(info: Record<string, any>) {
return this.pickFirstText(info.nick, info.nickname, info.name);
}
/**
* 判断 当前模块条件。
* @param err - 异常或失败对象;提取状态码、错误体、堆栈或失败原因。
*/
isNapcatAlreadyLoggedInError(err: unknown) {
return this.getErrorMessage(err).includes('QQ Is Logined');
}
/**
* 判断 当前模块条件。
* @param err - 异常或失败对象;提取状态码、错误体、堆栈或失败原因。
*/
isNapcatTemporaryError(err: unknown) {
return this.includesAny(this.getErrorMessage(err), [
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'NapCat 请求超时',
'NapCat 未返回登录二维码',
'NapCat 二维码仍未刷新',
'NapCat WebUI 登录态仍阻止生成新二维码',
'QRCode Get Error',
'socket hang up',
]);
}
/**
* 判断 当前模块条件。
* @param err - 异常或失败对象;提取状态码、错误体、堆栈或失败原因。
*/
isNapcatQrcodePendingError(err: unknown) {
return this.getErrorMessage(err).includes('QRCode Get Error');
}
/**
* 判断 当前模块条件。
* @param status - 公共基础设施列表;使用 `isOffline`、`loginError` 字段计算判断结果。
*/
isNapcatOfflineLoginStatus(status: NapcatLoginStatusLike) {
return (
!!status.isOffline || this.isNapcatOfflineLoginMessage(status.loginError)
);
}
/**
* 判断 当前模块条件。
* @param message - message 输入;驱动 `this.includesAny()` 的 公共基础设施步骤。
*/
isNapcatOfflineLoginMessage(message?: string) {
if (this.isNapcatOfflineFlagMessage(message)) return true;
return this.includesAny(message, [
'KickedOffLine',
'Not Login',
'not login',
'下线',
'离线',
'另一台终端',
'被踢',
'登录态失效',
]);
}
/**
* 判断 当前模块条件。
* @param message - message 输入;驱动 `this.includesAny()` 的 公共基础设施步骤。
*/
isNapcatOnlineLoginMessage(message?: string) {
if (this.isNapcatOfflineFlagMessage(message)) return false;
if (this.isNapcatOnlineFlagMessage(message)) return true;
return this.includesAny(message, [
'账号状态变更为在线',
'扫码登录成功',
'登录成功',
'Login Success',
]);
}
/**
* 判断 当前模块条件。
* @param message - message 输入;驱动 `this.matchesNapcatOnlineFlag()` 的 公共基础设施步骤。
*/
isNapcatOfflineFlagMessage(message?: string) {
return this.matchesNapcatOnlineFlag(message, false);
}
/**
* 判断 当前模块条件。
* @param message - message 输入;驱动 `this.matchesNapcatOnlineFlag()` 的 公共基础设施步骤。
*/
isNapcatOnlineFlagMessage(message?: string) {
return this.matchesNapcatOnlineFlag(message, true);
}
/**
* 执行 当前模块流程。
* @param message - message 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
extractNapcatCaptchaUrl(message?: string) {
const text = this.toTrimmedString(message);
if (!text) return '';
const proofWaterUrl = text.match(
/["']?proofWaterUrl["']?\s*[:]\s*["']?(https?:\/\/[^"'\s,}]+)/i,
)?.[1];
const fallbackUrl = text.includes('验证码')
? text.match(/https?:\/\/[^"'\s,)\]}>。;;、]+/i)?.[0]
: '';
return this.normalizeExtractedUrl(proofWaterUrl || fallbackUrl || '');
}
/**
* 判断 当前模块条件。
* @param message - message 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
isNapcatCaptchaRequiredMessage(message?: string) {
const text = this.toTrimmedString(message);
return (
!!this.extractNapcatCaptchaUrl(text) ||
this.includesAny(text, ['proofWaterUrl', '需要验证码', '验证码'])
);
}
/**
* 执行 当前模块流程。
* @param message - message 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
* @param expected - expected 输入;影响 matchesNapcatOnlineFlag 的返回值。
*/
private matchesNapcatOnlineFlag(message: unknown, expected: boolean) {
const text = this.toTrimmedString(message);
if (!text) return false;
return new RegExp(
`["']?isOnline["']?\\s*[:=]\\s*${expected ? 'true' : 'false'}\\b`,
'i',
).test(text);
}
/**
* 转换 当前模块输入。
* @param value - 待转换值;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
private normalizeExtractedUrl(value: string) {
return this.toTrimmedString(value).replace(/[)"'\]}>,。;;、,]+$/g, '');
}
/**
* 执行 当前模块流程。
* @param secret - secret 输入;驱动 `this.toTrimmedString()` 的 公共基础设施步骤。
*/
private deriveSecretKey(secret: unknown) {
const normalizedSecret = this.toTrimmedString(secret);
if (!normalizedSecret) {
throw new Error('密钥不能为空');
}
return createHash('sha256').update(normalizedSecret).digest();
}
/**
* 判断 当前模块条件。
* @param status - 公共基础设施列表;使用 `loginError` 字段计算判断结果。
*/
isNapcatExpiredQrcodeStatus(status: NapcatLoginStatusLike) {
const message = status.loginError || '';
return (
message.includes('二维码') &&
(message.includes('过期') || message.includes('失效'))
);
}
}