feat: 规范化 API 公共能力与 QQBot 登录流程

This commit is contained in:
sunlei 2026-06-04 10:36:28 +08:00
parent 0ddf785b8b
commit b0cf9323b8
85 changed files with 2118 additions and 1261 deletions

View File

@ -78,15 +78,25 @@
"json", "json",
"ts" "ts"
], ],
"rootDir": "src", "rootDir": ".",
"testRegex": ".*\\.spec\\.ts$", "testMatch": [
"<rootDir>/test/**/*.spec.ts"
],
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": "<rootDir>/test/tsconfig.json"
}
]
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}, },
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "src/**/*.(t|j)s"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

View File

@ -22,7 +22,6 @@ import { AdminUserManageController } from './user/admin-user-manage.controller';
import { AdminUserController } from './user/admin-user.controller'; import { AdminUserController } from './user/admin-user.controller';
import { AdminUser } from './user/admin-user.entity'; import { AdminUser } from './user/admin-user.entity';
import { AdminUserService } from './user/admin-user.service'; import { AdminUserService } from './user/admin-user.service';
import { ToolsService } from '@/common';
import { MinioClientModule } from '@/minio/minio.module'; import { MinioClientModule } from '@/minio/minio.module';
import { WordpressModule } from '@/wordpress/wordpress.module'; import { WordpressModule } from '@/wordpress/wordpress.module';
@ -58,7 +57,6 @@ import { WordpressModule } from '@/wordpress/wordpress.module';
AdminRoleService, AdminRoleService,
AdminTimezoneService, AdminTimezoneService,
AdminUserService, AdminUserService,
ToolsService,
], ],
}) })
export class AdminModule {} export class AdminModule {}

89
src/admin/admin.types.ts Normal file
View File

@ -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<string, any>;
export type AdminMenuType = 'button' | 'catalog' | 'embedded' | 'link' | 'menu';
export type AdminMenuInput = Partial<AdminMenu> & {
activePath?: string;
linkSrc?: string;
};
export type AdminRequest = Request & {
adminUser?: AdminUser;
};
export type AdminRoleInput = Partial<AdminRole> & {
permissions?: string[];
};
export type AdminRoleListQuery = Record<string, any>;
export type AdminTokenPayload = {
exp: number;
iat: number;
sub: string;
type: 'access' | 'refresh';
username: string;
};
export type AdminUserInput = Partial<AdminUser> & {
roleIds?: string[];
};
export type AdminUserListQuery = Record<string, any>;

View File

