diff --git a/package.json b/package.json index 2af4b1c..5bc57ca 100644 --- a/package.json +++ b/package.json @@ -78,15 +78,25 @@ "json", "ts" ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", + "rootDir": ".", + "testMatch": [ + "/test/**/*.spec.ts" + ], "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "tsconfig": "/test/tsconfig.json" + } + ] + }, + "moduleNameMapper": { + "^@/(.*)$": "/src/$1" }, "collectCoverageFrom": [ - "**/*.(t|j)s" + "src/**/*.(t|j)s" ], - "coverageDirectory": "../coverage", + "coverageDirectory": "coverage", "testEnvironment": "node" } } diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 9d76399..dcad556 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -22,7 +22,6 @@ import { AdminUserManageController } from './user/admin-user-manage.controller'; import { AdminUserController } from './user/admin-user.controller'; import { AdminUser } from './user/admin-user.entity'; import { AdminUserService } from './user/admin-user.service'; -import { ToolsService } from '@/common'; import { MinioClientModule } from '@/minio/minio.module'; import { WordpressModule } from '@/wordpress/wordpress.module'; @@ -58,7 +57,6 @@ import { WordpressModule } from '@/wordpress/wordpress.module'; AdminRoleService, AdminTimezoneService, AdminUserService, - ToolsService, ], }) export class AdminModule {} diff --git a/src/admin/admin.types.ts b/src/admin/admin.types.ts new file mode 100644 index 0000000..4f9f877 --- /dev/null +++ b/src/admin/admin.types.ts @@ -0,0 +1,89 @@ +import type { Request } from 'express'; +import type { AdminMenu } from './menu/admin-menu.entity'; +import type { AdminRole } from './role/admin-role.entity'; +import type { AdminUser } from './user/admin-user.entity'; + +export type AdminDemoTableRow = { + available: boolean; + category: string; + color: string; + currency: string; + description: string; + id: string; + imageUrl: string; + imageUrl2: string; + inProduction: boolean; + open: boolean; + price: string; + productName: string; + quantity: number; + rating: number; + releaseDate: string; + status: string; + tags: string[]; + weight: number; +}; + +export type AdminDictItem = { + childrenCode?: string | null; + label: string; + value: string; +}; + +export type AdminDictGroupItem = { + dictCode: string; + id: string; + itemCount: number; + label: string; + value: string; +}; + +export type AdminDictSerialized = { + childrenCode?: string | null; + createTime?: Date; + dictCode: string; + id: string; + label: string; + sort?: number; + status?: number; + updateTime?: Date; + value: string; +}; + +export type AdminDictTreeItem = AdminDictSerialized & { + children?: AdminDictTreeItem[]; + treeKey: string; +}; + +export type AdminMenuMeta = Record; + +export type AdminMenuType = 'button' | 'catalog' | 'embedded' | 'link' | 'menu'; + +export type AdminMenuInput = Partial & { + activePath?: string; + linkSrc?: string; +}; + +export type AdminRequest = Request & { + adminUser?: AdminUser; +}; + +export type AdminRoleInput = Partial & { + permissions?: string[]; +}; + +export type AdminRoleListQuery = Record; + +export type AdminTokenPayload = { + exp: number; + iat: number; + sub: string; + type: 'access' | 'refresh'; + username: string; +}; + +export type AdminUserInput = Partial & { + roleIds?: string[]; +}; + +export type AdminUserListQuery = Record; diff --git a/src/admin/auth/admin-auth.service.ts b/src/admin/auth/admin-auth.service.ts index a35c734..47f4bae 100644 --- a/src/admin/auth/admin-auth.service.ts +++ b/src/admin/auth/admin-auth.service.ts @@ -2,7 +2,7 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import type { Request, Response } from 'express'; import { Repository } from 'typeorm'; -import { throwVbenError } from '@/common'; +import { throwVbenError, ToolsService } from '@/common'; import { AdminUser } from '../user/admin-user.entity'; import { AdminTokenService } from './admin-token.service'; @@ -15,6 +15,7 @@ export class AdminAuthService { @InjectRepository(AdminUser) private readonly userRepository: Repository, private readonly tokenService: AdminTokenService, + private readonly toolsService: ToolsService, ) {} async login(username?: string, password?: string) { @@ -60,8 +61,8 @@ export class AdminAuthService { async currentUser(authHeader?: string, req?: Request) { const tokens = [ - this.readBearerToken(authHeader), - this.readCookie(req, ACCESS_TOKEN_COOKIE), + this.toolsService.readBearerToken(authHeader), + this.toolsService.readCookie(req, ACCESS_TOKEN_COOKIE), ].filter((token): token is string => !!token); const payload = tokens .map((token) => this.tokenService.verifyAccessToken(token)) @@ -78,12 +79,13 @@ export class AdminAuthService { status: 1, }, }); - if (!user) throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED); + if (!user) + throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED); return user; } getRefreshTokenFromRequest(req: Request) { - return this.readCookie(req, REFRESH_TOKEN_COOKIE); + return this.toolsService.readCookie(req, REFRESH_TOKEN_COOKIE); } setAccessTokenCookie(res: Response, token: string) { @@ -119,23 +121,6 @@ export class AdminAuthService { }); } - private readBearerToken(authHeader?: string) { - if (!authHeader?.startsWith('Bearer ')) return null; - return authHeader.split(' ')[1]; - } - - private readCookie(req: Request | undefined, cookieName: string) { - const cookie = req?.headers.cookie || ''; - const cookies = cookie - .split(';') - .reduce>((acc, item) => { - const [key, ...value] = item.trim().split('='); - if (key) acc[key] = decodeURIComponent(value.join('=')); - return acc; - }, {}); - return cookies[cookieName]; - } - private getTokenCookieOptions() { const secure = process.env.ADMIN_COOKIE_SECURE === 'true'; return { diff --git a/src/admin/auth/admin-token.service.ts b/src/admin/auth/admin-token.service.ts index efca004..eb502e7 100644 --- a/src/admin/auth/admin-token.service.ts +++ b/src/admin/auth/admin-token.service.ts @@ -1,14 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createHmac, timingSafeEqual } from 'crypto'; - -export type AdminTokenPayload = { - exp: number; - iat: number; - sub: string; - type: 'access' | 'refresh'; - username: string; -}; +import type { AdminTokenPayload } from '../admin.types'; @Injectable() export class AdminTokenService { diff --git a/src/admin/auth/jwt-auth.guard.ts b/src/admin/auth/jwt-auth.guard.ts index 2507891..0ff285e 100644 --- a/src/admin/auth/jwt-auth.guard.ts +++ b/src/admin/auth/jwt-auth.guard.ts @@ -1,17 +1,8 @@ -import { - CanActivate, - ExecutionContext, - Injectable, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import type { Request } from 'express'; -import { AdminUser } from '../user/admin-user.entity'; import { AdminAuthService } from './admin-auth.service'; import { IS_PUBLIC_KEY } from '@/common'; - -type AdminRequest = Request & { - adminUser?: AdminUser; -}; +import type { AdminRequest } from '../admin.types'; @Injectable() export class JwtAuthGuard implements CanActivate { diff --git a/src/admin/component/component.controller.ts b/src/admin/component/component.controller.ts index 739cda5..ecab7fa 100644 --- a/src/admin/component/component.controller.ts +++ b/src/admin/component/component.controller.ts @@ -23,6 +23,7 @@ import { ApiModelResponse, ApiPageResponse, ApiSuccessResponse, + type KtPageParams, ToolsService, } from '@/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -45,7 +46,7 @@ const componentExample = { class CompPageDto extends PartialType(Component) - implements PageParams + implements KtPageParams { @ApiProperty({ type: Number, @@ -83,7 +84,7 @@ export class ComponentController { @ApiPageResponse(Component, [componentExample], 1) async getList( @Res() res, - @Query() { pageNo, pageSize, ...args }: PageParams, + @Query() { pageNo, pageSize, ...args }: KtPageParams, ): Promise> { const list = await this.componentService.page({ pageNo, diff --git a/src/admin/component/component.service.ts b/src/admin/component/component.service.ts index 1948855..ca276a4 100644 --- a/src/admin/component/component.service.ts +++ b/src/admin/component/component.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Component } from './component.entity'; -import { ToolsService } from '@/common'; +import { ToolsService, type KtPage, type KtPageParams } from '@/common'; import { isNumber, omit, pick } from 'lodash'; import { DictService } from '@/admin/dict/dict.service'; @@ -28,7 +28,7 @@ export class ComponentService { pageNo, pageSize, ...args - }: PageParams): Promise> { + }: KtPageParams): Promise> { await this.dictService.refreshDecodeCache(); const hasOwnEntity = new Component(); diff --git a/src/admin/dict/dict.module.ts b/src/admin/dict/dict.module.ts index ff6c4e1..8022a77 100644 --- a/src/admin/dict/dict.module.ts +++ b/src/admin/dict/dict.module.ts @@ -3,13 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminAuthGuardModule } from '../auth/admin-auth-guard.module'; import { DictController } from './dict.controller'; import { DictService } from './dict.service'; -import { ToolsService } from '@/common'; import { AdminDict } from './admin-dict.entity'; @Module({ imports: [AdminAuthGuardModule, TypeOrmModule.forFeature([AdminDict])], controllers: [DictController], - providers: [DictService, ToolsService], + providers: [DictService], exports: [DictService], }) export class DictModule {} diff --git a/src/admin/dict/dict.service.ts b/src/admin/dict/dict.service.ts index 62e5e27..a974dfd 100644 --- a/src/admin/dict/dict.service.ts +++ b/src/admin/dict/dict.service.ts @@ -1,59 +1,40 @@ import { HttpStatus, Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Brackets, Repository } from 'typeorm'; -import { setDictDecodeCache, throwVbenError } from '@/common'; +import { + setDictDecodeCache, + throwVbenError, + ToolsService, + type KtDictOption, +} from '@/common'; import { AdminDict } from './admin-dict.entity'; import { AdminDictBodyDto, AdminDictQueryDto, AdminDictUpdateDto, } from './dict.dto'; +import type { + AdminDictGroupItem, + AdminDictItem, + AdminDictSerialized, + AdminDictTreeItem, +} from '../admin.types'; const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE'; -export type AdminDictItem = { - childrenCode?: string | null; - label: string; - value: string; -}; - -export type AdminDictGroupItem = { - dictCode: string; - id: string; - itemCount: number; - label: string; - value: string; -}; - -type AdminDictSerialized = { - childrenCode?: string | null; - createTime?: Date; - dictCode: string; - id: string; - label: string; - sort?: number; - status?: number; - updateTime?: Date; - value: string; -}; - -export type AdminDictTreeItem = AdminDictSerialized & { - children?: AdminDictTreeItem[]; - treeKey: string; -}; - @Injectable() export class DictService implements OnApplicationBootstrap { constructor( @InjectRepository(AdminDict) private readonly dictRepository: Repository, + private readonly toolsService: ToolsService, ) {} async onApplicationBootstrap() { await this.refreshDecodeCache(); } - async getDictByKey(dictKey: string): Promise { + async getDictByKey(dictKey: string): Promise { const list = await this.getDictItemsByKey(dictKey); return list.map(({ label, value }) => ({ @@ -63,13 +44,16 @@ export class DictService implements OnApplicationBootstrap { } async page(query: AdminDictQueryDto = {}) { - const pageNo = this.toPositiveNumber(query.pageNo ?? query.page, 1); - const pageSize = this.toPositiveNumber(query.pageSize, 20); + const pageNo = this.toolsService.toPositiveNumber( + query.pageNo ?? query.page, + 1, + ); + const pageSize = this.toolsService.toPositiveNumber(query.pageSize, 20); const builder = this.dictRepository .createQueryBuilder('dict') .where('dict.isDeleted = :isDeleted', { isDeleted: false }); - const keyword = this.normalizeText(query.keyword); + const keyword = this.toolsService.toTrimmedString(query.keyword); if (keyword) { builder.andWhere( new Brackets((subBuilder) => { @@ -132,13 +116,16 @@ export class DictService implements OnApplicationBootstrap { } async groups(query: AdminDictQueryDto = {}) { - const pageNo = this.toPositiveNumber(query.pageNo ?? query.page, 1); - const pageSize = this.toPositiveNumber(query.pageSize, 20); + const pageNo = this.toolsService.toPositiveNumber( + query.pageNo ?? query.page, + 1, + ); + const pageSize = this.toolsService.toPositiveNumber(query.pageSize, 20); const builder = this.dictRepository .createQueryBuilder('dict') .where('dict.isDeleted = :isDeleted', { isDeleted: false }); - const keyword = this.normalizeText(query.keyword); + const keyword = this.toolsService.toTrimmedString(query.keyword); if (keyword) { builder.andWhere('dict.dictCode LIKE :keyword', { keyword: `%${keyword}%`, @@ -206,7 +193,7 @@ export class DictService implements OnApplicationBootstrap { } async update(body: AdminDictUpdateDto) { - const id = this.normalizeText(body.id); + const id = this.toolsService.toTrimmedString(body.id); if (!id) throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST); const dict = await this.dictRepository.findOne({ @@ -240,7 +227,7 @@ export class DictService implements OnApplicationBootstrap { } async remove(id: string) { - const normalizedId = this.normalizeText(id); + const normalizedId = this.toolsService.toTrimmedString(id); if (!normalizedId) throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST); @@ -291,7 +278,7 @@ export class DictService implements OnApplicationBootstrap { })); } - async getComponentDictByType(type: number): Promise { + async getComponentDictByType(type: number): Promise { // 一级类型的 childrenCode 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。 const componentType = await this.dictRepository.findOne({ where: { @@ -337,7 +324,7 @@ export class DictService implements OnApplicationBootstrap { >, value?: string, ) { - const normalizedValue = this.normalizeText(value); + const normalizedValue = this.toolsService.toTrimmedString(value); if (!normalizedValue) return; builder.andWhere(`dict.${field} LIKE :${field}`, { @@ -361,7 +348,7 @@ export class DictService implements OnApplicationBootstrap { const dictCodes = new Set(items.map((item) => item.dictCode)); const referencedCodes = new Set( items - .map((item) => this.normalizeText(item.childrenCode)) + .map((item) => this.toolsService.toTrimmedString(item.childrenCode)) .filter((childrenCode) => childrenCode && dictCodes.has(childrenCode)), ); const rootCodes = [...dictCodes].filter( @@ -387,7 +374,7 @@ export class DictService implements OnApplicationBootstrap { treeKey: string, pathCodes: Set, ): AdminDictTreeItem { - const childrenCode = this.normalizeText(item.childrenCode); + const childrenCode = this.toolsService.toTrimmedString(item.childrenCode); const children = childrenCode && !pathCodes.has(childrenCode) ? byDictCode.get(childrenCode) @@ -458,7 +445,7 @@ export class DictService implements OnApplicationBootstrap { ), ); - const childrenCode = this.normalizeText(item.childrenCode); + const childrenCode = this.toolsService.toTrimmedString(item.childrenCode); if (!childrenCode) return; const children = byDictCode.get(childrenCode) || []; @@ -488,7 +475,7 @@ export class DictService implements OnApplicationBootstrap { const map = new Map(); items.forEach((item) => { - const childrenCode = this.normalizeText(item.childrenCode); + const childrenCode = this.toolsService.toTrimmedString(item.childrenCode); if (!childrenCode) return; const list = map.get(childrenCode) || []; @@ -507,7 +494,7 @@ export class DictService implements OnApplicationBootstrap { query.keyword, query.label, query.value, - ].some((value) => !!this.normalizeText(value)) || + ].some((value) => !!this.toolsService.toTrimmedString(value)) || ['0', '1'].includes(String(query.status)) ); } @@ -516,11 +503,11 @@ export class DictService implements OnApplicationBootstrap { item: AdminDictSerialized, query: AdminDictQueryDto, ) { - const keyword = this.normalizeText(query.keyword); + const keyword = this.toolsService.toTrimmedString(query.keyword); if ( keyword && ![item.childrenCode, item.dictCode, item.label, item.value].some( - (value) => this.includesText(value, keyword), + (value) => this.toolsService.includesText(value, keyword), ) ) { return false; @@ -542,32 +529,25 @@ export class DictService implements OnApplicationBootstrap { value: number | string | null | undefined, keyword?: string, ) { - const normalizedKeyword = this.normalizeText(keyword); + const normalizedKeyword = this.toolsService.toTrimmedString(keyword); if (!normalizedKeyword) return true; - return this.includesText(value, normalizedKeyword); - } - - private includesText( - value: number | string | null | undefined, - keyword: string, - ) { - return this.normalizeText(value) - .toLowerCase() - .includes(keyword.toLowerCase()); + return this.toolsService.includesText(value, normalizedKeyword); } private normalizeInput(body: AdminDictBodyDto): Partial { - const dictCode = this.normalizeText(body.dictCode); - const label = this.normalizeText(body.label); - const value = this.normalizeText(body.value); + const dictCode = this.toolsService.toTrimmedString(body.dictCode); + const label = this.toolsService.toTrimmedString(body.label); + const value = this.toolsService.toTrimmedString(body.value); if (!dictCode) throwVbenError('字典编码不能为空', HttpStatus.BAD_REQUEST); if (!label) throwVbenError('字典标签不能为空', HttpStatus.BAD_REQUEST); if (!value) throwVbenError('字典值不能为空', HttpStatus.BAD_REQUEST); return { - childrenCode: this.normalizeNullableText(body.childrenCode), + childrenCode: this.toolsService.normalizeNullableString( + body.childrenCode, + ), dictCode, label, sort: Number.isFinite(Number(body.sort)) ? Number(body.sort) : 0, @@ -576,15 +556,6 @@ export class DictService implements OnApplicationBootstrap { }; } - private normalizeNullableText(value?: number | string | null) { - const text = this.normalizeText(value); - return text || null; - } - - private normalizeText(value?: number | string | null) { - return value === undefined || value === null ? '' : String(value).trim(); - } - private serializeDict(dict: AdminDict) { return { childrenCode: dict.childrenCode, @@ -611,12 +582,4 @@ export class DictService implements OnApplicationBootstrap { value: item.dictCode, }; } - - private toPositiveNumber( - value: number | string | undefined, - fallback: number, - ) { - const nextValue = Number(value); - return Number.isFinite(nextValue) && nextValue > 0 ? nextValue : fallback; - } } diff --git a/src/admin/example/admin-example.controller.ts b/src/admin/example/admin-example.controller.ts index 2721b4d..2722068 100644 --- a/src/admin/example/admin-example.controller.ts +++ b/src/admin/example/admin-example.controller.ts @@ -14,57 +14,45 @@ import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { Public, vbenPage, vbenSuccess } from '@/common'; import { MinioClientService } from '@/minio/minio.service'; -import type { MinioUploadFile } from '@/minio/minio.service'; +import type { MinioUploadFile } from '@/minio/minio.types'; +import type { AdminDemoTableRow } from '../admin.types'; -type DemoTableRow = { - available: boolean; - category: string; - color: string; - currency: string; - description: string; - id: string; - imageUrl: string; - imageUrl2: string; - inProduction: boolean; - open: boolean; - price: string; - productName: string; - quantity: number; - rating: number; - releaseDate: string; - status: string; - tags: string[]; - weight: number; -}; +const DEMO_ROWS: AdminDemoTableRow[] = Array.from( + { length: 100 }, + (_, index) => { + const sequence = index + 1; + const categories = ['Dashboard', 'Form', 'Table', 'Chart', 'Workflow']; + const colors = ['Blue', 'Green', 'Purple', 'Orange', 'Slate']; + const statuses = ['success', 'warning', 'error']; -const DEMO_ROWS: DemoTableRow[] = Array.from({ length: 100 }, (_, index) => { - const sequence = index + 1; - const categories = ['Dashboard', 'Form', 'Table', 'Chart', 'Workflow']; - const colors = ['Blue', 'Green', 'Purple', 'Orange', 'Slate']; - const statuses = ['success', 'warning', 'error']; - - return { - available: sequence % 3 !== 0, - category: categories[index % categories.length], - color: colors[index % colors.length], - currency: 'CNY', - description: `真实 API 示例数据 ${sequence}`, - id: `demo-${String(sequence).padStart(3, '0')}`, - imageUrl: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', - imageUrl2: - 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp', - inProduction: sequence % 2 === 0, - open: sequence % 4 === 0, - price: `${(sequence * 3.6 + 19).toFixed(2)}`, - productName: `KT Admin 模板能力 ${sequence}`, - quantity: 10 + sequence, - rating: Number((3 + (sequence % 20) / 10).toFixed(1)), - releaseDate: new Date(2026, index % 12, (index % 28) + 1).toISOString(), - status: statuses[index % statuses.length], - tags: ['kt', 'admin', categories[index % categories.length].toLowerCase()], - weight: Number((1 + sequence / 10).toFixed(2)), - }; -}); + return { + available: sequence % 3 !== 0, + category: categories[index % categories.length], + color: colors[index % colors.length], + currency: 'CNY', + description: `真实 API 示例数据 ${sequence}`, + id: `demo-${String(sequence).padStart(3, '0')}`, + imageUrl: + 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', + imageUrl2: + 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp', + inProduction: sequence % 2 === 0, + open: sequence % 4 === 0, + price: `${(sequence * 3.6 + 19).toFixed(2)}`, + productName: `KT Admin 模板能力 ${sequence}`, + quantity: 10 + sequence, + rating: Number((3 + (sequence % 20) / 10).toFixed(1)), + releaseDate: new Date(2026, index % 12, (index % 28) + 1).toISOString(), + status: statuses[index % statuses.length], + tags: [ + 'kt', + 'admin', + categories[index % categories.length].toLowerCase(), + ], + weight: Number((1 + sequence / 10).toFixed(2)), + }; + }, +); @ApiTags('Admin - 示例') @Controller() @@ -85,9 +73,7 @@ export class AdminExampleController { @Get('table/list') @ApiOperation({ summary: '获取示例表格分页列表' }) - async tableList( - @Query() query: Record, - ) { + async tableList(@Query() query: Record) { const page = Math.max(Number(query.page || 1), 1); const pageSize = Math.max(Number(query.pageSize || 10), 1); const sorted = this.sortRows([...DEMO_ROWS], query.sortBy, query.sortOrder); @@ -152,16 +138,24 @@ export class AdminExampleController { return vbenSuccess('Test post handler'); } - private sortRows(rows: DemoTableRow[], sortBy?: string, sortOrder?: string) { + private sortRows( + rows: AdminDemoTableRow[], + sortBy?: string, + sortOrder?: string, + ) { if (!sortBy || !Object.hasOwn(rows[0], sortBy)) return rows; return rows.sort((prev, next) => { const prevValue = prev[sortBy]; const nextValue = next[sortBy]; - const result = String(prevValue).localeCompare(String(nextValue), 'zh-CN', { - numeric: true, - sensitivity: 'base', - }); + const result = String(prevValue).localeCompare( + String(nextValue), + 'zh-CN', + { + numeric: true, + sensitivity: 'base', + }, + ); return sortOrder === 'desc' ? -result : result; }); diff --git a/src/admin/menu/admin-menu.entity.ts b/src/admin/menu/admin-menu.entity.ts index 360a6f2..4cf022f 100644 --- a/src/admin/menu/admin-menu.entity.ts +++ b/src/admin/menu/admin-menu.entity.ts @@ -9,10 +9,7 @@ import { } from 'typeorm'; import { ensureSnowflakeId } from '@/common'; import { AdminRole } from '../role/admin-role.entity'; - -export type AdminMenuType = 'button' | 'catalog' | 'embedded' | 'link' | 'menu'; - -export type AdminMenuMeta = Record; +import type { AdminMenuMeta, AdminMenuType } from '../admin.types'; @Entity('admin_menu') export class AdminMenu { diff --git a/src/admin/menu/admin-menu.service.ts b/src/admin/menu/admin-menu.service.ts index 580275c..503b226 100644 --- a/src/admin/menu/admin-menu.service.ts +++ b/src/admin/menu/admin-menu.service.ts @@ -4,12 +4,8 @@ import { In, Repository } from 'typeorm'; import { toTree } from '@/common'; import { WordpressService } from '@/wordpress/wordpress.service'; import { AdminUser } from '../user/admin-user.entity'; -import { AdminMenu, AdminMenuMeta } from './admin-menu.entity'; - -type MenuInput = Partial & { - activePath?: string; - linkSrc?: string; -}; +import { AdminMenu } from './admin-menu.entity'; +import type { AdminMenuInput, AdminMenuMeta } from '../admin.types'; @Injectable() export class AdminMenuService { @@ -64,7 +60,7 @@ export class AdminMenuService { return !!menu && (!id || menu.id !== id); } - async createMenu(data: MenuInput) { + async createMenu(data: AdminMenuInput) { const entity = this.menuRepository.create({ ...this.normalizeMenuInput(data, true), }); @@ -72,7 +68,7 @@ export class AdminMenuService { return null; } - async updateMenu(id: string, data: MenuInput) { + async updateMenu(id: string, data: AdminMenuInput) { await this.menuRepository.update( { id }, { @@ -168,7 +164,7 @@ export class AdminMenuService { } private normalizeMenuInput( - data: MenuInput, + data: AdminMenuInput, includeEmptyMeta: boolean, ): Partial { const meta = this.normalizeMetaInput(data); @@ -188,7 +184,7 @@ export class AdminMenuService { return menu; } - private normalizeMetaInput(data: MenuInput): AdminMenuMeta { + private normalizeMetaInput(data: AdminMenuInput): AdminMenuMeta { const meta = this.normalizeMetaValue(data.meta); // 兼容表单库返回字面量 `meta.title` 的场景,避免更新菜单时把 meta 覆盖为空对象。 diff --git a/src/admin/role/admin-role.service.ts b/src/admin/role/admin-role.service.ts index 146338f..55a151c 100644 --- a/src/admin/role/admin-role.service.ts +++ b/src/admin/role/admin-role.service.ts @@ -4,12 +4,7 @@ import { Repository } from 'typeorm'; import { throwVbenError } from '@/common'; import { AdminMenu } from '../menu/admin-menu.entity'; import { AdminRole } from './admin-role.entity'; - -type RoleInput = Partial & { - permissions?: string[]; -}; - -type ListQuery = Record; +import type { AdminRoleInput, AdminRoleListQuery } from '../admin.types'; @Injectable() export class AdminRoleService { @@ -20,7 +15,7 @@ export class AdminRoleService { private readonly menuRepository: Repository, ) {} - async getRoleList(query: ListQuery) { + async getRoleList(query: AdminRoleListQuery) { const page = Number(query.page || 1); const pageSize = Number(query.pageSize || 20); const builder = this.roleRepository @@ -65,7 +60,7 @@ export class AdminRoleService { }; } - async createRole(data: RoleInput) { + async createRole(data: AdminRoleInput) { const role = this.roleRepository.create({ name: data.name, remark: data.remark || '', @@ -77,7 +72,7 @@ export class AdminRoleService { return null; } - async updateRole(id: string, data: RoleInput) { + async updateRole(id: string, data: AdminRoleInput) { const role = await this.roleRepository.findOne({ relations: ['menus'], where: { @@ -90,7 +85,8 @@ export class AdminRoleService { if (data.name !== undefined) role.name = data.name; if (data.remark !== undefined) role.remark = data.remark; if (data.status !== undefined) role.status = data.status; - if (data.permissions) role.menus = await this.findMenusByIds(data.permissions); + if (data.permissions) + role.menus = await this.findMenusByIds(data.permissions); await this.roleRepository.save(role); return null; diff --git a/src/admin/user/admin-user.service.ts b/src/admin/user/admin-user.service.ts index b7ba53f..b0d7c92 100644 --- a/src/admin/user/admin-user.service.ts +++ b/src/admin/user/admin-user.service.ts @@ -5,12 +5,7 @@ import { throwVbenError } from '@/common'; import { AdminDept } from '../dept/admin-dept.entity'; import { AdminRole } from '../role/admin-role.entity'; import { AdminUser } from './admin-user.entity'; - -type UserInput = Partial & { - roleIds?: string[]; -}; - -type ListQuery = Record; +import type { AdminUserInput, AdminUserListQuery } from '../admin.types'; @Injectable() export class AdminUserService { @@ -23,7 +18,7 @@ export class AdminUserService { private readonly deptRepository: Repository, ) {} - async getUserList(query: ListQuery) { + async getUserList(query: AdminUserListQuery) { const page = Number(query.page || 1); const pageSize = Number(query.pageSize || 20); const builder = this.userRepository @@ -80,7 +75,7 @@ export class AdminUserService { }; } - async createUser(data: UserInput) { + async createUser(data: AdminUserInput) { await this.ensureUsernameAvailable(String(data.username || '')); const user = this.userRepository.create({ @@ -97,7 +92,7 @@ export class AdminUserService { return null; } - async updateUser(id: string, data: UserInput) { + async updateUser(id: string, data: AdminUserInput) { const user = await this.userRepository.findOne({ relations: ['roles'], where: { diff --git a/src/app.module.ts b/src/app.module.ts index f554084..5a874f7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,7 +6,11 @@ import { AppService } from './app.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MinioModule } from 'nestjs-minio-client'; import { MinioClientModule } from './minio/minio.module'; -import { ApiExceptionFilter, SaveBodyInterceptor } from './common'; +import { + ApiExceptionFilter, + CommonModule, + SaveBodyInterceptor, +} from './common'; import { AdminModule } from './admin/admin.module'; import { WordpressModule } from './wordpress/wordpress.module'; import { QqbotModule } from './qqbot/qqbot.module'; @@ -49,6 +53,7 @@ import { QqbotModule } from './qqbot/qqbot.module'; inject: [ConfigService], }), MinioClientModule, + CommonModule, AdminModule, WordpressModule, QqbotModule, diff --git a/src/common/admin-response.ts b/src/common/admin-response.ts deleted file mode 100644 index cd47a41..0000000 --- a/src/common/admin-response.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; - -export type VbenResponse = { - code: 200; - data: T; - msg: string; -}; - -export type ApiErrorResponse = { - code: number; - msg: string; - err: any; -}; - -export const vbenSuccess = ( - data: T, - msg = '操作成功', -): VbenResponse => ({ - code: 200, - data, - msg, -}); - -export const vbenPage = (items: T[], total: number) => - vbenSuccess({ - items, - total, - }); - -export const throwVbenError = ( - message: string, - status = HttpStatus.BAD_REQUEST, - err: any = message, -): never => { - throw new HttpException( - { - msg: message, - err, - }, - status, - ); -}; diff --git a/src/common/common.module.ts b/src/common/common.module.ts new file mode 100644 index 0000000..64afa3a --- /dev/null +++ b/src/common/common.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { ToolsService } from './services/tool.service'; + +@Global() +@Module({ + exports: [ToolsService], + providers: [ToolsService], +}) +export class CommonModule {} diff --git a/src/common/decorators/decode-dict.decorator.ts b/src/common/decorators/decode-dict.decorator.ts index e13fb7d..2fe986c 100644 --- a/src/common/decorators/decode-dict.decorator.ts +++ b/src/common/decorators/decode-dict.decorator.ts @@ -1,13 +1,8 @@ -type DecodeDictKeyOptions = { - fallback?: string; - sourceKey?: string; - targetKey?: string; -}; - -type DictDecodeRule = DecodeDictKeyOptions & { - targetKey: string; - dictKeys: string[]; -}; +import type { + DecodeDictKeyOptions, + DictDecodeRule, + KtDictOption, +} from '../types'; const DICT_DECODE_RULES = Symbol('DICT_DECODE_RULES'); const DICT_DECODE_CACHE = new Map>(); @@ -78,7 +73,7 @@ export function decodeDictKeys(target: T): T { // DictService 从数据库刷新缓存后,实体 AfterLoad 可以同步完成字典映射。 export function setDictDecodeCache( - dicts: Array>, + dicts: Array>, ): void { DICT_DECODE_CACHE.clear(); diff --git a/src/common/filters/api-exception.filter.ts b/src/common/filters/api-exception.filter.ts index 0b1d165..a72a90d 100644 --- a/src/common/filters/api-exception.filter.ts +++ b/src/common/filters/api-exception.filter.ts @@ -6,14 +6,8 @@ import { HttpStatus, } from '@nestjs/common'; import { Response } from 'express'; -import { ApiErrorResponse } from '../admin-response'; - -type ExceptionBody = { - err?: unknown; - error?: unknown; - message?: unknown; - msg?: unknown; -}; +import { normalizeVbenErrorText } from '../response/vben-response'; +import type { ExceptionBody, KtErrorResponse } from '../types'; @Catch() export class ApiExceptionFilter implements ExceptionFilter { @@ -22,12 +16,13 @@ export class ApiExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const status = this.getStatus(exception); const body = this.getBody(exception); + const msg = this.getMessage(status, body, exception); response.status(status).json({ code: status, - msg: this.getMessage(status, body, exception), - err: this.getErr(status, body, exception), - } satisfies ApiErrorResponse); + msg, + err: this.getErr(status, body, exception, msg), + } satisfies KtErrorResponse); } private getStatus(exception: unknown) { @@ -65,17 +60,22 @@ export class ApiExceptionFilter implements ExceptionFilter { status: number, body: ExceptionBody | string | null, exception: unknown, + fallback: string, ) { - if (typeof body === 'string') return body; - if (body?.err !== undefined) return body.err; - if (body?.error !== undefined) return body.error; - if (body?.message !== undefined) return body.message; - if (exception instanceof Error) return exception.message; + if (typeof body === 'string') return normalizeVbenErrorText(body, fallback); + if (body?.err !== undefined) + return normalizeVbenErrorText(body.err, fallback); + if (body?.error !== undefined) + return normalizeVbenErrorText(body.error, fallback); + if (body?.message !== undefined) + return normalizeVbenErrorText(body.message, fallback); + if (exception instanceof Error) + return normalizeVbenErrorText(exception.message, fallback); return status >= 500 ? 'Internal server error' : '操作失败'; } private stringifyMessage(message: unknown) { - return Array.isArray(message) ? message.join('; ') : String(message); + return normalizeVbenErrorText(message); } } diff --git a/src/common/index.ts b/src/common/index.ts index 5d4d8b4..2f89687 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,10 +1,13 @@ -export * from './admin-response'; -export * from './admin-tree'; +export * from './common.module'; export * from './decorators/current-admin-user.decorator'; export * from './decorators/decode-dict.decorator'; export * from './decorators/public.decorator'; export * from './filters/api-exception.filter'; export * from './interceptors/save-body.interceptor'; -export * from './snowflake-id'; +export * from './response/vben-response'; export * from './services/tool.service'; +export * from './snowflake/snowflake-id'; +export * from './snowflake/snowflake-id.subscriber'; export * from './swagger/swagger-response'; +export * from './tree/tree.util'; +export * from './types'; diff --git a/src/common/response/vben-response.ts b/src/common/response/vben-response.ts new file mode 100644 index 0000000..e224321 --- /dev/null +++ b/src/common/response/vben-response.ts @@ -0,0 +1,57 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import type { ExceptionBody, KtSuccessResponse } from '../types'; + +export const normalizeVbenErrorText = ( + value: unknown, + fallback = '操作失败', +): string => { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => normalizeVbenErrorText(item, '')).join('; '); + } + if (value instanceof Error) return value.message || fallback; + if (typeof value === 'object') { + const record = value as Record; + const nested = record.msg ?? record.message ?? record.error ?? record.err; + + if (nested !== undefined) return normalizeVbenErrorText(nested, fallback); + + try { + return JSON.stringify(value); + } catch { + return fallback; + } + } + + return String(value); +}; + +export const vbenSuccess = ( + data: T, + msg = '操作成功', +): KtSuccessResponse => ({ + code: 200, + data, + msg, +}); + +export const vbenPage = (items: T[], total: number) => + vbenSuccess({ + items, + total, + }); + +export const throwVbenError = ( + message: string, + status = HttpStatus.BAD_REQUEST, + err: unknown = message, +): never => { + throw new HttpException( + { + msg: message, + err: normalizeVbenErrorText(err, message), + } satisfies ExceptionBody, + status, + ); +}; diff --git a/src/common/services/tool.service.ts b/src/common/services/tool.service.ts index e50a8a3..db8cc0b 100644 --- a/src/common/services/tool.service.ts +++ b/src/common/services/tool.service.ts @@ -1,5 +1,13 @@ 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 { @@ -14,7 +22,7 @@ export class ToolsService { return captcha; } - res(code: number, msg: string, data: any): Res { + res(code: number, msg: string, data: any): KtResponse { if (code === 200) { return { code, @@ -26,11 +34,11 @@ export class ToolsService { return { code, msg, - err: data, + err: normalizeVbenErrorText(data, msg), }; } - page(list: T[], total: number): Page { + page(list: T[], total: number): KtPage { const retn = { list, total, @@ -86,7 +94,7 @@ export class ToolsService { label: string, value: any, other: Partial, - ): Dict { + ): KtDictOption { const options = { label, value, @@ -95,4 +103,212 @@ export class ToolsService { return options; } + + 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}`; + } + + sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + toTrimmedString(value: unknown) { + return `${value ?? ''}`.trim(); + } + + normalizeWhitespaceText(value: unknown) { + return this.toTrimmedString(value).replace(/\s+/g, ' '); + } + + toStringId(value: number | string | undefined | null) { + return value === undefined || value === null ? '' : `${value}`; + } + + toPositiveNumber( + value: number | string | undefined | null, + fallback: number, + ) { + const nextValue = Number(value); + return Number.isFinite(nextValue) && nextValue > 0 ? nextValue : fallback; + } + + 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, + }; + } + + 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()); + } + + normalizeNullableString(value: unknown) { + if (value === undefined || value === null) return null; + const nextValue = this.toTrimmedString(value); + return nextValue ? nextValue : null; + } + + pickFirstText(...values: unknown[]) { + for (const value of values) { + const text = this.toTrimmedString(value); + if (text) return text; + } + return ''; + } + + includesAny(value: unknown, keywords: string[]) { + const text = `${value ?? ''}`; + return keywords.some((keyword) => text.includes(keyword)); + } + + includesText(value: unknown, keyword: unknown) { + const normalizedKeyword = this.toTrimmedString(keyword); + if (!normalizedKeyword) return true; + + return this.toTrimmedString(value) + .toLowerCase() + .includes(normalizedKeyword.toLowerCase()); + } + + isSameText(left: unknown, right?: unknown) { + const rightText = this.toTrimmedString(right); + return !!rightText && this.toTrimmedString(left) === rightText; + } + + pickDefined>(payload: T) { + return Object.entries(payload).reduce>((acc, [key, value]) => { + if (value !== undefined && value !== null && value !== '') { + acc[key as keyof T] = value as T[keyof T]; + } + return acc; + }, {}); + } + + readHeader( + request: { headers?: Record } | undefined, + name: string, + ) { + const value = request?.headers?.[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : value; + } + + readCookie( + request: { headers?: Record } | 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; + } + } + + readBearerToken(authHeader?: string) { + if (!authHeader?.startsWith('Bearer ')) return null; + return authHeader.split(' ')[1] || null; + } + + pickQrcode(data?: Record | null) { + if (!data) return ''; + return this.pickFirstText(data.qrcode, data.qrcodeurl, data.url); + } + + 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; + } + + pickNapcatSelfId(info: Record) { + return this.pickFirstText(info.uin, info.self_id, info.selfId); + } + + pickNapcatNickname(info: Record) { + return this.pickFirstText(info.nick, info.nickname, info.name); + } + + isNapcatAlreadyLoggedInError(err: unknown) { + return this.getErrorMessage(err).includes('QQ Is Logined'); + } + + isNapcatTemporaryError(err: unknown) { + return this.includesAny(this.getErrorMessage(err), [ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'NapCat 请求超时', + 'NapCat 未返回登录二维码', + 'NapCat 二维码仍未刷新', + 'QRCode Get Error', + 'socket hang up', + ]); + } + + isNapcatQrcodePendingError(err: unknown) { + return this.getErrorMessage(err).includes('QRCode Get Error'); + } + + isNapcatOfflineLoginStatus(status: NapcatLoginStatusLike) { + return ( + !!status.isOffline || this.isNapcatOfflineLoginMessage(status.loginError) + ); + } + + isNapcatOfflineLoginMessage(message?: string) { + return this.includesAny(message, [ + 'KickedOffLine', + 'Not Login', + 'not login', + '下线', + '离线', + '另一台终端', + '被踢', + '登录态失效', + ]); + } + + isNapcatExpiredQrcodeStatus(status: NapcatLoginStatusLike) { + const message = status.loginError || ''; + return ( + message.includes('二维码') && + (message.includes('过期') || message.includes('失效')) + ); + } } diff --git a/src/common/snowflake-id.subscriber.ts b/src/common/snowflake/snowflake-id.subscriber.ts similarity index 100% rename from src/common/snowflake-id.subscriber.ts rename to src/common/snowflake/snowflake-id.subscriber.ts diff --git a/src/common/snowflake-id.ts b/src/common/snowflake/snowflake-id.ts similarity index 97% rename from src/common/snowflake-id.ts rename to src/common/snowflake/snowflake-id.ts index 0d16df2..06194e0 100644 --- a/src/common/snowflake-id.ts +++ b/src/common/snowflake/snowflake-id.ts @@ -1,3 +1,5 @@ +import type { SnowflakeEntity } from '../types'; + const TWEPOCH = 1288834974657n; const WORKER_ID_BITS = 5n; const DATACENTER_ID_BITS = 5n; @@ -76,10 +78,6 @@ const snowflakeIdGenerator = new SnowflakeIdGenerator(); export const createSnowflakeId = () => snowflakeIdGenerator.nextId(); -export type SnowflakeEntity = { - id?: number | string | null; -}; - export const isEmptySnowflakeId = (id: SnowflakeEntity['id']) => id === undefined || id === null || id === '' || id === 0 || id === '0'; diff --git a/src/common/swagger/swagger-response.ts b/src/common/swagger/swagger-response.ts index 83ab10d..7d1498f 100644 --- a/src/common/swagger/swagger-response.ts +++ b/src/common/swagger/swagger-response.ts @@ -1,16 +1,12 @@ import { applyDecorators, Type } from '@nestjs/common'; import { ApiExtraModels, ApiOkResponse, ApiProperty } from '@nestjs/swagger'; import type { OpenAPIObject } from '@nestjs/swagger'; - -type SwaggerSchema = Record; -type SwaggerOperation = Record; -type SwaggerComponents = NonNullable; - -type ApiResponseOptions = { - description?: string; - schema?: SwaggerSchema; - example: any; -}; +import type { + ApiResponseOptions, + SwaggerComponents, + SwaggerOperation, + SwaggerSchema, +} from '../types'; const primitiveTypeMap = { string: String, @@ -208,6 +204,7 @@ const standardErrorSchema = { example: '操作失败', }, err: { + type: 'string', description: '错误详情', example: 'Bad Request', }, diff --git a/src/common/admin-tree.ts b/src/common/tree/tree.util.ts similarity index 100% rename from src/common/admin-tree.ts rename to src/common/tree/tree.util.ts diff --git a/src/common/types/api-exception.types.ts b/src/common/types/api-exception.types.ts new file mode 100644 index 0000000..685e2cd --- /dev/null +++ b/src/common/types/api-exception.types.ts @@ -0,0 +1,6 @@ +export type ExceptionBody = { + err?: unknown; + error?: unknown; + message?: unknown; + msg?: unknown; +}; diff --git a/src/common/types/api.types.ts b/src/common/types/api.types.ts new file mode 100644 index 0000000..b241492 --- /dev/null +++ b/src/common/types/api.types.ts @@ -0,0 +1,35 @@ +export type KtSuccessResponse = { + code: 200; + data: T; + msg: string; +}; + +export type KtErrorResponse = { + code: number; + err: string; + msg: string; +}; + +export type KtResponse = KtErrorResponse | KtSuccessResponse; + +export type KtPage = { + list: T[]; + total: number; +}; + +export type KtPageQuery = { + pageNo?: number | string; + pageSize?: number | string; +}; + +export type KtPageParams> = { + pageNo: number; + pageSize: number; +} & Partial; + +export type KtDictOption = { + label: string; + value: V; +} & Partial; + +export type KtMaybeArray = T | readonly T[]; diff --git a/src/common/types/decode-dict.types.ts b/src/common/types/decode-dict.types.ts new file mode 100644 index 0000000..0e95a7a --- /dev/null +++ b/src/common/types/decode-dict.types.ts @@ -0,0 +1,10 @@ +export type DecodeDictKeyOptions = { + fallback?: string; + sourceKey?: string; + targetKey?: string; +}; + +export type DictDecodeRule = DecodeDictKeyOptions & { + targetKey: string; + dictKeys: string[]; +}; diff --git a/src/common/types/index.ts b/src/common/types/index.ts new file mode 100644 index 0000000..c48ddbf --- /dev/null +++ b/src/common/types/index.ts @@ -0,0 +1,6 @@ +export * from './api.types'; +export * from './api-exception.types'; +export * from './decode-dict.types'; +export * from './snowflake.types'; +export * from './swagger.types'; +export * from './tool.types'; diff --git a/src/common/types/snowflake.types.ts b/src/common/types/snowflake.types.ts new file mode 100644 index 0000000..e01c94d --- /dev/null +++ b/src/common/types/snowflake.types.ts @@ -0,0 +1,3 @@ +export type SnowflakeEntity = { + id?: number | string | null; +}; diff --git a/src/common/types/swagger.types.ts b/src/common/types/swagger.types.ts new file mode 100644 index 0000000..31148bc --- /dev/null +++ b/src/common/types/swagger.types.ts @@ -0,0 +1,21 @@ +import type { OpenAPIObject } from '@nestjs/swagger'; + +export type ApiResponseOptions = { + description?: string; + schema?: SwaggerSchema; + example: any; +}; + +export type SwaggerComponents = NonNullable; + +export type SwaggerDocumentGroup = { + matcher: SwaggerPathMatcher; + name: string; + path: string; +}; + +export type SwaggerOperation = Record; + +export type SwaggerPathMatcher = (path: string) => boolean; + +export type SwaggerSchema = Record; diff --git a/src/common/types/tool.types.ts b/src/common/types/tool.types.ts new file mode 100644 index 0000000..9268333 --- /dev/null +++ b/src/common/types/tool.types.ts @@ -0,0 +1,9 @@ +export type NapcatLoginStatusLike = { + isOffline?: boolean; + loginError?: string; +}; + +export type QrcodeLookupOptions = { + requireFresh?: boolean; + staleQrcode?: string; +}; diff --git a/src/main.ts b/src/main.ts index 61c9f70..e4f3c48 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,16 +5,9 @@ import type { OpenAPIObject } from '@nestjs/swagger'; import { urlencoded, json } from 'express'; import { knife4jSetup } from '@kwitsukasa/knife4j-swagger-vue3'; import type { Service } from '@kwitsukasa/knife4j-swagger-vue3'; +import type { SwaggerDocumentGroup, SwaggerPathMatcher } from './common'; import { applySwaggerResponseExamples } from './common'; -type SwaggerPathMatcher = (path: string) => boolean; - -interface SwaggerDocumentGroup { - matcher: SwaggerPathMatcher; - name: string; - path: string; -} - const adminSwaggerPathPrefixes = [ '/auth', '/component', diff --git a/src/minio/minio.controller.ts b/src/minio/minio.controller.ts index 49f72b2..317e547 100644 --- a/src/minio/minio.controller.ts +++ b/src/minio/minio.controller.ts @@ -22,7 +22,7 @@ import { } from '@nestjs/swagger'; import { Response } from 'express'; import { MinioClientService } from './minio.service'; -import type { MinioUploadFile } from './minio.service'; +import type { MinioUploadFile } from './minio.types'; import { ApiFileDownloadResponse, ApiArrayResponse, diff --git a/src/minio/minio.module.ts b/src/minio/minio.module.ts index b74f673..06fa78e 100644 --- a/src/minio/minio.module.ts +++ b/src/minio/minio.module.ts @@ -3,12 +3,11 @@ import { ConfigModule } from '@nestjs/config'; import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module'; import { MinioClientController } from './minio.controller'; import { MinioClientService } from './minio.service'; -import { ToolsService } from '@/common'; @Module({ imports: [AdminAuthGuardModule, ConfigModule], controllers: [MinioClientController], - providers: [MinioClientService, ToolsService], + providers: [MinioClientService], exports: [MinioClientService], }) export class MinioClientModule {} diff --git a/src/minio/minio.service.ts b/src/minio/minio.service.ts index 4e3e54e..19bf55a 100644 --- a/src/minio/minio.service.ts +++ b/src/minio/minio.service.ts @@ -1,39 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MinioService } from 'nestjs-minio-client'; -import type { Readable } from 'stream'; - -export type MinioUploadFile = { - originalname: string; - mimetype: string; - size: number; - buffer: Buffer; -}; - -type UploadObjectOptions = { - bucketName?: string; - objectName?: string; - file: MinioUploadFile; -}; - -type ListObjectOptions = { - bucketName?: string; - prefix?: string; - recursive?: boolean; -}; - -type MinioObjectResult = { - stream: Readable; - stat: { - size: number; - etag: string; - lastModified: Date; - metaData: Record; - versionId?: string | null; - }; - bucketName: string; - objectName: string; -}; +import type { + MinioListObjectOptions, + MinioObjectResult, + MinioUploadObjectOptions, +} from './minio.types'; @Injectable() export class MinioClientService { @@ -75,7 +47,11 @@ export class MinioClientService { return targetBucket; } - async uploadObject({ bucketName, objectName, file }: UploadObjectOptions) { + async uploadObject({ + bucketName, + objectName, + file, + }: MinioUploadObjectOptions) { if (!file) { throw new BadRequestException('请选择要上传的文件'); } @@ -108,7 +84,7 @@ export class MinioClientService { bucketName, prefix = '', recursive = true, - }: ListObjectOptions) { + }: MinioListObjectOptions) { const targetBucket = this.getBucketName(bucketName); const exists = await this.client.bucketExists(targetBucket); diff --git a/src/minio/minio.types.ts b/src/minio/minio.types.ts new file mode 100644 index 0000000..17a17a1 --- /dev/null +++ b/src/minio/minio.types.ts @@ -0,0 +1,33 @@ +import type { Readable } from 'stream'; + +export type MinioUploadFile = { + buffer: Buffer; + mimetype: string; + originalname: string; + size: number; +}; + +export type MinioUploadObjectOptions = { + bucketName?: string; + file: MinioUploadFile; + objectName?: string; +}; + +export type MinioListObjectOptions = { + bucketName?: string; + prefix?: string; + recursive?: boolean; +}; + +export type MinioObjectResult = { + bucketName: string; + objectName: string; + stat: { + etag: string; + lastModified: Date; + metaData: Record; + size: number; + versionId?: string | null; + }; + stream: Readable; +}; diff --git a/src/qqbot/account/qqbot-account-ability.entity.ts b/src/qqbot/account/qqbot-account-ability.entity.ts index bb1acb2..40ab68d 100644 --- a/src/qqbot/account/qqbot-account-ability.entity.ts +++ b/src/qqbot/account/qqbot-account-ability.entity.ts @@ -8,8 +8,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { ensureSnowflakeId } from '@/common'; - -export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule'; +import type { QqbotAccountAbilityType } from '../qqbot.types'; @Entity('qqbot_account_ability') @Index('uk_qqbot_account_ability', ['accountId', 'abilityType', 'abilityKey'], { diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index 29b2b4c..c302795 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -1,11 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { throwVbenError } from '@/common'; -import { - QqbotAccountAbility, - type QqbotAccountAbilityType, -} from './qqbot-account-ability.entity'; +import { throwVbenError, ToolsService } from '@/common'; +import { QqbotAccountAbility } from './qqbot-account-ability.entity'; import { QqbotAccount } from './qqbot-account.entity'; import type { QqbotAccountBodyDto, @@ -15,28 +12,15 @@ import type { import { QqbotAccountNapcat } from '../napcat/qqbot-account-napcat.entity'; import { QqbotNapcatContainer } from '../napcat/qqbot-napcat-container.entity'; import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service'; +import { + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, +} from '../qqbot.constants'; import type { - QqbotAccountNapcatBindStatus, + QqbotAccountAbilityType, + QqbotAccountListItem, QqbotConnectionRole, - QqbotNapcatContainerStatus, } from '../qqbot.types'; -import { getPageParams, normalizeNullableString } from '../qqbot.utils'; - -export type QqbotAccountNapcatRuntimeInfo = { - bindStatus?: QqbotAccountNapcatBindStatus; - containerId?: string; - containerName?: string; - containerStatus?: QqbotNapcatContainerStatus; - lastCheckedAt?: Date | null; - lastError?: null | string; - lastLoginAt?: Date | null; - lastStartedAt?: Date | null; - webuiPort?: null | number; -}; - -export type QqbotAccountListItem = QqbotAccount & { - napcat?: null | QqbotAccountNapcatRuntimeInfo; -}; @Injectable() export class QqbotAccountService { @@ -50,10 +34,15 @@ export class QqbotAccountService { @InjectRepository(QqbotNapcatContainer) private readonly napcatContainerRepository: Repository, private readonly napcatContainerService: QqbotNapcatContainerService, + private readonly toolsService: ToolsService, ) {} async page(query: QqbotAccountQueryDto) { - const { pageNo, pageSize, skip } = getPageParams(query); + const { pageNo, pageSize, skip } = this.toolsService.getPageParams( + query, + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, + ); const builder = this.accountRepository .createQueryBuilder('account') .where('account.isDeleted = :isDeleted', { isDeleted: false }); @@ -451,7 +440,7 @@ export class QqbotAccountService { private normalizeBody(body: Partial) { return { - accessToken: normalizeNullableString(body.accessToken), + accessToken: this.toolsService.normalizeNullableString(body.accessToken), connectionMode: body.connectionMode || 'reverse-ws', enabled: body.enabled ?? true, name: body.name || '', diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index ca7631a..49500d8 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -3,83 +3,25 @@ import * as https from 'https'; import { createHash, randomUUID } from 'crypto'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { throwVbenError } from '@/common'; -import type { QqbotLoginScanMode, QqbotLoginScanStatus } from '../qqbot.types'; -import { - QqbotNapcatContainerService, - type QqbotNapcatRuntime, -} from '../napcat/qqbot-napcat-container.service'; +import { throwVbenError, ToolsService } from '@/common'; +import type { + NapcatApiResponse, + NapcatCredential, + NapcatLoginInfo, + NapcatLoginStatus, + NapcatQrcode, + NapcatRestartOptions, + QqbotLoginScanMode, + QqbotLoginScanResult, + QqbotLoginScanSession, + QqbotLoginScanStatus, + QqbotNapcatRuntime, + QrcodeLookupOptions, + QrcodeRefreshOptions, +} from '../qqbot.types'; +import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service'; import { QqbotAccountService } from './qqbot-account.service'; -type NapcatApiResponse = { - code: number; - data?: T; - message?: string; -}; - -type NapcatCredential = { - Credential?: string; -}; - -type NapcatLoginInfo = Record & { - avatarUrl?: string; - nick?: string; - nickname?: string; - online?: boolean; - uin?: number | string; -}; - -type NapcatLoginStatus = { - isLogin?: boolean; - isOffline?: boolean; - loginError?: string; - qrcodeurl?: string; -}; - -type NapcatQrcode = { - qrcode?: string; - qrcodeurl?: string; - url?: string; -}; - -type QqbotLoginScanSession = { - accountId?: string; - containerId?: string; - containerName?: string; - createdAt: number; - errorMessage?: string; - expiresAt: number; - expectedSelfId?: string; - id: string; - mode: QqbotLoginScanMode; - qrcode?: string; - status: QqbotLoginScanStatus; - webuiPort?: null | number; -}; - -type QrcodeLookupOptions = { - requireFresh?: boolean; - staleQrcode?: string; -}; - -type QrcodeRefreshOptions = QrcodeLookupOptions & { - fallbackStatus?: NapcatLoginStatus; -}; - -export type QqbotLoginScanResult = { - accountId?: string; - containerId?: string; - containerName?: string; - errorMessage?: string; - expiresAt?: number; - mode: QqbotLoginScanMode; - qrcode?: string; - selfId?: string; - sessionId?: string; - status: QqbotLoginScanStatus; - webuiPort?: null | number; -}; - @Injectable() export class QqbotNapcatLoginService { private readonly sessions = new Map(); @@ -92,6 +34,7 @@ export class QqbotNapcatLoginService { private readonly configService: ConfigService, private readonly accountService: QqbotAccountService, private readonly containerService: QqbotNapcatContainerService, + private readonly toolsService: ToolsService, ) {} async startCreate() { @@ -125,16 +68,49 @@ export class QqbotNapcatLoginService { } const container = await this.getSessionContainer(session); - const loginStatus = await this.getLoginStatus(container); - session.qrcode = await this.refreshOrGetQrcode(container, true, { - fallbackStatus: loginStatus, - requireFresh: true, - staleQrcode: session.qrcode || loginStatus.qrcodeurl, - }); - session.expiresAt = Date.now() + this.getSessionTtlMs(); - session.errorMessage = undefined; - this.sessions.set(session.id, session); - return this.toResult(session); + let loginStatus: NapcatLoginStatus; + try { + loginStatus = await this.getLoginStatus(container); + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + await this.restartNapcatForLogin(container, { waitForReady: false }); + session.lastRestartedAt = Date.now(); + return this.keepSessionPending( + session, + 'NapCat 通信超时,已尝试重启容器并重新生成二维码', + true, + ); + } + + if (loginStatus.isOffline) { + await this.restartNapcatForLogin(container, { waitForReady: false }); + session.lastRestartedAt = Date.now(); + return this.keepSessionPending( + session, + loginStatus.loginError || + 'NapCat 账号已离线,已重启容器并重新生成二维码', + true, + ); + } + + try { + session.qrcode = await this.refreshOrGetQrcode(container, false, { + fallbackStatus: loginStatus, + requireFresh: true, + staleQrcode: session.qrcode || loginStatus.qrcodeurl, + }); + session.expiresAt = Date.now() + this.getSessionTtlMs(); + session.errorMessage = undefined; + this.sessions.set(session.id, session); + return this.toResult(session); + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + return this.keepSessionPending( + session, + 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新', + true, + ); + } } async status(sessionId: string) { @@ -144,11 +120,27 @@ export class QqbotNapcatLoginService { } const container = await this.getSessionContainer(session); - const status = await this.getLoginStatus(container); + let status: NapcatLoginStatus; + try { + status = await this.getLoginStatus(container); + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + return this.keepSessionPending( + session, + 'NapCat 正在重启或生成二维码,请稍后', + ); + } if (!status.isLogin) { session.errorMessage = status.loginError || undefined; - if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) { + if ( + status.qrcodeurl && + !this.toolsService.isNapcatExpiredQrcodeStatus(status) + ) { session.qrcode = status.qrcodeurl; + } else if (status.isOffline) { + session.qrcode = undefined; + } else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) { + await this.tryUpdatePendingQrcode(container, session, status); } this.sessions.set(session.id, session); return this.toResult(session); @@ -178,6 +170,21 @@ export class QqbotNapcatLoginService { try { const loginStatus = await this.getLoginStatus(container, true); + if (loginStatus.isOffline) { + await this.restartNapcatForLogin(container, { waitForReady: false }); + const session = this.createSession({ + ...options, + container, + status: 'pending', + }); + session.lastRestartedAt = Date.now(); + session.errorMessage = + loginStatus.loginError || + 'NapCat 账号已离线,已重启容器并重新生成二维码'; + this.sessions.set(session.id, session); + return this.toResult(session); + } + if (loginStatus.isLogin) { const session = this.createSession({ ...options, @@ -190,7 +197,8 @@ export class QqbotNapcatLoginService { const qrcode = await this.refreshOrGetQrcode(container, true, { fallbackStatus: loginStatus, - requireFresh: this.isExpiredQrcodeStatus(loginStatus), + requireFresh: + this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus), staleQrcode: loginStatus.qrcodeurl, }); const session = this.createSession({ @@ -205,7 +213,9 @@ export class QqbotNapcatLoginService { const cleanupError = await this.cleanupRuntimeContainer(container); if (cleanupError) { throwVbenError( - `${this.getErrorMessage(err)};清理未绑定容器失败:${cleanupError}`, + `${this.toolsService.getErrorMessage( + err, + )};清理未绑定容器失败:${cleanupError}`, ); } throw err; @@ -217,7 +227,11 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, ): Promise { const loginInfo = await this.getLoginInfo(container); - const selfId = this.getSelfId(loginInfo); + if (loginInfo.online === false) { + return this.failSession(session, 'NapCat 当前账号已离线,请重新更新登录'); + } + + const selfId = this.toolsService.pickNapcatSelfId(loginInfo); if (!selfId) { return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); } @@ -230,7 +244,7 @@ export class QqbotNapcatLoginService { const accountId = await this.accountService.ensureScannedAccount({ accountId: session.accountId, - name: this.getNickname(loginInfo), + name: this.toolsService.pickNapcatNickname(loginInfo), selfId, }); await this.containerService.bindAccount(accountId, session.containerId); @@ -284,6 +298,19 @@ export class QqbotNapcatLoginService { }; } + private keepSessionPending( + session: QqbotLoginScanSession, + errorMessage: string, + clearQrcode = false, + ) { + session.status = 'pending'; + session.errorMessage = errorMessage; + session.expiresAt = Date.now() + this.getSessionTtlMs(); + if (clearQrcode) session.qrcode = undefined; + this.sessions.set(session.id, session); + return this.toResult(session); + } + private getSession(sessionId: string) { const session = this.sessions.get(sessionId); if (!session) { @@ -343,21 +370,43 @@ export class QqbotNapcatLoginService { } } + private async tryUpdatePendingQrcode( + container: QqbotNapcatRuntime, + session: QqbotLoginScanSession, + status: NapcatLoginStatus, + ) { + try { + const qrcode = await this.getQrcode(container, false, { + requireFresh: !!session.qrcode, + staleQrcode: session.qrcode || status.qrcodeurl, + }); + if (qrcode) { + session.qrcode = qrcode; + session.errorMessage = status.loginError || undefined; + } + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + session.errorMessage = + session.errorMessage || 'NapCat 正在重新生成二维码,请稍后'; + } + } + private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) { try { await this.containerService.removeUnboundContainer(container.id); return null; } catch (err) { - return this.getErrorMessage(err); + return this.toolsService.getErrorMessage(err); } } private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) { if (!retry) { - return this.postNapcat( + const status = await this.postNapcat( container, '/api/QQLogin/CheckLoginStatus', ); + return this.normalizeLoginStatus(container, status); } let lastError: unknown; @@ -366,19 +415,61 @@ export class QqbotNapcatLoginService { ); for (let index = 0; index < attempts; index += 1) { try { - return await this.postNapcat( + const status = await this.postNapcat( container, '/api/QQLogin/CheckLoginStatus', ); + return await this.normalizeLoginStatus(container, status); } catch (err) { lastError = err; - if (!this.isTemporaryNapcatError(err)) break; - await this.sleep(1500); + if (!this.toolsService.isNapcatTemporaryError(err)) break; + await this.toolsService.sleep(1500); } } throw lastError; } + private async normalizeLoginStatus( + container: QqbotNapcatRuntime, + status: NapcatLoginStatus, + ) { + if (this.toolsService.isNapcatOfflineLoginStatus(status)) { + return this.toOfflineLoginStatus(status); + } + + if (!status.isLogin) return status; + + try { + const loginInfo = await this.getLoginInfo(container); + if (loginInfo.online === false) { + return this.toOfflineLoginStatus( + status, + 'NapCat 账号已离线,请重新扫码登录', + ); + } + } catch (err) { + const errorMessage = this.toolsService.getErrorMessage(err); + if (this.toolsService.isNapcatOfflineLoginMessage(errorMessage)) { + return this.toOfflineLoginStatus(status, errorMessage); + } + throw err; + } + + return status; + } + + private toOfflineLoginStatus( + status: NapcatLoginStatus, + errorMessage = 'NapCat 账号已离线,请重新扫码登录', + ): NapcatLoginStatus { + return { + ...status, + isLogin: false, + isOffline: true, + loginError: status.loginError || errorMessage, + }; + } + private async getLoginInfo(container: QqbotNapcatRuntime) { return this.postNapcat( container, @@ -396,9 +487,9 @@ export class QqbotNapcatLoginService { container, '/api/QQLogin/RefreshQRcode', ); - return this.pickQrcode(data); + return this.toolsService.pickQrcode(data); } catch (err) { - if (this.isAlreadyLoggedIn(err)) return ''; + if (this.toolsService.isNapcatAlreadyLoggedInError(err)) return ''; throw err; } }); @@ -415,17 +506,20 @@ export class QqbotNapcatLoginService { container, '/api/QQLogin/GetQQLoginQrcode', ); - const qrcode = this.pickQrcode(data); + const qrcode = this.toolsService.pickQrcode(data); if (!qrcode) { return this.getQrcodeFromStatus(container, options); } - return this.ensureFreshQrcode(qrcode, options); + return this.toolsService.ensureFreshQrcode(qrcode, options); } catch (err) { - if (this.isAlreadyLoggedIn(err)) { + if (this.toolsService.isNapcatAlreadyLoggedInError(err)) { const status = await this.getLoginStatus(container); - return this.ensureFreshQrcode(status.qrcodeurl || '', options); + return this.toolsService.ensureFreshQrcode( + status.qrcodeurl || '', + options, + ); } - if (this.isQrcodePending(err)) { + if (this.toolsService.isNapcatQrcodePendingError(err)) { return this.getQrcodeFromStatus(container, options); } throw err; @@ -450,14 +544,17 @@ export class QqbotNapcatLoginService { try { const refreshedQrcode = await this.callRefreshQrcode(container, retry); if (refreshedQrcode) { - return this.ensureFreshQrcode(refreshedQrcode, lookupOptions); + return this.toolsService.ensureFreshQrcode( + refreshedQrcode, + lookupOptions, + ); } return await this.getQrcode(container, retry, lookupOptions); } catch (err) { if ( !lookupOptions.requireFresh && fallbackStatus?.qrcodeurl && - !this.isExpiredQrcodeStatus(fallbackStatus) + !this.toolsService.isNapcatExpiredQrcodeStatus(fallbackStatus) ) { return fallbackStatus.qrcodeurl; } @@ -470,8 +567,11 @@ export class QqbotNapcatLoginService { options: QrcodeLookupOptions = {}, ) { const status = await this.getLoginStatus(container); - if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) { - return this.ensureFreshQrcode(status.qrcodeurl, options); + if ( + status.qrcodeurl && + !this.toolsService.isNapcatExpiredQrcodeStatus(status) + ) { + return this.toolsService.ensureFreshQrcode(status.qrcodeurl, options); } if (options.requireFresh && status.qrcodeurl) { throw new Error('NapCat 二维码仍未刷新'); @@ -479,30 +579,6 @@ export class QqbotNapcatLoginService { throwVbenError('NapCat 未返回登录二维码'); } - private ensureFreshQrcode(qrcode: string, options: QrcodeLookupOptions = {}) { - const normalized = `${qrcode || ''}`.trim(); - if (options.requireFresh && !normalized) { - throw new Error('NapCat 二维码仍未刷新'); - } - if ( - normalized && - options.requireFresh && - this.isSameQrcode(normalized, options.staleQrcode) - ) { - throw new Error('NapCat 二维码仍未刷新'); - } - return normalized; - } - - private isSameQrcode(left: string, right?: string) { - return !!right && left.trim() === right.trim(); - } - - private pickQrcode(data?: NapcatQrcode | null) { - if (!data) return ''; - return `${data.qrcode || data.qrcodeurl || data.url || ''}`.trim(); - } - private async postNapcat( container: QqbotNapcatRuntime, path: string, @@ -512,7 +588,10 @@ export class QqbotNapcatLoginService { return this.requestNapcat(container, path, body, credential); } - private async restartNapcatForLogin(container: QqbotNapcatRuntime) { + private async restartNapcatForLogin( + container: QqbotNapcatRuntime, + options: NapcatRestartOptions = {}, + ) { const restartedByContainer = await this.containerService.restartRuntimeContainer(container); if (!restartedByContainer) { @@ -522,12 +601,14 @@ export class QqbotNapcatLoginService { '/api/QQLogin/RestartNapCat', ); } catch (err) { - if (!this.isTemporaryNapcatError(err)) throw err; + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; } } this.credentials.delete(this.getCredentialCacheKey(container)); - await this.sleep(this.getRestartDelayMs()); + if (options.waitForReady === false) return; + + await this.toolsService.sleep(this.getRestartDelayMs()); await this.getLoginStatus(container, true); } @@ -616,7 +697,7 @@ export class QqbotNapcatLoginService { req.write(payload); req.end(); }).catch((err): never => { - const message = this.getErrorMessage(err); + const message = this.toolsService.getErrorMessage(err); return throwVbenError(message || 'NapCat 请求失败'); }); } @@ -646,45 +727,6 @@ export class QqbotNapcatLoginService { ); } - private getSelfId(info: NapcatLoginInfo) { - return `${info.uin || info.self_id || info.selfId || ''}`.trim(); - } - - private getNickname(info: NapcatLoginInfo) { - return `${info.nick || info.nickname || info.name || ''}`.trim(); - } - - private isAlreadyLoggedIn(err: unknown) { - return this.getErrorMessage(err).includes('QQ Is Logined'); - } - - private isTemporaryNapcatError(err: unknown) { - const message = this.getErrorMessage(err); - return [ - 'ECONNREFUSED', - 'ECONNRESET', - 'ETIMEDOUT', - 'NapCat 请求超时', - 'NapCat 未返回登录二维码', - 'NapCat 二维码仍未刷新', - 'QRCode Get Error', - 'socket hang up', - ].some((keyword) => message.includes(keyword)); - } - - private isQrcodePending(err: unknown) { - const message = this.getErrorMessage(err); - return message.includes('QRCode Get Error'); - } - - private isExpiredQrcodeStatus(status: NapcatLoginStatus) { - const message = status.loginError || ''; - return ( - message.includes('二维码') && - (message.includes('过期') || message.includes('失效')) - ); - } - private async executeNapcatRequest( retry: boolean, action: () => Promise, @@ -700,23 +742,10 @@ export class QqbotNapcatLoginService { return await action(); } catch (err) { lastError = err; - if (!this.isTemporaryNapcatError(err)) break; - await this.sleep(1500); + if (!this.toolsService.isNapcatTemporaryError(err)) break; + await this.toolsService.sleep(1500); } } throw lastError; } - - private getErrorMessage(err: unknown) { - const response = (err as any)?.getResponse?.(); - if (typeof response?.msg === 'string') return response.msg; - if (typeof response?.message === 'string') return response.message; - return err instanceof Error ? err.message : `${err}`; - } - - private sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); - } } diff --git a/src/qqbot/command/qqbot-command-engine.service.ts b/src/qqbot/command/qqbot-command-engine.service.ts index e240974..34d72fb 100644 --- a/src/qqbot/command/qqbot-command-engine.service.ts +++ b/src/qqbot/command/qqbot-command-engine.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ToolsService } from '@/common'; import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service'; import type { QqbotNormalizedMessage } from '../qqbot.types'; import { QqbotSendService } from '../send/qqbot-send.service'; @@ -18,6 +19,7 @@ export class QqbotCommandEngineService { private readonly pluginRegistry: QqbotPluginRegistryService, private readonly replyTemplate: QqbotReplyTemplateService, private readonly sendService: QqbotSendService, + private readonly toolsService: ToolsService, ) {} async handleMessage(message: QqbotNormalizedMessage) { @@ -61,8 +63,10 @@ export class QqbotCommandEngineService { status: 'success', }); } catch (err) { - const errorMessage = - err instanceof Error ? err.message : '命令执行失败'; + const errorMessage = this.toolsService.getErrorMessage( + err, + '命令执行失败', + ); await this.commandService.logExecution({ command, errorMessage, @@ -113,7 +117,10 @@ export class QqbotCommandEngineService { status: 'success', }; } catch (err) { - const errorMessage = err instanceof Error ? err.message : '命令执行失败'; + const errorMessage = this.toolsService.getErrorMessage( + err, + '命令执行失败', + ); return { command: this.commandService.toResponse(command), errorMessage, @@ -167,7 +174,10 @@ export class QqbotCommandEngineService { targetType: message.messageType, }); } catch (err) { - const sendErr = err instanceof Error ? err.message : '错误回复发送失败'; + const sendErr = this.toolsService.getErrorMessage( + err, + '错误回复发送失败', + ); this.logger.warn(`QQBot 命令错误回复发送失败: ${sendErr}`); } } diff --git a/src/qqbot/command/qqbot-command-log.entity.ts b/src/qqbot/command/qqbot-command-log.entity.ts index dc42e7f..2d85e3d 100644 --- a/src/qqbot/command/qqbot-command-log.entity.ts +++ b/src/qqbot/command/qqbot-command-log.entity.ts @@ -8,8 +8,7 @@ import { } from 'typeorm'; import { ensureSnowflakeId } from '@/common'; import type { QqbotMessageType } from '../qqbot.types'; - -export type QqbotCommandLogStatus = 'failed' | 'success'; +import type { QqbotCommandLogStatus } from '../qqbot.types'; @Entity('qqbot_command_log') export class QqbotCommandLog { @@ -52,7 +51,12 @@ export class QqbotCommandLog { @Column({ default: 'success', length: 32 }) status: QqbotCommandLogStatus; - @Column({ default: null, name: 'error_message', nullable: true, type: 'text' }) + @Column({ + default: null, + name: 'error_message', + nullable: true, + type: 'text', + }) errorMessage: string | null; @CreateDateColumn({ name: 'create_time' }) diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts index cf33bd2..59c1f06 100644 --- a/src/qqbot/command/qqbot-command-parser.service.ts +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -6,23 +6,17 @@ import { buildQqbotFf14MarketCatalog, buildQqbotFf14MarketCatalogFromTree, QQBOT_FF14_MARKET_DICT_CODES, - type QqbotFf14MarketCatalog, isQqbotFf14DataCenterName, isQqbotFf14LocationName, isQqbotFf14RegionName, isQqbotFf14WorldName, splitQqbotFf14WorldPath, } from '../plugins/ff14Market/qqbot-ff14-worlds'; +import type { QqbotFf14MarketCatalog } from '../plugins/ff14Market/qqbot-ff14-market.types'; +import type { QqbotCommandMatchResult } from '../qqbot.types'; const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL'; -export type QqbotCommandMatchResult = { - alias: string; - input: Record; - matched: true; - rawArgs: string; -}; - @Injectable() export class QqbotCommandParserService { constructor(private readonly dictService: DictService) {} diff --git a/src/qqbot/command/qqbot-command.controller.ts b/src/qqbot/command/qqbot-command.controller.ts index 9fd333b..4d24370 100644 --- a/src/qqbot/command/qqbot-command.controller.ts +++ b/src/qqbot/command/qqbot-command.controller.ts @@ -10,7 +10,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; -import { vbenSuccess } from '@/common'; +import { ToolsService, vbenSuccess } from '@/common'; import { QqbotCommandBodyDto, QqbotCommandQueryDto, @@ -19,7 +19,6 @@ import { } from './qqbot-command.dto'; import { QqbotCommandEngineService } from './qqbot-command-engine.service'; import { QqbotCommandService } from './qqbot-command.service'; -import { normalizeBoolean } from '../qqbot.utils'; @ApiTags('QQBot - 在线命令') @Controller('qqbot/command') @@ -28,6 +27,7 @@ export class QqbotCommandController { constructor( private readonly commandEngine: QqbotCommandEngineService, private readonly commandService: QqbotCommandService, + private readonly toolsService: ToolsService, ) {} @Get('list') @@ -65,7 +65,10 @@ export class QqbotCommandController { @ApiQuery({ name: 'enabled', type: Boolean }) async toggle(@Query('id') id: string, @Query('enabled') enabled: string) { return vbenSuccess( - await this.commandService.toggle(id, normalizeBoolean(enabled)), + await this.commandService.toggle( + id, + this.toolsService.normalizeBoolean(enabled), + ), ); } diff --git a/src/qqbot/command/qqbot-command.dto.ts b/src/qqbot/command/qqbot-command.dto.ts index 5e39118..c816feb 100644 --- a/src/qqbot/command/qqbot-command.dto.ts +++ b/src/qqbot/command/qqbot-command.dto.ts @@ -1,13 +1,13 @@ import { PartialType } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { KtPageQuery } from '@/common'; import type { QqbotCommandParserType, QqbotMessageType, QqbotRuleTargetType, } from '../qqbot.types'; -import type { QqbotPageQuery } from '../qqbot.utils'; -export class QqbotCommandQueryDto implements QqbotPageQuery { +export class QqbotCommandQueryDto implements KtPageQuery { @ApiPropertyOptional() pageNo?: number | string; diff --git a/src/qqbot/command/qqbot-command.service.ts b/src/qqbot/command/qqbot-command.service.ts index f6a6a33..4416ec7 100644 --- a/src/qqbot/command/qqbot-command.service.ts +++ b/src/qqbot/command/qqbot-command.service.ts @@ -1,15 +1,18 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; -import { throwVbenError } from '@/common'; +import { throwVbenError, ToolsService } from '@/common'; import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service'; +import { + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, +} from '../qqbot.constants'; import type { QqbotCommandParserType, QqbotNormalizedMessage, QqbotRuleTargetType, } from '../qqbot.types'; -import { getPageParams, normalizeBoolean } from '../qqbot.utils'; import type { QqbotCommandBodyDto, QqbotCommandQueryDto, @@ -27,10 +30,15 @@ export class QqbotCommandService { private readonly commandLogRepository: Repository, private readonly accountService: QqbotAccountService, private readonly pluginRegistry: QqbotPluginRegistryService, + private readonly toolsService: ToolsService, ) {} async page(query: QqbotCommandQueryDto) { - const { pageNo, pageSize, skip } = getPageParams(query); + const { pageNo, pageSize, skip } = this.toolsService.getPageParams( + query, + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, + ); const builder = this.commandRepository .createQueryBuilder('command') .where('command.isDeleted = :isDeleted', { isDeleted: false }); @@ -67,7 +75,7 @@ export class QqbotCommandService { } if (query.enabled !== undefined && `${query.enabled}` !== '') { builder.andWhere('command.enabled = :enabled', { - enabled: normalizeBoolean(query.enabled), + enabled: this.toolsService.normalizeBoolean(query.enabled), }); } diff --git a/src/qqbot/config/qqbot-config.service.ts b/src/qqbot/config/qqbot-config.service.ts index c7fe50d..032bdc3 100644 --- a/src/qqbot/config/qqbot-config.service.ts +++ b/src/qqbot/config/qqbot-config.service.ts @@ -2,17 +2,13 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { QqbotConfig } from './qqbot-config.entity'; +import type { QqbotPermissionConfig } from '../qqbot.types'; const QQBOT_PERMISSION_CONFIG_KEYS = { allowlistEnabled: 'permission.allowlistEnabled', blocklistEnabled: 'permission.blocklistEnabled', } as const; -export type QqbotPermissionConfig = { - allowlistEnabled: boolean; - blocklistEnabled: boolean; -}; - @Injectable() export class QqbotConfigService { constructor( @@ -22,8 +18,14 @@ export class QqbotConfigService { async getPermissionConfig(): Promise { const [allowlistEnabled, blocklistEnabled] = await Promise.all([ - this.getBooleanConfig(QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled, false), - this.getBooleanConfig(QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled, true), + this.getBooleanConfig( + QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled, + false, + ), + this.getBooleanConfig( + QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled, + true, + ), ]); return { allowlistEnabled, blocklistEnabled }; @@ -65,11 +67,7 @@ export class QqbotConfigService { return record.configValue === 'true'; } - async setBooleanConfig( - configKey: string, - value: boolean, - remark: string, - ) { + async setBooleanConfig(configKey: string, value: boolean, remark: string) { const exists = await this.configRepository.findOne({ where: { configKey }, }); diff --git a/src/qqbot/connection/qqbot-reverse-ws.service.ts b/src/qqbot/connection/qqbot-reverse-ws.service.ts index 9848416..518ce66 100644 --- a/src/qqbot/connection/qqbot-reverse-ws.service.ts +++ b/src/qqbot/connection/qqbot-reverse-ws.service.ts @@ -9,29 +9,25 @@ import { import { ConfigService } from '@nestjs/config'; import { HttpAdapterHost, ModuleRef } from '@nestjs/core'; import WebSocket = require('ws'); +import { ToolsService } from '@/common'; import { QQBOT_MQTT_TOPICS, QQBOT_REVERSE_WS_PATH } from '../qqbot.constants'; import type { QqbotConnectionRole, QqbotOneBotActionResponse, QqbotOneBotEvent, + QqbotPendingAction, } from '../qqbot.types'; import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotEventService } from '../event/qqbot-event.service'; import { QqbotBusService } from '../mqtt/qqbot-bus.service'; -type PendingAction = { - reject: (reason: Error) => void; - resolve: (value: QqbotOneBotActionResponse) => void; - timer: NodeJS.Timeout; -}; - @Injectable() export class QqbotReverseWsService implements OnApplicationBootstrap, OnModuleDestroy { private readonly logger = new Logger(QqbotReverseWsService.name); private readonly connections = new Map(); - private readonly pendingActions = new Map(); + private readonly pendingActions = new Map(); private server: WebSocket.Server | null = null; constructor( @@ -40,6 +36,7 @@ export class QqbotReverseWsService private readonly moduleRef: ModuleRef, private readonly accountService: QqbotAccountService, private readonly busService: QqbotBusService, + private readonly toolsService: ToolsService, ) {} onApplicationBootstrap() { @@ -178,7 +175,7 @@ export class QqbotReverseWsService try { await this.handleMessage(selfId, raw); } catch (err) { - const message = err instanceof Error ? err.message : `${err}`; + const message = this.toolsService.getErrorMessage(err); this.logger.warn(`QQBot 处理 WS 消息失败 ${selfId}: ${message}`); } } diff --git a/src/qqbot/event/qqbot-event-normalizer.ts b/src/qqbot/event/qqbot-event-normalizer.ts index e36a2d1..0e582b9 100644 --- a/src/qqbot/event/qqbot-event-normalizer.ts +++ b/src/qqbot/event/qqbot-event-normalizer.ts @@ -1,9 +1,9 @@ +import type { ToolsService } from '@/common'; import type { QqbotMessageType, QqbotNormalizedMessage, QqbotOneBotEvent, } from '../qqbot.types'; -import { toStringId } from '../qqbot.utils'; export function isOneBotMessageEvent( payload: QqbotOneBotEvent, @@ -16,12 +16,15 @@ export function isOneBotMessageEvent( export function normalizeOneBotMessage( payload: QqbotOneBotEvent, + toolsService: ToolsService, ): QqbotNormalizedMessage { const messageType = normalizeMessageType(payload.message_type) || 'private'; const channelId = - toStringId(payload.channel_id) || toStringId(payload.guild_id) || undefined; - const groupId = toStringId(payload.group_id) || undefined; - const userId = toStringId(payload.user_id); + toolsService.toStringId(payload.channel_id) || + toolsService.toStringId(payload.guild_id) || + undefined; + const groupId = toolsService.toStringId(payload.group_id) || undefined; + const userId = toolsService.toStringId(payload.user_id); const targetId = messageType === 'group' ? groupId || '' @@ -37,13 +40,13 @@ export function normalizeOneBotMessage( : new Date(), groupId, messageId: - toStringId(payload.message_id) || + toolsService.toStringId(payload.message_id) || `${payload.time || Date.now()}-${targetId}-${userId}`, messageText, messageType, rawEvent: payload, rawMessage: payload.raw_message || messageText, - selfId: toStringId(payload.self_id), + selfId: toolsService.toStringId(payload.self_id), senderNickname: payload.sender?.card || payload.sender?.nickname || diff --git a/src/qqbot/event/qqbot-event.service.ts b/src/qqbot/event/qqbot-event.service.ts index 3717672..c741f11 100644 --- a/src/qqbot/event/qqbot-event.service.ts +++ b/src/qqbot/event/qqbot-event.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ToolsService } from '@/common'; import { QQBOT_MQTT_TOPICS } from '../qqbot.constants'; import { QqbotDedupeService } from '../dedupe/qqbot-dedupe.service'; import { QqbotMessageService } from '../message/qqbot-message.service'; @@ -20,6 +21,7 @@ export class QqbotEventService { private readonly dedupeService: QqbotDedupeService, private readonly messageService: QqbotMessageService, private readonly ruleEngineService: QqbotRuleEngineService, + private readonly toolsService: ToolsService, ) {} async handleIncoming(payload: QqbotOneBotEvent) { @@ -32,7 +34,7 @@ export class QqbotEventService { } if (!isOneBotMessageEvent(payload)) return; - const message = normalizeOneBotMessage(payload); + const message = normalizeOneBotMessage(payload, this.toolsService); if (!message.selfId || !message.targetId || !message.userId) { this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略'); return; diff --git a/src/qqbot/message/qqbot-message.service.ts b/src/qqbot/message/qqbot-message.service.ts index 95d7791..c3616ba 100644 --- a/src/qqbot/message/qqbot-message.service.ts +++ b/src/qqbot/message/qqbot-message.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { ToolsService } from '@/common'; import { QqbotConversation } from './qqbot-conversation.entity'; import { QqbotMessage } from './qqbot-message.entity'; import type { @@ -8,7 +9,10 @@ import type { QqbotMessageQueryDto, } from './qqbot-message.dto'; import type { QqbotMessageType, QqbotNormalizedMessage } from '../qqbot.types'; -import { getPageParams } from '../qqbot.utils'; +import { + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, +} from '../qqbot.constants'; @Injectable() export class QqbotMessageService { @@ -17,10 +21,15 @@ export class QqbotMessageService { private readonly conversationRepository: Repository, @InjectRepository(QqbotMessage) private readonly messageRepository: Repository, + private readonly toolsService: ToolsService, ) {} async conversationPage(query: QqbotConversationQueryDto) { - const { pageNo, pageSize, skip } = getPageParams(query); + const { pageNo, pageSize, skip } = this.toolsService.getPageParams( + query, + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, + ); const builder = this.conversationRepository .createQueryBuilder('conversation') .where('conversation.isDeleted = :isDeleted', { isDeleted: false }); @@ -50,7 +59,11 @@ export class QqbotMessageService { } async messagePage(query: QqbotMessageQueryDto) { - const { pageNo, pageSize, skip } = getPageParams(query); + const { pageNo, pageSize, skip } = this.toolsService.getPageParams( + query, + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, + ); const builder = this.messageRepository.createQueryBuilder('message'); if (query.conversationId) { diff --git a/src/qqbot/mqtt/qqbot-bus.service.ts b/src/qqbot/mqtt/qqbot-bus.service.ts index befe37f..9100f22 100644 --- a/src/qqbot/mqtt/qqbot-bus.service.ts +++ b/src/qqbot/mqtt/qqbot-bus.service.ts @@ -8,8 +8,7 @@ import { import { ConfigService } from '@nestjs/config'; import * as mqtt from 'mqtt'; import type { MqttClient } from 'mqtt'; - -type Handler = (payload: any) => Promise | void; +import type { QqbotBusHandler } from '../qqbot.types'; @Injectable() export class QqbotBusService implements OnModuleInit, OnModuleDestroy { @@ -58,7 +57,7 @@ export class QqbotBusService implements OnModuleInit, OnModuleDestroy { this.client.publish(topic, JSON.stringify(payload)); } - subscribe(topic: string, handler: Handler) { + subscribe(topic: string, handler: QqbotBusHandler) { this.emitter.on(topic, handler); return () => this.emitter.off(topic, handler); } diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 0ea99d3..ee521d4 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -4,18 +4,11 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { throwVbenError } from '@/common'; +import { throwVbenError, ToolsService } from '@/common'; import { QqbotAccount } from '../account/qqbot-account.entity'; import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; - -export type QqbotNapcatRuntime = { - baseUrl: string; - id?: string; - name: string; - webuiPort?: null | number; - webuiToken?: null | string; -}; +import type { QqbotNapcatRuntime } from '../qqbot.types'; @Injectable() export class QqbotNapcatContainerService { @@ -25,6 +18,7 @@ export class QqbotNapcatContainerService { private readonly containerRepository: Repository, @InjectRepository(QqbotAccountNapcat) private readonly bindingRepository: Repository, + private readonly toolsService: ToolsService, ) {} async prepareCreateContainer() { @@ -311,7 +305,7 @@ fi webuiToken: token, }; } catch (err) { - const message = this.getErrorMessage(err); + const message = this.toolsService.getErrorMessage(err); await this.containerRepository.update( { id: container.id }, { @@ -596,8 +590,4 @@ docker run -d \\ child.stdin.end(); }); } - - private getErrorMessage(err: unknown) { - return err instanceof Error ? err.message : `${err}`; - } } diff --git a/src/qqbot/permission/qqbot-permission.service.ts b/src/qqbot/permission/qqbot-permission.service.ts index 2fa9227..cb6e811 100644 --- a/src/qqbot/permission/qqbot-permission.service.ts +++ b/src/qqbot/permission/qqbot-permission.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Brackets, Repository } from 'typeorm'; -import { throwVbenError } from '@/common'; +import { throwVbenError, ToolsService } from '@/common'; import { QqbotAllowlist } from './qqbot-allowlist.entity'; import { QqbotBlocklist } from './qqbot-blocklist.entity'; import type { @@ -11,11 +11,15 @@ import type { QqbotPermissionUpdateDto, } from './qqbot-permission.dto'; import { QqbotConfigService } from '../config/qqbot-config.service'; -import type { QqbotNormalizedMessage } from '../qqbot.types'; -import { getPageParams } from '../qqbot.utils'; - -type PermissionKind = 'allowlist' | 'blocklist'; -type PermissionEntity = QqbotAllowlist | QqbotBlocklist; +import { + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, +} from '../qqbot.constants'; +import type { + QqbotNormalizedMessage, + QqbotPermissionEntity, + QqbotPermissionKind, +} from '../qqbot.types'; @Injectable() export class QqbotPermissionService { @@ -25,6 +29,7 @@ export class QqbotPermissionService { private readonly allowlistRepository: Repository, @InjectRepository(QqbotBlocklist) private readonly blocklistRepository: Repository, + private readonly toolsService: ToolsService, ) {} async getConfig() { @@ -35,8 +40,12 @@ export class QqbotPermissionService { return this.configService.updatePermissionConfig(body); } - async page(kind: PermissionKind, query: QqbotPermissionQueryDto) { - const { pageNo, pageSize, skip } = getPageParams(query); + async page(kind: QqbotPermissionKind, query: QqbotPermissionQueryDto) { + const { pageNo, pageSize, skip } = this.toolsService.getPageParams( + query, + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, + ); const repository = this.getRepository(kind); const builder = repository .createQueryBuilder('permission') @@ -64,7 +73,7 @@ export class QqbotPermissionService { } if (query.preciseUser !== undefined && `${query.preciseUser}` !== '') { builder.andWhere('permission.preciseUser = :preciseUser', { - preciseUser: this.normalizeBoolean(query.preciseUser), + preciseUser: this.toolsService.normalizeBoolean(query.preciseUser), }); } @@ -76,18 +85,18 @@ export class QqbotPermissionService { return { list, pageNo, pageSize, total }; } - async save(kind: PermissionKind, body: QqbotPermissionBodyDto) { + async save(kind: QqbotPermissionKind, body: QqbotPermissionBodyDto) { const repository = this.getRepository(kind); const payload = this.normalizeBody(body); const saved = await repository.save( repository.create({ ...payload, - } as PermissionEntity), + } as QqbotPermissionEntity), ); return saved.id; } - async update(kind: PermissionKind, body: QqbotPermissionUpdateDto) { + async update(kind: QqbotPermissionKind, body: QqbotPermissionUpdateDto) { const repository = this.getRepository(kind); const payload = this.normalizeBody(body); await repository.update( @@ -99,7 +108,7 @@ export class QqbotPermissionService { return true; } - async remove(kind: PermissionKind, id: string) { + async remove(kind: QqbotPermissionKind, id: string) { const repository = this.getRepository(kind); await repository.update({ id } as any, { isDeleted: true } as any); return true; @@ -118,7 +127,7 @@ export class QqbotPermissionService { } private async existsMatched( - repository: Repository, + repository: Repository, message: QqbotNormalizedMessage, ) { const count = await repository @@ -182,7 +191,7 @@ export class QqbotPermissionService { private normalizeBody( body: Partial, - ): Partial { + ): Partial { const targetType = body.targetType === 'private' ? 'qq' : body.targetType; const normalizedTargetType = targetType || 'qq'; const targetId = `${body.targetId || ''}`.trim(); @@ -213,14 +222,10 @@ export class QqbotPermissionService { targetId, targetType: normalizedTargetType, userId: preciseUser ? userId : '', - } as Partial; + } as Partial; } - private normalizeBoolean(value: unknown) { - return value === true || value === 'true' || value === 1 || value === '1'; - } - - private getRepository(kind: PermissionKind) { + private getRepository(kind: QqbotPermissionKind) { return kind === 'allowlist' ? this.allowlistRepository : this.blocklistRepository; diff --git a/src/qqbot/plugin/qqbot-event-plugin-registry.service.ts b/src/qqbot/plugin/qqbot-event-plugin-registry.service.ts index 75c8500..9ca3c5f 100644 --- a/src/qqbot/plugin/qqbot-event-plugin-registry.service.ts +++ b/src/qqbot/plugin/qqbot-event-plugin-registry.service.ts @@ -6,7 +6,7 @@ import type { QqbotEventPluginDefinition, QqbotPluginHealth, QqbotPluginOperationSummary, -} from './qqbot-plugin.types'; +} from '../qqbot.types'; @Injectable() export class QqbotEventPluginRegistryService { diff --git a/src/qqbot/plugin/qqbot-plugin-registry.service.ts b/src/qqbot/plugin/qqbot-plugin-registry.service.ts index cc5dfa4..3fc5b6a 100644 --- a/src/qqbot/plugin/qqbot-plugin-registry.service.ts +++ b/src/qqbot/plugin/qqbot-plugin-registry.service.ts @@ -8,7 +8,7 @@ import type { QqbotPluginOperationContext, QqbotPluginOperationSummary, QqbotPluginSummary, -} from './qqbot-plugin.types'; +} from '../qqbot.types'; @Injectable() export class QqbotPluginRegistryService implements OnModuleInit { diff --git a/src/qqbot/plugin/qqbot-plugin.controller.ts b/src/qqbot/plugin/qqbot-plugin.controller.ts index ecb160a..6347c9b 100644 --- a/src/qqbot/plugin/qqbot-plugin.controller.ts +++ b/src/qqbot/plugin/qqbot-plugin.controller.ts @@ -12,7 +12,7 @@ import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { vbenSuccess } from '@/common'; import { QqbotEventPluginRegistryService } from './qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from './qqbot-plugin-registry.service'; -import type { QqbotPluginTriggerMode } from './qqbot-plugin.types'; +import type { QqbotPluginTriggerMode } from '../qqbot.types'; @ApiTags('QQBot - 插件能力') @Controller('qqbot/plugin') diff --git a/src/qqbot/plugin/qqbot-plugin.types.ts b/src/qqbot/plugin/qqbot-plugin.types.ts deleted file mode 100644 index 60b25ac..0000000 --- a/src/qqbot/plugin/qqbot-plugin.types.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { - QqbotNormalizedMessage, - QqbotPluginHealthStatus, -} from '../qqbot.types'; -import type { QqbotCommand } from '../command/qqbot-command.entity'; - -export type QqbotPluginHealth = { - checkedAt: string; - message?: string; - name?: string; - pluginKey?: string; - status: QqbotPluginHealthStatus; - triggerMode?: QqbotPluginTriggerMode; -}; - -export type QqbotPluginTriggerMode = 'command' | 'event'; - -export type QqbotPluginOperationContext = { - args?: Record; - command?: QqbotCommand; - message?: QqbotNormalizedMessage; -}; - -export type QqbotPluginOperation = { - cacheTtlMs?: number; - description?: string; - inputSchema?: Record; - key: string; - name: string; - outputSchema?: Record; - execute: ( - input: Input, - context: QqbotPluginOperationContext, - ) => Promise; -}; - -export type QqbotIntegrationPlugin = { - description?: string; - healthCheck?: () => Promise; - key: string; - name: string; - operations: QqbotPluginOperation[]; - version: string; -}; - -export type QqbotEventPluginSummary = { - accountName?: string; - bound: boolean; - connectStatus?: string; - description?: string; - key: string; - name: string; - remark?: string; - selfId: string; - triggerType: 'message'; - version: string; -}; - -export type QqbotEventPluginDefinition = { - description?: string; - key: string; - name: string; - remark?: string; - triggerType: 'message'; - version: string; -}; - -export type QqbotPluginSummary = { - description?: string; - key: string; - name: string; - operationCount: number; - triggerMode: QqbotPluginTriggerMode; - version: string; -}; - -export type QqbotPluginOperationSummary = { - cacheTtlMs?: number; - description?: string; - inputSchema?: Record; - key: string; - name: string; - outputSchema?: Record; - pluginKey: string; - triggerMode: QqbotPluginTriggerMode; -}; diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts index 4619615..eccdc5a 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts @@ -9,63 +9,14 @@ import { QQBOT_FF14_MARKET_DICT_CODES, resolveQqbotFf14MarketTarget, } from './qqbot-ff14-worlds'; - -type HttpMethod = 'GET'; - -type XivapiSearchItem = { - fields?: { - Icon?: string | { path?: string; path_hr1?: string }; - IsUntradable?: boolean; - LevelItem?: number | { row_id?: number; value?: number }; - Name?: string; - }; - id?: number; - name?: string; - row_id?: number; - sheet?: string; -}; - -type UniversalisListing = { - hq?: boolean; - lastReviewTime?: number; - pricePerUnit?: number; - quantity?: number; - retainerName?: string; - total?: number; - worldName?: string; -}; - -type UniversalisMarketResponse = { - currentAveragePrice?: number; - currentAveragePriceHQ?: number; - currentAveragePriceNQ?: number; - itemID?: number; - lastUploadTime?: number; - listings?: UniversalisListing[]; - minPrice?: number; - minPriceHQ?: number; - minPriceNQ?: number; - worldName?: string; -}; - -export type QqbotFf14ResolvedItem = { - icon?: string; - isUntradable?: boolean; - itemId: number; - itemLevel?: number; - name: string; -}; - -export type QqbotFf14PriceResult = { - averagePrice?: number; - hq?: boolean; - item: QqbotFf14ResolvedItem; - listings: UniversalisListing[]; - minPrice?: number; - replyText: string; - updatedAt?: string; - world: string; -}; +import type { + Ff14HttpMethod, + QqbotFf14PriceResult, + QqbotFf14ResolvedItem, + UniversalisListing, + UniversalisMarketResponse, + XivapiSearchItem, +} from './qqbot-ff14-market.types'; @Injectable() export class QqbotFf14ClientService { @@ -416,7 +367,7 @@ export class QqbotFf14ClientService { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } - private requestJson(url: URL, method: HttpMethod, context: string) { + private requestJson(url: URL, method: Ff14HttpMethod, context: string) { return new Promise((resolve, reject) => { const client = url.protocol === 'http:' ? http : https; const request = client.request( diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts index 61bd247..2b8848c 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts @@ -1,14 +1,19 @@ import { Injectable } from '@nestjs/common'; -import type { QqbotIntegrationPlugin } from '../../plugin/qqbot-plugin.types'; +import { ToolsService } from '@/common'; +import type { QqbotIntegrationPlugin } from '../../qqbot.types'; import { QqbotFf14ClientService } from './qqbot-ff14-client.service'; @Injectable() export class QqbotFf14MarketPluginService { - constructor(private readonly ff14ClientService: QqbotFf14ClientService) {} + constructor( + private readonly ff14ClientService: QqbotFf14ClientService, + private readonly toolsService: ToolsService, + ) {} getPlugin(): QqbotIntegrationPlugin { return { - description: '对接 XIVAPI v2 与 Universalis,提供 FF14 物品解析和市场价格查询能力。', + description: + '对接 XIVAPI v2 与 Universalis,提供 FF14 物品解析和市场价格查询能力。', healthCheck: async () => { const checkedAt = new Date().toISOString(); try { @@ -24,7 +29,7 @@ export class QqbotFf14MarketPluginService { } catch (err) { return { checkedAt, - message: err instanceof Error ? err.message : 'FF14 插件不可用', + message: this.toolsService.getErrorMessage(err, 'FF14 插件不可用'), status: 'degraded', }; } @@ -82,7 +87,8 @@ export class QqbotFf14MarketPluginService { }, type: 'object', }, - execute: async (input) => await this.ff14ClientService.getPrice(input), + execute: async (input) => + await this.ff14ClientService.getPrice(input), }, ], version: '1.0.0', diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-market.types.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.types.ts new file mode 100644 index 0000000..870eaa7 --- /dev/null +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-market.types.ts @@ -0,0 +1,76 @@ +export type Ff14HttpMethod = 'GET'; + +export type QqbotFf14DataCenter = { + name: string; + region: string; + worlds: string[]; +}; + +export type QqbotFf14MarketCatalog = { + dataCenters: QqbotFf14DataCenter[]; + defaultRegion?: string; + regions: string[]; +}; + +export type QqbotFf14MarketTarget = { + dataCenter?: string; + label: string; + region?: string; + target: string; + world?: string; +}; + +export type QqbotFf14ResolvedItem = { + icon?: string; + isUntradable?: boolean; + itemId: number; + itemLevel?: number; + name: string; +}; + +export type UniversalisListing = { + hq?: boolean; + lastReviewTime?: number; + pricePerUnit?: number; + quantity?: number; + retainerName?: string; + total?: number; + worldName?: string; +}; + +export type QqbotFf14PriceResult = { + averagePrice?: number; + hq?: boolean; + item: QqbotFf14ResolvedItem; + listings: UniversalisListing[]; + minPrice?: number; + replyText: string; + updatedAt?: string; + world: string; +}; + +export type UniversalisMarketResponse = { + currentAveragePrice?: number; + currentAveragePriceHQ?: number; + currentAveragePriceNQ?: number; + itemID?: number; + lastUploadTime?: number; + listings?: UniversalisListing[]; + minPrice?: number; + minPriceHQ?: number; + minPriceNQ?: number; + worldName?: string; +}; + +export type XivapiSearchItem = { + fields?: { + Icon?: string | { path?: string; path_hr1?: string }; + IsUntradable?: boolean; + LevelItem?: number | { row_id?: number; value?: number }; + Name?: string; + }; + id?: number; + name?: string; + row_id?: number; + sheet?: string; +}; diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts index 5b7f9fa..766f7a4 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts @@ -1,27 +1,12 @@ import type { AdminDictItem, AdminDictTreeItem, -} from '../../../admin/dict/dict.service'; - -export type QqbotFf14DataCenter = { - name: string; - region: string; - worlds: string[]; -}; - -export type QqbotFf14MarketCatalog = { - dataCenters: QqbotFf14DataCenter[]; - defaultRegion?: string; - regions: string[]; -}; - -export type QqbotFf14MarketTarget = { - dataCenter?: string; - label: string; - region?: string; - target: string; - world?: string; -}; +} from '../../../admin/admin.types'; +import type { + QqbotFf14DataCenter, + QqbotFf14MarketCatalog, + QqbotFf14MarketTarget, +} from './qqbot-ff14-market.types'; export const QQBOT_FF14_MARKET_DICT_CODES = { dataCenter: 'FF14_MARKET_DATA_CENTER', diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts index 1c89e02..b97f7d3 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts @@ -3,114 +3,23 @@ import { ConfigService } from '@nestjs/config'; import * as http from 'node:http'; import * as https from 'node:https'; import { DictService } from '../../../admin/dict/dict.service'; - -type HttpMethod = 'GET' | 'POST'; - -type FflogsTokenResponse = { - access_token?: string; - expires_in?: number; - token_type?: string; -}; - -type FflogsGraphqlResponse = { - data?: T; - errors?: Array<{ message?: string }>; -}; - -type FflogsCharacter = { - id?: number; - lodestoneID?: number; - name?: string; - recentReports?: { - data?: FflogsRecentReport[]; - }; - server?: { - name?: string; - slug?: string; - }; - zoneRankings?: unknown; -}; - -type FflogsReportFight = { - difficulty?: number | null; - encounterID?: number; - endTime?: number; - id?: number; - kill?: boolean | null; - name?: string; - startTime?: number; -}; - -type FflogsRecentReport = { - code?: string; - endTime?: number; - fights?: FflogsReportFight[]; - startTime?: number; - title?: string; - zone?: { - id?: number; - name?: string; - }; -}; - -type FflogsCharacterSummaryResponse = { - characterData?: { - character?: FflogsCharacter | null; - }; -}; - -type FflogsReportFightMetricsResponse = { - reportData?: { - report?: { - damage?: unknown; - dpsRankings?: unknown; - healing?: unknown; - hpsRankings?: unknown; - } | null; - }; -}; - -type FflogsRankingItem = Record; - -type FflogsEncounterLookup = { - displayName: string; - encounterId?: number; - input: string; - keys: string[]; -}; - -type FflogsEncounterFightCandidate = { - absoluteStartTime: number; - fight: FflogsReportFight; - report: FflogsRecentReport; -}; - -type FflogsParseMetric = { - amount?: number; - color: string; - percent?: number; - rank?: string; -}; - -export type QqbotFflogsEncounterLogItem = { - adps?: number; - color: string; - damageScore?: number; - dps?: number; - durationMs?: number; - encounterName: string; - fightId?: number; - healingColor: string; - healingScore?: number; - hps?: number; - kill?: boolean | null; - logCode: string; - logUrl: string; - ndps?: number; - rdps?: number; - reportTitle?: string; - startTime?: number; -}; +import type { + FflogsCharacterSummaryResponse, + FflogsEncounterFightCandidate, + FflogsEncounterLookup, + FflogsGraphqlResponse, + FflogsHttpMethod, + FflogsLocalizationMaps, + FflogsParseMetric, + FflogsRankingItem, + FflogsRecentReport, + FflogsReportFight, + FflogsReportFightMetricsResponse, + FflogsTokenResponse, + QqbotFflogsCharacterSummaryInput, + QqbotFflogsCharacterSummaryResult, + QqbotFflogsEncounterLogItem, +} from './qqbot-fflogs.types'; const FFLOGS_LOCALIZATION_DICT_CODES = { encounter: 'FFLOGS_ENCOUNTER_LABEL', @@ -120,45 +29,6 @@ const FFLOGS_LOCALIZATION_DICT_CODES = { serverRegion: 'FFLOGS_SERVER_REGION_LABEL', }; -type FflogsLocalizationMaps = Record< - keyof typeof FFLOGS_LOCALIZATION_DICT_CODES, - Map ->; - -export type QqbotFflogsCharacterSummaryInput = { - character?: string; - characterName?: string; - className?: string; - difficulty?: number | string; - encounter?: string; - encounterName?: string; - limit?: number | string; - metric?: string; - partition?: number | string; - reportsLimit?: number | string; - role?: string; - server?: string; - serverRegion?: string; - serverSlug?: string; - size?: number | string; - specName?: string; - timeframe?: string; - zoneId?: number | string; -}; - -export type QqbotFflogsCharacterSummaryResult = { - allStarText?: string; - characterId?: number; - characterName: string; - encounterName?: string; - logs?: QqbotFflogsEncounterLogItem[]; - rankings: FflogsRankingItem[]; - replyText: string; - serverName: string; - serverRegion: string; - url: string; -}; - @Injectable() export class QqbotFflogsClientService { private accessToken = ''; @@ -655,7 +525,7 @@ export class QqbotFflogsClientService { private requestJson( url: URL, - method: HttpMethod, + method: FflogsHttpMethod, options: { body?: string; headers?: Record } = {}, ) { return new Promise((resolve, reject) => { diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts index 8bd2058..db9f933 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts @@ -1,10 +1,14 @@ import { Injectable } from '@nestjs/common'; -import type { QqbotIntegrationPlugin } from '../../plugin/qqbot-plugin.types'; +import { ToolsService } from '@/common'; +import type { QqbotIntegrationPlugin } from '../../qqbot.types'; import { QqbotFflogsClientService } from './qqbot-fflogs-client.service'; @Injectable() export class QqbotFflogsPluginService { - constructor(private readonly fflogsClientService: QqbotFflogsClientService) {} + constructor( + private readonly fflogsClientService: QqbotFflogsClientService, + private readonly toolsService: ToolsService, + ) {} getPlugin(): QqbotIntegrationPlugin { return { @@ -22,7 +26,10 @@ export class QqbotFflogsPluginService { } catch (err) { return { checkedAt, - message: err instanceof Error ? err.message : 'FFLogs 插件不可用', + message: this.toolsService.getErrorMessage( + err, + 'FFLogs 插件不可用', + ), status: 'degraded', }; } diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts new file mode 100644 index 0000000..8491165 --- /dev/null +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts @@ -0,0 +1,153 @@ +export type FflogsCharacter = { + id?: number; + lodestoneID?: number; + name?: string; + recentReports?: { + data?: FflogsRecentReport[]; + }; + server?: { + name?: string; + slug?: string; + }; + zoneRankings?: unknown; +}; + +export type FflogsCharacterSummaryResponse = { + characterData?: { + character?: FflogsCharacter | null; + }; +}; + +export type FflogsEncounterFightCandidate = { + absoluteStartTime: number; + fight: FflogsReportFight; + report: FflogsRecentReport; +}; + +export type FflogsEncounterLookup = { + displayName: string; + encounterId?: number; + input: string; + keys: string[]; +}; + +export type FflogsGraphqlResponse = { + data?: T; + errors?: Array<{ message?: string }>; +}; + +export type FflogsHttpMethod = 'GET' | 'POST'; + +export type FflogsLocalizationKey = + | 'encounter' + | 'job' + | 'metric' + | 'role' + | 'serverRegion'; + +export type FflogsLocalizationMaps = Record< + FflogsLocalizationKey, + Map +>; + +export type FflogsParseMetric = { + amount?: number; + color: string; + percent?: number; + rank?: string; +}; + +export type FflogsRankingItem = Record; + +export type FflogsRecentReport = { + code?: string; + endTime?: number; + fights?: FflogsReportFight[]; + startTime?: number; + title?: string; + zone?: { + id?: number; + name?: string; + }; +}; + +export type FflogsReportFight = { + difficulty?: number | null; + encounterID?: number; + endTime?: number; + id?: number; + kill?: boolean | null; + name?: string; + startTime?: number; +}; + +export type FflogsReportFightMetricsResponse = { + reportData?: { + report?: { + damage?: unknown; + dpsRankings?: unknown; + healing?: unknown; + hpsRankings?: unknown; + } | null; + }; +}; + +export type FflogsTokenResponse = { + access_token?: string; + expires_in?: number; + token_type?: string; +}; + +export type QqbotFflogsCharacterSummaryInput = { + character?: string; + characterName?: string; + className?: string; + difficulty?: number | string; + encounter?: string; + encounterName?: string; + limit?: number | string; + metric?: string; + partition?: number | string; + reportsLimit?: number | string; + role?: string; + server?: string; + serverRegion?: string; + serverSlug?: string; + size?: number | string; + specName?: string; + timeframe?: string; + zoneId?: number | string; +}; + +export type QqbotFflogsCharacterSummaryResult = { + allStarText?: string; + characterId?: number; + characterName: string; + encounterName?: string; + logs?: QqbotFflogsEncounterLogItem[]; + rankings: FflogsRankingItem[]; + replyText: string; + serverName: string; + serverRegion: string; + url: string; +}; + +export type QqbotFflogsEncounterLogItem = { + adps?: number; + color: string; + damageScore?: number; + dps?: number; + durationMs?: number; + encounterName: string; + fightId?: number; + healingColor: string; + healingScore?: number; + hps?: number; + kill?: boolean | null; + logCode: string; + logUrl: string; + ndps?: number; + rdps?: number; + reportTitle?: string; + startTime?: number; +}; diff --git a/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts b/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts index bfd58b0..9585bc0 100644 --- a/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts +++ b/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts @@ -1,23 +1,18 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { ToolsService } from '@/common'; import { QqbotAccountService } from '../../account/qqbot-account.service'; import type { QqbotEventPluginDefinition, QqbotEventPluginSummary, -} from '../../plugin/qqbot-plugin.types'; -import type { QqbotNormalizedMessage } from '../../qqbot.types'; + QqbotNormalizedMessage, + QqbotRepeaterConversationState, +} from '../../qqbot.types'; import { QqbotSendService } from '../../send/qqbot-send.service'; const QQBOT_REPEATER_VERSION = '1.0.0'; const QQBOT_REPEATER_PLUGIN_KEY = 'repeater'; -type QqbotRepeaterConversationState = { - count: number; - lastText: string; - repeatedText: string; - updatedAt: number; -}; - @Injectable() export class QqbotRepeaterPluginService { private readonly logger = new Logger(QqbotRepeaterPluginService.name); @@ -39,6 +34,7 @@ export class QqbotRepeaterPluginService { private readonly configService: ConfigService, private readonly accountService: QqbotAccountService, private readonly sendService: QqbotSendService, + private readonly toolsService: ToolsService, ) {} async getSummary(params: { @@ -96,7 +92,7 @@ export class QqbotRepeaterPluginService { async handleMessage(message: QqbotNormalizedMessage) { if (!(await this.isBound(message.selfId))) return false; - const text = this.normalizeText(message.messageText); + const text = this.toolsService.normalizeWhitespaceText(message.messageText); if (!this.canRepeat(message, text)) { this.resetState(message); return false; @@ -120,7 +116,7 @@ export class QqbotRepeaterPluginService { }); return true; } catch (err) { - const errMsg = err instanceof Error ? err.message : '复读失败'; + const errMsg = this.toolsService.getErrorMessage(err, '复读失败'); this.logger.warn(`QQBot 复读机发送失败: ${errMsg}`); return false; } @@ -173,10 +169,6 @@ export class QqbotRepeaterPluginService { return state.count >= this.getThreshold() && state.repeatedText !== text; } - private normalizeText(value: string) { - return `${value || ''}`.trim().replace(/\s+/g, ' '); - } - private resetState(message: QqbotNormalizedMessage) { this.states.delete(this.buildStateKey(message)); } diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index 059f44f..b5f74e6 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -1,3 +1,46 @@ +import type { QrcodeLookupOptions } from '@/common/types'; +import type { QqbotAccount } from './account/qqbot-account.entity'; +import type { QqbotCommand } from './command/qqbot-command.entity'; +import type { QqbotAllowlist } from './permission/qqbot-allowlist.entity'; +import type { QqbotBlocklist } from './permission/qqbot-blocklist.entity'; + +export type { QrcodeLookupOptions } from '@/common/types'; + +export type NapcatApiResponse = { + code: number; + data?: T; + message?: string; +}; + +export type NapcatCredential = { + Credential?: string; +}; + +export type NapcatLoginInfo = Record & { + avatarUrl?: string; + nick?: string; + nickname?: string; + online?: boolean; + uin?: number | string; +}; + +export type NapcatLoginStatus = { + isLogin?: boolean; + isOffline?: boolean; + loginError?: string; + qrcodeurl?: string; +}; + +export type NapcatQrcode = { + qrcode?: string; + qrcodeurl?: string; + url?: string; +}; + +export type NapcatRestartOptions = { + waitForReady?: boolean; +}; + export type QqbotConnectionMode = 'reverse-ws'; export type QqbotConnectionRole = 'API' | 'Event' | 'Universal'; @@ -20,18 +63,135 @@ export type QqbotNapcatContainerStatus = export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending'; +export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule'; + +export type QqbotAccountNapcatRuntimeInfo = { + bindStatus?: QqbotAccountNapcatBindStatus; + containerId?: string; + containerName?: string; + containerStatus?: QqbotNapcatContainerStatus; + lastCheckedAt?: Date | null; + lastError?: null | string; + lastLoginAt?: Date | null; + lastStartedAt?: Date | null; + webuiPort?: null | number; +}; + +export type QqbotAccountListItem = QqbotAccount & { + napcat?: null | QqbotAccountNapcatRuntimeInfo; +}; + export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex'; export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private'; export type QqbotCommandParserType = 'ff14Price' | 'fflogsCharacter' | 'plain'; +export type QqbotCommandLogStatus = 'failed' | 'success'; + +export type QqbotCommandMatchResult = { + alias: string; + input: Record; + matched: true; + rawArgs: string; +}; + export type QqbotPluginHealthStatus = 'degraded' | 'healthy' | 'offline'; +export type QqbotPluginHealth = { + checkedAt: string; + message?: string; + name?: string; + pluginKey?: string; + status: QqbotPluginHealthStatus; + triggerMode?: QqbotPluginTriggerMode; +}; + +export type QqbotPluginTriggerMode = 'command' | 'event'; + +export type QqbotPluginOperationContext = { + args?: Record; + command?: QqbotCommand; + message?: QqbotNormalizedMessage; +}; + +export type QqbotPluginOperation = { + cacheTtlMs?: number; + description?: string; + inputSchema?: Record; + key: string; + name: string; + outputSchema?: Record; + execute: ( + input: Input, + context: QqbotPluginOperationContext, + ) => Promise; +}; + +export type QqbotIntegrationPlugin = { + description?: string; + healthCheck?: () => Promise; + key: string; + name: string; + operations: QqbotPluginOperation[]; + version: string; +}; + +export type QqbotEventPluginSummary = { + accountName?: string; + bound: boolean; + connectStatus?: string; + description?: string; + key: string; + name: string; + remark?: string; + selfId: string; + triggerType: 'message'; + version: string; +}; + +export type QqbotEventPluginDefinition = { + description?: string; + key: string; + name: string; + remark?: string; + triggerType: 'message'; + version: string; +}; + +export type QqbotPluginSummary = { + description?: string; + key: string; + name: string; + operationCount: number; + triggerMode: QqbotPluginTriggerMode; + version: string; +}; + +export type QqbotPluginOperationSummary = { + cacheTtlMs?: number; + description?: string; + inputSchema?: Record; + key: string; + name: string; + outputSchema?: Record; + pluginKey: string; + triggerMode: QqbotPluginTriggerMode; +}; + export type QqbotSendStatus = 'failed' | 'pending' | 'success'; export type QqbotPermissionTargetType = 'channel' | 'group' | 'private' | 'qq'; +export type QqbotPermissionConfig = { + allowlistEnabled: boolean; + blocklistEnabled: boolean; +}; + +export type QqbotPermissionEntity = QqbotAllowlist | QqbotBlocklist; + +export type QqbotPermissionKind = 'allowlist' | 'blocklist'; + export type QqbotOneBotEvent = Record & { channel_id?: number | string; group_id?: number | string; @@ -69,3 +229,68 @@ export type QqbotOneBotActionResponse = { retcode?: number; status?: string; }; + +export type QqbotBusHandler = (payload: any) => Promise | void; + +export type QqbotLoginScanResult = { + accountId?: string; + containerId?: string; + containerName?: string; + errorMessage?: string; + expiresAt?: number; + mode: QqbotLoginScanMode; + qrcode?: string; + selfId?: string; + sessionId?: string; + status: QqbotLoginScanStatus; + webuiPort?: null | number; +}; + +export type QqbotLoginScanSession = { + accountId?: string; + containerId?: string; + containerName?: string; + createdAt: number; + errorMessage?: string; + expiresAt: number; + expectedSelfId?: string; + id: string; + lastRestartedAt?: number; + mode: QqbotLoginScanMode; + qrcode?: string; + status: QqbotLoginScanStatus; + webuiPort?: null | number; +}; + +export type QqbotNapcatRuntime = { + baseUrl: string; + id?: string; + name: string; + webuiPort?: null | number; + webuiToken?: null | string; +}; + +export type QqbotPendingAction = { + reject: (reason: Error) => void; + resolve: (value: QqbotOneBotActionResponse) => void; + timer: NodeJS.Timeout; +}; + +export type QqbotReverseActionSender = { + sendAction: ( + selfId: string, + action: string, + params: Record, + ) => Promise; +}; + +export type QqbotRepeaterConversationState = { + count: number; + lastText: string; + repeatedText: string; + updatedAt: number; +}; + +export type QrcodeRefreshOptions = QrcodeLookupOptions & { + fallbackStatus?: NapcatLoginStatus; +}; diff --git a/src/qqbot/qqbot.utils.ts b/src/qqbot/qqbot.utils.ts deleted file mode 100644 index 745466e..0000000 --- a/src/qqbot/qqbot.utils.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - QQBOT_DEFAULT_PAGE_NO, - QQBOT_DEFAULT_PAGE_SIZE, -} from './qqbot.constants'; - -export type QqbotPageQuery = { - pageNo?: number | string; - pageSize?: number | string; -}; - -export function toStringId(value: number | string | undefined) { - return value === undefined || value === null ? '' : `${value}`; -} - -export function toNumber(value: number | string | undefined, fallback: number) { - const nextValue = Number(value); - return Number.isFinite(nextValue) && nextValue > 0 ? nextValue : fallback; -} - -export function getPageParams(query: QqbotPageQuery = {}) { - const pageNo = toNumber(query.pageNo, QQBOT_DEFAULT_PAGE_NO); - const pageSize = toNumber(query.pageSize, QQBOT_DEFAULT_PAGE_SIZE); - return { - pageNo, - pageSize, - skip: (pageNo - 1) * pageSize, - }; -} - -export function normalizeBoolean(value: any, 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()); -} - -export function normalizeNullableString(value: any) { - if (value === undefined || value === null) return null; - const nextValue = `${value}`.trim(); - return nextValue ? nextValue : null; -} diff --git a/src/qqbot/rule/qqbot-rule-engine.service.ts b/src/qqbot/rule/qqbot-rule-engine.service.ts index 85d8eaa..83fb4d2 100644 --- a/src/qqbot/rule/qqbot-rule-engine.service.ts +++ b/src/qqbot/rule/qqbot-rule-engine.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ToolsService } from '@/common'; import type { QqbotNormalizedMessage } from '../qqbot.types'; import { QqbotCommandEngineService } from '../command/qqbot-command-engine.service'; import { QqbotPermissionService } from '../permission/qqbot-permission.service'; @@ -16,6 +17,7 @@ export class QqbotRuleEngineService { private readonly repeaterPluginService: QqbotRepeaterPluginService, private readonly ruleService: QqbotRuleService, private readonly sendService: QqbotSendService, + private readonly toolsService: ToolsService, ) {} async handleMessage(message: QqbotNormalizedMessage) { @@ -41,7 +43,7 @@ export class QqbotRuleEngineService { targetType: message.messageType, }); } catch (err) { - const errMsg = err instanceof Error ? err.message : '自动回复失败'; + const errMsg = this.toolsService.getErrorMessage(err, '自动回复失败'); this.logger.warn(`QQBot 自动回复失败: ${errMsg}`); } return; diff --git a/src/qqbot/rule/qqbot-rule.controller.ts b/src/qqbot/rule/qqbot-rule.controller.ts index 9cc950b..bdcea7b 100644 --- a/src/qqbot/rule/qqbot-rule.controller.ts +++ b/src/qqbot/rule/qqbot-rule.controller.ts @@ -10,20 +10,22 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; -import { vbenSuccess } from '@/common'; +import { ToolsService, vbenSuccess } from '@/common'; import { QqbotRuleBodyDto, QqbotRuleQueryDto, QqbotRuleUpdateDto, } from './qqbot-rule.dto'; import { QqbotRuleService } from './qqbot-rule.service'; -import { normalizeBoolean } from '../qqbot.utils'; @ApiTags('QQBot - 自动回复规则') @Controller('qqbot/rule') @UseGuards(JwtAuthGuard) export class QqbotRuleController { - constructor(private readonly ruleService: QqbotRuleService) {} + constructor( + private readonly ruleService: QqbotRuleService, + private readonly toolsService: ToolsService, + ) {} @Get('list') @ApiOperation({ summary: 'QQBot 自动回复规则分页' }) @@ -60,7 +62,10 @@ export class QqbotRuleController { @ApiQuery({ name: 'enabled', type: Boolean }) async toggle(@Query('id') id: string, @Query('enabled') enabled: string) { return vbenSuccess( - await this.ruleService.toggle(id, normalizeBoolean(enabled)), + await this.ruleService.toggle( + id, + this.toolsService.normalizeBoolean(enabled), + ), ); } } diff --git a/src/qqbot/rule/qqbot-rule.service.ts b/src/qqbot/rule/qqbot-rule.service.ts index 61055f8..aa83e5e 100644 --- a/src/qqbot/rule/qqbot-rule.service.ts +++ b/src/qqbot/rule/qqbot-rule.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { throwVbenError } from '@/common'; +import { throwVbenError, ToolsService } from '@/common'; import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotRule } from './qqbot-rule.entity'; import type { @@ -14,7 +14,10 @@ import type { QqbotRuleMatchType, QqbotRuleTargetType, } from '../qqbot.types'; -import { getPageParams, normalizeBoolean } from '../qqbot.utils'; +import { + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, +} from '../qqbot.constants'; @Injectable() export class QqbotRuleService { @@ -22,10 +25,15 @@ export class QqbotRuleService { @InjectRepository(QqbotRule) private readonly ruleRepository: Repository, private readonly accountService: QqbotAccountService, + private readonly toolsService: ToolsService, ) {} async page(query: QqbotRuleQueryDto) { - const { pageNo, pageSize, skip } = getPageParams(query); + const { pageNo, pageSize, skip } = this.toolsService.getPageParams( + query, + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, + ); const builder = this.ruleRepository .createQueryBuilder('rule') .where('rule.isDeleted = :isDeleted', { isDeleted: false }); @@ -52,7 +60,7 @@ export class QqbotRuleService { } if (query.enabled !== undefined && `${query.enabled}` !== '') { builder.andWhere('rule.enabled = :enabled', { - enabled: normalizeBoolean(query.enabled), + enabled: this.toolsService.normalizeBoolean(query.enabled), }); } diff --git a/src/qqbot/send/qqbot-send.service.ts b/src/qqbot/send/qqbot-send.service.ts index 611a7a1..e59be16 100644 --- a/src/qqbot/send/qqbot-send.service.ts +++ b/src/qqbot/send/qqbot-send.service.ts @@ -2,14 +2,19 @@ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { throwVbenError } from '@/common'; +import { throwVbenError, ToolsService } from '@/common'; import { QqbotAccountService } from '../account/qqbot-account.service'; -import type { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service'; import { QQBOT_MQTT_TOPICS } from '../qqbot.constants'; import { QqbotBusService } from '../mqtt/qqbot-bus.service'; import { QqbotMessageService } from '../message/qqbot-message.service'; -import type { QqbotMessageType } from '../qqbot.types'; -import { getPageParams } from '../qqbot.utils'; +import type { + QqbotMessageType, + QqbotReverseActionSender, +} from '../qqbot.types'; +import { + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, +} from '../qqbot.constants'; import { QqbotRateLimitService } from './qqbot-rate-limit.service'; import { QqbotSendLog } from './qqbot-send-log.entity'; import type { @@ -28,10 +33,15 @@ export class QqbotSendService { private readonly messageService: QqbotMessageService, private readonly moduleRef: ModuleRef, private readonly rateLimitService: QqbotRateLimitService, + private readonly toolsService: ToolsService, ) {} async logPage(query: QqbotSendLogQueryDto) { - const { pageNo, pageSize, skip } = getPageParams(query); + const { pageNo, pageSize, skip } = this.toolsService.getPageParams( + query, + QQBOT_DEFAULT_PAGE_NO, + QQBOT_DEFAULT_PAGE_SIZE, + ); const builder = this.sendLogRepository.createQueryBuilder('log'); if (query.selfId) { @@ -152,7 +162,7 @@ export class QqbotSendService { if (!success) throwVbenError(response.message || 'OneBot 发送失败'); return { ...response, logId: log.id }; } catch (err) { - const message = err instanceof Error ? err.message : 'OneBot 发送失败'; + const message = this.toolsService.getErrorMessage(err, 'OneBot 发送失败'); await this.sendLogRepository.update( { id: log.id }, { @@ -164,11 +174,11 @@ export class QqbotSendService { } } - private async getReverseWsService() { + private async getReverseWsService(): Promise { const { QqbotReverseWsService } = await import( '../connection/qqbot-reverse-ws.service' ); - return this.moduleRef.get(QqbotReverseWsService, { + return this.moduleRef.get(QqbotReverseWsService, { strict: false, }); } diff --git a/src/types/res.d.ts b/src/types/res.d.ts deleted file mode 100644 index 483fa06..0000000 --- a/src/types/res.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -type Res = - | { - code: 200; - msg: string; - data: any; - } - | { - code: number; - msg: string; - err: any; - }; - -type Page = { - list: T[]; - total: number; -}; - -type Dict = { - label: string; - value: any; -} & Partial; - -type PageParams = { - pageSize: number; - pageNo: number; -} & Partial; - -type Many = T | readonly T[]; diff --git a/src/wordpress/wordpress.service.ts b/src/wordpress/wordpress.service.ts index 7687dec..26d8b13 100644 --- a/src/wordpress/wordpress.service.ts +++ b/src/wordpress/wordpress.service.ts @@ -1,63 +1,23 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Request, Response as ExpressResponse } from 'express'; -import { throwVbenError } from '@/common'; +import { throwVbenError, ToolsService } from '@/common'; import type { WordpressArticleBodyDto, WordpressArticleListQueryDto, WordpressTermBodyDto, WordpressTermListQueryDto, } from './wordpress.dto'; - -export type WordpressAuthContext = { - authorization?: string; - cookie?: string; - nonce?: string; -}; - -export type WordpressLoginResult = { - auth: { - nonce: string; - type: 'cookie'; - }; - user: any; -}; - -export type WordpressOptionalLoginResult = - | { - available: false; - error: WordpressAvailabilityError; - result: null; - } - | { - available: true; - error: null; - result: WordpressLoginResult & { cookie: string }; - }; - -export type WordpressAvailabilityError = { - error: any; - message: string; - status: number; -}; - -type WordpressAvailabilityCache = { - available: boolean; - checkedAt: number; - error?: WordpressAvailabilityError; -}; - -type WordpressRequestOptions = { - auth: WordpressAuthContext; - body?: Record; - method?: 'GET' | 'POST' | 'DELETE'; - query?: Record; -}; - -type WordpressResponse = { - data: T; - total?: number; -}; +import type { + WordpressAuthContext, + WordpressAvailabilityCache, + WordpressAvailabilityError, + WordpressLoginResult, + WordpressOptionalLoginResult, + WordpressPagedQueryDto, + WordpressRequestOptions, + WordpressResponse, +} from './wordpress.types'; const WORDPRESS_COOKIE_PREFIXES = [ 'wordpress_', @@ -72,19 +32,22 @@ const WORDPRESS_AUTH_COOKIE = 'kt_wordpress_auth'; export class WordpressService { private availabilityCache: null | WordpressAvailabilityCache = null; - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly toolsService: ToolsService, + ) {} getAuthContext(request: Request): WordpressAuthContext { const authorization = - this.readHeader(request, 'x-wordpress-authorization') || - this.readHeader(request, 'x-wp-authorization') || + this.toolsService.readHeader(request, 'x-wordpress-authorization') || + this.toolsService.readHeader(request, 'x-wp-authorization') || this.getForwardableAuthorization(request); const nonce = - this.readHeader(request, 'x-wp-nonce') || - this.readHeader(request, 'x-wordpress-nonce'); + this.toolsService.readHeader(request, 'x-wp-nonce') || + this.toolsService.readHeader(request, 'x-wordpress-nonce'); const cookie = - this.readHeader(request, 'x-wordpress-cookie') || - this.readCookie(request, WORDPRESS_AUTH_COOKIE) || + this.toolsService.readHeader(request, 'x-wordpress-cookie') || + this.toolsService.readCookie(request, WORDPRESS_AUTH_COOKIE) || this.getWordpressCookie(request.headers.cookie); return { @@ -439,13 +402,20 @@ export class WordpressService { let data = await this.parseResponse(response); // 部分 WordPress 网关会拦截 DELETE;REST API 官方支持用 _method=DELETE 通过 POST 兜底。 - if (!response.ok && response.status === 405 && options.method === 'DELETE') { - response = await fetch(this.getMethodOverrideUrl(urls[index], 'DELETE'), { - headers: this.getHeaders(options.auth, false), - method: 'POST', - redirect: 'follow', - signal: controller.signal, - }); + if ( + !response.ok && + response.status === 405 && + options.method === 'DELETE' + ) { + response = await fetch( + this.getMethodOverrideUrl(urls[index], 'DELETE'), + { + headers: this.getHeaders(options.auth, false), + method: 'POST', + redirect: 'follow', + signal: controller.signal, + }, + ); data = await this.parseResponse(response); } @@ -460,7 +430,7 @@ export class WordpressService { if (!response.ok) { throwVbenError( - this.getErrorMessage(data, response.status), + this.getWordpressResponseErrorMessage(data, response.status), response.status, data, ); @@ -734,7 +704,7 @@ export class WordpressService { } private getArticleBody(body: WordpressArticleBodyDto) { - return this.pickDefined({ + return this.toolsService.pickDefined({ categories: this.normalizeIdList(body.categories), content: body.content, excerpt: body.excerpt, @@ -748,7 +718,7 @@ export class WordpressService { } private getTermBody(body: WordpressTermBodyDto) { - return this.pickDefined({ + return this.toolsService.pickDefined({ description: body.description, name: body.name, parent: body.parent, @@ -784,19 +754,6 @@ export class WordpressService { .join(','); } - private pickDefined(payload: Record) { - return Object.entries(payload).reduce>( - (acc, [key, value]) => { - if (value !== undefined && value !== null && value !== '') { - acc[key] = value; - } - - return acc; - }, - {}, - ); - } - private async parseResponse(response: globalThis.Response) { const text = await response.text(); @@ -809,14 +766,14 @@ export class WordpressService { } } - private getErrorMessage(data: any, status: number) { + private getWordpressResponseErrorMessage(data: any, status: number) { if (data?.message) return data.message; if (typeof data === 'string' && data) return data; return `WordPress 请求失败:${status}`; } private throwWordpressNetworkError(err: unknown): never { - const message = err instanceof Error ? err.message : '未知错误'; + const message = this.toolsService.getErrorMessage(err, '未知错误'); const cause = this.getErrorCause(err); // fetch 的 DNS、连接拒绝等底层异常不是 HttpException,需要统一转成业务响应。 @@ -852,7 +809,7 @@ export class WordpressService { return { error: err instanceof Error ? err.name : 'WordPressUnavailable', - message: err instanceof Error ? err.message : 'WordPress 暂不可用', + message: this.toolsService.getErrorMessage(err, 'WordPress 暂不可用'), status: HttpStatus.BAD_GATEWAY, }; } @@ -928,31 +885,11 @@ export class WordpressService { return ''; } - private readHeader(request: Request, name: string) { - const value = request.headers[name.toLowerCase()]; - return Array.isArray(value) ? value[0] : value; - } - - private readCookie(request: Request, 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('='); - - try { - return decodeURIComponent(value.join('=')); - } catch { - return value.join('='); - } - } - private getForwardableAuthorization(request: Request) { - const authorization = this.readHeader(request, 'authorization'); + const authorization = this.toolsService.readHeader( + request, + 'authorization', + ); if (!authorization || this.isLikelyAdminAuthorization(authorization)) { return undefined; @@ -997,7 +934,3 @@ export class WordpressService { return cookies.length ? cookies.join('; ') : undefined; } } - -type WordpressPagedQueryDto = - | WordpressArticleListQueryDto - | WordpressTermListQueryDto; diff --git a/src/wordpress/wordpress.types.ts b/src/wordpress/wordpress.types.ts new file mode 100644 index 0000000..b30c058 --- /dev/null +++ b/src/wordpress/wordpress.types.ts @@ -0,0 +1,58 @@ +import type { + WordpressArticleListQueryDto, + WordpressTermListQueryDto, +} from './wordpress.dto'; + +export type WordpressAuthContext = { + authorization?: string; + cookie?: string; + nonce?: string; +}; + +export type WordpressAvailabilityError = { + error: any; + message: string; + status: number; +}; + +export type WordpressAvailabilityCache = { + available: boolean; + checkedAt: number; + error?: WordpressAvailabilityError; +}; + +export type WordpressLoginResult = { + auth: { + nonce: string; + type: 'cookie'; + }; + user: any; +}; + +export type WordpressOptionalLoginResult = + | { + available: false; + error: WordpressAvailabilityError; + result: null; + } + | { + available: true; + error: null; + result: WordpressLoginResult & { cookie: string }; + }; + +export type WordpressPagedQueryDto = + | WordpressArticleListQueryDto + | WordpressTermListQueryDto; + +export type WordpressRequestOptions = { + auth: WordpressAuthContext; + body?: Record; + method?: 'GET' | 'POST' | 'DELETE'; + query?: Record; +}; + +export type WordpressResponse = { + data: T; + total?: number; +}; diff --git a/src/admin/dict/dict.service.spec.ts b/test/admin/dict/dict.service.spec.ts similarity index 85% rename from src/admin/dict/dict.service.spec.ts rename to test/admin/dict/dict.service.spec.ts index 50571f6..c97d72b 100644 --- a/src/admin/dict/dict.service.spec.ts +++ b/test/admin/dict/dict.service.spec.ts @@ -1,15 +1,16 @@ -jest.mock( - '@/common', - () => ({ +jest.mock('@/common', () => { + const actual = jest.requireActual('@/common'); + return { + ...actual, setDictDecodeCache: jest.fn(), throwVbenError: (message: string) => { throw new Error(message); }, - }), - { virtual: true }, -); + }; +}); -import { DictService } from './dict.service'; +import { ToolsService } from '@/common'; +import { DictService } from '@/admin/dict/dict.service'; describe('DictService', () => { const dictRows = [ @@ -49,9 +50,12 @@ describe('DictService', () => { ]; function createService() { - return new DictService({ - find: jest.fn().mockResolvedValue(dictRows), - } as any); + return new DictService( + { + find: jest.fn().mockResolvedValue(dictRows), + } as any, + new ToolsService(), + ); } function createGroupService() { @@ -77,9 +81,12 @@ describe('DictService', () => { return { builder, - service: new DictService({ - createQueryBuilder: jest.fn().mockReturnValue(builder), - } as any), + service: new DictService( + { + createQueryBuilder: jest.fn().mockReturnValue(builder), + } as any, + new ToolsService(), + ), }; } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index c5b07e9..1baf5cd 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -27,6 +27,7 @@ import { collectControllerRoutes, routeKey, } from './helpers/controller-route.helper'; +import type { RouteTestCase } from './test.types'; const component = { id: '2041739550026043392', @@ -56,6 +57,41 @@ const chartOptions = [ }, ]; +const dictItem = { + childrenCode: 'CHART', + dictCode: 'COMPONENT_TYPE', + id: '2041700000000300001', + label: '图表', + sort: 1, + status: 1, + value: '1', +}; + +const dictTreeItem = { + ...dictItem, + children: [ + { + childrenCode: null, + dictCode: 'CHART', + id: '2041700000000300002', + label: '折线图', + sort: 1, + status: 1, + treeKey: '2041700000000300001/2041700000000300002', + value: '1', + }, + ], + treeKey: '2041700000000300001', +}; + +const dictGroupItem = { + dictCode: 'COMPONENT_TYPE', + id: 'dict-code:COMPONENT_TYPE', + itemCount: 2, + label: 'COMPONENT_TYPE', + value: 'COMPONENT_TYPE', +}; + const uploadResult = { bucketName: 'kt-template-online', objectName: 'uploads/demo.txt', @@ -128,8 +164,17 @@ const unauthorizedException = () => ); const dictServiceMock = { + codes: jest.fn(), + getDictCodeOptions: jest.fn(), getDictByKey: jest.fn(), getComponentDictByType: jest.fn(), + groups: jest.fn(), + page: jest.fn(), + remove: jest.fn(), + save: jest.fn(), + toggle: jest.fn(), + tree: jest.fn(), + update: jest.fn(), }; const minioServiceMock = { @@ -177,9 +222,6 @@ const controllerClasses = [ ]; const controllerRoutes = collectControllerRoutes(controllerClasses); -type HttpServer = Parameters[0]; -type RouteTestCase = (server: HttpServer) => Promise; - const routeTestCases: Record = { 'GET /': async (server) => { await request(server).get('/').expect(301).expect('Location', '/api#/'); @@ -347,6 +389,182 @@ const routeTestCases: Record = { }); }, + 'GET /dict/list': async (server) => { + dictServiceMock.page.mockResolvedValue({ + items: [dictItem], + total: 1, + }); + + const response = await request(server) + .get('/dict/list') + .query({ dictCode: 'COMPONENT_TYPE', pageNo: 1, pageSize: 10 }) + .expect(200); + + expect(dictServiceMock.page).toHaveBeenCalledWith({ + dictCode: 'COMPONENT_TYPE', + pageNo: '1', + pageSize: '10', + }); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: { + items: [dictItem], + total: 1, + }, + }); + }, + + 'GET /dict/tree': async (server) => { + dictServiceMock.tree.mockResolvedValue([dictTreeItem]); + + const response = await request(server) + .get('/dict/tree') + .query({ dictCode: 'COMPONENT_TYPE' }) + .expect(200); + + expect(dictServiceMock.tree).toHaveBeenCalledWith({ + dictCode: 'COMPONENT_TYPE', + }); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: [dictTreeItem], + }); + }, + + 'GET /dict/groups': async (server) => { + dictServiceMock.groups.mockResolvedValue({ + items: [dictGroupItem], + total: 1, + }); + + const response = await request(server) + .get('/dict/groups') + .query({ keyword: 'COMPONENT', pageNo: 1, pageSize: 10 }) + .expect(200); + + expect(dictServiceMock.groups).toHaveBeenCalledWith({ + keyword: 'COMPONENT', + pageNo: '1', + pageSize: '10', + }); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: { + items: [dictGroupItem], + total: 1, + }, + }); + }, + + 'GET /dict/codes': async (server) => { + dictServiceMock.getDictCodeOptions.mockResolvedValue([ + { + label: 'COMPONENT_TYPE', + value: 'COMPONENT_TYPE', + }, + ]); + + const response = await request(server).get('/dict/codes').expect(200); + + expect(dictServiceMock.getDictCodeOptions).toHaveBeenCalledWith(); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: [ + { + label: 'COMPONENT_TYPE', + value: 'COMPONENT_TYPE', + }, + ], + }); + }, + + 'POST /dict/save': async (server) => { + dictServiceMock.save.mockResolvedValue(dictItem.id); + + const response = await request(server) + .post('/dict/save') + .send({ + childrenCode: 'CHART', + dictCode: 'COMPONENT_TYPE', + label: '图表', + sort: 1, + status: 1, + value: '1', + }) + .expect(200); + + expect(dictServiceMock.save).toHaveBeenCalledWith({ + childrenCode: 'CHART', + dictCode: 'COMPONENT_TYPE', + label: '图表', + sort: 1, + status: 1, + value: '1', + }); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: dictItem.id, + }); + }, + + 'POST /dict/update': async (server) => { + dictServiceMock.update.mockResolvedValue(null); + + const response = await request(server) + .post('/dict/update') + .send({ + id: dictItem.id, + label: '图表', + }) + .expect(200); + + expect(dictServiceMock.update).toHaveBeenCalledWith({ + id: dictItem.id, + label: '图表', + }); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: null, + }); + }, + + 'DELETE /dict/:id': async (server) => { + dictServiceMock.remove.mockResolvedValue(null); + + const response = await request(server) + .delete(`/dict/${dictItem.id}`) + .expect(200); + + expect(dictServiceMock.remove).toHaveBeenCalledWith(dictItem.id); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: null, + }); + }, + + 'POST /dict/toggle': async (server) => { + dictServiceMock.toggle.mockResolvedValue(null); + + const response = await request(server) + .post('/dict/toggle') + .query({ id: dictItem.id, status: 0 }) + .expect(200); + + expect(dictServiceMock.toggle).toHaveBeenCalledWith(dictItem.id, 0); + expect(response.body).toEqual({ + code: 200, + msg: '操作成功', + data: null, + }); + }, + 'GET /minio/check': async (server) => { minioServiceMock.checkConnection.mockResolvedValue({ bucketName: 'demo-bucket', @@ -580,7 +798,9 @@ const routeTestCases: Record = { .post('/wordpress/auth/login') .expect(201); - expect(wordpressServiceMock.loginWithConfiguredAdmin).toHaveBeenCalledWith(); + expect( + wordpressServiceMock.loginWithConfiguredAdmin, + ).toHaveBeenCalledWith(); expect(wordpressServiceMock.setAuthCookie).toHaveBeenCalledWith( expect.anything(), wordpressLoginResult.cookie, @@ -1058,7 +1278,32 @@ describe('KT Template Online API (e2e)', () => { expect(response.body).toEqual({ code: 400, msg: '操作失败', - err: false, + err: 'false', + }); + }); + + it('serializes object error details as a string for frontend parsing', async () => { + wordpressServiceMock.checkAuth.mockRejectedValue( + new HttpException( + { + msg: 'WordPress 请求失败', + err: { + code: 'WORDPRESS_NETWORK_ERROR', + message: 'connect ECONNREFUSED 127.0.0.1:8080', + }, + }, + HttpStatus.BAD_GATEWAY, + ), + ); + + const response = await request(app.getHttpServer()) + .get('/wordpress/auth/check') + .expect(502); + + expect(response.body).toEqual({ + code: 502, + msg: 'WordPress 请求失败', + err: 'connect ECONNREFUSED 127.0.0.1:8080', }); }); @@ -1089,9 +1334,7 @@ describe('KT Template Online API (e2e)', () => { jest.clearAllMocks(); authServiceMock.currentUser.mockRejectedValue(unauthorizedException()); - await request(app.getHttpServer()) - .get('/wordpress/auth/check') - .expect(401); + await request(app.getHttpServer()).get('/wordpress/auth/check').expect(401); expect(wordpressServiceMock.checkAuth).not.toHaveBeenCalled(); }); diff --git a/test/helpers/controller-route.helper.ts b/test/helpers/controller-route.helper.ts index d7ac31a..8ecc8dd 100644 --- a/test/helpers/controller-route.helper.ts +++ b/test/helpers/controller-route.helper.ts @@ -1,12 +1,6 @@ import { RequestMethod, Type } from '@nestjs/common'; import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; - -export interface ControllerRoute { - controllerName: string; - handlerName: string; - method: string; - path: string; -} +import type { ControllerRoute } from '../test.types'; const requestMethodMap: Partial> = { [RequestMethod.GET]: 'GET', diff --git a/src/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts similarity index 63% rename from src/qqbot/account/qqbot-napcat-login.service.spec.ts rename to test/qqbot/account/qqbot-napcat-login.service.spec.ts index 73b2c0e..a0cc099 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -1,25 +1,25 @@ -jest.mock( - '@/common', - () => ({ - ensureSnowflakeId: jest.fn(), - setDictDecodeCache: jest.fn(), - throwVbenError: (message: string) => { - throw new Error(message); - }, - }), - { virtual: true }, -); +jest.mock('@/common', () => ({ + ToolsService: jest.requireActual('@/common').ToolsService, + ensureSnowflakeId: jest.fn(), + setDictDecodeCache: jest.fn(), + throwVbenError: (message: string) => { + throw new Error(message); + }, +})); import { ConfigService } from '@nestjs/config'; -import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service'; -import { QqbotAccountService } from './qqbot-account.service'; -import { QqbotNapcatLoginService } from './qqbot-napcat-login.service'; +import { ToolsService } from '@/common'; +import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; +import { QqbotNapcatLoginService } from '@/qqbot/account/qqbot-napcat-login.service'; +import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service'; describe('QqbotNapcatLoginService', () => { + const toolsService = new ToolsService(); const service = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, + toolsService, ); beforeEach(() => { @@ -75,6 +75,7 @@ describe('QqbotNapcatLoginService', () => { { get: jest.fn() } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), ); const startScan = jest .spyOn(refreshService as any, 'startScan') @@ -121,12 +122,102 @@ describe('QqbotNapcatLoginService', () => { const result = await service.refreshQrcode('session-refresh-qrcode'); expect(result.qrcode).toBe('new-qrcode'); - expect((service as any).getQrcode).toHaveBeenCalledWith(container, true, { + expect((service as any).getQrcode).toHaveBeenCalledWith(container, false, { requireFresh: true, staleQrcode: 'old-qrcode', }); }); + it('keeps the refresh session pending when NapCat is still regenerating qrcode', async () => { + (service as any).sessions.set('session-pending-qrcode', { + containerId: 'container-pending', + containerName: 'napcat-pending', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + id: 'session-pending-qrcode', + mode: 'refresh', + qrcode: 'old-qrcode', + status: 'pending', + webuiPort: 6105, + }); + jest + .spyOn(service as any, 'getSessionContainer') + .mockResolvedValue({ id: 'container-pending' }); + jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeurl: 'old-qrcode', + }); + jest + .spyOn(service as any, 'callRefreshQrcode') + .mockRejectedValue(new Error('NapCat 二维码仍未刷新')); + + const result = await service.refreshQrcode('session-pending-qrcode'); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBeUndefined(); + expect(result.errorMessage).toContain('正在重新生成二维码'); + }); + + it('normalizes login status to offline when login info reports offline', async () => { + jest + .spyOn(service as any, 'postNapcat') + .mockResolvedValueOnce({ isLogin: true, qrcodeurl: 'old-qrcode' }) + .mockResolvedValueOnce({ online: false, uin: '10001' }); + + const result = await (service as any).getLoginStatus({ + id: 'container-offline', + }); + + expect(result).toEqual( + expect.objectContaining({ + isLogin: false, + isOffline: true, + }), + ); + }); + + it('restarts container and returns pending when refresh login status is effectively offline', async () => { + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + {} as QqbotNapcatContainerService, + new ToolsService(), + ); + jest + .spyOn(refreshService as any, 'cleanupSessions') + .mockResolvedValue(null); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + isOffline: true, + loginError: 'NapCat 账号已离线', + qrcodeurl: 'old-qrcode', + }); + const restartNapcatForLogin = jest + .spyOn(refreshService as any, 'restartNapcatForLogin') + .mockResolvedValue(undefined); + + const result = await (refreshService as any).startScan( + { + accountId: 'account-1', + expectedSelfId: '10001', + mode: 'refresh', + }, + { + baseUrl: 'http://127.0.0.1:6105/', + id: 'container-offline', + name: 'napcat-10001', + }, + ); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBeUndefined(); + expect(result.errorMessage).toBe('NapCat 账号已离线'); + expect(restartNapcatForLogin).toHaveBeenCalledWith( + expect.objectContaining({ id: 'container-offline' }), + { waitForReady: false }, + ); + }); + it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -140,8 +231,11 @@ describe('QqbotNapcatLoginService', () => { { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), ); - jest.spyOn(refreshService as any, 'sleep').mockResolvedValue(undefined); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isLogin: false, qrcodeurl: 'new-status-qrcode', @@ -181,7 +275,9 @@ describe('QqbotNapcatLoginService', () => { }); it('retries while NapCat still exposes the stale qrcode', async () => { - jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined); + jest + .spyOn((service as any).toolsService, 'sleep') + .mockResolvedValue(undefined); jest .spyOn(service as any, 'postNapcat') .mockResolvedValueOnce({ qrcode: 'old-qrcode' }) diff --git a/src/qqbot/command/qqbot-command-parser.service.spec.ts b/test/qqbot/command/qqbot-command-parser.service.spec.ts similarity index 90% rename from src/qqbot/command/qqbot-command-parser.service.spec.ts rename to test/qqbot/command/qqbot-command-parser.service.spec.ts index 84fadb4..00ea858 100644 --- a/src/qqbot/command/qqbot-command-parser.service.spec.ts +++ b/test/qqbot/command/qqbot-command-parser.service.spec.ts @@ -7,9 +7,9 @@ jest.mock( { virtual: true }, ); -import { DictService } from '../../admin/dict/dict.service'; -import { QqbotCommandParserService } from './qqbot-command-parser.service'; -import type { QqbotCommand } from './qqbot-command.entity'; +import { DictService } from '@/admin/dict/dict.service'; +import type { QqbotCommand } from '@/qqbot/command/qqbot-command.entity'; +import { QqbotCommandParserService } from '@/qqbot/command/qqbot-command-parser.service'; describe('QqbotCommandParserService FFLogs parser', () => { const command = { diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts b/test/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts similarity index 97% rename from src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts rename to test/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts index 82606d4..f7b974c 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts +++ b/test/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts @@ -2,7 +2,7 @@ import { buildQqbotFf14MarketCatalog, buildQqbotFf14MarketCatalogFromTree, resolveQqbotFf14MarketTarget, -} from './qqbot-ff14-worlds'; +} from '@/qqbot/plugins/ff14Market/qqbot-ff14-worlds'; describe('qqbot ff14 market worlds', () => { const catalog = buildQqbotFf14MarketCatalog({ diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts b/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts similarity index 96% rename from src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts rename to test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts index 9be98bd..4b08157 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts +++ b/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts @@ -8,8 +8,8 @@ jest.mock( ); import { ConfigService } from '@nestjs/config'; -import { DictService } from '../../../admin/dict/dict.service'; -import { QqbotFflogsClientService } from './qqbot-fflogs-client.service'; +import { DictService } from '@/admin/dict/dict.service'; +import { QqbotFflogsClientService } from '@/qqbot/plugins/fflogs/qqbot-fflogs-client.service'; describe('QqbotFflogsClientService', () => { const dicts = { diff --git a/test/test.types.ts b/test/test.types.ts new file mode 100644 index 0000000..8b42438 --- /dev/null +++ b/test/test.types.ts @@ -0,0 +1,12 @@ +import request = require('supertest'); + +export type ControllerRoute = { + controllerName: string; + handlerName: string; + method: string; + path: string; +}; + +export type HttpServer = Parameters[0]; + +export type RouteTestCase = (server: HttpServer) => Promise;