feat: 规范化 API 公共能力与 QQBot 登录流程
This commit is contained in:
parent
0ddf785b8b
commit
b0cf9323b8
20
package.json
20
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
89
src/admin/admin.types.ts
Normal 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>;
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 覆盖为空对象。
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
};
|
||||
9
src/common/common.module.ts
Normal file
9
src/common/common.module.ts
Normal 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 {}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
57
src/common/response/vben-response.ts
Normal file
57
src/common/response/vben-response.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
@ -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('失效'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
6
src/common/types/api-exception.types.ts
Normal file
6
src/common/types/api-exception.types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type ExceptionBody = {
|
||||
err?: unknown;
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
msg?: unknown;
|
||||
};
|
||||
35
src/common/types/api.types.ts
Normal file
35
src/common/types/api.types.ts
Normal 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[];
|
||||
10
src/common/types/decode-dict.types.ts
Normal file
10
src/common/types/decode-dict.types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type DecodeDictKeyOptions = {
|
||||
fallback?: string;
|
||||
sourceKey?: string;
|
||||
targetKey?: string;
|
||||
};
|
||||
|
||||
export type DictDecodeRule = DecodeDictKeyOptions & {
|
||||
targetKey: string;
|
||||
dictKeys: string[];
|
||||
};
|
||||
6
src/common/types/index.ts
Normal file
6
src/common/types/index.ts
Normal 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';
|
||||
3
src/common/types/snowflake.types.ts
Normal file
3
src/common/types/snowflake.types.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type SnowflakeEntity = {
|
||||
id?: number | string | null;
|
||||
};
|
||||
21
src/common/types/swagger.types.ts
Normal file
21
src/common/types/swagger.types.ts
Normal 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>;
|
||||
9
src/common/types/tool.types.ts
Normal file
9
src/common/types/tool.types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type NapcatLoginStatusLike = {
|
||||
isOffline?: boolean;
|
||||
loginError?: string;
|
||||
};
|
||||
|
||||
export type QrcodeLookupOptions = {
|
||||
requireFresh?: boolean;
|
||||
staleQrcode?: string;
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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
33
src/minio/minio.types.ts
Normal 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;
|
||||
};
|
||||
@ -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'], {
|
||||
|
||||
@ -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 || '',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -6,7 +6,7 @@ import type {
|
||||
QqbotEventPluginDefinition,
|
||||
QqbotPluginHealth,
|
||||
QqbotPluginOperationSummary,
|
||||
} from './qqbot-plugin.types';
|
||||
} from '../qqbot.types';
|
||||
|
||||
@Injectable()
|
||||
export class QqbotEventPluginRegistryService {
|
||||
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
QqbotPluginOperationContext,
|
||||
QqbotPluginOperationSummary,
|
||||
QqbotPluginSummary,
|
||||
} from './qqbot-plugin.types';
|
||||
} from '../qqbot.types';
|
||||
|
||||
@Injectable()
|
||||
export class QqbotPluginRegistryService implements OnModuleInit {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
|
||||
76
src/qqbot/plugins/ff14Market/qqbot-ff14-market.types.ts
Normal file
76
src/qqbot/plugins/ff14Market/qqbot-ff14-market.types.ts
Normal 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;
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
153
src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts
Normal file
153
src/qqbot/plugins/fflogs/qqbot-fflogs.types.ts
Normal 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;
|
||||
};
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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
28
src/types/res.d.ts
vendored
@ -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[];
|
||||
@ -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 网关会拦截 DELETE;REST API 官方支持用 _method=DELETE 通过 POST 兜底。
|
||||
if (!response.ok && response.status === 405 && options.method === 'DELETE') {
|
||||
response = await fetch(this.getMethodOverrideUrl(urls[index], 'DELETE'), {
|
||||
headers: this.getHeaders(options.auth, false),
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
!response.ok &&
|
||||
response.status === 405 &&
|
||||
options.method === 'DELETE'
|
||||
) {
|
||||
response = await fetch(
|
||||
this.getMethodOverrideUrl(urls[index], 'DELETE'),
|
||||
{
|
||||
headers: this.getHeaders(options.auth, false),
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
data = await this.parseResponse(response);
|
||||
}
|
||||
|
||||
@ -460,7 +430,7 @@ export class WordpressService {
|
||||
|
||||
if (!response.ok) {
|
||||
throwVbenError(
|
||||
this.getErrorMessage(data, response.status),
|
||||
this.getWordpressResponseErrorMessage(data, response.status),
|
||||
response.status,
|
||||
data,
|
||||
);
|
||||
@ -734,7 +704,7 @@ export class WordpressService {
|
||||
}
|
||||
|
||||
private getArticleBody(body: WordpressArticleBodyDto) {
|
||||
return this.pickDefined({
|
||||
return this.toolsService.pickDefined({
|
||||
categories: this.normalizeIdList(body.categories),
|
||||
content: body.content,
|
||||
excerpt: body.excerpt,
|
||||
@ -748,7 +718,7 @@ export class WordpressService {
|
||||
}
|
||||
|
||||
private getTermBody(body: WordpressTermBodyDto) {
|
||||
return this.pickDefined({
|
||||
return this.toolsService.pickDefined({
|
||||
description: body.description,
|
||||
name: body.name,
|
||||
parent: body.parent,
|
||||
@ -784,19 +754,6 @@ export class WordpressService {
|
||||
.join(',');
|
||||
}
|
||||
|
||||
private pickDefined(payload: Record<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;
|
||||
|
||||
58
src/wordpress/wordpress.types.ts
Normal file
58
src/wordpress/wordpress.types.ts
Normal 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;
|
||||
};
|
||||
@ -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(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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' })
|
||||
@ -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 = {
|
||||
@ -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({
|
||||
@ -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
12
test/test.types.ts
Normal 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>;
|
||||
Loading…
Reference in New Issue
Block a user