@ -2,7 +2,7 @@ import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import { AdminUser } from '../user/admin-user.entity'; import { AdminUser } from '../user/admin-user.entity';
import { AdminTokenService } from './admin-token.service'; import { AdminTokenService } from './admin-token.service';
@ -15,6 +15,7 @@ export class AdminAuthService {
@InjectRepository(AdminUser) @InjectRepository(AdminUser)
private readonly userRepository: Repository<AdminUser>, private readonly userRepository: Repository<AdminUser>,
private readonly tokenService: AdminTokenService, private readonly tokenService: AdminTokenService,
private readonly toolsService: ToolsService,
) {} ) {}
async login(username?: string, password?: string) { async login(username?: string, password?: string) {
@ -60,8 +61,8 @@ export class AdminAuthService {
async currentUser(authHeader?: string, req?: Request) { async currentUser(authHeader?: string, req?: Request) {
const tokens = [ const tokens = [
this.readBearerToken(authHeader), this.toolsService.readBearerToken(authHeader),
this.readCookie(req, ACCESS_TOKEN_COOKIE), this.toolsService.readCookie(req, ACCESS_TOKEN_COOKIE),
].filter((token): token is string => !!token); ].filter((token): token is string => !!token);
const payload = tokens const payload = tokens
.map((token) => this.tokenService.verifyAccessToken(token)) .map((token) => this.tokenService.verifyAccessToken(token))
@ -78,12 +79,13 @@ export class AdminAuthService {
status: 1, status: 1,
}, },
}); });
if (!user) throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED); if (!user)
throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED);
return user; return user;
} }
getRefreshTokenFromRequest(req: Request) { getRefreshTokenFromRequest(req: Request) {
return this.readCookie(req, REFRESH_TOKEN_COOKIE); return this.toolsService.readCookie(req, REFRESH_TOKEN_COOKIE);
} }
setAccessTokenCookie(res: Response, token: string) { 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<Record<string, string>>((acc, item) => {
const [key, ...value] = item.trim().split('=');
if (key) acc[key] = decodeURIComponent(value.join('='));
return acc;
}, {});
return cookies[cookieName];
}
private getTokenCookieOptions() { private getTokenCookieOptions() {
const secure = process.env.ADMIN_COOKIE_SECURE === 'true'; const secure = process.env.ADMIN_COOKIE_SECURE === 'true';
return { return {

View File

@ -1,14 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'crypto'; import { createHmac, timingSafeEqual } from 'crypto';
import type { AdminTokenPayload } from '../admin.types';
export type AdminTokenPayload = {
exp: number;
iat: number;
sub: string;
type: 'access' | 'refresh';
username: string;
};
@Injectable() @Injectable()
export class AdminTokenService { export class AdminTokenService {

View File

@ -1,17 +1,8 @@
import { import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { AdminUser } from '../user/admin-user.entity';
import { AdminAuthService } from './admin-auth.service'; import { AdminAuthService } from './admin-auth.service';
import { IS_PUBLIC_KEY } from '@/common'; import { IS_PUBLIC_KEY } from '@/common';
import type { AdminRequest } from '../admin.types';
type AdminRequest = Request & {
adminUser?: AdminUser;
};
@Injectable() @Injectable()
export class JwtAuthGuard implements CanActivate { export class JwtAuthGuard implements CanActivate {

View File

@ -23,6 +23,7 @@ import {
ApiModelResponse, ApiModelResponse,
ApiPageResponse, ApiPageResponse,
ApiSuccessResponse, ApiSuccessResponse,
type KtPageParams,
ToolsService, ToolsService,
} from '@/common'; } from '@/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@ -45,7 +46,7 @@ const componentExample = {
class CompPageDto class CompPageDto
extends PartialType(Component) extends PartialType(Component)
implements PageParams<Component> implements KtPageParams<Component>
{ {
@ApiProperty({ @ApiProperty({
type: Number, type: Number,
@ -83,7 +84,7 @@ export class ComponentController {
@ApiPageResponse(Component, [componentExample], 1) @ApiPageResponse(Component, [componentExample], 1)
async getList( async getList(
@Res() res, @Res() res,
@Query() { pageNo, pageSize, ...args }: PageParams<Component>, @Query() { pageNo, pageSize, ...args }: KtPageParams<Component>,
): Promise<PaginatedDto<Component>> { ): Promise<PaginatedDto<Component>> {
const list = await this.componentService.page({ const list = await this.componentService.page({
pageNo, pageNo,

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Component } from './component.entity'; import { Component } from './component.entity';
import { ToolsService } from '@/common'; import { ToolsService, type KtPage, type KtPageParams } from '@/common';
import { isNumber, omit, pick } from 'lodash'; import { isNumber, omit, pick } from 'lodash';
import { DictService } from '@/admin/dict/dict.service'; import { DictService } from '@/admin/dict/dict.service';
@ -28,7 +28,7 @@ export class ComponentService {
pageNo, pageNo,
pageSize, pageSize,
...args ...args
}: PageParams<Component>): Promise<Page<Component>> { }: KtPageParams<Component>): Promise<KtPage<Component>> {
await this.dictService.refreshDecodeCache(); await this.dictService.refreshDecodeCache();
const hasOwnEntity = new Component(); const hasOwnEntity = new Component();

View File

@ -3,13 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '../auth/admin-auth-guard.module'; import { AdminAuthGuardModule } from '../auth/admin-auth-guard.module';
import { DictController } from './dict.controller'; import { DictController } from './dict.controller';
import { DictService } from './dict.service'; import { DictService } from './dict.service';
import { ToolsService } from '@/common';
import { AdminDict } from './admin-dict.entity'; import { AdminDict } from './admin-dict.entity';
@Module({ @Module({
imports: [AdminAuthGuardModule, TypeOrmModule.forFeature([AdminDict])], imports: [AdminAuthGuardModule, TypeOrmModule.forFeature([AdminDict])],
controllers: [DictController], controllers: [DictController],
providers: [DictService, ToolsService], providers: [DictService],
exports: [DictService], exports: [DictService],
}) })
export class DictModule {} export class DictModule {}

View File

@ -1,59 +1,40 @@
import { HttpStatus, Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { HttpStatus, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from '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 { AdminDict } from './admin-dict.entity';
import { import {
AdminDictBodyDto, AdminDictBodyDto,
AdminDictQueryDto, AdminDictQueryDto,
AdminDictUpdateDto, AdminDictUpdateDto,
} from './dict.dto'; } from './dict.dto';
import type {
AdminDictGroupItem,
AdminDictItem,
AdminDictSerialized,
AdminDictTreeItem,
} from '../admin.types';
const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE'; 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() @Injectable()
export class DictService implements OnApplicationBootstrap { export class DictService implements OnApplicationBootstrap {
constructor( constructor(
@InjectRepository(AdminDict) @InjectRepository(AdminDict)
private readonly dictRepository: Repository<AdminDict>, private readonly dictRepository: Repository<AdminDict>,
private readonly toolsService: ToolsService,
) {} ) {}
async onApplicationBootstrap() { async onApplicationBootstrap() {
await this.refreshDecodeCache(); await this.refreshDecodeCache();
} }
async getDictByKey(dictKey: string): Promise<Dict[]> { async getDictByKey(dictKey: string): Promise<KtDictOption[]> {
const list = await this.getDictItemsByKey(dictKey); const list = await this.getDictItemsByKey(dictKey);
return list.map(({ label, value }) => ({ return list.map(({ label, value }) => ({
@ -63,13 +44,16 @@ export class DictService implements OnApplicationBootstrap {
} }
async page(query: AdminDictQueryDto = {}) { async page(query: AdminDictQueryDto = {}) {
const pageNo = this.toPositiveNumber(query.pageNo ?? query.page, 1); const pageNo = this.toolsService.toPositiveNumber(
const pageSize = this.toPositiveNumber(query.pageSize, 20); query.pageNo ?? query.page,
1,
);
const pageSize = this.toolsService.toPositiveNumber(query.pageSize, 20);
const builder = this.dictRepository const builder = this.dictRepository
.createQueryBuilder('dict') .createQueryBuilder('dict')
.where('dict.isDeleted = :isDeleted', { isDeleted: false }); .where('dict.isDeleted = :isDeleted', { isDeleted: false });
const keyword = this.normalizeText(query.keyword); const keyword = this.toolsService.toTrimmedString(query.keyword);
if (keyword) { if (keyword) {
builder.andWhere( builder.andWhere(
new Brackets((subBuilder) => { new Brackets((subBuilder) => {
@ -132,13 +116,16 @@ export class DictService implements OnApplicationBootstrap {
} }
async groups(query: AdminDictQueryDto = {}) { async groups(query: AdminDictQueryDto = {}) {
const pageNo = this.toPositiveNumber(query.pageNo ?? query.page, 1); const pageNo = this.toolsService.toPositiveNumber(
const pageSize = this.toPositiveNumber(query.pageSize, 20); query.pageNo ?? query.page,
1,
);
const pageSize = this.toolsService.toPositiveNumber(query.pageSize, 20);
const builder = this.dictRepository const builder = this.dictRepository
.createQueryBuilder('dict') .createQueryBuilder('dict')
.where('dict.isDeleted = :isDeleted', { isDeleted: false }); .where('dict.isDeleted = :isDeleted', { isDeleted: false });
const keyword = this.normalizeText(query.keyword); const keyword = this.toolsService.toTrimmedString(query.keyword);
if (keyword) { if (keyword) {
builder.andWhere('dict.dictCode LIKE :keyword', { builder.andWhere('dict.dictCode LIKE :keyword', {
keyword: `%${keyword}%`, keyword: `%${keyword}%`,
@ -206,7 +193,7 @@ export class DictService implements OnApplicationBootstrap {
} }
async update(body: AdminDictUpdateDto) { async update(body: AdminDictUpdateDto) {
const id = this.normalizeText(body.id); const id = this.toolsService.toTrimmedString(body.id);
if (!id) throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST); if (!id) throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST);
const dict = await this.dictRepository.findOne({ const dict = await this.dictRepository.findOne({
@ -240,7 +227,7 @@ export class DictService implements OnApplicationBootstrap {
} }
async remove(id: string) { async remove(id: string) {
const normalizedId = this.normalizeText(id); const normalizedId = this.toolsService.toTrimmedString(id);
if (!normalizedId) if (!normalizedId)
throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST); throwVbenError('字典项ID不能为空', HttpStatus.BAD_REQUEST);
@ -291,7 +278,7 @@ export class DictService implements OnApplicationBootstrap {
})); }));
} }
async getComponentDictByType(type: number): Promise<Dict[]> { async getComponentDictByType(type: number): Promise<KtDictOption[]> {
// 一级类型的 childrenCode 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。 // 一级类型的 childrenCode 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。
const componentType = await this.dictRepository.findOne({ const componentType = await this.dictRepository.findOne({
where: { where: {
@ -337,7 +324,7 @@ export class DictService implements OnApplicationBootstrap {
>, >,
value?: string, value?: string,
) { ) {
const normalizedValue = this.normalizeText(value); const normalizedValue = this.toolsService.toTrimmedString(value);
if (!normalizedValue) return; if (!normalizedValue) return;
builder.andWhere(`dict.${field} LIKE :${field}`, { 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 dictCodes = new Set(items.map((item) => item.dictCode));
const referencedCodes = new Set( const referencedCodes = new Set(
items items
.map((item) => this.normalizeText(item.childrenCode)) .map((item) => this.toolsService.toTrimmedString(item.childrenCode))
.filter((childrenCode) => childrenCode && dictCodes.has(childrenCode)), .filter((childrenCode) => childrenCode && dictCodes.has(childrenCode)),
); );
const rootCodes = [...dictCodes].filter( const rootCodes = [...dictCodes].filter(
@ -387,7 +374,7 @@ export class DictService implements OnApplicationBootstrap {
treeKey: string, treeKey: string,
pathCodes: Set<string>, pathCodes: Set<string>,
): AdminDictTreeItem { ): AdminDictTreeItem {
const childrenCode = this.normalizeText(item.childrenCode); const childrenCode = this.toolsService.toTrimmedString(item.childrenCode);
const children = const children =
childrenCode && !pathCodes.has(childrenCode) childrenCode && !pathCodes.has(childrenCode)
? byDictCode.get(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; if (!childrenCode) return;
const children = byDictCode.get(childrenCode) || []; const children = byDictCode.get(childrenCode) || [];
@ -488,7 +475,7 @@ export class DictService implements OnApplicationBootstrap {
const map = new Map<string, AdminDictSerialized[]>(); const map = new Map<string, AdminDictSerialized[]>();
items.forEach((item) => { items.forEach((item) => {
const childrenCode = this.normalizeText(item.childrenCode); const childrenCode = this.toolsService.toTrimmedString(item.childrenCode);
if (!childrenCode) return; if (!childrenCode) return;
const list = map.get(childrenCode) || []; const list = map.get(childrenCode) || [];
@ -507,7 +494,7 @@ export class DictService implements OnApplicationBootstrap {
query.keyword, query.keyword,
query.label, query.label,
query.value, query.value,
].some((value) => !!this.normalizeText(value)) || ].some((value) => !!this.toolsService.toTrimmedString(value)) ||
['0', '1'].includes(String(query.status)) ['0', '1'].includes(String(query.status))
); );
} }
@ -516,11 +503,11 @@ export class DictService implements OnApplicationBootstrap {
item: AdminDictSerialized, item: AdminDictSerialized,
query: AdminDictQueryDto, query: AdminDictQueryDto,
) { ) {
const keyword = this.normalizeText(query.keyword); const keyword = this.toolsService.toTrimmedString(query.keyword);
if ( if (
keyword && keyword &&
![item.childrenCode, item.dictCode, item.label, item.value].some( ![item.childrenCode, item.dictCode, item.label, item.value].some(
(value) => this.includesText(value, keyword), (value) => this.toolsService.includesText(value, keyword),
) )
) { ) {
return false; return false;
@ -542,32 +529,25 @@ export class DictService implements OnApplicationBootstrap {
value: number | string | null | undefined, value: number | string | null | undefined,
keyword?: string, keyword?: string,
) { ) {
const normalizedKeyword = this.normalizeText(keyword); const normalizedKeyword = this.toolsService.toTrimmedString(keyword);
if (!normalizedKeyword) return true; if (!normalizedKeyword) return true;
return this.includesText(value, normalizedKeyword); return this.toolsService.includesText(value, normalizedKeyword);
}
private includesText(
value: number | string | null | undefined,
keyword: string,
) {
return this.normalizeText(value)
.toLowerCase()
.includes(keyword.toLowerCase());
} }
private normalizeInput(body: AdminDictBodyDto): Partial<AdminDict> { private normalizeInput(body: AdminDictBodyDto): Partial<AdminDict> {
const dictCode = this.normalizeText(body.dictCode); const dictCode = this.toolsService.toTrimmedString(body.dictCode);
const label = this.normalizeText(body.label); const label = this.toolsService.toTrimmedString(body.label);
const value = this.normalizeText(body.value); const value = this.toolsService.toTrimmedString(body.value);
if (!dictCode) throwVbenError('字典编码不能为空', HttpStatus.BAD_REQUEST); if (!dictCode) throwVbenError('字典编码不能为空', HttpStatus.BAD_REQUEST);
if (!label) throwVbenError('字典标签不能为空', HttpStatus.BAD_REQUEST); if (!label) throwVbenError('字典标签不能为空', HttpStatus.BAD_REQUEST);
if (!value) throwVbenError('字典值不能为空', HttpStatus.BAD_REQUEST); if (!value) throwVbenError('字典值不能为空', HttpStatus.BAD_REQUEST);
return { return {
childrenCode: this.normalizeNullableText(body.childrenCode), childrenCode: this.toolsService.normalizeNullableString(
body.childrenCode,
),
dictCode, dictCode,
label, label,
sort: Number.isFinite(Number(body.sort)) ? Number(body.sort) : 0, 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) { private serializeDict(dict: AdminDict) {
return { return {
childrenCode: dict.childrenCode, childrenCode: dict.childrenCode,
@ -611,12 +582,4 @@ export class DictService implements OnApplicationBootstrap {
value: item.dictCode, value: item.dictCode,
}; };
} }
private toPositiveNumber(
value: number | string | undefined,
fallback: number,
) {
const nextValue = Number(value);
return Number.isFinite(nextValue) && nextValue > 0 ? nextValue : fallback;
}
} }

View File

@ -14,30 +14,12 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Public, vbenPage, vbenSuccess } from '@/common'; import { Public, vbenPage, vbenSuccess } from '@/common';
import { MinioClientService } from '@/minio/minio.service'; 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 = { const DEMO_ROWS: AdminDemoTableRow[] = Array.from(
available: boolean; { length: 100 },
category: string; (_, index) => {
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: DemoTableRow[] = Array.from({ length: 100 }, (_, index) => {
const sequence = index + 1; const sequence = index + 1;
const categories = ['Dashboard', 'Form', 'Table', 'Chart', 'Workflow']; const categories = ['Dashboard', 'Form', 'Table', 'Chart', 'Workflow'];
const colors = ['Blue', 'Green', 'Purple', 'Orange', 'Slate']; const colors = ['Blue', 'Green', 'Purple', 'Orange', 'Slate'];
@ -50,7 +32,8 @@ const DEMO_ROWS: DemoTableRow[] = Array.from({ length: 100 }, (_, index) => {
currency: 'CNY', currency: 'CNY',
description: `真实 API 示例数据 ${sequence}`, description: `真实 API 示例数据 ${sequence}`,
id: `demo-${String(sequence).padStart(3, '0')}`, id: `demo-${String(sequence).padStart(3, '0')}`,
imageUrl: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', imageUrl:
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
imageUrl2: imageUrl2:
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp', 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp',
inProduction: sequence % 2 === 0, inProduction: sequence % 2 === 0,
@ -61,10 +44,15 @@ const DEMO_ROWS: DemoTableRow[] = Array.from({ length: 100 }, (_, index) => {
rating: Number((3 + (sequence % 20) / 10).toFixed(1)), rating: Number((3 + (sequence % 20) / 10).toFixed(1)),
releaseDate: new Date(2026, index % 12, (index % 28) + 1).toISOString(), releaseDate: new Date(2026, index % 12, (index % 28) + 1).toISOString(),
status: statuses[index % statuses.length], status: statuses[index % statuses.length],
tags: ['kt', 'admin', categories[index % categories.length].toLowerCase()], tags: [
'kt',
'admin',
categories[index % categories.length].toLowerCase(),
],
weight: Number((1 + sequence / 10).toFixed(2)), weight: Number((1 + sequence / 10).toFixed(2)),
}; };
}); },
);
@ApiTags('Admin - 示例') @ApiTags('Admin - 示例')
@Controller() @Controller()
@ -85,9 +73,7 @@ export class AdminExampleController {
@Get('table/list') @Get('table/list')
@ApiOperation({ summary: '获取示例表格分页列表' }) @ApiOperation({ summary: '获取示例表格分页列表' })
async tableList( async tableList(@Query() query: Record<string, any>) {
@Query() query: Record<string, any>,
) {
const page = Math.max(Number(query.page || 1), 1); const page = Math.max(Number(query.page || 1), 1);
const pageSize = Math.max(Number(query.pageSize || 10), 1); const pageSize = Math.max(Number(query.pageSize || 10), 1);
const sorted = this.sortRows([...DEMO_ROWS], query.sortBy, query.sortOrder); const sorted = this.sortRows([...DEMO_ROWS], query.sortBy, query.sortOrder);
@ -152,16 +138,24 @@ export class AdminExampleController {
return vbenSuccess('Test post handler'); 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; if (!sortBy || !Object.hasOwn(rows[0], sortBy)) return rows;
return rows.sort((prev, next) => { return rows.sort((prev, next) => {
const prevValue = prev[sortBy]; const prevValue = prev[sortBy];
const nextValue = next[sortBy]; const nextValue = next[sortBy];
const result = String(prevValue).localeCompare(String(nextValue), 'zh-CN', { const result = String(prevValue).localeCompare(
String(nextValue),
'zh-CN',
{
numeric: true, numeric: true,
sensitivity: 'base', sensitivity: 'base',
}); },
);
return sortOrder === 'desc' ? -result : result; return sortOrder === 'desc' ? -result : result;
}); });

View File

@ -9,10 +9,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { ensureSnowflakeId } from '@/common'; import { ensureSnowflakeId } from '@/common';
import { AdminRole } from '../role/admin-role.entity'; import { AdminRole } from '../role/admin-role.entity';
import type { AdminMenuMeta, AdminMenuType } from '../admin.types';
export type AdminMenuType = 'button' | 'catalog' | 'embedded' | 'link' | 'menu';
export type AdminMenuMeta = Record<string, any>;
@Entity('admin_menu') @Entity('admin_menu')
export class AdminMenu { export class AdminMenu {

View File

@ -4,12 +4,8 @@ import { In, Repository } from 'typeorm';
import { toTree } from '@/common'; import { toTree } from '@/common';
import { WordpressService } from '@/wordpress/wordpress.service'; import { WordpressService } from '@/wordpress/wordpress.service';
import { AdminUser } from '../user/admin-user.entity'; import { AdminUser } from '../user/admin-user.entity';
import { AdminMenu, AdminMenuMeta } from './admin-menu.entity'; import { AdminMenu } from './admin-menu.entity';
import type { AdminMenuInput, AdminMenuMeta } from '../admin.types';
type MenuInput = Partial<AdminMenu> & {
activePath?: string;
linkSrc?: string;
};
@Injectable() @Injectable()
export class AdminMenuService { export class AdminMenuService {
@ -64,7 +60,7 @@ export class AdminMenuService {
return !!menu && (!id || menu.id !== id); return !!menu && (!id || menu.id !== id);
} }
async createMenu(data: MenuInput) { async createMenu(data: AdminMenuInput) {
const entity = this.menuRepository.create({ const entity = this.menuRepository.create({
...this.normalizeMenuInput(data, true), ...this.normalizeMenuInput(data, true),
}); });
@ -72,7 +68,7 @@ export class AdminMenuService {
return null; return null;
} }
async updateMenu(id: string, data: MenuInput) { async updateMenu(id: string, data: AdminMenuInput) {
await this.menuRepository.update( await this.menuRepository.update(
{ id }, { id },
{ {
@ -168,7 +164,7 @@ export class AdminMenuService {
} }
private normalizeMenuInput( private normalizeMenuInput(
data: MenuInput, data: AdminMenuInput,
includeEmptyMeta: boolean, includeEmptyMeta: boolean,
): Partial<AdminMenu> { ): Partial<AdminMenu> {
const meta = this.normalizeMetaInput(data); const meta = this.normalizeMetaInput(data);
@ -188,7 +184,7 @@ export class AdminMenuService {
return menu; return menu;
} }
private normalizeMetaInput(data: MenuInput): AdminMenuMeta { private normalizeMetaInput(data: AdminMenuInput): AdminMenuMeta {
const meta = this.normalizeMetaValue(data.meta); const meta = this.normalizeMetaValue(data.meta);
// 兼容表单库返回字面量 `meta.title` 的场景,避免更新菜单时把 meta 覆盖为空对象。 // 兼容表单库返回字面量 `meta.title` 的场景,避免更新菜单时把 meta 覆盖为空对象。

View File

@ -4,12 +4,7 @@ import { Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError } from '@/common';
import { AdminMenu } from '../menu/admin-menu.entity'; import { AdminMenu } from '../menu/admin-menu.entity';
import { AdminRole } from './admin-role.entity'; import { AdminRole } from './admin-role.entity';
import type { AdminRoleInput, AdminRoleListQuery } from '../admin.types';
type RoleInput = Partial<AdminRole> & {
permissions?: string[];
};
type ListQuery = Record<string, any>;
@Injectable() @Injectable()
export class AdminRoleService { export class AdminRoleService {
@ -20,7 +15,7 @@ export class AdminRoleService {
private readonly menuRepository: Repository<AdminMenu>, private readonly menuRepository: Repository<AdminMenu>,
) {} ) {}
async getRoleList(query: ListQuery) { async getRoleList(query: AdminRoleListQuery) {
const page = Number(query.page || 1); const page = Number(query.page || 1);
const pageSize = Number(query.pageSize || 20); const pageSize = Number(query.pageSize || 20);
const builder = this.roleRepository const builder = this.roleRepository
@ -65,7 +60,7 @@ export class AdminRoleService {
}; };
} }
async createRole(data: RoleInput) { async createRole(data: AdminRoleInput) {
const role = this.roleRepository.create({ const role = this.roleRepository.create({
name: data.name, name: data.name,
remark: data.remark || '', remark: data.remark || '',
@ -77,7 +72,7 @@ export class AdminRoleService {
return null; return null;
} }
async updateRole(id: string, data: RoleInput) { async updateRole(id: string, data: AdminRoleInput) {
const role = await this.roleRepository.findOne({ const role = await this.roleRepository.findOne({
relations: ['menus'], relations: ['menus'],
where: { where: {
@ -90,7 +85,8 @@ export class AdminRoleService {
if (data.name !== undefined) role.name = data.name; if (data.name !== undefined) role.name = data.name;
if (data.remark !== undefined) role.remark = data.remark; if (data.remark !== undefined) role.remark = data.remark;
if (data.status !== undefined) role.status = data.status; 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); await this.roleRepository.save(role);
return null; return null;

View File

@ -5,12 +5,7 @@ import { throwVbenError } from '@/common';
import { AdminDept } from '../dept/admin-dept.entity'; import { AdminDept } from '../dept/admin-dept.entity';
import { AdminRole } from '../role/admin-role.entity'; import { AdminRole } from '../role/admin-role.entity';
import { AdminUser } from './admin-user.entity'; import { AdminUser } from './admin-user.entity';
import type { AdminUserInput, AdminUserListQuery } from '../admin.types';
type UserInput = Partial<AdminUser> & {
roleIds?: string[];
};
type ListQuery = Record<string, any>;
@Injectable() @Injectable()
export class AdminUserService { export class AdminUserService {
@ -23,7 +18,7 @@ export class AdminUserService {
private readonly deptRepository: Repository<AdminDept>, private readonly deptRepository: Repository<AdminDept>,
) {} ) {}
async getUserList(query: ListQuery) { async getUserList(query: AdminUserListQuery) {
const page = Number(query.page || 1); const page = Number(query.page || 1);
const pageSize = Number(query.pageSize || 20); const pageSize = Number(query.pageSize || 20);
const builder = this.userRepository 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 || '')); await this.ensureUsernameAvailable(String(data.username || ''));
const user = this.userRepository.create({ const user = this.userRepository.create({
@ -97,7 +92,7 @@ export class AdminUserService {
return null; return null;
} }
async updateUser(id: string, data: UserInput) { async updateUser(id: string, data: AdminUserInput) {
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
relations: ['roles'], relations: ['roles'],
where: { where: {

View File

@ -6,7 +6,11 @@ import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { MinioModule } from 'nestjs-minio-client'; import { MinioModule } from 'nestjs-minio-client';
import { MinioClientModule } from './minio/minio.module'; import { MinioClientModule } from './minio/minio.module';
import { ApiExceptionFilter, SaveBodyInterceptor } from './common'; import {
ApiExceptionFilter,
CommonModule,
SaveBodyInterceptor,
} from './common';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { WordpressModule } from './wordpress/wordpress.module'; import { WordpressModule } from './wordpress/wordpress.module';
import { QqbotModule } from './qqbot/qqbot.module'; import { QqbotModule } from './qqbot/qqbot.module';
@ -49,6 +53,7 @@ import { QqbotModule } from './qqbot/qqbot.module';
inject: [ConfigService], inject: [ConfigService],
}), }),
MinioClientModule, MinioClientModule,
CommonModule,
AdminModule, AdminModule,
WordpressModule, WordpressModule,
QqbotModule, QqbotModule,

View File

@ -1,42 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type VbenResponse<T = any> = {
code: 200;
data: T;
msg: string;
};
export type ApiErrorResponse = {
code: number;
msg: string;
err: any;
};
export const vbenSuccess = <T = any>(
data: T,
msg = '操作成功',
): VbenResponse<T> => ({
code: 200,
data,
msg,
});
export const vbenPage = <T = any>(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,
);
};

View File

@ -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 {}

View File

@ -1,13 +1,8 @@
type DecodeDictKeyOptions = { import type {
fallback?: string; DecodeDictKeyOptions,
sourceKey?: string; DictDecodeRule,
targetKey?: string; KtDictOption,
}; } from '../types';
type DictDecodeRule = DecodeDictKeyOptions & {
targetKey: string;
dictKeys: string[];
};
const DICT_DECODE_RULES = Symbol('DICT_DECODE_RULES'); const DICT_DECODE_RULES = Symbol('DICT_DECODE_RULES');
const DICT_DECODE_CACHE = new Map<string, Map<string, string>>(); const DICT_DECODE_CACHE = new Map<string, Map<string, string>>();
@ -78,7 +73,7 @@ export function decodeDictKeys<T extends object>(target: T): T {
// DictService 从数据库刷新缓存后,实体 AfterLoad 可以同步完成字典映射。 // DictService 从数据库刷新缓存后,实体 AfterLoad 可以同步完成字典映射。
export function setDictDecodeCache( export function setDictDecodeCache(
dicts: Array<Dict<{ dictKey: string }>>, dicts: Array<KtDictOption<{ dictKey: string }>>,
): void { ): void {
DICT_DECODE_CACHE.clear(); DICT_DECODE_CACHE.clear();

View File

@ -6,14 +6,8 @@ import {
HttpStatus, HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { ApiErrorResponse } from '../admin-response'; import { normalizeVbenErrorText } from '../response/vben-response';
import type { ExceptionBody, KtErrorResponse } from '../types';
type ExceptionBody = {
err?: unknown;
error?: unknown;
message?: unknown;
msg?: unknown;
};
@Catch() @Catch()
export class ApiExceptionFilter implements ExceptionFilter { export class ApiExceptionFilter implements ExceptionFilter {
@ -22,12 +16,13 @@ export class ApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const status = this.getStatus(exception); const status = this.getStatus(exception);
const body = this.getBody(exception); const body = this.getBody(exception);
const msg = this.getMessage(status, body, exception);
response.status(status).json({ response.status(status).json({
code: status, code: status,
msg: this.getMessage(status, body, exception), msg,
err: this.getErr(status, body, exception), err: this.getErr(status, body, exception, msg),
} satisfies ApiErrorResponse); } satisfies KtErrorResponse);
} }
private getStatus(exception: unknown) { private getStatus(exception: unknown) {
@ -65,17 +60,22 @@ export class ApiExceptionFilter implements ExceptionFilter {
status: number, status: number,
body: ExceptionBody | string | null, body: ExceptionBody | string | null,
exception: unknown, exception: unknown,
fallback: string,
) { ) {
if (typeof body === 'string') return body; if (typeof body === 'string') return normalizeVbenErrorText(body, fallback);
if (body?.err !== undefined) return body.err; if (body?.err !== undefined)
if (body?.error !== undefined) return body.error; return normalizeVbenErrorText(body.err, fallback);
if (body?.message !== undefined) return body.message; if (body?.error !== undefined)
if (exception instanceof Error) return exception.message; 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' : '操作失败'; return status >= 500 ? 'Internal server error' : '操作失败';
} }
private stringifyMessage(message: unknown) { private stringifyMessage(message: unknown) {
return Array.isArray(message) ? message.join('; ') : String(message); return normalizeVbenErrorText(message);
} }
} }

View File

@ -1,10 +1,13 @@
export * from './admin-response'; export * from './common.module';
export * from './admin-tree';
export * from './decorators/current-admin-user.decorator'; export * from './decorators/current-admin-user.decorator';
export * from './decorators/decode-dict.decorator'; export * from './decorators/decode-dict.decorator';
export * from './decorators/public.decorator'; export * from './decorators/public.decorator';
export * from './filters/api-exception.filter'; export * from './filters/api-exception.filter';
export * from './interceptors/save-body.interceptor'; export * from './interceptors/save-body.interceptor';
export * from './snowflake-id'; export * from './response/vben-response';
export * from './services/tool.service'; export * from './services/tool.service';
export * from './snowflake/snowflake-id';
export * from './snowflake/snowflake-id.subscriber';
export * from './swagger/swagger-response'; export * from './swagger/swagger-response';
export * from './tree/tree.util';
export * from './types';

View File

@ -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<string, unknown>;
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 = <T = any>(
data: T,
msg = '操作成功',
): KtSuccessResponse<T> => ({
code: 200,
data,
msg,
});
export const vbenPage = <T = any>(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,
);
};

View File

@ -1,5 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha'; import * as svgCaptcha from 'svg-captcha';
import { normalizeVbenErrorText } from '../response/vben-response';
import type {
KtDictOption,
KtPage,
KtResponse,
NapcatLoginStatusLike,
QrcodeLookupOptions,
} from '../types';
@Injectable() @Injectable()
export class ToolsService { export class ToolsService {
@ -14,7 +22,7 @@ export class ToolsService {
return captcha; return captcha;
} }
res(code: number, msg: string, data: any): Res { res(code: number, msg: string, data: any): KtResponse {
if (code === 200) { if (code === 200) {
return { return {
code, code,
@ -26,11 +34,11 @@ export class ToolsService {
return { return {
code, code,
msg, msg,
err: data, err: normalizeVbenErrorText(data, msg),
}; };
} }
page<T = any>(list: T[], total: number): Page<T> { page<T = any>(list: T[], total: number): KtPage<T> {
const retn = { const retn = {
list, list,
total, total,
@ -86,7 +94,7 @@ export class ToolsService {
label: string, label: string,
value: any, value: any,
other: Partial<T>, other: Partial<T>,
): Dict<T> { ): KtDictOption<T> {
const options = { const options = {
label, label,
value, value,
@ -95,4 +103,212 @@ export class ToolsService {
return options; 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<void>((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<T extends Record<string, unknown>>(payload: T) {
return Object.entries(payload).reduce<Partial<T>>((acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key as keyof T] = value as T[keyof T];
}
return acc;
}, {});
}
readHeader(
request: { headers?: Record<string, any> } | undefined,
name: string,
) {
const value = request?.headers?.[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
}
readCookie(
request: { headers?: Record<string, any> } | undefined,
cookieName: string,
) {
const cookieHeader = request?.headers?.cookie || '';
const cookie = `${cookieHeader}`.split(';').find((item) => {
const [key] = item.trim().split('=');
return key === cookieName;
});
if (!cookie) return undefined;
const [, ...value] = cookie.trim().split('=');
const joined = value.join('=');
try {
return decodeURIComponent(joined);
} catch {
return joined;
}
}
readBearerToken(authHeader?: string) {
if (!authHeader?.startsWith('Bearer ')) return null;
return authHeader.split(' ')[1] || null;
}
pickQrcode(data?: Record<string, any> | 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<string, any>) {
return this.pickFirstText(info.uin, info.self_id, info.selfId);
}
pickNapcatNickname(info: Record<string, any>) {
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('失效'))
);
}
} }

View File

@ -1,3 +1,5 @@
import type { SnowflakeEntity } from '../types';
const TWEPOCH = 1288834974657n; const TWEPOCH = 1288834974657n;
const WORKER_ID_BITS = 5n; const WORKER_ID_BITS = 5n;
const DATACENTER_ID_BITS = 5n; const DATACENTER_ID_BITS = 5n;
@ -76,10 +78,6 @@ const snowflakeIdGenerator = new SnowflakeIdGenerator();
export const createSnowflakeId = () => snowflakeIdGenerator.nextId(); export const createSnowflakeId = () => snowflakeIdGenerator.nextId();
export type SnowflakeEntity = {
id?: number | string | null;
};
export const isEmptySnowflakeId = (id: SnowflakeEntity['id']) => export const isEmptySnowflakeId = (id: SnowflakeEntity['id']) =>
id === undefined || id === null || id === '' || id === 0 || id === '0'; id === undefined || id === null || id === '' || id === 0 || id === '0';

View File

@ -1,16 +1,12 @@
import { applyDecorators, Type } from '@nestjs/common'; import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiProperty } from '@nestjs/swagger'; import { ApiExtraModels, ApiOkResponse, ApiProperty } from '@nestjs/swagger';
import type { OpenAPIObject } from '@nestjs/swagger'; import type { OpenAPIObject } from '@nestjs/swagger';
import type {
type SwaggerSchema = Record<string, any>; ApiResponseOptions,
type SwaggerOperation = Record<string, any>; SwaggerComponents,
type SwaggerComponents = NonNullable<OpenAPIObject['components']>; SwaggerOperation,
SwaggerSchema,
type ApiResponseOptions = { } from '../types';
description?: string;
schema?: SwaggerSchema;
example: any;
};
const primitiveTypeMap = { const primitiveTypeMap = {
string: String, string: String,
@ -208,6 +204,7 @@ const standardErrorSchema = {
example: '操作失败', example: '操作失败',
}, },
err: { err: {
type: 'string',
description: '错误详情', description: '错误详情',
example: 'Bad Request', example: 'Bad Request',
}, },

View File

@ -0,0 +1,6 @@
export type ExceptionBody = {
err?: unknown;
error?: unknown;
message?: unknown;
msg?: unknown;
};

View File

@ -0,0 +1,35 @@
export type KtSuccessResponse<T = any> = {
code: 200;
data: T;
msg: string;
};
export type KtErrorResponse = {
code: number;
err: string;
msg: string;
};
export type KtResponse<T = any> = KtErrorResponse | KtSuccessResponse<T>;
export type KtPage<T = any> = {
list: T[];
total: number;
};
export type KtPageQuery = {
pageNo?: number | string;
pageSize?: number | string;
};
export type KtPageParams<T = Record<string, any>> = {
pageNo: number;
pageSize: number;
} & Partial<T>;
export type KtDictOption<T = object, V = any> = {
label: string;
value: V;
} & Partial<T>;
export type KtMaybeArray<T> = T | readonly T[];

View File

@ -0,0 +1,10 @@
export type DecodeDictKeyOptions = {
fallback?: string;
sourceKey?: string;
targetKey?: string;
};
export type DictDecodeRule = DecodeDictKeyOptions & {
targetKey: string;
dictKeys: string[];
};

View File

@ -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';

View File

@ -0,0 +1,3 @@
export type SnowflakeEntity = {
id?: number | string | null;
};

View File

@ -0,0 +1,21 @@
import type { OpenAPIObject } from '@nestjs/swagger';
export type ApiResponseOptions = {
description?: string;
schema?: SwaggerSchema;
example: any;
};
export type SwaggerComponents = NonNullable<OpenAPIObject['components']>;
export type SwaggerDocumentGroup = {
matcher: SwaggerPathMatcher;
name: string;
path: string;
};
export type SwaggerOperation = Record<string, any>;
export type SwaggerPathMatcher = (path: string) => boolean;
export type SwaggerSchema = Record<string, any>;

View File

@ -0,0 +1,9 @@
export type NapcatLoginStatusLike = {
isOffline?: boolean;
loginError?: string;
};
export type QrcodeLookupOptions = {
requireFresh?: boolean;
staleQrcode?: string;
};

View File

@ -5,16 +5,9 @@ import type { OpenAPIObject } from '@nestjs/swagger';
import { urlencoded, json } from 'express'; import { urlencoded, json } from 'express';
import { knife4jSetup } from '@kwitsukasa/knife4j-swagger-vue3'; import { knife4jSetup } from '@kwitsukasa/knife4j-swagger-vue3';
import type { Service } from '@kwitsukasa/knife4j-swagger-vue3'; import type { Service } from '@kwitsukasa/knife4j-swagger-vue3';
import type { SwaggerDocumentGroup, SwaggerPathMatcher } from './common';
import { applySwaggerResponseExamples } from './common'; import { applySwaggerResponseExamples } from './common';
type SwaggerPathMatcher = (path: string) => boolean;
interface SwaggerDocumentGroup {
matcher: SwaggerPathMatcher;
name: string;
path: string;
}
const adminSwaggerPathPrefixes = [ const adminSwaggerPathPrefixes = [
'/auth', '/auth',
'/component', '/component',

View File

@ -22,7 +22,7 @@ import {
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { MinioClientService } from './minio.service'; import { MinioClientService } from './minio.service';
import type { MinioUploadFile } from './minio.service'; import type { MinioUploadFile } from './minio.types';
import { import {
ApiFileDownloadResponse, ApiFileDownloadResponse,
ApiArrayResponse, ApiArrayResponse,

View File

@ -3,12 +3,11 @@ import { ConfigModule } from '@nestjs/config';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module'; import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { MinioClientController } from './minio.controller'; import { MinioClientController } from './minio.controller';
import { MinioClientService } from './minio.service'; import { MinioClientService } from './minio.service';
import { ToolsService } from '@/common';
@Module({ @Module({
imports: [AdminAuthGuardModule, ConfigModule], imports: [AdminAuthGuardModule, ConfigModule],
controllers: [MinioClientController], controllers: [MinioClientController],
providers: [MinioClientService, ToolsService], providers: [MinioClientService],
exports: [MinioClientService], exports: [MinioClientService],
}) })
export class MinioClientModule {} export class MinioClientModule {}

View File

@ -1,39 +1,11 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MinioService } from 'nestjs-minio-client'; import { MinioService } from 'nestjs-minio-client';
import type { Readable } from 'stream'; import type {
MinioListObjectOptions,
export type MinioUploadFile = { MinioObjectResult,
originalname: string; MinioUploadObjectOptions,
mimetype: string; } from './minio.types';
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<string, any>;
versionId?: string | null;
};
bucketName: string;
objectName: string;
};
@Injectable() @Injectable()
export class MinioClientService { export class MinioClientService {
@ -75,7 +47,11 @@ export class MinioClientService {
return targetBucket; return targetBucket;
} }
async uploadObject({ bucketName, objectName, file }: UploadObjectOptions) { async uploadObject({
bucketName,
objectName,
file,
}: MinioUploadObjectOptions) {
if (!file) { if (!file) {
throw new BadRequestException('请选择要上传的文件'); throw new BadRequestException('请选择要上传的文件');
} }
@ -108,7 +84,7 @@ export class MinioClientService {
bucketName, bucketName,
prefix = '', prefix = '',
recursive = true, recursive = true,
}: ListObjectOptions) { }: MinioListObjectOptions) {
const targetBucket = this.getBucketName(bucketName); const targetBucket = this.getBucketName(bucketName);
const exists = await this.client.bucketExists(targetBucket); const exists = await this.client.bucketExists(targetBucket);

33
src/minio/minio.types.ts Normal file
View File

@ -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<string, any>;
size: number;
versionId?: string | null;
};
stream: Readable;
};

View File

@ -8,8 +8,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { ensureSnowflakeId } from '@/common'; import { ensureSnowflakeId } from '@/common';
import type { QqbotAccountAbilityType } from '../qqbot.types';
export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule';
@Entity('qqbot_account_ability') @Entity('qqbot_account_ability')
@Index('uk_qqbot_account_ability', ['accountId', 'abilityType', 'abilityKey'], { @Index('uk_qqbot_account_ability', ['accountId', 'abilityType', 'abilityKey'], {

View File

@ -1,11 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import { import { QqbotAccountAbility } from './qqbot-account-ability.entity';
QqbotAccountAbility,
type QqbotAccountAbilityType,
} from './qqbot-account-ability.entity';
import { QqbotAccount } from './qqbot-account.entity'; import { QqbotAccount } from './qqbot-account.entity';
import type { import type {
QqbotAccountBodyDto, QqbotAccountBodyDto,
@ -15,28 +12,15 @@ import type {
import { QqbotAccountNapcat } from '../napcat/qqbot-account-napcat.entity'; import { QqbotAccountNapcat } from '../napcat/qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from '../napcat/qqbot-napcat-container.entity'; import { QqbotNapcatContainer } from '../napcat/qqbot-napcat-container.entity';
import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service'; import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service';
import {
QQBOT_DEFAULT_PAGE_NO,
QQBOT_DEFAULT_PAGE_SIZE,
} from '../qqbot.constants';
import type { import type {
QqbotAccountNapcatBindStatus, QqbotAccountAbilityType,
QqbotAccountListItem,
QqbotConnectionRole, QqbotConnectionRole,
QqbotNapcatContainerStatus,
} from '../qqbot.types'; } 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() @Injectable()
export class QqbotAccountService { export class QqbotAccountService {
@ -50,10 +34,15 @@ export class QqbotAccountService {
@InjectRepository(QqbotNapcatContainer) @InjectRepository(QqbotNapcatContainer)
private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>, private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>,
private readonly napcatContainerService: QqbotNapcatContainerService, private readonly napcatContainerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
) {} ) {}
async page(query: QqbotAccountQueryDto) { 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 const builder = this.accountRepository
.createQueryBuilder('account') .createQueryBuilder('account')
.where('account.isDeleted = :isDeleted', { isDeleted: false }); .where('account.isDeleted = :isDeleted', { isDeleted: false });
@ -451,7 +440,7 @@ export class QqbotAccountService {
private normalizeBody(body: Partial<QqbotAccountBodyDto>) { private normalizeBody(body: Partial<QqbotAccountBodyDto>) {
return { return {
accessToken: normalizeNullableString(body.accessToken), accessToken: this.toolsService.normalizeNullableString(body.accessToken),
connectionMode: body.connectionMode || 'reverse-ws', connectionMode: body.connectionMode || 'reverse-ws',
enabled: body.enabled ?? true, enabled: body.enabled ?? true,
name: body.name || '', name: body.name || '',

View File

@ -3,83 +3,25 @@ import * as https from 'https';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import type { QqbotLoginScanMode, QqbotLoginScanStatus } from '../qqbot.types'; import type {
import { NapcatApiResponse,
QqbotNapcatContainerService, NapcatCredential,
type QqbotNapcatRuntime, NapcatLoginInfo,
} from '../napcat/qqbot-napcat-container.service'; 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'; import { QqbotAccountService } from './qqbot-account.service';
type NapcatApiResponse<T> = {
code: number;
data?: T;
message?: string;
};
type NapcatCredential = {
Credential?: string;
};
type NapcatLoginInfo = Record<string, any> & {
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() @Injectable()
export class QqbotNapcatLoginService { export class QqbotNapcatLoginService {
private readonly sessions = new Map<string, QqbotLoginScanSession>(); private readonly sessions = new Map<string, QqbotLoginScanSession>();
@ -92,6 +34,7 @@ export class QqbotNapcatLoginService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly containerService: QqbotNapcatContainerService, private readonly containerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
) {} ) {}
async startCreate() { async startCreate() {
@ -125,8 +68,33 @@ export class QqbotNapcatLoginService {
} }
const container = await this.getSessionContainer(session); const container = await this.getSessionContainer(session);
const loginStatus = await this.getLoginStatus(container); let loginStatus: NapcatLoginStatus;
session.qrcode = await this.refreshOrGetQrcode(container, true, { 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, fallbackStatus: loginStatus,
requireFresh: true, requireFresh: true,
staleQrcode: session.qrcode || loginStatus.qrcodeurl, staleQrcode: session.qrcode || loginStatus.qrcodeurl,
@ -135,6 +103,14 @@ export class QqbotNapcatLoginService {
session.errorMessage = undefined; session.errorMessage = undefined;
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
return this.toResult(session); return this.toResult(session);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
return this.keepSessionPending(
session,
'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新',
true,
);
}
} }
async status(sessionId: string) { async status(sessionId: string) {
@ -144,11 +120,27 @@ export class QqbotNapcatLoginService {
} }
const container = await this.getSessionContainer(session); 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) { if (!status.isLogin) {
session.errorMessage = status.loginError || undefined; session.errorMessage = status.loginError || undefined;
if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) { if (
status.qrcodeurl &&
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
) {
session.qrcode = status.qrcodeurl; 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); this.sessions.set(session.id, session);
return this.toResult(session); return this.toResult(session);
@ -178,6 +170,21 @@ export class QqbotNapcatLoginService {
try { try {
const loginStatus = await this.getLoginStatus(container, true); 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) { if (loginStatus.isLogin) {
const session = this.createSession({ const session = this.createSession({
...options, ...options,
@ -190,7 +197,8 @@ export class QqbotNapcatLoginService {
const qrcode = await this.refreshOrGetQrcode(container, true, { const qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus, fallbackStatus: loginStatus,
requireFresh: this.isExpiredQrcodeStatus(loginStatus), requireFresh:
this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus),
staleQrcode: loginStatus.qrcodeurl, staleQrcode: loginStatus.qrcodeurl,
}); });
const session = this.createSession({ const session = this.createSession({
@ -205,7 +213,9 @@ export class QqbotNapcatLoginService {
const cleanupError = await this.cleanupRuntimeContainer(container); const cleanupError = await this.cleanupRuntimeContainer(container);
if (cleanupError) { if (cleanupError) {
throwVbenError( throwVbenError(
`${this.getErrorMessage(err)};清理未绑定容器失败:${cleanupError}`, `${this.toolsService.getErrorMessage(
err,
)}${cleanupError}`,
); );
} }
throw err; throw err;
@ -217,7 +227,11 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
): Promise<QqbotLoginScanResult> { ): Promise<QqbotLoginScanResult> {
const loginInfo = await this.getLoginInfo(container); 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) { if (!selfId) {
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
} }
@ -230,7 +244,7 @@ export class QqbotNapcatLoginService {
const accountId = await this.accountService.ensureScannedAccount({ const accountId = await this.accountService.ensureScannedAccount({
accountId: session.accountId, accountId: session.accountId,
name: this.getNickname(loginInfo), name: this.toolsService.pickNapcatNickname(loginInfo),
selfId, selfId,
}); });
await this.containerService.bindAccount(accountId, session.containerId); 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) { private getSession(sessionId: string) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { 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) { private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) {
try { try {
await this.containerService.removeUnboundContainer(container.id); await this.containerService.removeUnboundContainer(container.id);
return null; return null;
} catch (err) { } catch (err) {
return this.getErrorMessage(err); return this.toolsService.getErrorMessage(err);
} }
} }
private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) { private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) {
if (!retry) { if (!retry) {
return this.postNapcat<NapcatLoginStatus>( const status = await this.postNapcat<NapcatLoginStatus>(
container, container,
'/api/QQLogin/CheckLoginStatus', '/api/QQLogin/CheckLoginStatus',
); );
return this.normalizeLoginStatus(container, status);
} }
let lastError: unknown; let lastError: unknown;
@ -366,19 +415,61 @@ export class QqbotNapcatLoginService {
); );
for (let index = 0; index < attempts; index += 1) { for (let index = 0; index < attempts; index += 1) {
try { try {
return await this.postNapcat<NapcatLoginStatus>( const status = await this.postNapcat<NapcatLoginStatus>(
container, container,
'/api/QQLogin/CheckLoginStatus', '/api/QQLogin/CheckLoginStatus',
); );
return await this.normalizeLoginStatus(container, status);
} catch (err) { } catch (err) {
lastError = err; lastError = err;
if (!this.isTemporaryNapcatError(err)) break; if (!this.toolsService.isNapcatTemporaryError(err)) break;
await this.sleep(1500); await this.toolsService.sleep(1500);
} }
} }
throw lastError; 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) { private async getLoginInfo(container: QqbotNapcatRuntime) {
return this.postNapcat<NapcatLoginInfo>( return this.postNapcat<NapcatLoginInfo>(
container, container,
@ -396,9 +487,9 @@ export class QqbotNapcatLoginService {
container, container,
'/api/QQLogin/RefreshQRcode', '/api/QQLogin/RefreshQRcode',
); );
return this.pickQrcode(data); return this.toolsService.pickQrcode(data);
} catch (err) { } catch (err) {
if (this.isAlreadyLoggedIn(err)) return ''; if (this.toolsService.isNapcatAlreadyLoggedInError(err)) return '';
throw err; throw err;
} }
}); });
@ -415,17 +506,20 @@ export class QqbotNapcatLoginService {
container, container,
'/api/QQLogin/GetQQLoginQrcode', '/api/QQLogin/GetQQLoginQrcode',
); );
const qrcode = this.pickQrcode(data); const qrcode = this.toolsService.pickQrcode(data);
if (!qrcode) { if (!qrcode) {
return this.getQrcodeFromStatus(container, options); return this.getQrcodeFromStatus(container, options);
} }
return this.ensureFreshQrcode(qrcode, options); return this.toolsService.ensureFreshQrcode(qrcode, options);
} catch (err) { } catch (err) {
if (this.isAlreadyLoggedIn(err)) { if (this.toolsService.isNapcatAlreadyLoggedInError(err)) {
const status = await this.getLoginStatus(container); 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); return this.getQrcodeFromStatus(container, options);
} }
throw err; throw err;
@ -450,14 +544,17 @@ export class QqbotNapcatLoginService {
try { try {
const refreshedQrcode = await this.callRefreshQrcode(container, retry); const refreshedQrcode = await this.callRefreshQrcode(container, retry);
if (refreshedQrcode) { if (refreshedQrcode) {
return this.ensureFreshQrcode(refreshedQrcode, lookupOptions); return this.toolsService.ensureFreshQrcode(
refreshedQrcode,
lookupOptions,
);
} }
return await this.getQrcode(container, retry, lookupOptions); return await this.getQrcode(container, retry, lookupOptions);
} catch (err) { } catch (err) {
if ( if (
!lookupOptions.requireFresh && !lookupOptions.requireFresh &&
fallbackStatus?.qrcodeurl && fallbackStatus?.qrcodeurl &&
!this.isExpiredQrcodeStatus(fallbackStatus) !this.toolsService.isNapcatExpiredQrcodeStatus(fallbackStatus)
) { ) {
return fallbackStatus.qrcodeurl; return fallbackStatus.qrcodeurl;
} }
@ -470,8 +567,11 @@ export class QqbotNapcatLoginService {
options: QrcodeLookupOptions = {}, options: QrcodeLookupOptions = {},
) { ) {
const status = await this.getLoginStatus(container); const status = await this.getLoginStatus(container);
if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) { if (
return this.ensureFreshQrcode(status.qrcodeurl, options); status.qrcodeurl &&
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
) {
return this.toolsService.ensureFreshQrcode(status.qrcodeurl, options);
} }
if (options.requireFresh && status.qrcodeurl) { if (options.requireFresh && status.qrcodeurl) {
throw new Error('NapCat 二维码仍未刷新'); throw new Error('NapCat 二维码仍未刷新');
@ -479,30 +579,6 @@ export class QqbotNapcatLoginService {
throwVbenError('NapCat 未返回登录二维码'); 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<T>( private async postNapcat<T>(
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
path: string, path: string,
@ -512,7 +588,10 @@ export class QqbotNapcatLoginService {
return this.requestNapcat<T>(container, path, body, credential); return this.requestNapcat<T>(container, path, body, credential);
} }
private async restartNapcatForLogin(container: QqbotNapcatRuntime) { private async restartNapcatForLogin(
container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {},
) {
const restartedByContainer = const restartedByContainer =
await this.containerService.restartRuntimeContainer(container); await this.containerService.restartRuntimeContainer(container);
if (!restartedByContainer) { if (!restartedByContainer) {
@ -522,12 +601,14 @@ export class QqbotNapcatLoginService {
'/api/QQLogin/RestartNapCat', '/api/QQLogin/RestartNapCat',
); );
} catch (err) { } catch (err) {
if (!this.isTemporaryNapcatError(err)) throw err; if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
} }
} }
this.credentials.delete(this.getCredentialCacheKey(container)); 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); await this.getLoginStatus(container, true);
} }
@ -616,7 +697,7 @@ export class QqbotNapcatLoginService {
req.write(payload); req.write(payload);
req.end(); req.end();
}).catch((err): never => { }).catch((err): never => {
const message = this.getErrorMessage(err); const message = this.toolsService.getErrorMessage(err);
return throwVbenError(message || 'NapCat 请求失败'); 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<T>( private async executeNapcatRequest<T>(
retry: boolean, retry: boolean,
action: () => Promise<T>, action: () => Promise<T>,
@ -700,23 +742,10 @@ export class QqbotNapcatLoginService {
return await action(); return await action();
} catch (err) { } catch (err) {
lastError = err; lastError = err;
if (!this.isTemporaryNapcatError(err)) break; if (!this.toolsService.isNapcatTemporaryError(err)) break;
await this.sleep(1500); await this.toolsService.sleep(1500);
} }
} }
throw lastError; 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);
});
}
} }

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ToolsService } from '@/common';
import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service'; import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service';
import type { QqbotNormalizedMessage } from '../qqbot.types'; import type { QqbotNormalizedMessage } from '../qqbot.types';
import { QqbotSendService } from '../send/qqbot-send.service'; import { QqbotSendService } from '../send/qqbot-send.service';
@ -18,6 +19,7 @@ export class QqbotCommandEngineService {
private readonly pluginRegistry: QqbotPluginRegistryService, private readonly pluginRegistry: QqbotPluginRegistryService,
private readonly replyTemplate: QqbotReplyTemplateService, private readonly replyTemplate: QqbotReplyTemplateService,
private readonly sendService: QqbotSendService, private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService,
) {} ) {}
async handleMessage(message: QqbotNormalizedMessage) { async handleMessage(message: QqbotNormalizedMessage) {
@ -61,8 +63,10 @@ export class QqbotCommandEngineService {
status: 'success', status: 'success',
}); });
} catch (err) { } catch (err) {
const errorMessage = const errorMessage = this.toolsService.getErrorMessage(
err instanceof Error ? err.message : '命令执行失败'; err,
'命令执行失败',
);
await this.commandService.logExecution({ await this.commandService.logExecution({
command, command,
errorMessage, errorMessage,
@ -113,7 +117,10 @@ export class QqbotCommandEngineService {
status: 'success', status: 'success',
}; };
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : '命令执行失败'; const errorMessage = this.toolsService.getErrorMessage(
err,
'命令执行失败',
);
return { return {
command: this.commandService.toResponse(command), command: this.commandService.toResponse(command),
errorMessage, errorMessage,
@ -167,7 +174,10 @@ export class QqbotCommandEngineService {
targetType: message.messageType, targetType: message.messageType,
}); });
} catch (err) { } catch (err) {
const sendErr = err instanceof Error ? err.message : '错误回复发送失败'; const sendErr = this.toolsService.getErrorMessage(
err,
'错误回复发送失败',
);
this.logger.warn(`QQBot 命令错误回复发送失败: ${sendErr}`); this.logger.warn(`QQBot 命令错误回复发送失败: ${sendErr}`);
} }
} }

View File

@ -8,8 +8,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { ensureSnowflakeId } from '@/common'; import { ensureSnowflakeId } from '@/common';
import type { QqbotMessageType } from '../qqbot.types'; import type { QqbotMessageType } from '../qqbot.types';
import type { QqbotCommandLogStatus } from '../qqbot.types';
export type QqbotCommandLogStatus = 'failed' | 'success';
@Entity('qqbot_command_log') @Entity('qqbot_command_log')
export class QqbotCommandLog { export class QqbotCommandLog {
@ -52,7 +51,12 @@ export class QqbotCommandLog {
@Column({ default: 'success', length: 32 }) @Column({ default: 'success', length: 32 })
status: QqbotCommandLogStatus; 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; errorMessage: string | null;
@CreateDateColumn({ name: 'create_time' }) @CreateDateColumn({ name: 'create_time' })

View File

@ -6,23 +6,17 @@ import {
buildQqbotFf14MarketCatalog, buildQqbotFf14MarketCatalog,
buildQqbotFf14MarketCatalogFromTree, buildQqbotFf14MarketCatalogFromTree,
QQBOT_FF14_MARKET_DICT_CODES, QQBOT_FF14_MARKET_DICT_CODES,
type QqbotFf14MarketCatalog,
isQqbotFf14DataCenterName, isQqbotFf14DataCenterName,
isQqbotFf14LocationName, isQqbotFf14LocationName,
isQqbotFf14RegionName, isQqbotFf14RegionName,
isQqbotFf14WorldName, isQqbotFf14WorldName,
splitQqbotFf14WorldPath, splitQqbotFf14WorldPath,
} from '../plugins/ff14Market/qqbot-ff14-worlds'; } 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'; const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL';
export type QqbotCommandMatchResult = {
alias: string;
input: Record<string, any>;
matched: true;
rawArgs: string;
};
@Injectable() @Injectable()
export class QqbotCommandParserService { export class QqbotCommandParserService {
constructor(private readonly dictService: DictService) {} constructor(private readonly dictService: DictService) {}

View File

@ -10,7 +10,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { vbenSuccess } from '@/common'; import { ToolsService, vbenSuccess } from '@/common';
import { import {
QqbotCommandBodyDto, QqbotCommandBodyDto,
QqbotCommandQueryDto, QqbotCommandQueryDto,
@ -19,7 +19,6 @@ import {
} from './qqbot-command.dto'; } from './qqbot-command.dto';
import { QqbotCommandEngineService } from './qqbot-command-engine.service'; import { QqbotCommandEngineService } from './qqbot-command-engine.service';
import { QqbotCommandService } from './qqbot-command.service'; import { QqbotCommandService } from './qqbot-command.service';
import { normalizeBoolean } from '../qqbot.utils';
@ApiTags('QQBot - 在线命令') @ApiTags('QQBot - 在线命令')
@Controller('qqbot/command') @Controller('qqbot/command')
@ -28,6 +27,7 @@ export class QqbotCommandController {
constructor( constructor(
private readonly commandEngine: QqbotCommandEngineService, private readonly commandEngine: QqbotCommandEngineService,
private readonly commandService: QqbotCommandService, private readonly commandService: QqbotCommandService,
private readonly toolsService: ToolsService,
) {} ) {}
@Get('list') @Get('list')
@ -65,7 +65,10 @@ export class QqbotCommandController {
@ApiQuery({ name: 'enabled', type: Boolean }) @ApiQuery({ name: 'enabled', type: Boolean })
async toggle(@Query('id') id: string, @Query('enabled') enabled: string) { async toggle(@Query('id') id: string, @Query('enabled') enabled: string) {
return vbenSuccess( return vbenSuccess(
await this.commandService.toggle(id, normalizeBoolean(enabled)), await this.commandService.toggle(
id,
this.toolsService.normalizeBoolean(enabled),
),
); );
} }

View File

@ -1,13 +1,13 @@
import { PartialType } from '@nestjs/swagger'; import { PartialType } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { KtPageQuery } from '@/common';
import type { import type {
QqbotCommandParserType, QqbotCommandParserType,
QqbotMessageType, QqbotMessageType,
QqbotRuleTargetType, QqbotRuleTargetType,
} from '../qqbot.types'; } from '../qqbot.types';
import type { QqbotPageQuery } from '../qqbot.utils';
export class QqbotCommandQueryDto implements QqbotPageQuery { export class QqbotCommandQueryDto implements KtPageQuery {
@ApiPropertyOptional() @ApiPropertyOptional()
pageNo?: number | string; pageNo?: number | string;

View File

@ -1,15 +1,18 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm'; import { Not, Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotAccountService } from '../account/qqbot-account.service';
import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service'; import { QqbotPluginRegistryService } from '../plugin/qqbot-plugin-registry.service';
import {
QQBOT_DEFAULT_PAGE_NO,
QQBOT_DEFAULT_PAGE_SIZE,
} from '../qqbot.constants';
import type { import type {
QqbotCommandParserType, QqbotCommandParserType,
QqbotNormalizedMessage, QqbotNormalizedMessage,
QqbotRuleTargetType, QqbotRuleTargetType,
} from '../qqbot.types'; } from '../qqbot.types';
import { getPageParams, normalizeBoolean } from '../qqbot.utils';
import type { import type {
QqbotCommandBodyDto, QqbotCommandBodyDto,
QqbotCommandQueryDto, QqbotCommandQueryDto,
@ -27,10 +30,15 @@ export class QqbotCommandService {
private readonly commandLogRepository: Repository<QqbotCommandLog>, private readonly commandLogRepository: Repository<QqbotCommandLog>,
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly pluginRegistry: QqbotPluginRegistryService, private readonly pluginRegistry: QqbotPluginRegistryService,
private readonly toolsService: ToolsService,
) {} ) {}
async page(query: QqbotCommandQueryDto) { 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 const builder = this.commandRepository
.createQueryBuilder('command') .createQueryBuilder('command')
.where('command.isDeleted = :isDeleted', { isDeleted: false }); .where('command.isDeleted = :isDeleted', { isDeleted: false });
@ -67,7 +75,7 @@ export class QqbotCommandService {
} }
if (query.enabled !== undefined && `${query.enabled}` !== '') { if (query.enabled !== undefined && `${query.enabled}` !== '') {
builder.andWhere('command.enabled = :enabled', { builder.andWhere('command.enabled = :enabled', {
enabled: normalizeBoolean(query.enabled), enabled: this.toolsService.normalizeBoolean(query.enabled),
}); });
} }

View File

@ -2,17 +2,13 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { QqbotConfig } from './qqbot-config.entity'; import { QqbotConfig } from './qqbot-config.entity';
import type { QqbotPermissionConfig } from '../qqbot.types';
const QQBOT_PERMISSION_CONFIG_KEYS = { const QQBOT_PERMISSION_CONFIG_KEYS = {
allowlistEnabled: 'permission.allowlistEnabled', allowlistEnabled: 'permission.allowlistEnabled',
blocklistEnabled: 'permission.blocklistEnabled', blocklistEnabled: 'permission.blocklistEnabled',
} as const; } as const;
export type QqbotPermissionConfig = {
allowlistEnabled: boolean;
blocklistEnabled: boolean;
};
@Injectable() @Injectable()
export class QqbotConfigService { export class QqbotConfigService {
constructor( constructor(
@ -22,8 +18,14 @@ export class QqbotConfigService {
async getPermissionConfig(): Promise<QqbotPermissionConfig> { async getPermissionConfig(): Promise<QqbotPermissionConfig> {
const [allowlistEnabled, blocklistEnabled] = await Promise.all([ const [allowlistEnabled, blocklistEnabled] = await Promise.all([
this.getBooleanConfig(QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled, false), this.getBooleanConfig(
this.getBooleanConfig(QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled, true), QQBOT_PERMISSION_CONFIG_KEYS.allowlistEnabled,
false,
),
this.getBooleanConfig(
QQBOT_PERMISSION_CONFIG_KEYS.blocklistEnabled,
true,
),
]); ]);
return { allowlistEnabled, blocklistEnabled }; return { allowlistEnabled, blocklistEnabled };
@ -65,11 +67,7 @@ export class QqbotConfigService {
return record.configValue === 'true'; return record.configValue === 'true';
} }
async setBooleanConfig( async setBooleanConfig(configKey: string, value: boolean, remark: string) {
configKey: string,
value: boolean,
remark: string,
) {
const exists = await this.configRepository.findOne({ const exists = await this.configRepository.findOne({
where: { configKey }, where: { configKey },
}); });

View File

@ -9,29 +9,25 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpAdapterHost, ModuleRef } from '@nestjs/core'; import { HttpAdapterHost, ModuleRef } from '@nestjs/core';
import WebSocket = require('ws'); import WebSocket = require('ws');
import { ToolsService } from '@/common';
import { QQBOT_MQTT_TOPICS, QQBOT_REVERSE_WS_PATH } from '../qqbot.constants'; import { QQBOT_MQTT_TOPICS, QQBOT_REVERSE_WS_PATH } from '../qqbot.constants';
import type { import type {
QqbotConnectionRole, QqbotConnectionRole,
QqbotOneBotActionResponse, QqbotOneBotActionResponse,
QqbotOneBotEvent, QqbotOneBotEvent,
QqbotPendingAction,
} from '../qqbot.types'; } from '../qqbot.types';
import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotAccountService } from '../account/qqbot-account.service';
import { QqbotEventService } from '../event/qqbot-event.service'; import { QqbotEventService } from '../event/qqbot-event.service';
import { QqbotBusService } from '../mqtt/qqbot-bus.service'; import { QqbotBusService } from '../mqtt/qqbot-bus.service';
type PendingAction = {
reject: (reason: Error) => void;
resolve: (value: QqbotOneBotActionResponse) => void;
timer: NodeJS.Timeout;
};
@Injectable() @Injectable()
export class QqbotReverseWsService export class QqbotReverseWsService
implements OnApplicationBootstrap, OnModuleDestroy implements OnApplicationBootstrap, OnModuleDestroy
{ {
private readonly logger = new Logger(QqbotReverseWsService.name); private readonly logger = new Logger(QqbotReverseWsService.name);
private readonly connections = new Map<string, WebSocket>(); private readonly connections = new Map<string, WebSocket>();
private readonly pendingActions = new Map<string, PendingAction>(); private readonly pendingActions = new Map<string, QqbotPendingAction>();
private server: WebSocket.Server | null = null; private server: WebSocket.Server | null = null;
constructor( constructor(
@ -40,6 +36,7 @@ export class QqbotReverseWsService
private readonly moduleRef: ModuleRef, private readonly moduleRef: ModuleRef,
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly busService: QqbotBusService, private readonly busService: QqbotBusService,
private readonly toolsService: ToolsService,
) {} ) {}
onApplicationBootstrap() { onApplicationBootstrap() {
@ -178,7 +175,7 @@ export class QqbotReverseWsService
try { try {
await this.handleMessage(selfId, raw); await this.handleMessage(selfId, raw);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : `${err}`; const message = this.toolsService.getErrorMessage(err);
this.logger.warn(`QQBot 处理 WS 消息失败 ${selfId}: ${message}`); this.logger.warn(`QQBot 处理 WS 消息失败 ${selfId}: ${message}`);
} }
} }

View File

@ -1,9 +1,9 @@
import type { ToolsService } from '@/common';
import type { import type {
QqbotMessageType, QqbotMessageType,
QqbotNormalizedMessage, QqbotNormalizedMessage,
QqbotOneBotEvent, QqbotOneBotEvent,
} from '../qqbot.types'; } from '../qqbot.types';
import { toStringId } from '../qqbot.utils';
export function isOneBotMessageEvent( export function isOneBotMessageEvent(
payload: QqbotOneBotEvent, payload: QqbotOneBotEvent,
@ -16,12 +16,15 @@ export function isOneBotMessageEvent(
export function normalizeOneBotMessage( export function normalizeOneBotMessage(
payload: QqbotOneBotEvent, payload: QqbotOneBotEvent,
toolsService: ToolsService,
): QqbotNormalizedMessage { ): QqbotNormalizedMessage {
const messageType = normalizeMessageType(payload.message_type) || 'private'; const messageType = normalizeMessageType(payload.message_type) || 'private';
const channelId = const channelId =
toStringId(payload.channel_id) || toStringId(payload.guild_id) || undefined; toolsService.toStringId(payload.channel_id) ||
const groupId = toStringId(payload.group_id) || undefined; toolsService.toStringId(payload.guild_id) ||
const userId = toStringId(payload.user_id); undefined;
const groupId = toolsService.toStringId(payload.group_id) || undefined;
const userId = toolsService.toStringId(payload.user_id);
const targetId = const targetId =
messageType === 'group' messageType === 'group'
? groupId || '' ? groupId || ''
@ -37,13 +40,13 @@ export function normalizeOneBotMessage(
: new Date(), : new Date(),
groupId, groupId,
messageId: messageId:
toStringId(payload.message_id) || toolsService.toStringId(payload.message_id) ||
`${payload.time || Date.now()}-${targetId}-${userId}`, `${payload.time || Date.now()}-${targetId}-${userId}`,
messageText, messageText,
messageType, messageType,
rawEvent: payload, rawEvent: payload,
rawMessage: payload.raw_message || messageText, rawMessage: payload.raw_message || messageText,
selfId: toStringId(payload.self_id), selfId: toolsService.toStringId(payload.self_id),
senderNickname: senderNickname:
payload.sender?.card || payload.sender?.card ||
payload.sender?.nickname || payload.sender?.nickname ||

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ToolsService } from '@/common';
import { QQBOT_MQTT_TOPICS } from '../qqbot.constants'; import { QQBOT_MQTT_TOPICS } from '../qqbot.constants';
import { QqbotDedupeService } from '../dedupe/qqbot-dedupe.service'; import { QqbotDedupeService } from '../dedupe/qqbot-dedupe.service';
import { QqbotMessageService } from '../message/qqbot-message.service'; import { QqbotMessageService } from '../message/qqbot-message.service';
@ -20,6 +21,7 @@ export class QqbotEventService {
private readonly dedupeService: QqbotDedupeService, private readonly dedupeService: QqbotDedupeService,
private readonly messageService: QqbotMessageService, private readonly messageService: QqbotMessageService,
private readonly ruleEngineService: QqbotRuleEngineService, private readonly ruleEngineService: QqbotRuleEngineService,
private readonly toolsService: ToolsService,
) {} ) {}
async handleIncoming(payload: QqbotOneBotEvent) { async handleIncoming(payload: QqbotOneBotEvent) {
@ -32,7 +34,7 @@ export class QqbotEventService {
} }
if (!isOneBotMessageEvent(payload)) return; if (!isOneBotMessageEvent(payload)) return;
const message = normalizeOneBotMessage(payload); const message = normalizeOneBotMessage(payload, this.toolsService);
if (!message.selfId || !message.targetId || !message.userId) { if (!message.selfId || !message.targetId || !message.userId) {
this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略'); this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略');
return; return;

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { ToolsService } from '@/common';
import { QqbotConversation } from './qqbot-conversation.entity'; import { QqbotConversation } from './qqbot-conversation.entity';
import { QqbotMessage } from './qqbot-message.entity'; import { QqbotMessage } from './qqbot-message.entity';
import type { import type {
@ -8,7 +9,10 @@ import type {
QqbotMessageQueryDto, QqbotMessageQueryDto,
} from './qqbot-message.dto'; } from './qqbot-message.dto';
import type { QqbotMessageType, QqbotNormalizedMessage } from '../qqbot.types'; 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() @Injectable()
export class QqbotMessageService { export class QqbotMessageService {
@ -17,10 +21,15 @@ export class QqbotMessageService {
private readonly conversationRepository: Repository<QqbotConversation>, private readonly conversationRepository: Repository<QqbotConversation>,
@InjectRepository(QqbotMessage) @InjectRepository(QqbotMessage)
private readonly messageRepository: Repository<QqbotMessage>, private readonly messageRepository: Repository<QqbotMessage>,
private readonly toolsService: ToolsService,
) {} ) {}
async conversationPage(query: QqbotConversationQueryDto) { 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 const builder = this.conversationRepository
.createQueryBuilder('conversation') .createQueryBuilder('conversation')
.where('conversation.isDeleted = :isDeleted', { isDeleted: false }); .where('conversation.isDeleted = :isDeleted', { isDeleted: false });
@ -50,7 +59,11 @@ export class QqbotMessageService {
} }
async messagePage(query: QqbotMessageQueryDto) { 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'); const builder = this.messageRepository.createQueryBuilder('message');
if (query.conversationId) { if (query.conversationId) {

View File

@ -8,8 +8,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as mqtt from 'mqtt'; import * as mqtt from 'mqtt';
import type { MqttClient } from 'mqtt'; import type { MqttClient } from 'mqtt';
import type { QqbotBusHandler } from '../qqbot.types';
type Handler = (payload: any) => Promise<void> | void;
@Injectable() @Injectable()
export class QqbotBusService implements OnModuleInit, OnModuleDestroy { export class QqbotBusService implements OnModuleInit, OnModuleDestroy {
@ -58,7 +57,7 @@ export class QqbotBusService implements OnModuleInit, OnModuleDestroy {
this.client.publish(topic, JSON.stringify(payload)); this.client.publish(topic, JSON.stringify(payload));
} }
subscribe(topic: string, handler: Handler) { subscribe(topic: string, handler: QqbotBusHandler) {
this.emitter.on(topic, handler); this.emitter.on(topic, handler);
return () => this.emitter.off(topic, handler); return () => this.emitter.off(topic, handler);
} }

View File

@ -4,18 +4,11 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import { QqbotAccount } from '../account/qqbot-account.entity'; import { QqbotAccount } from '../account/qqbot-account.entity';
import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; import { QqbotAccountNapcat } from './qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
import type { QqbotNapcatRuntime } from '../qqbot.types';
export type QqbotNapcatRuntime = {
baseUrl: string;
id?: string;
name: string;
webuiPort?: null | number;
webuiToken?: null | string;
};
@Injectable() @Injectable()
export class QqbotNapcatContainerService { export class QqbotNapcatContainerService {
@ -25,6 +18,7 @@ export class QqbotNapcatContainerService {
private readonly containerRepository: Repository<QqbotNapcatContainer>, private readonly containerRepository: Repository<QqbotNapcatContainer>,
@InjectRepository(QqbotAccountNapcat) @InjectRepository(QqbotAccountNapcat)
private readonly bindingRepository: Repository<QqbotAccountNapcat>, private readonly bindingRepository: Repository<QqbotAccountNapcat>,
private readonly toolsService: ToolsService,
) {} ) {}
async prepareCreateContainer() { async prepareCreateContainer() {
@ -311,7 +305,7 @@ fi
webuiToken: token, webuiToken: token,
}; };
} catch (err) { } catch (err) {
const message = this.getErrorMessage(err); const message = this.toolsService.getErrorMessage(err);
await this.containerRepository.update( await this.containerRepository.update(
{ id: container.id }, { id: container.id },
{ {
@ -596,8 +590,4 @@ docker run -d \\
child.stdin.end(); child.stdin.end();
}); });
} }
private getErrorMessage(err: unknown) {
return err instanceof Error ? err.message : `${err}`;
}
} }

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from 'typeorm'; import { Brackets, Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import { QqbotAllowlist } from './qqbot-allowlist.entity'; import { QqbotAllowlist } from './qqbot-allowlist.entity';
import { QqbotBlocklist } from './qqbot-blocklist.entity'; import { QqbotBlocklist } from './qqbot-blocklist.entity';
import type { import type {
@ -11,11 +11,15 @@ import type {
QqbotPermissionUpdateDto, QqbotPermissionUpdateDto,
} from './qqbot-permission.dto'; } from './qqbot-permission.dto';
import { QqbotConfigService } from '../config/qqbot-config.service'; import { QqbotConfigService } from '../config/qqbot-config.service';
import type { QqbotNormalizedMessage } from '../qqbot.types'; import {
import { getPageParams } from '../qqbot.utils'; QQBOT_DEFAULT_PAGE_NO,
QQBOT_DEFAULT_PAGE_SIZE,
type PermissionKind = 'allowlist' | 'blocklist'; } from '../qqbot.constants';
type PermissionEntity = QqbotAllowlist | QqbotBlocklist; import type {
QqbotNormalizedMessage,
QqbotPermissionEntity,
QqbotPermissionKind,
} from '../qqbot.types';
@Injectable() @Injectable()
export class QqbotPermissionService { export class QqbotPermissionService {
@ -25,6 +29,7 @@ export class QqbotPermissionService {
private readonly allowlistRepository: Repository<QqbotAllowlist>, private readonly allowlistRepository: Repository<QqbotAllowlist>,
@InjectRepository(QqbotBlocklist) @InjectRepository(QqbotBlocklist)
private readonly blocklistRepository: Repository<QqbotBlocklist>, private readonly blocklistRepository: Repository<QqbotBlocklist>,
private readonly toolsService: ToolsService,
) {} ) {}
async getConfig() { async getConfig() {
@ -35,8 +40,12 @@ export class QqbotPermissionService {
return this.configService.updatePermissionConfig(body); return this.configService.updatePermissionConfig(body);
} }
async page(kind: PermissionKind, query: QqbotPermissionQueryDto) { async page(kind: QqbotPermissionKind, query: QqbotPermissionQueryDto) {
const { pageNo, pageSize, skip } = getPageParams(query); const { pageNo, pageSize, skip } = this.toolsService.getPageParams(
query,
QQBOT_DEFAULT_PAGE_NO,
QQBOT_DEFAULT_PAGE_SIZE,
);
const repository = this.getRepository(kind); const repository = this.getRepository(kind);
const builder = repository const builder = repository
.createQueryBuilder('permission') .createQueryBuilder('permission')
@ -64,7 +73,7 @@ export class QqbotPermissionService {
} }
if (query.preciseUser !== undefined && `${query.preciseUser}` !== '') { if (query.preciseUser !== undefined && `${query.preciseUser}` !== '') {
builder.andWhere('permission.preciseUser = :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 }; return { list, pageNo, pageSize, total };
} }
async save(kind: PermissionKind, body: QqbotPermissionBodyDto) { async save(kind: QqbotPermissionKind, body: QqbotPermissionBodyDto) {
const repository = this.getRepository(kind); const repository = this.getRepository(kind);
const payload = this.normalizeBody(body); const payload = this.normalizeBody(body);
const saved = await repository.save( const saved = await repository.save(
repository.create({ repository.create({
...payload, ...payload,
} as PermissionEntity), } as QqbotPermissionEntity),
); );
return saved.id; return saved.id;
} }
async update(kind: PermissionKind, body: QqbotPermissionUpdateDto) { async update(kind: QqbotPermissionKind, body: QqbotPermissionUpdateDto) {
const repository = this.getRepository(kind); const repository = this.getRepository(kind);
const payload = this.normalizeBody(body); const payload = this.normalizeBody(body);
await repository.update( await repository.update(
@ -99,7 +108,7 @@ export class QqbotPermissionService {
return true; return true;
} }
async remove(kind: PermissionKind, id: string) { async remove(kind: QqbotPermissionKind, id: string) {
const repository = this.getRepository(kind); const repository = this.getRepository(kind);
await repository.update({ id } as any, { isDeleted: true } as any); await repository.update({ id } as any, { isDeleted: true } as any);
return true; return true;
@ -118,7 +127,7 @@ export class QqbotPermissionService {
} }
private async existsMatched( private async existsMatched(
repository: Repository<PermissionEntity>, repository: Repository<QqbotPermissionEntity>,
message: QqbotNormalizedMessage, message: QqbotNormalizedMessage,
) { ) {
const count = await repository const count = await repository
@ -182,7 +191,7 @@ export class QqbotPermissionService {
private normalizeBody( private normalizeBody(
body: Partial<QqbotPermissionBodyDto>, body: Partial<QqbotPermissionBodyDto>,
): Partial<PermissionEntity> { ): Partial<QqbotPermissionEntity> {
const targetType = body.targetType === 'private' ? 'qq' : body.targetType; const targetType = body.targetType === 'private' ? 'qq' : body.targetType;
const normalizedTargetType = targetType || 'qq'; const normalizedTargetType = targetType || 'qq';
const targetId = `${body.targetId || ''}`.trim(); const targetId = `${body.targetId || ''}`.trim();
@ -213,14 +222,10 @@ export class QqbotPermissionService {
targetId, targetId,
targetType: normalizedTargetType, targetType: normalizedTargetType,
userId: preciseUser ? userId : '', userId: preciseUser ? userId : '',
} as Partial<PermissionEntity>; } as Partial<QqbotPermissionEntity>;
} }
private normalizeBoolean(value: unknown) { private getRepository(kind: QqbotPermissionKind) {
return value === true || value === 'true' || value === 1 || value === '1';
}
private getRepository(kind: PermissionKind) {
return kind === 'allowlist' return kind === 'allowlist'
? this.allowlistRepository ? this.allowlistRepository
: this.blocklistRepository; : this.blocklistRepository;

View File

@ -6,7 +6,7 @@ import type {
QqbotEventPluginDefinition, QqbotEventPluginDefinition,
QqbotPluginHealth, QqbotPluginHealth,
QqbotPluginOperationSummary, QqbotPluginOperationSummary,
} from './qqbot-plugin.types'; } from '../qqbot.types';
@Injectable() @Injectable()
export class QqbotEventPluginRegistryService { export class QqbotEventPluginRegistryService {

View File

@ -8,7 +8,7 @@ import type {
QqbotPluginOperationContext, QqbotPluginOperationContext,
QqbotPluginOperationSummary, QqbotPluginOperationSummary,
QqbotPluginSummary, QqbotPluginSummary,
} from './qqbot-plugin.types'; } from '../qqbot.types';
@Injectable() @Injectable()
export class QqbotPluginRegistryService implements OnModuleInit { export class QqbotPluginRegistryService implements OnModuleInit {

View File

@ -12,7 +12,7 @@ import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { vbenSuccess } from '@/common'; import { vbenSuccess } from '@/common';
import { QqbotEventPluginRegistryService } from './qqbot-event-plugin-registry.service'; import { QqbotEventPluginRegistryService } from './qqbot-event-plugin-registry.service';
import { QqbotPluginRegistryService } from './qqbot-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 - 插件能力') @ApiTags('QQBot - 插件能力')
@Controller('qqbot/plugin') @Controller('qqbot/plugin')

View File

@ -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<string, any>;
command?: QqbotCommand;
message?: QqbotNormalizedMessage;
};
export type QqbotPluginOperation<Input = any, Output = any> = {
cacheTtlMs?: number;
description?: string;
inputSchema?: Record<string, any>;
key: string;
name: string;
outputSchema?: Record<string, any>;
execute: (
input: Input,
context: QqbotPluginOperationContext,
) => Promise<Output>;
};
export type QqbotIntegrationPlugin = {
description?: string;
healthCheck?: () => Promise<QqbotPluginHealth>;
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<string, any>;
key: string;
name: string;
outputSchema?: Record<string, any>;
pluginKey: string;
triggerMode: QqbotPluginTriggerMode;
};

View File

@ -9,63 +9,14 @@ import {
QQBOT_FF14_MARKET_DICT_CODES, QQBOT_FF14_MARKET_DICT_CODES,
resolveQqbotFf14MarketTarget, resolveQqbotFf14MarketTarget,
} from './qqbot-ff14-worlds'; } from './qqbot-ff14-worlds';
import type {
type HttpMethod = 'GET'; Ff14HttpMethod,
QqbotFf14PriceResult,
type XivapiSearchItem = { QqbotFf14ResolvedItem,
fields?: { UniversalisListing,
Icon?: string | { path?: string; path_hr1?: string }; UniversalisMarketResponse,
IsUntradable?: boolean; XivapiSearchItem,
LevelItem?: number | { row_id?: number; value?: number }; } from './qqbot-ff14-market.types';
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;
};
@Injectable() @Injectable()
export class QqbotFf14ClientService { export class QqbotFf14ClientService {
@ -416,7 +367,7 @@ export class QqbotFf14ClientService {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
} }
private requestJson<T>(url: URL, method: HttpMethod, context: string) { private requestJson<T>(url: URL, method: Ff14HttpMethod, context: string) {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const client = url.protocol === 'http:' ? http : https; const client = url.protocol === 'http:' ? http : https;
const request = client.request( const request = client.request(

View File

@ -1,14 +1,19 @@
import { Injectable } from '@nestjs/common'; 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'; import { QqbotFf14ClientService } from './qqbot-ff14-client.service';
@Injectable() @Injectable()
export class QqbotFf14MarketPluginService { export class QqbotFf14MarketPluginService {
constructor(private readonly ff14ClientService: QqbotFf14ClientService) {} constructor(
private readonly ff14ClientService: QqbotFf14ClientService,
private readonly toolsService: ToolsService,
) {}
getPlugin(): QqbotIntegrationPlugin { getPlugin(): QqbotIntegrationPlugin {
return { return {
description: '对接 XIVAPI v2 与 Universalis提供 FF14 物品解析和市场价格查询能力。', description:
'对接 XIVAPI v2 与 Universalis提供 FF14 物品解析和市场价格查询能力。',
healthCheck: async () => { healthCheck: async () => {
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
try { try {
@ -24,7 +29,7 @@ export class QqbotFf14MarketPluginService {
} catch (err) { } catch (err) {
return { return {
checkedAt, checkedAt,
message: err instanceof Error ? err.message : 'FF14 插件不可用', message: this.toolsService.getErrorMessage(err, 'FF14 插件不可用'),
status: 'degraded', status: 'degraded',
}; };
} }
@ -82,7 +87,8 @@ export class QqbotFf14MarketPluginService {
}, },
type: 'object', type: 'object',
}, },
execute: async (input) => await this.ff14ClientService.getPrice(input), execute: async (input) =>
await this.ff14ClientService.getPrice(input),
}, },
], ],
version: '1.0.0', version: '1.0.0',

View File

@ -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;
};

View File

@ -1,27 +1,12 @@
import type { import type {
AdminDictItem, AdminDictItem,
AdminDictTreeItem, AdminDictTreeItem,
} from '../../../admin/dict/dict.service'; } from '../../../admin/admin.types';
import type {
export type QqbotFf14DataCenter = { QqbotFf14DataCenter,
name: string; QqbotFf14MarketCatalog,
region: string; QqbotFf14MarketTarget,
worlds: string[]; } from './qqbot-ff14-market.types';
};
export type QqbotFf14MarketCatalog = {
dataCenters: QqbotFf14DataCenter[];
defaultRegion?: string;
regions: string[];
};
export type QqbotFf14MarketTarget = {
dataCenter?: string;
label: string;
region?: string;
target: string;
world?: string;
};
export const QQBOT_FF14_MARKET_DICT_CODES = { export const QQBOT_FF14_MARKET_DICT_CODES = {
dataCenter: 'FF14_MARKET_DATA_CENTER', dataCenter: 'FF14_MARKET_DATA_CENTER',

View File

@ -3,114 +3,23 @@ import { ConfigService } from '@nestjs/config';
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
import { DictService } from '../../../admin/dict/dict.service'; import { DictService } from '../../../admin/dict/dict.service';
import type {
type HttpMethod = 'GET' | 'POST'; FflogsCharacterSummaryResponse,
FflogsEncounterFightCandidate,
type FflogsTokenResponse = { FflogsEncounterLookup,
access_token?: string; FflogsGraphqlResponse,
expires_in?: number; FflogsHttpMethod,
token_type?: string; FflogsLocalizationMaps,
}; FflogsParseMetric,
FflogsRankingItem,
type FflogsGraphqlResponse<T> = { FflogsRecentReport,
data?: T; FflogsReportFight,
errors?: Array<{ message?: string }>; FflogsReportFightMetricsResponse,
}; FflogsTokenResponse,
QqbotFflogsCharacterSummaryInput,
type FflogsCharacter = { QqbotFflogsCharacterSummaryResult,
id?: number; QqbotFflogsEncounterLogItem,
lodestoneID?: number; } from './qqbot-fflogs.types';
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<string, any>;
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;
};
const FFLOGS_LOCALIZATION_DICT_CODES = { const FFLOGS_LOCALIZATION_DICT_CODES = {
encounter: 'FFLOGS_ENCOUNTER_LABEL', encounter: 'FFLOGS_ENCOUNTER_LABEL',
@ -120,45 +29,6 @@ const FFLOGS_LOCALIZATION_DICT_CODES = {
serverRegion: 'FFLOGS_SERVER_REGION_LABEL', serverRegion: 'FFLOGS_SERVER_REGION_LABEL',
}; };
type FflogsLocalizationMaps = Record<
keyof typeof FFLOGS_LOCALIZATION_DICT_CODES,
Map<string, 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;
};
@Injectable() @Injectable()
export class QqbotFflogsClientService { export class QqbotFflogsClientService {
private accessToken = ''; private accessToken = '';
@ -655,7 +525,7 @@ export class QqbotFflogsClientService {
private requestJson<T>( private requestJson<T>(
url: URL, url: URL,
method: HttpMethod, method: FflogsHttpMethod,
options: { body?: string; headers?: Record<string, string> } = {}, options: { body?: string; headers?: Record<string, string> } = {},
) { ) {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {

View File

@ -1,10 +1,14 @@
import { Injectable } from '@nestjs/common'; 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'; import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
@Injectable() @Injectable()
export class QqbotFflogsPluginService { export class QqbotFflogsPluginService {
constructor(private readonly fflogsClientService: QqbotFflogsClientService) {} constructor(
private readonly fflogsClientService: QqbotFflogsClientService,
private readonly toolsService: ToolsService,
) {}
getPlugin(): QqbotIntegrationPlugin { getPlugin(): QqbotIntegrationPlugin {
return { return {
@ -22,7 +26,10 @@ export class QqbotFflogsPluginService {
} catch (err) { } catch (err) {
return { return {
checkedAt, checkedAt,
message: err instanceof Error ? err.message : 'FFLogs 插件不可用', message: this.toolsService.getErrorMessage(
err,
'FFLogs 插件不可用',
),
status: 'degraded', status: 'degraded',
}; };
} }

View File

@ -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<T> = {
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<string, string>
>;
export type FflogsParseMetric = {
amount?: number;
color: string;
percent?: number;
rank?: string;
};
export type FflogsRankingItem = Record<string, any>;
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;
};

View File

@ -1,23 +1,18 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ToolsService } from '@/common';
import { QqbotAccountService } from '../../account/qqbot-account.service'; import { QqbotAccountService } from '../../account/qqbot-account.service';
import type { import type {
QqbotEventPluginDefinition, QqbotEventPluginDefinition,
QqbotEventPluginSummary, QqbotEventPluginSummary,
} from '../../plugin/qqbot-plugin.types'; QqbotNormalizedMessage,
import type { QqbotNormalizedMessage } from '../../qqbot.types'; QqbotRepeaterConversationState,
} from '../../qqbot.types';
import { QqbotSendService } from '../../send/qqbot-send.service'; import { QqbotSendService } from '../../send/qqbot-send.service';
const QQBOT_REPEATER_VERSION = '1.0.0'; const QQBOT_REPEATER_VERSION = '1.0.0';
const QQBOT_REPEATER_PLUGIN_KEY = 'repeater'; const QQBOT_REPEATER_PLUGIN_KEY = 'repeater';
type QqbotRepeaterConversationState = {
count: number;
lastText: string;
repeatedText: string;
updatedAt: number;
};
@Injectable() @Injectable()
export class QqbotRepeaterPluginService { export class QqbotRepeaterPluginService {
private readonly logger = new Logger(QqbotRepeaterPluginService.name); private readonly logger = new Logger(QqbotRepeaterPluginService.name);
@ -39,6 +34,7 @@ export class QqbotRepeaterPluginService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly sendService: QqbotSendService, private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService,
) {} ) {}
async getSummary(params: { async getSummary(params: {
@ -96,7 +92,7 @@ export class QqbotRepeaterPluginService {
async handleMessage(message: QqbotNormalizedMessage) { async handleMessage(message: QqbotNormalizedMessage) {
if (!(await this.isBound(message.selfId))) return false; 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)) { if (!this.canRepeat(message, text)) {
this.resetState(message); this.resetState(message);
return false; return false;
@ -120,7 +116,7 @@ export class QqbotRepeaterPluginService {
}); });
return true; return true;
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : '复读失败'; const errMsg = this.toolsService.getErrorMessage(err, '复读失败');
this.logger.warn(`QQBot 复读机发送失败: ${errMsg}`); this.logger.warn(`QQBot 复读机发送失败: ${errMsg}`);
return false; return false;
} }
@ -173,10 +169,6 @@ export class QqbotRepeaterPluginService {
return state.count >= this.getThreshold() && state.repeatedText !== text; return state.count >= this.getThreshold() && state.repeatedText !== text;
} }
private normalizeText(value: string) {
return `${value || ''}`.trim().replace(/\s+/g, ' ');
}
private resetState(message: QqbotNormalizedMessage) { private resetState(message: QqbotNormalizedMessage) {
this.states.delete(this.buildStateKey(message)); this.states.delete(this.buildStateKey(message));
} }

View File

@ -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<T> = {
code: number;
data?: T;
message?: string;
};
export type NapcatCredential = {
Credential?: string;
};
export type NapcatLoginInfo = Record<string, any> & {
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 QqbotConnectionMode = 'reverse-ws';
export type QqbotConnectionRole = 'API' | 'Event' | 'Universal'; export type QqbotConnectionRole = 'API' | 'Event' | 'Universal';
@ -20,18 +63,135 @@ export type QqbotNapcatContainerStatus =
export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending'; 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 QqbotRuleMatchType = 'equals' | 'keyword' | 'regex';
export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private'; export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private';
export type QqbotCommandParserType = 'ff14Price' | 'fflogsCharacter' | 'plain'; export type QqbotCommandParserType = 'ff14Price' | 'fflogsCharacter' | 'plain';
export type QqbotCommandLogStatus = 'failed' | 'success';
export type QqbotCommandMatchResult = {
alias: string;
input: Record<string, any>;
matched: true;
rawArgs: string;
};
export type QqbotPluginHealthStatus = 'degraded' | 'healthy' | 'offline'; 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<string, any>;
command?: QqbotCommand;
message?: QqbotNormalizedMessage;
};
export type QqbotPluginOperation<Input = any, Output = any> = {
cacheTtlMs?: number;
description?: string;
inputSchema?: Record<string, any>;
key: string;
name: string;
outputSchema?: Record<string, any>;
execute: (
input: Input,
context: QqbotPluginOperationContext,
) => Promise<Output>;
};
export type QqbotIntegrationPlugin = {
description?: string;
healthCheck?: () => Promise<QqbotPluginHealth>;
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<string, any>;
key: string;
name: string;
outputSchema?: Record<string, any>;
pluginKey: string;
triggerMode: QqbotPluginTriggerMode;
};
export type QqbotSendStatus = 'failed' | 'pending' | 'success'; export type QqbotSendStatus = 'failed' | 'pending' | 'success';
export type QqbotPermissionTargetType = 'channel' | 'group' | 'private' | 'qq'; 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<string, any> & { export type QqbotOneBotEvent = Record<string, any> & {
channel_id?: number | string; channel_id?: number | string;
group_id?: number | string; group_id?: number | string;
@ -69,3 +229,68 @@ export type QqbotOneBotActionResponse = {
retcode?: number; retcode?: number;
status?: string; status?: string;
}; };
export type QqbotBusHandler = (payload: any) => Promise<void> | 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<string, any>,
) => Promise<QqbotOneBotActionResponse>;
};
export type QqbotRepeaterConversationState = {
count: number;
lastText: string;
repeatedText: string;
updatedAt: number;
};
export type QrcodeRefreshOptions = QrcodeLookupOptions & {
fallbackStatus?: NapcatLoginStatus;
};

View File

@ -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;
}

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ToolsService } from '@/common';
import type { QqbotNormalizedMessage } from '../qqbot.types'; import type { QqbotNormalizedMessage } from '../qqbot.types';
import { QqbotCommandEngineService } from '../command/qqbot-command-engine.service'; import { QqbotCommandEngineService } from '../command/qqbot-command-engine.service';
import { QqbotPermissionService } from '../permission/qqbot-permission.service'; import { QqbotPermissionService } from '../permission/qqbot-permission.service';
@ -16,6 +17,7 @@ export class QqbotRuleEngineService {
private readonly repeaterPluginService: QqbotRepeaterPluginService, private readonly repeaterPluginService: QqbotRepeaterPluginService,
private readonly ruleService: QqbotRuleService, private readonly ruleService: QqbotRuleService,
private readonly sendService: QqbotSendService, private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService,
) {} ) {}
async handleMessage(message: QqbotNormalizedMessage) { async handleMessage(message: QqbotNormalizedMessage) {
@ -41,7 +43,7 @@ export class QqbotRuleEngineService {
targetType: message.messageType, targetType: message.messageType,
}); });
} catch (err) { } catch (err) {
const errMsg = err instanceof Error ? err.message : '自动回复失败'; const errMsg = this.toolsService.getErrorMessage(err, '自动回复失败');
this.logger.warn(`QQBot 自动回复失败: ${errMsg}`); this.logger.warn(`QQBot 自动回复失败: ${errMsg}`);
} }
return; return;

View File

@ -10,20 +10,22 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { vbenSuccess } from '@/common'; import { ToolsService, vbenSuccess } from '@/common';
import { import {
QqbotRuleBodyDto, QqbotRuleBodyDto,
QqbotRuleQueryDto, QqbotRuleQueryDto,
QqbotRuleUpdateDto, QqbotRuleUpdateDto,
} from './qqbot-rule.dto'; } from './qqbot-rule.dto';
import { QqbotRuleService } from './qqbot-rule.service'; import { QqbotRuleService } from './qqbot-rule.service';
import { normalizeBoolean } from '../qqbot.utils';
@ApiTags('QQBot - 自动回复规则') @ApiTags('QQBot - 自动回复规则')
@Controller('qqbot/rule') @Controller('qqbot/rule')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class QqbotRuleController { export class QqbotRuleController {
constructor(private readonly ruleService: QqbotRuleService) {} constructor(
private readonly ruleService: QqbotRuleService,
private readonly toolsService: ToolsService,
) {}
@Get('list') @Get('list')
@ApiOperation({ summary: 'QQBot 自动回复规则分页' }) @ApiOperation({ summary: 'QQBot 自动回复规则分页' })
@ -60,7 +62,10 @@ export class QqbotRuleController {
@ApiQuery({ name: 'enabled', type: Boolean }) @ApiQuery({ name: 'enabled', type: Boolean })
async toggle(@Query('id') id: string, @Query('enabled') enabled: string) { async toggle(@Query('id') id: string, @Query('enabled') enabled: string) {
return vbenSuccess( return vbenSuccess(
await this.ruleService.toggle(id, normalizeBoolean(enabled)), await this.ruleService.toggle(
id,
this.toolsService.normalizeBoolean(enabled),
),
); );
} }
} }

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotAccountService } from '../account/qqbot-account.service';
import { QqbotRule } from './qqbot-rule.entity'; import { QqbotRule } from './qqbot-rule.entity';
import type { import type {
@ -14,7 +14,10 @@ import type {
QqbotRuleMatchType, QqbotRuleMatchType,
QqbotRuleTargetType, QqbotRuleTargetType,
} from '../qqbot.types'; } from '../qqbot.types';
import { getPageParams, normalizeBoolean } from '../qqbot.utils'; import {
QQBOT_DEFAULT_PAGE_NO,
QQBOT_DEFAULT_PAGE_SIZE,
} from '../qqbot.constants';
@Injectable() @Injectable()
export class QqbotRuleService { export class QqbotRuleService {
@ -22,10 +25,15 @@ export class QqbotRuleService {
@InjectRepository(QqbotRule) @InjectRepository(QqbotRule)
private readonly ruleRepository: Repository<QqbotRule>, private readonly ruleRepository: Repository<QqbotRule>,
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly toolsService: ToolsService,
) {} ) {}
async page(query: QqbotRuleQueryDto) { 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 const builder = this.ruleRepository
.createQueryBuilder('rule') .createQueryBuilder('rule')
.where('rule.isDeleted = :isDeleted', { isDeleted: false }); .where('rule.isDeleted = :isDeleted', { isDeleted: false });
@ -52,7 +60,7 @@ export class QqbotRuleService {
} }
if (query.enabled !== undefined && `${query.enabled}` !== '') { if (query.enabled !== undefined && `${query.enabled}` !== '') {
builder.andWhere('rule.enabled = :enabled', { builder.andWhere('rule.enabled = :enabled', {
enabled: normalizeBoolean(query.enabled), enabled: this.toolsService.normalizeBoolean(query.enabled),
}); });
} }

View File

@ -2,14 +2,19 @@ import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import { QqbotAccountService } from '../account/qqbot-account.service'; import { QqbotAccountService } from '../account/qqbot-account.service';
import type { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service';
import { QQBOT_MQTT_TOPICS } from '../qqbot.constants'; import { QQBOT_MQTT_TOPICS } from '../qqbot.constants';
import { QqbotBusService } from '../mqtt/qqbot-bus.service'; import { QqbotBusService } from '../mqtt/qqbot-bus.service';
import { QqbotMessageService } from '../message/qqbot-message.service'; import { QqbotMessageService } from '../message/qqbot-message.service';
import type { QqbotMessageType } from '../qqbot.types'; import type {
import { getPageParams } from '../qqbot.utils'; 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 { QqbotRateLimitService } from './qqbot-rate-limit.service';
import { QqbotSendLog } from './qqbot-send-log.entity'; import { QqbotSendLog } from './qqbot-send-log.entity';
import type { import type {
@ -28,10 +33,15 @@ export class QqbotSendService {
private readonly messageService: QqbotMessageService, private readonly messageService: QqbotMessageService,
private readonly moduleRef: ModuleRef, private readonly moduleRef: ModuleRef,
private readonly rateLimitService: QqbotRateLimitService, private readonly rateLimitService: QqbotRateLimitService,
private readonly toolsService: ToolsService,
) {} ) {}
async logPage(query: QqbotSendLogQueryDto) { 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'); const builder = this.sendLogRepository.createQueryBuilder('log');
if (query.selfId) { if (query.selfId) {
@ -152,7 +162,7 @@ export class QqbotSendService {
if (!success) throwVbenError(response.message || 'OneBot 发送失败'); if (!success) throwVbenError(response.message || 'OneBot 发送失败');
return { ...response, logId: log.id }; return { ...response, logId: log.id };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'OneBot 发送失败'; const message = this.toolsService.getErrorMessage(err, 'OneBot 发送失败');
await this.sendLogRepository.update( await this.sendLogRepository.update(
{ id: log.id }, { id: log.id },
{ {
@ -164,11 +174,11 @@ export class QqbotSendService {
} }
} }
private async getReverseWsService() { private async getReverseWsService(): Promise<QqbotReverseActionSender> {
const { QqbotReverseWsService } = await import( const { QqbotReverseWsService } = await import(
'../connection/qqbot-reverse-ws.service' '../connection/qqbot-reverse-ws.service'
); );
return this.moduleRef.get<QqbotReverseWsService>(QqbotReverseWsService, { return this.moduleRef.get<QqbotReverseActionSender>(QqbotReverseWsService, {
strict: false, strict: false,
}); });
} }

28
src/types/res.d.ts vendored
View File

@ -1,28 +0,0 @@
type Res =
| {
code: 200;
msg: string;
data: any;
}
| {
code: number;
msg: string;
err: any;
};
type Page<T = any> = {
list: T[];
total: number;
};
type Dict<T = object> = {
label: string;
value: any;
} & Partial<T>;
type PageParams<T> = {
pageSize: number;
pageNo: number;
} & Partial<T>;
type Many<T> = T | readonly T[];

View File

@ -1,63 +1,23 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { Request, Response as ExpressResponse } from 'express'; import type { Request, Response as ExpressResponse } from 'express';
import { throwVbenError } from '@/common'; import { throwVbenError, ToolsService } from '@/common';
import type { import type {
WordpressArticleBodyDto, WordpressArticleBodyDto,
WordpressArticleListQueryDto, WordpressArticleListQueryDto,
WordpressTermBodyDto, WordpressTermBodyDto,
WordpressTermListQueryDto, WordpressTermListQueryDto,
} from './wordpress.dto'; } from './wordpress.dto';
import type {
export type WordpressAuthContext = { WordpressAuthContext,
authorization?: string; WordpressAvailabilityCache,
cookie?: string; WordpressAvailabilityError,
nonce?: string; WordpressLoginResult,
}; WordpressOptionalLoginResult,
WordpressPagedQueryDto,
export type WordpressLoginResult = { WordpressRequestOptions,
auth: { WordpressResponse,
nonce: string; } from './wordpress.types';
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<string, unknown>;
method?: 'GET' | 'POST' | 'DELETE';
query?: Record<string, unknown>;
};
type WordpressResponse<T> = {
data: T;
total?: number;
};
const WORDPRESS_COOKIE_PREFIXES = [ const WORDPRESS_COOKIE_PREFIXES = [
'wordpress_', 'wordpress_',
@ -72,19 +32,22 @@ const WORDPRESS_AUTH_COOKIE = 'kt_wordpress_auth';
export class WordpressService { export class WordpressService {
private availabilityCache: null | WordpressAvailabilityCache = null; private availabilityCache: null | WordpressAvailabilityCache = null;
constructor(private readonly configService: ConfigService) {} constructor(
private readonly configService: ConfigService,
private readonly toolsService: ToolsService,
) {}
getAuthContext(request: Request): WordpressAuthContext { getAuthContext(request: Request): WordpressAuthContext {
const authorization = const authorization =
this.readHeader(request, 'x-wordpress-authorization') || this.toolsService.readHeader(request, 'x-wordpress-authorization') ||
this.readHeader(request, 'x-wp-authorization') || this.toolsService.readHeader(request, 'x-wp-authorization') ||
this.getForwardableAuthorization(request); this.getForwardableAuthorization(request);
const nonce = const nonce =
this.readHeader(request, 'x-wp-nonce') || this.toolsService.readHeader(request, 'x-wp-nonce') ||
this.readHeader(request, 'x-wordpress-nonce'); this.toolsService.readHeader(request, 'x-wordpress-nonce');
const cookie = const cookie =
this.readHeader(request, 'x-wordpress-cookie') || this.toolsService.readHeader(request, 'x-wordpress-cookie') ||
this.readCookie(request, WORDPRESS_AUTH_COOKIE) || this.toolsService.readCookie(request, WORDPRESS_AUTH_COOKIE) ||
this.getWordpressCookie(request.headers.cookie); this.getWordpressCookie(request.headers.cookie);
return { return {
@ -439,13 +402,20 @@ export class WordpressService {
let data = await this.parseResponse(response); let data = await this.parseResponse(response);
// 部分 WordPress 网关会拦截 DELETEREST API 官方支持用 _method=DELETE 通过 POST 兜底。 // 部分 WordPress 网关会拦截 DELETEREST API 官方支持用 _method=DELETE 通过 POST 兜底。
if (!response.ok && response.status === 405 && options.method === 'DELETE') { if (
response = await fetch(this.getMethodOverrideUrl(urls[index], 'DELETE'), { !response.ok &&
response.status === 405 &&
options.method === 'DELETE'
) {
response = await fetch(
this.getMethodOverrideUrl(urls[index], 'DELETE'),
{
headers: this.getHeaders(options.auth, false), headers: this.getHeaders(options.auth, false),
method: 'POST', method: 'POST',
redirect: 'follow', redirect: 'follow',
signal: controller.signal, signal: controller.signal,
}); },
);
data = await this.parseResponse(response); data = await this.parseResponse(response);
} }
@ -460,7 +430,7 @@ export class WordpressService {
if (!response.ok) { if (!response.ok) {
throwVbenError( throwVbenError(
this.getErrorMessage(data, response.status), this.getWordpressResponseErrorMessage(data, response.status),
response.status, response.status,
data, data,
); );
@ -734,7 +704,7 @@ export class WordpressService {
} }
private getArticleBody(body: WordpressArticleBodyDto) { private getArticleBody(body: WordpressArticleBodyDto) {
return this.pickDefined({ return this.toolsService.pickDefined({
categories: this.normalizeIdList(body.categories), categories: this.normalizeIdList(body.categories),
content: body.content, content: body.content,
excerpt: body.excerpt, excerpt: body.excerpt,
@ -748,7 +718,7 @@ export class WordpressService {
} }
private getTermBody(body: WordpressTermBodyDto) { private getTermBody(body: WordpressTermBodyDto) {
return this.pickDefined({ return this.toolsService.pickDefined({
description: body.description, description: body.description,
name: body.name, name: body.name,
parent: body.parent, parent: body.parent,
@ -784,19 +754,6 @@ export class WordpressService {
.join(','); .join(',');
} }
private pickDefined(payload: Record<string, unknown>) {
return Object.entries(payload).reduce<Record<string, unknown>>(
(acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key] = value;
}
return acc;
},
{},
);
}
private async parseResponse(response: globalThis.Response) { private async parseResponse(response: globalThis.Response) {
const text = await response.text(); 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 (data?.message) return data.message;
if (typeof data === 'string' && data) return data; if (typeof data === 'string' && data) return data;
return `WordPress 请求失败:${status}`; return `WordPress 请求失败:${status}`;
} }
private throwWordpressNetworkError(err: unknown): never { private throwWordpressNetworkError(err: unknown): never {
const message = err instanceof Error ? err.message : '未知错误'; const message = this.toolsService.getErrorMessage(err, '未知错误');
const cause = this.getErrorCause(err); const cause = this.getErrorCause(err);
// fetch 的 DNS、连接拒绝等底层异常不是 HttpException需要统一转成业务响应。 // fetch 的 DNS、连接拒绝等底层异常不是 HttpException需要统一转成业务响应。
@ -852,7 +809,7 @@ export class WordpressService {
return { return {
error: err instanceof Error ? err.name : 'WordPressUnavailable', error: err instanceof Error ? err.name : 'WordPressUnavailable',
message: err instanceof Error ? err.message : 'WordPress 暂不可用', message: this.toolsService.getErrorMessage(err, 'WordPress 暂不可用'),
status: HttpStatus.BAD_GATEWAY, status: HttpStatus.BAD_GATEWAY,
}; };
} }
@ -928,31 +885,11 @@ export class WordpressService {
return ''; 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) { private getForwardableAuthorization(request: Request) {
const authorization = this.readHeader(request, 'authorization'); const authorization = this.toolsService.readHeader(
request,
'authorization',
);
if (!authorization || this.isLikelyAdminAuthorization(authorization)) { if (!authorization || this.isLikelyAdminAuthorization(authorization)) {
return undefined; return undefined;
@ -997,7 +934,3 @@ export class WordpressService {
return cookies.length ? cookies.join('; ') : undefined; return cookies.length ? cookies.join('; ') : undefined;
} }
} }
type WordpressPagedQueryDto =
| WordpressArticleListQueryDto
| WordpressTermListQueryDto;

View File

@ -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<string, unknown>;
method?: 'GET' | 'POST' | 'DELETE';
query?: Record<string, unknown>;
};
export type WordpressResponse<T> = {
data: T;
total?: number;
};

View File

@ -1,15 +1,16 @@
jest.mock( jest.mock('@/common', () => {
'@/common', const actual = jest.requireActual('@/common');
() => ({ return {
...actual,
setDictDecodeCache: jest.fn(), setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => { throwVbenError: (message: string) => {
throw new Error(message); throw new Error(message);
}, },
}), };
{ virtual: true }, });
);
import { DictService } from './dict.service'; import { ToolsService } from '@/common';
import { DictService } from '@/admin/dict/dict.service';
describe('DictService', () => { describe('DictService', () => {
const dictRows = [ const dictRows = [
@ -49,9 +50,12 @@ describe('DictService', () => {
]; ];
function createService() { function createService() {
return new DictService({ return new DictService(
{
find: jest.fn().mockResolvedValue(dictRows), find: jest.fn().mockResolvedValue(dictRows),
} as any); } as any,
new ToolsService(),
);
} }
function createGroupService() { function createGroupService() {
@ -77,9 +81,12 @@ describe('DictService', () => {
return { return {
builder, builder,
service: new DictService({ service: new DictService(
{
createQueryBuilder: jest.fn().mockReturnValue(builder), createQueryBuilder: jest.fn().mockReturnValue(builder),
} as any), } as any,
new ToolsService(),
),
}; };
} }

View File

@ -27,6 +27,7 @@ import {
collectControllerRoutes, collectControllerRoutes,
routeKey, routeKey,
} from './helpers/controller-route.helper'; } from './helpers/controller-route.helper';
import type { RouteTestCase } from './test.types';
const component = { const component = {
id: '2041739550026043392', 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 = { const uploadResult = {
bucketName: 'kt-template-online', bucketName: 'kt-template-online',
objectName: 'uploads/demo.txt', objectName: 'uploads/demo.txt',
@ -128,8 +164,17 @@ const unauthorizedException = () =>
); );
const dictServiceMock = { const dictServiceMock = {
codes: jest.fn(),
getDictCodeOptions: jest.fn(),
getDictByKey: jest.fn(), getDictByKey: jest.fn(),
getComponentDictByType: 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 = { const minioServiceMock = {
@ -177,9 +222,6 @@ const controllerClasses = [
]; ];
const controllerRoutes = collectControllerRoutes(controllerClasses); const controllerRoutes = collectControllerRoutes(controllerClasses);
type HttpServer = Parameters<typeof request>[0];
type RouteTestCase = (server: HttpServer) => Promise<void>;
const routeTestCases: Record<string, RouteTestCase> = { const routeTestCases: Record<string, RouteTestCase> = {
'GET /': async (server) => { 'GET /': async (server) => {
await request(server).get('/').expect(301).expect('Location', '/api#/'); await request(server).get('/').expect(301).expect('Location', '/api#/');
@ -347,6 +389,182 @@ const routeTestCases: Record<string, RouteTestCase> = {
}); });
}, },
'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) => { 'GET /minio/check': async (server) => {
minioServiceMock.checkConnection.mockResolvedValue({ minioServiceMock.checkConnection.mockResolvedValue({
bucketName: 'demo-bucket', bucketName: 'demo-bucket',
@ -580,7 +798,9 @@ const routeTestCases: Record<string, RouteTestCase> = {
.post('/wordpress/auth/login') .post('/wordpress/auth/login')
.expect(201); .expect(201);
expect(wordpressServiceMock.loginWithConfiguredAdmin).toHaveBeenCalledWith(); expect(
wordpressServiceMock.loginWithConfiguredAdmin,
).toHaveBeenCalledWith();
expect(wordpressServiceMock.setAuthCookie).toHaveBeenCalledWith( expect(wordpressServiceMock.setAuthCookie).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
wordpressLoginResult.cookie, wordpressLoginResult.cookie,
@ -1058,7 +1278,32 @@ describe('KT Template Online API (e2e)', () => {
expect(response.body).toEqual({ expect(response.body).toEqual({
code: 400, code: 400,
msg: '操作失败', 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(); jest.clearAllMocks();
authServiceMock.currentUser.mockRejectedValue(unauthorizedException()); authServiceMock.currentUser.mockRejectedValue(unauthorizedException());
await request(app.getHttpServer()) await request(app.getHttpServer()).get('/wordpress/auth/check').expect(401);
.get('/wordpress/auth/check')
.expect(401);
expect(wordpressServiceMock.checkAuth).not.toHaveBeenCalled(); expect(wordpressServiceMock.checkAuth).not.toHaveBeenCalled();
}); });

View File

@ -1,12 +1,6 @@
import { RequestMethod, Type } from '@nestjs/common'; import { RequestMethod, Type } from '@nestjs/common';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import type { ControllerRoute } from '../test.types';
export interface ControllerRoute {
controllerName: string;
handlerName: string;
method: string;
path: string;
}
const requestMethodMap: Partial<Record<RequestMethod, string>> = { const requestMethodMap: Partial<Record<RequestMethod, string>> = {
[RequestMethod.GET]: 'GET', [RequestMethod.GET]: 'GET',

View File

@ -1,25 +1,25 @@
jest.mock( jest.mock('@/common', () => ({
'@/common', ToolsService: jest.requireActual('@/common').ToolsService,
() => ({
ensureSnowflakeId: jest.fn(), ensureSnowflakeId: jest.fn(),
setDictDecodeCache: jest.fn(), setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => { throwVbenError: (message: string) => {
throw new Error(message); throw new Error(message);
}, },
}), }));
{ virtual: true },
);
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service'; import { ToolsService } from '@/common';
import { QqbotAccountService } from './qqbot-account.service'; import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service';
import { QqbotNapcatLoginService } from './qqbot-napcat-login.service'; import { QqbotNapcatLoginService } from '@/qqbot/account/qqbot-napcat-login.service';
import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service';
describe('QqbotNapcatLoginService', () => { describe('QqbotNapcatLoginService', () => {
const toolsService = new ToolsService();
const service = new QqbotNapcatLoginService( const service = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService, { get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService, {} as QqbotAccountService,
{} as QqbotNapcatContainerService, {} as QqbotNapcatContainerService,
toolsService,
); );
beforeEach(() => { beforeEach(() => {
@ -75,6 +75,7 @@ describe('QqbotNapcatLoginService', () => {
{ get: jest.fn() } as unknown as ConfigService, { get: jest.fn() } as unknown as ConfigService,
accountService as unknown as QqbotAccountService, accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService, containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
); );
const startScan = jest const startScan = jest
.spyOn(refreshService as any, 'startScan') .spyOn(refreshService as any, 'startScan')
@ -121,12 +122,102 @@ describe('QqbotNapcatLoginService', () => {
const result = await service.refreshQrcode('session-refresh-qrcode'); const result = await service.refreshQrcode('session-refresh-qrcode');
expect(result.qrcode).toBe('new-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, requireFresh: true,
staleQrcode: 'old-qrcode', 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 () => { it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => {
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
@ -140,8 +231,11 @@ describe('QqbotNapcatLoginService', () => {
{ get: jest.fn() } as unknown as ConfigService, { get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService, {} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService, 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({ jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
isLogin: false, isLogin: false,
qrcodeurl: 'new-status-qrcode', qrcodeurl: 'new-status-qrcode',
@ -181,7 +275,9 @@ describe('QqbotNapcatLoginService', () => {
}); });
it('retries while NapCat still exposes the stale qrcode', async () => { 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 jest
.spyOn(service as any, 'postNapcat') .spyOn(service as any, 'postNapcat')
.mockResolvedValueOnce({ qrcode: 'old-qrcode' }) .mockResolvedValueOnce({ qrcode: 'old-qrcode' })

View File

@ -7,9 +7,9 @@ jest.mock(
{ virtual: true }, { virtual: true },
); );
import { DictService } from '../../admin/dict/dict.service'; import { DictService } from '@/admin/dict/dict.service';
import { QqbotCommandParserService } from './qqbot-command-parser.service'; import type { QqbotCommand } from '@/qqbot/command/qqbot-command.entity';
import type { QqbotCommand } from './qqbot-command.entity'; import { QqbotCommandParserService } from '@/qqbot/command/qqbot-command-parser.service';
describe('QqbotCommandParserService FFLogs parser', () => { describe('QqbotCommandParserService FFLogs parser', () => {
const command = { const command = {

View File

@ -2,7 +2,7 @@ import {
buildQqbotFf14MarketCatalog, buildQqbotFf14MarketCatalog,
buildQqbotFf14MarketCatalogFromTree, buildQqbotFf14MarketCatalogFromTree,
resolveQqbotFf14MarketTarget, resolveQqbotFf14MarketTarget,
} from './qqbot-ff14-worlds'; } from '@/qqbot/plugins/ff14Market/qqbot-ff14-worlds';
describe('qqbot ff14 market worlds', () => { describe('qqbot ff14 market worlds', () => {
const catalog = buildQqbotFf14MarketCatalog({ const catalog = buildQqbotFf14MarketCatalog({

View File

@ -8,8 +8,8 @@ jest.mock(
); );
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DictService } from '../../../admin/dict/dict.service'; import { DictService } from '@/admin/dict/dict.service';
import { QqbotFflogsClientService } from './qqbot-fflogs-client.service'; import { QqbotFflogsClientService } from '@/qqbot/plugins/fflogs/qqbot-fflogs-client.service';
describe('QqbotFflogsClientService', () => { describe('QqbotFflogsClientService', () => {
const dicts = { const dicts = {

12
test/test.types.ts Normal file
View File

@ -0,0 +1,12 @@
import request = require('supertest');
export type ControllerRoute = {
controllerName: string;
handlerName: string;
method: string;
path: string;
};
export type HttpServer = Parameters<typeof request>[0];
export type RouteTestCase = (server: HttpServer) => Promise<void>;