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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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 * as svgCaptcha from 'svg-captcha';
import { normalizeVbenErrorText } from '../response/vben-response';
import type {
KtDictOption,
KtPage,
KtResponse,
NapcatLoginStatusLike,
QrcodeLookupOptions,
} from '../types';
@Injectable()
export class ToolsService {
@ -14,7 +22,7 @@ export class ToolsService {
return captcha;
}
res(code: number, msg: string, data: any): Res {
res(code: number, msg: string, data: any): KtResponse {
if (code === 200) {
return {
code,
@ -26,11 +34,11 @@ export class ToolsService {
return {
code,
msg,
err: data,
err: normalizeVbenErrorText(data, msg),
};
}
page<T = any>(list: T[], total: number): Page<T> {
page<T = any>(list: T[], total: number): KtPage<T> {
const retn = {
list,
total,
@ -86,7 +94,7 @@ export class ToolsService {
label: string,
value: any,
other: Partial<T>,
): Dict<T> {
): KtDictOption<T> {
const options = {
label,
value,
@ -95,4 +103,212 @@ export class ToolsService {
return options;
}
getErrorMessage(err: unknown, fallback = '') {
const response = (err as any)?.getResponse?.();
if (typeof response?.msg === 'string') return response.msg;
if (typeof response?.message === 'string') return response.message;
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (err === undefined || err === null) return fallback;
return `${err}`;
}
sleep(ms: number) {
return new Promise<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 WORKER_ID_BITS = 5n;
const DATACENTER_ID_BITS = 5n;
@ -76,10 +78,6 @@ const snowflakeIdGenerator = new SnowflakeIdGenerator();
export const createSnowflakeId = () => snowflakeIdGenerator.nextId();
export type SnowflakeEntity = {
id?: number | string | null;
};
export const isEmptySnowflakeId = (id: SnowflakeEntity['id']) =>
id === undefined || id === null || id === '' || id === 0 || id === '0';

View File

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

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 { knife4jSetup } from '@kwitsukasa/knife4j-swagger-vue3';
import type { Service } from '@kwitsukasa/knife4j-swagger-vue3';
import type { SwaggerDocumentGroup, SwaggerPathMatcher } from './common';
import { applySwaggerResponseExamples } from './common';
type SwaggerPathMatcher = (path: string) => boolean;
interface SwaggerDocumentGroup {
matcher: SwaggerPathMatcher;
name: string;
path: string;
}
const adminSwaggerPathPrefixes = [
'/auth',
'/component',

View File

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

View File

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

View File

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

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

View File

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

View File

@ -3,83 +3,25 @@ import * as https from 'https';
import { createHash, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { throwVbenError } from '@/common';
import type { QqbotLoginScanMode, QqbotLoginScanStatus } from '../qqbot.types';
import {
QqbotNapcatContainerService,
type QqbotNapcatRuntime,
} from '../napcat/qqbot-napcat-container.service';
import { throwVbenError, ToolsService } from '@/common';
import type {
NapcatApiResponse,
NapcatCredential,
NapcatLoginInfo,
NapcatLoginStatus,
NapcatQrcode,
NapcatRestartOptions,
QqbotLoginScanMode,
QqbotLoginScanResult,
QqbotLoginScanSession,
QqbotLoginScanStatus,
QqbotNapcatRuntime,
QrcodeLookupOptions,
QrcodeRefreshOptions,
} from '../qqbot.types';
import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service';
import { QqbotAccountService } from './qqbot-account.service';
type NapcatApiResponse<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()
export class QqbotNapcatLoginService {
private readonly sessions = new Map<string, QqbotLoginScanSession>();
@ -92,6 +34,7 @@ export class QqbotNapcatLoginService {
private readonly configService: ConfigService,
private readonly accountService: QqbotAccountService,
private readonly containerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
) {}
async startCreate() {
@ -125,16 +68,49 @@ export class QqbotNapcatLoginService {
}
const container = await this.getSessionContainer(session);
const loginStatus = await this.getLoginStatus(container);
session.qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus,
requireFresh: true,
staleQrcode: session.qrcode || loginStatus.qrcodeurl,
});
session.expiresAt = Date.now() + this.getSessionTtlMs();
session.errorMessage = undefined;
this.sessions.set(session.id, session);
return this.toResult(session);
let loginStatus: NapcatLoginStatus;
try {
loginStatus = await this.getLoginStatus(container);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
return this.keepSessionPending(
session,
'NapCat 通信超时,已尝试重启容器并重新生成二维码',
true,
);
}
if (loginStatus.isOffline) {
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
return this.keepSessionPending(
session,
loginStatus.loginError ||
'NapCat 账号已离线,已重启容器并重新生成二维码',
true,
);
}
try {
session.qrcode = await this.refreshOrGetQrcode(container, false, {
fallbackStatus: loginStatus,
requireFresh: true,
staleQrcode: session.qrcode || loginStatus.qrcodeurl,
});
session.expiresAt = Date.now() + this.getSessionTtlMs();
session.errorMessage = undefined;
this.sessions.set(session.id, session);
return this.toResult(session);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
return this.keepSessionPending(
session,
'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新',
true,
);
}
}
async status(sessionId: string) {
@ -144,11 +120,27 @@ export class QqbotNapcatLoginService {
}
const container = await this.getSessionContainer(session);
const status = await this.getLoginStatus(container);
let status: NapcatLoginStatus;
try {
status = await this.getLoginStatus(container);
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
return this.keepSessionPending(
session,
'NapCat 正在重启或生成二维码,请稍后',
);
}
if (!status.isLogin) {
session.errorMessage = status.loginError || undefined;
if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) {
if (
status.qrcodeurl &&
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
) {
session.qrcode = status.qrcodeurl;
} else if (status.isOffline) {
session.qrcode = undefined;
} else if (!this.toolsService.isNapcatExpiredQrcodeStatus(status)) {
await this.tryUpdatePendingQrcode(container, session, status);
}
this.sessions.set(session.id, session);
return this.toResult(session);
@ -178,6 +170,21 @@ export class QqbotNapcatLoginService {
try {
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isOffline) {
await this.restartNapcatForLogin(container, { waitForReady: false });
const session = this.createSession({
...options,
container,
status: 'pending',
});
session.lastRestartedAt = Date.now();
session.errorMessage =
loginStatus.loginError ||
'NapCat 账号已离线,已重启容器并重新生成二维码';
this.sessions.set(session.id, session);
return this.toResult(session);
}
if (loginStatus.isLogin) {
const session = this.createSession({
...options,
@ -190,7 +197,8 @@ export class QqbotNapcatLoginService {
const qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus,
requireFresh: this.isExpiredQrcodeStatus(loginStatus),
requireFresh:
this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus),
staleQrcode: loginStatus.qrcodeurl,
});
const session = this.createSession({
@ -205,7 +213,9 @@ export class QqbotNapcatLoginService {
const cleanupError = await this.cleanupRuntimeContainer(container);
if (cleanupError) {
throwVbenError(
`${this.getErrorMessage(err)};清理未绑定容器失败:${cleanupError}`,
`${this.toolsService.getErrorMessage(
err,
)}${cleanupError}`,
);
}
throw err;
@ -217,7 +227,11 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime,
): Promise<QqbotLoginScanResult> {
const loginInfo = await this.getLoginInfo(container);
const selfId = this.getSelfId(loginInfo);
if (loginInfo.online === false) {
return this.failSession(session, 'NapCat 当前账号已离线,请重新更新登录');
}
const selfId = this.toolsService.pickNapcatSelfId(loginInfo);
if (!selfId) {
return this.failSession(session, 'NapCat 已登录但未返回 QQ 号');
}
@ -230,7 +244,7 @@ export class QqbotNapcatLoginService {
const accountId = await this.accountService.ensureScannedAccount({
accountId: session.accountId,
name: this.getNickname(loginInfo),
name: this.toolsService.pickNapcatNickname(loginInfo),
selfId,
});
await this.containerService.bindAccount(accountId, session.containerId);
@ -284,6 +298,19 @@ export class QqbotNapcatLoginService {
};
}
private keepSessionPending(
session: QqbotLoginScanSession,
errorMessage: string,
clearQrcode = false,
) {
session.status = 'pending';
session.errorMessage = errorMessage;
session.expiresAt = Date.now() + this.getSessionTtlMs();
if (clearQrcode) session.qrcode = undefined;
this.sessions.set(session.id, session);
return this.toResult(session);
}
private getSession(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
@ -343,21 +370,43 @@ export class QqbotNapcatLoginService {
}
}
private async tryUpdatePendingQrcode(
container: QqbotNapcatRuntime,
session: QqbotLoginScanSession,
status: NapcatLoginStatus,
) {
try {
const qrcode = await this.getQrcode(container, false, {
requireFresh: !!session.qrcode,
staleQrcode: session.qrcode || status.qrcodeurl,
});
if (qrcode) {
session.qrcode = qrcode;
session.errorMessage = status.loginError || undefined;
}
} catch (err) {
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
session.errorMessage =
session.errorMessage || 'NapCat 正在重新生成二维码,请稍后';
}
}
private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) {
try {
await this.containerService.removeUnboundContainer(container.id);
return null;
} catch (err) {
return this.getErrorMessage(err);
return this.toolsService.getErrorMessage(err);
}
}
private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) {
if (!retry) {
return this.postNapcat<NapcatLoginStatus>(
const status = await this.postNapcat<NapcatLoginStatus>(
container,
'/api/QQLogin/CheckLoginStatus',
);
return this.normalizeLoginStatus(container, status);
}
let lastError: unknown;
@ -366,19 +415,61 @@ export class QqbotNapcatLoginService {
);
for (let index = 0; index < attempts; index += 1) {
try {
return await this.postNapcat<NapcatLoginStatus>(
const status = await this.postNapcat<NapcatLoginStatus>(
container,
'/api/QQLogin/CheckLoginStatus',
);
return await this.normalizeLoginStatus(container, status);
} catch (err) {
lastError = err;
if (!this.isTemporaryNapcatError(err)) break;
await this.sleep(1500);
if (!this.toolsService.isNapcatTemporaryError(err)) break;
await this.toolsService.sleep(1500);
}
}
throw lastError;
}
private async normalizeLoginStatus(
container: QqbotNapcatRuntime,
status: NapcatLoginStatus,
) {
if (this.toolsService.isNapcatOfflineLoginStatus(status)) {
return this.toOfflineLoginStatus(status);
}
if (!status.isLogin) return status;
try {
const loginInfo = await this.getLoginInfo(container);
if (loginInfo.online === false) {
return this.toOfflineLoginStatus(
status,
'NapCat 账号已离线,请重新扫码登录',
);
}
} catch (err) {
const errorMessage = this.toolsService.getErrorMessage(err);
if (this.toolsService.isNapcatOfflineLoginMessage(errorMessage)) {
return this.toOfflineLoginStatus(status, errorMessage);
}
throw err;
}
return status;
}
private toOfflineLoginStatus(
status: NapcatLoginStatus,
errorMessage = 'NapCat 账号已离线,请重新扫码登录',
): NapcatLoginStatus {
return {
...status,
isLogin: false,
isOffline: true,
loginError: status.loginError || errorMessage,
};
}
private async getLoginInfo(container: QqbotNapcatRuntime) {
return this.postNapcat<NapcatLoginInfo>(
container,
@ -396,9 +487,9 @@ export class QqbotNapcatLoginService {
container,
'/api/QQLogin/RefreshQRcode',
);
return this.pickQrcode(data);
return this.toolsService.pickQrcode(data);
} catch (err) {
if (this.isAlreadyLoggedIn(err)) return '';
if (this.toolsService.isNapcatAlreadyLoggedInError(err)) return '';
throw err;
}
});
@ -415,17 +506,20 @@ export class QqbotNapcatLoginService {
container,
'/api/QQLogin/GetQQLoginQrcode',
);
const qrcode = this.pickQrcode(data);
const qrcode = this.toolsService.pickQrcode(data);
if (!qrcode) {
return this.getQrcodeFromStatus(container, options);
}
return this.ensureFreshQrcode(qrcode, options);
return this.toolsService.ensureFreshQrcode(qrcode, options);
} catch (err) {
if (this.isAlreadyLoggedIn(err)) {
if (this.toolsService.isNapcatAlreadyLoggedInError(err)) {
const status = await this.getLoginStatus(container);
return this.ensureFreshQrcode(status.qrcodeurl || '', options);
return this.toolsService.ensureFreshQrcode(
status.qrcodeurl || '',
options,
);
}
if (this.isQrcodePending(err)) {
if (this.toolsService.isNapcatQrcodePendingError(err)) {
return this.getQrcodeFromStatus(container, options);
}
throw err;
@ -450,14 +544,17 @@ export class QqbotNapcatLoginService {
try {
const refreshedQrcode = await this.callRefreshQrcode(container, retry);
if (refreshedQrcode) {
return this.ensureFreshQrcode(refreshedQrcode, lookupOptions);
return this.toolsService.ensureFreshQrcode(
refreshedQrcode,
lookupOptions,
);
}
return await this.getQrcode(container, retry, lookupOptions);
} catch (err) {
if (
!lookupOptions.requireFresh &&
fallbackStatus?.qrcodeurl &&
!this.isExpiredQrcodeStatus(fallbackStatus)
!this.toolsService.isNapcatExpiredQrcodeStatus(fallbackStatus)
) {
return fallbackStatus.qrcodeurl;
}
@ -470,8 +567,11 @@ export class QqbotNapcatLoginService {
options: QrcodeLookupOptions = {},
) {
const status = await this.getLoginStatus(container);
if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) {
return this.ensureFreshQrcode(status.qrcodeurl, options);
if (
status.qrcodeurl &&
!this.toolsService.isNapcatExpiredQrcodeStatus(status)
) {
return this.toolsService.ensureFreshQrcode(status.qrcodeurl, options);
}
if (options.requireFresh && status.qrcodeurl) {
throw new Error('NapCat 二维码仍未刷新');
@ -479,30 +579,6 @@ export class QqbotNapcatLoginService {
throwVbenError('NapCat 未返回登录二维码');
}
private ensureFreshQrcode(qrcode: string, options: QrcodeLookupOptions = {}) {
const normalized = `${qrcode || ''}`.trim();
if (options.requireFresh && !normalized) {
throw new Error('NapCat 二维码仍未刷新');
}
if (
normalized &&
options.requireFresh &&
this.isSameQrcode(normalized, options.staleQrcode)
) {
throw new Error('NapCat 二维码仍未刷新');
}
return normalized;
}
private isSameQrcode(left: string, right?: string) {
return !!right && left.trim() === right.trim();
}
private pickQrcode(data?: NapcatQrcode | null) {
if (!data) return '';
return `${data.qrcode || data.qrcodeurl || data.url || ''}`.trim();
}
private async postNapcat<T>(
container: QqbotNapcatRuntime,
path: string,
@ -512,7 +588,10 @@ export class QqbotNapcatLoginService {
return this.requestNapcat<T>(container, path, body, credential);
}
private async restartNapcatForLogin(container: QqbotNapcatRuntime) {
private async restartNapcatForLogin(
container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {},
) {
const restartedByContainer =
await this.containerService.restartRuntimeContainer(container);
if (!restartedByContainer) {
@ -522,12 +601,14 @@ export class QqbotNapcatLoginService {
'/api/QQLogin/RestartNapCat',
);
} catch (err) {
if (!this.isTemporaryNapcatError(err)) throw err;
if (!this.toolsService.isNapcatTemporaryError(err)) throw err;
}
}
this.credentials.delete(this.getCredentialCacheKey(container));
await this.sleep(this.getRestartDelayMs());
if (options.waitForReady === false) return;
await this.toolsService.sleep(this.getRestartDelayMs());
await this.getLoginStatus(container, true);
}
@ -616,7 +697,7 @@ export class QqbotNapcatLoginService {
req.write(payload);
req.end();
}).catch((err): never => {
const message = this.getErrorMessage(err);
const message = this.toolsService.getErrorMessage(err);
return throwVbenError(message || 'NapCat 请求失败');
});
}
@ -646,45 +727,6 @@ export class QqbotNapcatLoginService {
);
}
private getSelfId(info: NapcatLoginInfo) {
return `${info.uin || info.self_id || info.selfId || ''}`.trim();
}
private getNickname(info: NapcatLoginInfo) {
return `${info.nick || info.nickname || info.name || ''}`.trim();
}
private isAlreadyLoggedIn(err: unknown) {
return this.getErrorMessage(err).includes('QQ Is Logined');
}
private isTemporaryNapcatError(err: unknown) {
const message = this.getErrorMessage(err);
return [
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'NapCat 请求超时',
'NapCat 未返回登录二维码',
'NapCat 二维码仍未刷新',
'QRCode Get Error',
'socket hang up',
].some((keyword) => message.includes(keyword));
}
private isQrcodePending(err: unknown) {
const message = this.getErrorMessage(err);
return message.includes('QRCode Get Error');
}
private isExpiredQrcodeStatus(status: NapcatLoginStatus) {
const message = status.loginError || '';
return (
message.includes('二维码') &&
(message.includes('过期') || message.includes('失效'))
);
}
private async executeNapcatRequest<T>(
retry: boolean,
action: () => Promise<T>,
@ -700,23 +742,10 @@ export class QqbotNapcatLoginService {
return await action();
} catch (err) {
lastError = err;
if (!this.isTemporaryNapcatError(err)) break;
await this.sleep(1500);
if (!this.toolsService.isNapcatTemporaryError(err)) break;
await this.toolsService.sleep(1500);
}
}
throw lastError;
}
private getErrorMessage(err: unknown) {
const response = (err as any)?.getResponse?.();
if (typeof response?.msg === 'string') return response.msg;
if (typeof response?.message === 'string') return response.message;
return err instanceof Error ? err.message : `${err}`;
}
private sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,
resolveQqbotFf14MarketTarget,
} from './qqbot-ff14-worlds';
type HttpMethod = 'GET';
type XivapiSearchItem = {
fields?: {
Icon?: string | { path?: string; path_hr1?: string };
IsUntradable?: boolean;
LevelItem?: number | { row_id?: number; value?: number };
Name?: string;
};
id?: number;
name?: string;
row_id?: number;
sheet?: string;
};
type UniversalisListing = {
hq?: boolean;
lastReviewTime?: number;
pricePerUnit?: number;
quantity?: number;
retainerName?: string;
total?: number;
worldName?: string;
};
type UniversalisMarketResponse = {
currentAveragePrice?: number;
currentAveragePriceHQ?: number;
currentAveragePriceNQ?: number;
itemID?: number;
lastUploadTime?: number;
listings?: UniversalisListing[];
minPrice?: number;
minPriceHQ?: number;
minPriceNQ?: number;
worldName?: string;
};
export type QqbotFf14ResolvedItem = {
icon?: string;
isUntradable?: boolean;
itemId: number;
itemLevel?: number;
name: string;
};
export type QqbotFf14PriceResult = {
averagePrice?: number;
hq?: boolean;
item: QqbotFf14ResolvedItem;
listings: UniversalisListing[];
minPrice?: number;
replyText: string;
updatedAt?: string;
world: string;
};
import type {
Ff14HttpMethod,
QqbotFf14PriceResult,
QqbotFf14ResolvedItem,
UniversalisListing,
UniversalisMarketResponse,
XivapiSearchItem,
} from './qqbot-ff14-market.types';
@Injectable()
export class QqbotFf14ClientService {
@ -416,7 +367,7 @@ export class QqbotFf14ClientService {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
private requestJson<T>(url: URL, method: HttpMethod, context: string) {
private requestJson<T>(url: URL, method: Ff14HttpMethod, context: string) {
return new Promise<T>((resolve, reject) => {
const client = url.protocol === 'http:' ? http : https;
const request = client.request(

View File

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

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 {
AdminDictItem,
AdminDictTreeItem,
} from '../../../admin/dict/dict.service';
export type QqbotFf14DataCenter = {
name: string;
region: string;
worlds: string[];
};
export type QqbotFf14MarketCatalog = {
dataCenters: QqbotFf14DataCenter[];
defaultRegion?: string;
regions: string[];
};
export type QqbotFf14MarketTarget = {
dataCenter?: string;
label: string;
region?: string;
target: string;
world?: string;
};
} from '../../../admin/admin.types';
import type {
QqbotFf14DataCenter,
QqbotFf14MarketCatalog,
QqbotFf14MarketTarget,
} from './qqbot-ff14-market.types';
export const QQBOT_FF14_MARKET_DICT_CODES = {
dataCenter: 'FF14_MARKET_DATA_CENTER',

View File

@ -3,114 +3,23 @@ import { ConfigService } from '@nestjs/config';
import * as http from 'node:http';
import * as https from 'node:https';
import { DictService } from '../../../admin/dict/dict.service';
type HttpMethod = 'GET' | 'POST';
type FflogsTokenResponse = {
access_token?: string;
expires_in?: number;
token_type?: string;
};
type FflogsGraphqlResponse<T> = {
data?: T;
errors?: Array<{ message?: string }>;
};
type FflogsCharacter = {
id?: number;
lodestoneID?: number;
name?: string;
recentReports?: {
data?: FflogsRecentReport[];
};
server?: {
name?: string;
slug?: string;
};
zoneRankings?: unknown;
};
type FflogsReportFight = {
difficulty?: number | null;
encounterID?: number;
endTime?: number;
id?: number;
kill?: boolean | null;
name?: string;
startTime?: number;
};
type FflogsRecentReport = {
code?: string;
endTime?: number;
fights?: FflogsReportFight[];
startTime?: number;
title?: string;
zone?: {
id?: number;
name?: string;
};
};
type FflogsCharacterSummaryResponse = {
characterData?: {
character?: FflogsCharacter | null;
};
};
type FflogsReportFightMetricsResponse = {
reportData?: {
report?: {
damage?: unknown;
dpsRankings?: unknown;
healing?: unknown;
hpsRankings?: unknown;
} | null;
};
};
type FflogsRankingItem = Record<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;
};
import type {
FflogsCharacterSummaryResponse,
FflogsEncounterFightCandidate,
FflogsEncounterLookup,
FflogsGraphqlResponse,
FflogsHttpMethod,
FflogsLocalizationMaps,
FflogsParseMetric,
FflogsRankingItem,
FflogsRecentReport,
FflogsReportFight,
FflogsReportFightMetricsResponse,
FflogsTokenResponse,
QqbotFflogsCharacterSummaryInput,
QqbotFflogsCharacterSummaryResult,
QqbotFflogsEncounterLogItem,
} from './qqbot-fflogs.types';
const FFLOGS_LOCALIZATION_DICT_CODES = {
encounter: 'FFLOGS_ENCOUNTER_LABEL',
@ -120,45 +29,6 @@ const FFLOGS_LOCALIZATION_DICT_CODES = {
serverRegion: 'FFLOGS_SERVER_REGION_LABEL',
};
type FflogsLocalizationMaps = Record<
keyof typeof FFLOGS_LOCALIZATION_DICT_CODES,
Map<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()
export class QqbotFflogsClientService {
private accessToken = '';
@ -655,7 +525,7 @@ export class QqbotFflogsClientService {
private requestJson<T>(
url: URL,
method: HttpMethod,
method: FflogsHttpMethod,
options: { body?: string; headers?: Record<string, string> } = {},
) {
return new Promise<T>((resolve, reject) => {

View File

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

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

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 QqbotConnectionRole = 'API' | 'Event' | 'Universal';
@ -20,18 +63,135 @@ export type QqbotNapcatContainerStatus =
export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending';
export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule';
export type QqbotAccountNapcatRuntimeInfo = {
bindStatus?: QqbotAccountNapcatBindStatus;
containerId?: string;
containerName?: string;
containerStatus?: QqbotNapcatContainerStatus;
lastCheckedAt?: Date | null;
lastError?: null | string;
lastLoginAt?: Date | null;
lastStartedAt?: Date | null;
webuiPort?: null | number;
};
export type QqbotAccountListItem = QqbotAccount & {
napcat?: null | QqbotAccountNapcatRuntimeInfo;
};
export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex';
export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private';
export type QqbotCommandParserType = 'ff14Price' | 'fflogsCharacter' | 'plain';
export type QqbotCommandLogStatus = 'failed' | 'success';
export type QqbotCommandMatchResult = {
alias: string;
input: Record<string, any>;
matched: true;
rawArgs: string;
};
export type QqbotPluginHealthStatus = 'degraded' | 'healthy' | 'offline';
export type QqbotPluginHealth = {
checkedAt: string;
message?: string;
name?: string;
pluginKey?: string;
status: QqbotPluginHealthStatus;
triggerMode?: QqbotPluginTriggerMode;
};
export type QqbotPluginTriggerMode = 'command' | 'event';
export type QqbotPluginOperationContext = {
args?: Record<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 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> & {
channel_id?: number | string;
group_id?: number | string;
@ -69,3 +229,68 @@ export type QqbotOneBotActionResponse = {
retcode?: number;
status?: string;
};
export type QqbotBusHandler = (payload: any) => Promise<void> | 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 { ToolsService } from '@/common';
import type { QqbotNormalizedMessage } from '../qqbot.types';
import { QqbotCommandEngineService } from '../command/qqbot-command-engine.service';
import { QqbotPermissionService } from '../permission/qqbot-permission.service';
@ -16,6 +17,7 @@ export class QqbotRuleEngineService {
private readonly repeaterPluginService: QqbotRepeaterPluginService,
private readonly ruleService: QqbotRuleService,
private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService,
) {}
async handleMessage(message: QqbotNormalizedMessage) {
@ -41,7 +43,7 @@ export class QqbotRuleEngineService {
targetType: message.messageType,
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : '自动回复失败';
const errMsg = this.toolsService.getErrorMessage(err, '自动回复失败');
this.logger.warn(`QQBot 自动回复失败: ${errMsg}`);
}
return;

View File

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

View File

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

View File

@ -2,14 +2,19 @@ import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError } from '@/common';
import { throwVbenError, ToolsService } from '@/common';
import { QqbotAccountService } from '../account/qqbot-account.service';
import type { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service';
import { QQBOT_MQTT_TOPICS } from '../qqbot.constants';
import { QqbotBusService } from '../mqtt/qqbot-bus.service';
import { QqbotMessageService } from '../message/qqbot-message.service';
import type { QqbotMessageType } from '../qqbot.types';
import { getPageParams } from '../qqbot.utils';
import type {
QqbotMessageType,
QqbotReverseActionSender,
} from '../qqbot.types';
import {
QQBOT_DEFAULT_PAGE_NO,
QQBOT_DEFAULT_PAGE_SIZE,
} from '../qqbot.constants';
import { QqbotRateLimitService } from './qqbot-rate-limit.service';
import { QqbotSendLog } from './qqbot-send-log.entity';
import type {
@ -28,10 +33,15 @@ export class QqbotSendService {
private readonly messageService: QqbotMessageService,
private readonly moduleRef: ModuleRef,
private readonly rateLimitService: QqbotRateLimitService,
private readonly toolsService: ToolsService,
) {}
async logPage(query: QqbotSendLogQueryDto) {
const { pageNo, pageSize, skip } = getPageParams(query);
const { pageNo, pageSize, skip } = this.toolsService.getPageParams(
query,
QQBOT_DEFAULT_PAGE_NO,
QQBOT_DEFAULT_PAGE_SIZE,
);
const builder = this.sendLogRepository.createQueryBuilder('log');
if (query.selfId) {
@ -152,7 +162,7 @@ export class QqbotSendService {
if (!success) throwVbenError(response.message || 'OneBot 发送失败');
return { ...response, logId: log.id };
} catch (err) {
const message = err instanceof Error ? err.message : 'OneBot 发送失败';
const message = this.toolsService.getErrorMessage(err, 'OneBot 发送失败');
await this.sendLogRepository.update(
{ id: log.id },
{
@ -164,11 +174,11 @@ export class QqbotSendService {
}
}
private async getReverseWsService() {
private async getReverseWsService(): Promise<QqbotReverseActionSender> {
const { QqbotReverseWsService } = await import(
'../connection/qqbot-reverse-ws.service'
);
return this.moduleRef.get<QqbotReverseWsService>(QqbotReverseWsService, {
return this.moduleRef.get<QqbotReverseActionSender>(QqbotReverseWsService, {
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 { ConfigService } from '@nestjs/config';
import type { Request, Response as ExpressResponse } from 'express';
import { throwVbenError } from '@/common';
import { throwVbenError, ToolsService } from '@/common';
import type {
WordpressArticleBodyDto,
WordpressArticleListQueryDto,
WordpressTermBodyDto,
WordpressTermListQueryDto,
} from './wordpress.dto';
export type WordpressAuthContext = {
authorization?: string;
cookie?: string;
nonce?: string;
};
export type WordpressLoginResult = {
auth: {
nonce: string;
type: 'cookie';
};
user: any;
};
export type WordpressOptionalLoginResult =
| {
available: false;
error: WordpressAvailabilityError;
result: null;
}
| {
available: true;
error: null;
result: WordpressLoginResult & { cookie: string };
};
export type WordpressAvailabilityError = {
error: any;
message: string;
status: number;
};
type WordpressAvailabilityCache = {
available: boolean;
checkedAt: number;
error?: WordpressAvailabilityError;
};
type WordpressRequestOptions = {
auth: WordpressAuthContext;
body?: Record<string, unknown>;
method?: 'GET' | 'POST' | 'DELETE';
query?: Record<string, unknown>;
};
type WordpressResponse<T> = {
data: T;
total?: number;
};
import type {
WordpressAuthContext,
WordpressAvailabilityCache,
WordpressAvailabilityError,
WordpressLoginResult,
WordpressOptionalLoginResult,
WordpressPagedQueryDto,
WordpressRequestOptions,
WordpressResponse,
} from './wordpress.types';
const WORDPRESS_COOKIE_PREFIXES = [
'wordpress_',
@ -72,19 +32,22 @@ const WORDPRESS_AUTH_COOKIE = 'kt_wordpress_auth';
export class WordpressService {
private availabilityCache: null | WordpressAvailabilityCache = null;
constructor(private readonly configService: ConfigService) {}
constructor(
private readonly configService: ConfigService,
private readonly toolsService: ToolsService,
) {}
getAuthContext(request: Request): WordpressAuthContext {
const authorization =
this.readHeader(request, 'x-wordpress-authorization') ||
this.readHeader(request, 'x-wp-authorization') ||
this.toolsService.readHeader(request, 'x-wordpress-authorization') ||
this.toolsService.readHeader(request, 'x-wp-authorization') ||
this.getForwardableAuthorization(request);
const nonce =
this.readHeader(request, 'x-wp-nonce') ||
this.readHeader(request, 'x-wordpress-nonce');
this.toolsService.readHeader(request, 'x-wp-nonce') ||
this.toolsService.readHeader(request, 'x-wordpress-nonce');
const cookie =
this.readHeader(request, 'x-wordpress-cookie') ||
this.readCookie(request, WORDPRESS_AUTH_COOKIE) ||
this.toolsService.readHeader(request, 'x-wordpress-cookie') ||
this.toolsService.readCookie(request, WORDPRESS_AUTH_COOKIE) ||
this.getWordpressCookie(request.headers.cookie);
return {
@ -439,13 +402,20 @@ export class WordpressService {
let data = await this.parseResponse(response);
// 部分 WordPress 网关会拦截 DELETEREST API 官方支持用 _method=DELETE 通过 POST 兜底。
if (!response.ok && response.status === 405 && options.method === 'DELETE') {
response = await fetch(this.getMethodOverrideUrl(urls[index], 'DELETE'), {
headers: this.getHeaders(options.auth, false),
method: 'POST',
redirect: 'follow',
signal: controller.signal,
});
if (
!response.ok &&
response.status === 405 &&
options.method === 'DELETE'
) {
response = await fetch(
this.getMethodOverrideUrl(urls[index], 'DELETE'),
{
headers: this.getHeaders(options.auth, false),
method: 'POST',
redirect: 'follow',
signal: controller.signal,
},
);
data = await this.parseResponse(response);
}
@ -460,7 +430,7 @@ export class WordpressService {
if (!response.ok) {
throwVbenError(
this.getErrorMessage(data, response.status),
this.getWordpressResponseErrorMessage(data, response.status),
response.status,
data,
);
@ -734,7 +704,7 @@ export class WordpressService {
}
private getArticleBody(body: WordpressArticleBodyDto) {
return this.pickDefined({
return this.toolsService.pickDefined({
categories: this.normalizeIdList(body.categories),
content: body.content,
excerpt: body.excerpt,
@ -748,7 +718,7 @@ export class WordpressService {
}
private getTermBody(body: WordpressTermBodyDto) {
return this.pickDefined({
return this.toolsService.pickDefined({
description: body.description,
name: body.name,
parent: body.parent,
@ -784,19 +754,6 @@ export class WordpressService {
.join(',');
}
private pickDefined(payload: Record<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) {
const text = await response.text();
@ -809,14 +766,14 @@ export class WordpressService {
}
}
private getErrorMessage(data: any, status: number) {
private getWordpressResponseErrorMessage(data: any, status: number) {
if (data?.message) return data.message;
if (typeof data === 'string' && data) return data;
return `WordPress 请求失败:${status}`;
}
private throwWordpressNetworkError(err: unknown): never {
const message = err instanceof Error ? err.message : '未知错误';
const message = this.toolsService.getErrorMessage(err, '未知错误');
const cause = this.getErrorCause(err);
// fetch 的 DNS、连接拒绝等底层异常不是 HttpException需要统一转成业务响应。
@ -852,7 +809,7 @@ export class WordpressService {
return {
error: err instanceof Error ? err.name : 'WordPressUnavailable',
message: err instanceof Error ? err.message : 'WordPress 暂不可用',
message: this.toolsService.getErrorMessage(err, 'WordPress 暂不可用'),
status: HttpStatus.BAD_GATEWAY,
};
}
@ -928,31 +885,11 @@ export class WordpressService {
return '';
}
private readHeader(request: Request, name: string) {
const value = request.headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
}
private readCookie(request: Request, cookieName: string) {
const cookieHeader = request.headers.cookie || '';
const cookie = cookieHeader.split(';').find((item) => {
const [key] = item.trim().split('=');
return key === cookieName;
});
if (!cookie) return undefined;
const [, ...value] = cookie.trim().split('=');
try {
return decodeURIComponent(value.join('='));
} catch {
return value.join('=');
}
}
private getForwardableAuthorization(request: Request) {
const authorization = this.readHeader(request, 'authorization');
const authorization = this.toolsService.readHeader(
request,
'authorization',
);
if (!authorization || this.isLikelyAdminAuthorization(authorization)) {
return undefined;
@ -997,7 +934,3 @@ export class WordpressService {
return cookies.length ? cookies.join('; ') : undefined;
}
}
type WordpressPagedQueryDto =
| WordpressArticleListQueryDto
| WordpressTermListQueryDto;

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

View File

@ -27,6 +27,7 @@ import {
collectControllerRoutes,
routeKey,
} from './helpers/controller-route.helper';
import type { RouteTestCase } from './test.types';
const component = {
id: '2041739550026043392',
@ -56,6 +57,41 @@ const chartOptions = [
},
];
const dictItem = {
childrenCode: 'CHART',
dictCode: 'COMPONENT_TYPE',
id: '2041700000000300001',
label: '图表',
sort: 1,
status: 1,
value: '1',
};
const dictTreeItem = {
...dictItem,
children: [
{
childrenCode: null,
dictCode: 'CHART',
id: '2041700000000300002',
label: '折线图',
sort: 1,
status: 1,
treeKey: '2041700000000300001/2041700000000300002',
value: '1',
},
],
treeKey: '2041700000000300001',
};
const dictGroupItem = {
dictCode: 'COMPONENT_TYPE',
id: 'dict-code:COMPONENT_TYPE',
itemCount: 2,
label: 'COMPONENT_TYPE',
value: 'COMPONENT_TYPE',
};
const uploadResult = {
bucketName: 'kt-template-online',
objectName: 'uploads/demo.txt',
@ -128,8 +164,17 @@ const unauthorizedException = () =>
);
const dictServiceMock = {
codes: jest.fn(),
getDictCodeOptions: jest.fn(),
getDictByKey: jest.fn(),
getComponentDictByType: jest.fn(),
groups: jest.fn(),
page: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
toggle: jest.fn(),
tree: jest.fn(),
update: jest.fn(),
};
const minioServiceMock = {
@ -177,9 +222,6 @@ const controllerClasses = [
];
const controllerRoutes = collectControllerRoutes(controllerClasses);
type HttpServer = Parameters<typeof request>[0];
type RouteTestCase = (server: HttpServer) => Promise<void>;
const routeTestCases: Record<string, RouteTestCase> = {
'GET /': async (server) => {
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) => {
minioServiceMock.checkConnection.mockResolvedValue({
bucketName: 'demo-bucket',
@ -580,7 +798,9 @@ const routeTestCases: Record<string, RouteTestCase> = {
.post('/wordpress/auth/login')
.expect(201);
expect(wordpressServiceMock.loginWithConfiguredAdmin).toHaveBeenCalledWith();
expect(
wordpressServiceMock.loginWithConfiguredAdmin,
).toHaveBeenCalledWith();
expect(wordpressServiceMock.setAuthCookie).toHaveBeenCalledWith(
expect.anything(),
wordpressLoginResult.cookie,
@ -1058,7 +1278,32 @@ describe('KT Template Online API (e2e)', () => {
expect(response.body).toEqual({
code: 400,
msg: '操作失败',
err: false,
err: 'false',
});
});
it('serializes object error details as a string for frontend parsing', async () => {
wordpressServiceMock.checkAuth.mockRejectedValue(
new HttpException(
{
msg: 'WordPress 请求失败',
err: {
code: 'WORDPRESS_NETWORK_ERROR',
message: 'connect ECONNREFUSED 127.0.0.1:8080',
},
},
HttpStatus.BAD_GATEWAY,
),
);
const response = await request(app.getHttpServer())
.get('/wordpress/auth/check')
.expect(502);
expect(response.body).toEqual({
code: 502,
msg: 'WordPress 请求失败',
err: 'connect ECONNREFUSED 127.0.0.1:8080',
});
});
@ -1089,9 +1334,7 @@ describe('KT Template Online API (e2e)', () => {
jest.clearAllMocks();
authServiceMock.currentUser.mockRejectedValue(unauthorizedException());
await request(app.getHttpServer())
.get('/wordpress/auth/check')
.expect(401);
await request(app.getHttpServer()).get('/wordpress/auth/check').expect(401);
expect(wordpressServiceMock.checkAuth).not.toHaveBeenCalled();
});

View File

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

View File

@ -1,25 +1,25 @@
jest.mock(
'@/common',
() => ({
ensureSnowflakeId: jest.fn(),
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);
},
}),
{ virtual: true },
);
jest.mock('@/common', () => ({
ToolsService: jest.requireActual('@/common').ToolsService,
ensureSnowflakeId: jest.fn(),
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);
},
}));
import { ConfigService } from '@nestjs/config';
import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service';
import { QqbotAccountService } from './qqbot-account.service';
import { QqbotNapcatLoginService } from './qqbot-napcat-login.service';
import { ToolsService } from '@/common';
import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service';
import { QqbotNapcatLoginService } from '@/qqbot/account/qqbot-napcat-login.service';
import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service';
describe('QqbotNapcatLoginService', () => {
const toolsService = new ToolsService();
const service = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
{} as QqbotNapcatContainerService,
toolsService,
);
beforeEach(() => {
@ -75,6 +75,7 @@ describe('QqbotNapcatLoginService', () => {
{ get: jest.fn() } as unknown as ConfigService,
accountService as unknown as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const startScan = jest
.spyOn(refreshService as any, 'startScan')
@ -121,12 +122,102 @@ describe('QqbotNapcatLoginService', () => {
const result = await service.refreshQrcode('session-refresh-qrcode');
expect(result.qrcode).toBe('new-qrcode');
expect((service as any).getQrcode).toHaveBeenCalledWith(container, true, {
expect((service as any).getQrcode).toHaveBeenCalledWith(container, false, {
requireFresh: true,
staleQrcode: 'old-qrcode',
});
});
it('keeps the refresh session pending when NapCat is still regenerating qrcode', async () => {
(service as any).sessions.set('session-pending-qrcode', {
containerId: 'container-pending',
containerName: 'napcat-pending',
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
id: 'session-pending-qrcode',
mode: 'refresh',
qrcode: 'old-qrcode',
status: 'pending',
webuiPort: 6105,
});
jest
.spyOn(service as any, 'getSessionContainer')
.mockResolvedValue({ id: 'container-pending' });
jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
qrcodeurl: 'old-qrcode',
});
jest
.spyOn(service as any, 'callRefreshQrcode')
.mockRejectedValue(new Error('NapCat 二维码仍未刷新'));
const result = await service.refreshQrcode('session-pending-qrcode');
expect(result.status).toBe('pending');
expect(result.qrcode).toBeUndefined();
expect(result.errorMessage).toContain('正在重新生成二维码');
});
it('normalizes login status to offline when login info reports offline', async () => {
jest
.spyOn(service as any, 'postNapcat')
.mockResolvedValueOnce({ isLogin: true, qrcodeurl: 'old-qrcode' })
.mockResolvedValueOnce({ online: false, uin: '10001' });
const result = await (service as any).getLoginStatus({
id: 'container-offline',
});
expect(result).toEqual(
expect.objectContaining({
isLogin: false,
isOffline: true,
}),
);
});
it('restarts container and returns pending when refresh login status is effectively offline', async () => {
const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
{} as QqbotNapcatContainerService,
new ToolsService(),
);
jest
.spyOn(refreshService as any, 'cleanupSessions')
.mockResolvedValue(null);
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
isOffline: true,
loginError: 'NapCat 账号已离线',
qrcodeurl: 'old-qrcode',
});
const restartNapcatForLogin = jest
.spyOn(refreshService as any, 'restartNapcatForLogin')
.mockResolvedValue(undefined);
const result = await (refreshService as any).startScan(
{
accountId: 'account-1',
expectedSelfId: '10001',
mode: 'refresh',
},
{
baseUrl: 'http://127.0.0.1:6105/',
id: 'container-offline',
name: 'napcat-10001',
},
);
expect(result.status).toBe('pending');
expect(result.qrcode).toBeUndefined();
expect(result.errorMessage).toBe('NapCat 账号已离线');
expect(restartNapcatForLogin).toHaveBeenCalledWith(
expect.objectContaining({ id: 'container-offline' }),
{ waitForReady: false },
);
});
it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
@ -140,8 +231,11 @@ describe('QqbotNapcatLoginService', () => {
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
jest.spyOn(refreshService as any, 'sleep').mockResolvedValue(undefined);
jest
.spyOn((refreshService as any).toolsService, 'sleep')
.mockResolvedValue(undefined);
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
qrcodeurl: 'new-status-qrcode',
@ -181,7 +275,9 @@ describe('QqbotNapcatLoginService', () => {
});
it('retries while NapCat still exposes the stale qrcode', async () => {
jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
jest
.spyOn((service as any).toolsService, 'sleep')
.mockResolvedValue(undefined);
jest
.spyOn(service as any, 'postNapcat')
.mockResolvedValueOnce({ qrcode: 'old-qrcode' })

View File

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

View File

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

View File

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

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