From cf2c20ba2284e0fdcb55a65359a120422414a4b2 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 3 Jun 2026 21:53:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20QQBot=E6=98=A0=E5=B0=84=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=AD=97=E5=85=B8=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/qqbot-init.sql | 95 +++++++++ src/admin/dict/dict.service.ts | 20 +- .../command/qqbot-command-engine.service.ts | 22 +- .../command/qqbot-command-parser.service.ts | 50 +++-- .../ff14Market/qqbot-ff14-client.service.ts | 63 ++++-- .../ff14Market/qqbot-ff14-worlds.spec.ts | 37 ++++ .../plugins/ff14Market/qqbot-ff14-worlds.ts | 189 ++++++++++-------- .../qqbot-fflogs-client.service.spec.ts | 69 +++++++ .../fflogs/qqbot-fflogs-client.service.ts | 185 +++++++++++++++-- src/qqbot/qqbot.module.ts | 2 + 10 files changed, 581 insertions(+), 151 deletions(-) create mode 100644 src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts create mode 100644 src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 7d501f7..c8763c3 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -543,6 +543,101 @@ ON DUPLICATE KEY UPDATE `status` = VALUES(`status`), `is_deleted` = 0; +INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`) +VALUES + (2041700000000300601, 'FFLOGS_ENCOUNTER_LABEL', 'M9S Vamp Fatale', 'vampfatale', NULL, 1, 1), + (2041700000000300602, 'FFLOGS_ENCOUNTER_LABEL', 'M10S Red Hot and Deep Blue', 'redhotanddeepblue', NULL, 2, 1), + (2041700000000300603, 'FFLOGS_ENCOUNTER_LABEL', 'M11S The Tyrant', 'thetyrant', NULL, 3, 1), + (2041700000000300604, 'FFLOGS_ENCOUNTER_LABEL', 'M12S P1 Lindwurm', 'lindwurm', NULL, 4, 1), + (2041700000000300605, 'FFLOGS_ENCOUNTER_LABEL', 'M12S P2 Lindwurm II', 'lindwurmii', NULL, 5, 1), + (2041700000000300701, 'FFLOGS_JOB_LABEL', '骑士', 'paladin', NULL, 1, 1), + (2041700000000300702, 'FFLOGS_JOB_LABEL', '战士', 'warrior', NULL, 2, 1), + (2041700000000300703, 'FFLOGS_JOB_LABEL', '暗黑骑士', 'darkknight', NULL, 3, 1), + (2041700000000300704, 'FFLOGS_JOB_LABEL', '绝枪战士', 'gunbreaker', NULL, 4, 1), + (2041700000000300705, 'FFLOGS_JOB_LABEL', '白魔法师', 'whitemage', NULL, 5, 1), + (2041700000000300706, 'FFLOGS_JOB_LABEL', '学者', 'scholar', NULL, 6, 1), + (2041700000000300707, 'FFLOGS_JOB_LABEL', '占星术士', 'astrologian', NULL, 7, 1), + (2041700000000300708, 'FFLOGS_JOB_LABEL', '贤者', 'sage', NULL, 8, 1), + (2041700000000300709, 'FFLOGS_JOB_LABEL', '武僧', 'monk', NULL, 9, 1), + (2041700000000300710, 'FFLOGS_JOB_LABEL', '龙骑士', 'dragoon', NULL, 10, 1), + (2041700000000300711, 'FFLOGS_JOB_LABEL', '忍者', 'ninja', NULL, 11, 1), + (2041700000000300712, 'FFLOGS_JOB_LABEL', '武士', 'samurai', NULL, 12, 1), + (2041700000000300713, 'FFLOGS_JOB_LABEL', '钐镰客', 'reaper', NULL, 13, 1), + (2041700000000300714, 'FFLOGS_JOB_LABEL', '蝰蛇剑士', 'viper', NULL, 14, 1), + (2041700000000300715, 'FFLOGS_JOB_LABEL', '吟游诗人', 'bard', NULL, 15, 1), + (2041700000000300716, 'FFLOGS_JOB_LABEL', '机工士', 'machinist', NULL, 16, 1), + (2041700000000300717, 'FFLOGS_JOB_LABEL', '舞者', 'dancer', NULL, 17, 1), + (2041700000000300718, 'FFLOGS_JOB_LABEL', '黑魔法师', 'blackmage', NULL, 18, 1), + (2041700000000300719, 'FFLOGS_JOB_LABEL', '召唤师', 'summoner', NULL, 19, 1), + (2041700000000300720, 'FFLOGS_JOB_LABEL', '赤魔法师', 'redmage', NULL, 20, 1), + (2041700000000300721, 'FFLOGS_JOB_LABEL', '绘灵法师', 'pictomancer', NULL, 21, 1), + (2041700000000300722, 'FFLOGS_JOB_LABEL', '青魔法师', 'bluemage', NULL, 22, 1), + (2041700000000300801, 'FFLOGS_METRIC_LABEL', 'DPS', 'dps', NULL, 1, 1), + (2041700000000300802, 'FFLOGS_METRIC_LABEL', 'HPS', 'hps', NULL, 2, 1), + (2041700000000300803, 'FFLOGS_METRIC_LABEL', 'rDPS', 'rdps', NULL, 3, 1), + (2041700000000300804, 'FFLOGS_METRIC_LABEL', 'aDPS', 'adps', NULL, 4, 1), + (2041700000000300805, 'FFLOGS_METRIC_LABEL', 'nDPS', 'ndps', NULL, 5, 1), + (2041700000000300806, 'FFLOGS_METRIC_LABEL', 'Boss DPS', 'bossdps', NULL, 6, 1), + (2041700000000300807, 'FFLOGS_METRIC_LABEL', 'Boss rDPS', 'bossrdps', NULL, 7, 1), + (2041700000000300901, 'FFLOGS_ROLE_LABEL', '坦克', 'tank', NULL, 1, 1), + (2041700000000300902, 'FFLOGS_ROLE_LABEL', '治疗', 'healer', NULL, 2, 1), + (2041700000000300903, 'FFLOGS_ROLE_LABEL', '输出', 'dps', NULL, 3, 1), + (2041700000000301001, 'FFLOGS_SERVER_REGION_LABEL', '国服', 'cn', NULL, 1, 1), + (2041700000000301002, 'FFLOGS_SERVER_REGION_LABEL', '日服', 'jp', NULL, 2, 1), + (2041700000000301003, 'FFLOGS_SERVER_REGION_LABEL', '美服', 'na', NULL, 3, 1), + (2041700000000301004, 'FFLOGS_SERVER_REGION_LABEL', '欧服', 'eu', NULL, 4, 1), + (2041700000000301005, 'FFLOGS_SERVER_REGION_LABEL', '韩服', 'kr', NULL, 5, 1), + (2041700000000301006, 'FFLOGS_SERVER_REGION_LABEL', '台服', 'tw', NULL, 6, 1), + (2041700000000301007, 'FFLOGS_SERVER_REGION_LABEL', '澳服', 'oc', NULL, 7, 1) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `children_code` = VALUES(`children_code`), + `sort` = VALUES(`sort`), + `status` = VALUES(`status`), + `is_deleted` = 0; + +INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`) +VALUES + (2041700000000301101, 'FF14_MARKET_REGION', '中国', '中国', NULL, 1, 1), + (2041700000000301201, 'FF14_MARKET_DATA_CENTER', '陆行鸟', '陆行鸟', '中国', 1, 1), + (2041700000000301202, 'FF14_MARKET_DATA_CENTER', '莫古力', '莫古力', '中国', 2, 1), + (2041700000000301203, 'FF14_MARKET_DATA_CENTER', '猫小胖', '猫小胖', '中国', 3, 1), + (2041700000000301204, 'FF14_MARKET_DATA_CENTER', '豆豆柴', '豆豆柴', '中国', 4, 1), + (2041700000000301301, 'FF14_MARKET_WORLD', '红玉海', '红玉海', '陆行鸟', 1, 1), + (2041700000000301302, 'FF14_MARKET_WORLD', '神意之地', '神意之地', '陆行鸟', 2, 1), + (2041700000000301303, 'FF14_MARKET_WORLD', '拉诺西亚', '拉诺西亚', '陆行鸟', 3, 1), + (2041700000000301304, 'FF14_MARKET_WORLD', '幻影群岛', '幻影群岛', '陆行鸟', 4, 1), + (2041700000000301305, 'FF14_MARKET_WORLD', '萌芽池', '萌芽池', '陆行鸟', 5, 1), + (2041700000000301306, 'FF14_MARKET_WORLD', '宇宙和音', '宇宙和音', '陆行鸟', 6, 1), + (2041700000000301307, 'FF14_MARKET_WORLD', '沃仙曦染', '沃仙曦染', '陆行鸟', 7, 1), + (2041700000000301308, 'FF14_MARKET_WORLD', '晨曦王座', '晨曦王座', '陆行鸟', 8, 1), + (2041700000000301309, 'FF14_MARKET_WORLD', '白银乡', '白银乡', '莫古力', 9, 1), + (2041700000000301310, 'FF14_MARKET_WORLD', '白金幻象', '白金幻象', '莫古力', 10, 1), + (2041700000000301311, 'FF14_MARKET_WORLD', '神拳痕', '神拳痕', '莫古力', 11, 1), + (2041700000000301312, 'FF14_MARKET_WORLD', '潮风亭', '潮风亭', '莫古力', 12, 1), + (2041700000000301313, 'FF14_MARKET_WORLD', '旅人栈桥', '旅人栈桥', '莫古力', 13, 1), + (2041700000000301314, 'FF14_MARKET_WORLD', '拂晓之间', '拂晓之间', '莫古力', 14, 1), + (2041700000000301315, 'FF14_MARKET_WORLD', '龙巢神殿', '龙巢神殿', '莫古力', 15, 1), + (2041700000000301316, 'FF14_MARKET_WORLD', '梦羽宝境', '梦羽宝境', '莫古力', 16, 1), + (2041700000000301317, 'FF14_MARKET_WORLD', '紫水栈桥', '紫水栈桥', '猫小胖', 17, 1), + (2041700000000301318, 'FF14_MARKET_WORLD', '延夏', '延夏', '猫小胖', 18, 1), + (2041700000000301319, 'FF14_MARKET_WORLD', '静语庄园', '静语庄园', '猫小胖', 19, 1), + (2041700000000301320, 'FF14_MARKET_WORLD', '摩杜纳', '摩杜纳', '猫小胖', 20, 1), + (2041700000000301321, 'FF14_MARKET_WORLD', '海猫茶屋', '海猫茶屋', '猫小胖', 21, 1), + (2041700000000301322, 'FF14_MARKET_WORLD', '柔风海湾', '柔风海湾', '猫小胖', 22, 1), + (2041700000000301323, 'FF14_MARKET_WORLD', '琥珀原', '琥珀原', '猫小胖', 23, 1), + (2041700000000301324, 'FF14_MARKET_WORLD', '水晶塔', '水晶塔', '豆豆柴', 24, 1), + (2041700000000301325, 'FF14_MARKET_WORLD', '银泪湖', '银泪湖', '豆豆柴', 25, 1), + (2041700000000301326, 'FF14_MARKET_WORLD', '太阳海岸', '太阳海岸', '豆豆柴', 26, 1), + (2041700000000301327, 'FF14_MARKET_WORLD', '伊修加德', '伊修加德', '豆豆柴', 27, 1), + (2041700000000301328, 'FF14_MARKET_WORLD', '红茶川', '红茶川', '豆豆柴', 28, 1) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `children_code` = VALUES(`children_code`), + `sort` = VALUES(`sort`), + `status` = VALUES(`status`), + `is_deleted` = 0; + INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`) VALUES (2041700000000100400, 0, 'QqBot', '/qqbot', NULL, '/qqbot/dashboard', NULL, 'catalog', '{"icon":"lucide:bot","order":110,"title":"QQBot 管理"}', 1, 110), diff --git a/src/admin/dict/dict.service.ts b/src/admin/dict/dict.service.ts index ce1c85e..51d280f 100644 --- a/src/admin/dict/dict.service.ts +++ b/src/admin/dict/dict.service.ts @@ -6,6 +6,12 @@ import { AdminDict } from './admin-dict.entity'; const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE'; +export type AdminDictItem = { + childrenCode?: string | null; + label: string; + value: string; +}; + @Injectable() export class DictService implements OnApplicationBootstrap { constructor( @@ -18,6 +24,15 @@ export class DictService implements OnApplicationBootstrap { } async getDictByKey(dictKey: string): Promise { + const list = await this.getDictItemsByKey(dictKey); + + return list.map(({ label, value }) => ({ + label, + value: Number.isNaN(Number(value)) ? value : Number(value), + })); + } + + async getDictItemsByKey(dictKey: string): Promise { const list = await this.dictRepository.find({ where: { dictCode: dictKey, @@ -30,9 +45,10 @@ export class DictService implements OnApplicationBootstrap { }, }); - return list.map(({ label, value }) => ({ + return list.map(({ childrenCode, label, value }) => ({ + childrenCode, label, - value: Number.isNaN(Number(value)) ? value : Number(value), + value, })); } diff --git a/src/qqbot/command/qqbot-command-engine.service.ts b/src/qqbot/command/qqbot-command-engine.service.ts index 4fb710a..e240974 100644 --- a/src/qqbot/command/qqbot-command-engine.service.ts +++ b/src/qqbot/command/qqbot-command-engine.service.ts @@ -23,7 +23,7 @@ export class QqbotCommandEngineService { async handleMessage(message: QqbotNormalizedMessage) { const commands = await this.commandService.listEnabledForMessage(message); for (const command of commands) { - const matched = this.commandParser.match(command, message); + const matched = await this.commandParser.match(command, message); if (!matched) continue; if (this.commandService.isInCooldown(command)) return true; @@ -61,7 +61,8 @@ export class QqbotCommandEngineService { status: 'success', }); } catch (err) { - const errorMessage = err instanceof Error ? err.message : '命令执行失败'; + const errorMessage = + err instanceof Error ? err.message : '命令执行失败'; await this.commandService.logExecution({ command, errorMessage, @@ -82,7 +83,7 @@ export class QqbotCommandEngineService { const command = body.commandId ? await this.commandService.findById(body.commandId) : await this.findMatchedCommand(message); - const matched = this.commandParser.match(command, message); + const matched = await this.commandParser.match(command, message); if (!matched) { return { matched: false, @@ -127,13 +128,12 @@ export class QqbotCommandEngineService { private async findMatchedCommand(message: QqbotNormalizedMessage) { const commands = await this.commandService.listEnabledForMessage(message); - const command = commands.find((item) => - this.commandParser.match(item, message), - ); - if (!command) { - throw new Error('未匹配到命令'); + for (const command of commands) { + if (await this.commandParser.match(command, message)) { + return command; + } } - return command; + throw new Error('未匹配到命令'); } private buildReplyText( @@ -202,7 +202,9 @@ export class QqbotCommandEngineService { ); } - private buildPreviewMessage(body: QqbotCommandTestDto): QqbotNormalizedMessage { + private buildPreviewMessage( + body: QqbotCommandTestDto, + ): QqbotNormalizedMessage { const targetType = body.targetType || 'private'; const targetId = body.targetId || body.userId || '10000'; const userId = body.userId || targetId; diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts index 6cc8d9b..154a972 100644 --- a/src/qqbot/command/qqbot-command-parser.service.ts +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; +import { DictService } from '../../admin/dict/dict.service'; import type { QqbotCommand } from './qqbot-command.entity'; import type { QqbotNormalizedMessage } from '../qqbot.types'; import { + buildQqbotFf14MarketCatalog, + QQBOT_FF14_MARKET_DICT_CODES, + type QqbotFf14MarketCatalog, isQqbotFf14DataCenterName, isQqbotFf14LocationName, isQqbotFf14RegionName, @@ -18,7 +22,9 @@ export type QqbotCommandMatchResult = { @Injectable() export class QqbotCommandParserService { - match(command: QqbotCommand, message: QqbotNormalizedMessage) { + constructor(private readonly dictService: DictService) {} + + async match(command: QqbotCommand, message: QqbotNormalizedMessage) { const source = `${message.messageText || ''}`.trim(); if (!source) return null; @@ -31,7 +37,7 @@ export class QqbotCommandParserService { if (rawArgs === null) continue; return { alias, - input: this.parseInput(command, rawArgs), + input: await this.parseInput(command, rawArgs), matched: true, rawArgs, } satisfies QqbotCommandMatchResult; @@ -57,7 +63,7 @@ export class QqbotCommandParserService { return null; } - private parseInput(command: QqbotCommand, rawArgs: string) { + private async parseInput(command: QqbotCommand, rawArgs: string) { if (command.parserKey === 'ff14Price') { return this.parseFf14PriceInput(rawArgs); } @@ -72,7 +78,8 @@ export class QqbotCommandParserService { }; } - private parseFf14PriceInput(rawArgs: string) { + private async parseFf14PriceInput(rawArgs: string) { + const catalog = await this.getFf14MarketCatalog(); const tokens = rawArgs.split(/\s+/).filter(Boolean); const flags = new Map(); const positional: string[] = []; @@ -113,7 +120,7 @@ export class QqbotCommandParserService { } if (!world && !dataCenter && positional.length > 1) { - const picked = this.pickTrailingFf14Location(positional); + const picked = this.pickTrailingFf14Location(catalog, positional); if (picked) { dataCenter = picked.dataCenter || dataCenter; item = picked.item; @@ -216,9 +223,12 @@ export class QqbotCommandParserService { }; } - private pickTrailingFf14Location(positional: string[]) { + private pickTrailingFf14Location( + catalog: QqbotFf14MarketCatalog, + positional: string[], + ) { const last = positional[positional.length - 1]; - if (!isQqbotFf14LocationName(last)) return null; + if (!isQqbotFf14LocationName(catalog, last)) return null; const path = splitQqbotFf14WorldPath(last); if (path.dataCenter && path.world) { @@ -234,10 +244,11 @@ export class QqbotCommandParserService { const beforePrevious = positional[positional.length - 3]; if ( previous && - isQqbotFf14DataCenterName(previous) && - isQqbotFf14WorldName(last) + isQqbotFf14DataCenterName(catalog, previous) && + isQqbotFf14WorldName(catalog, last) ) { - const hasRegion = beforePrevious && isQqbotFf14RegionName(beforePrevious); + const hasRegion = + beforePrevious && isQqbotFf14RegionName(catalog, beforePrevious); return { dataCenter: previous, item: positional.slice(0, hasRegion ? -3 : -2).join(' '), @@ -248,8 +259,8 @@ export class QqbotCommandParserService { if ( previous && - isQqbotFf14RegionName(previous) && - isQqbotFf14DataCenterName(last) + isQqbotFf14RegionName(catalog, previous) && + isQqbotFf14DataCenterName(catalog, last) ) { return { dataCenter: last, @@ -264,6 +275,21 @@ export class QqbotCommandParserService { }; } + private async getFf14MarketCatalog() { + const [regions, dataCenters, worlds] = await Promise.all([ + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.region), + this.dictService.getDictItemsByKey( + QQBOT_FF14_MARKET_DICT_CODES.dataCenter, + ), + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.world), + ]); + return buildQqbotFf14MarketCatalog({ + dataCenters, + regions, + worlds, + }); + } + private normalizeHq(value?: string | true) { if (value === undefined) return undefined; if (value === true) return true; diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts index 3d98bf4..858b7ea 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts @@ -2,7 +2,12 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as http from 'node:http'; import * as https from 'node:https'; -import { resolveQqbotFf14MarketTarget } from './qqbot-ff14-worlds'; +import { DictService } from '../../../admin/dict/dict.service'; +import { + buildQqbotFf14MarketCatalog, + QQBOT_FF14_MARKET_DICT_CODES, + resolveQqbotFf14MarketTarget, +} from './qqbot-ff14-worlds'; type HttpMethod = 'GET'; @@ -67,7 +72,10 @@ export class QqbotFf14ClientService { private readonly xivapiChsBaseUrl: string; private readonly universalisBaseUrl: string; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly dictService: DictService, + ) { this.xivapiBaseUrl = this.configService.get('FF14_XIVAPI_BASE_URL') || 'https://v2.xivapi.com/api'; @@ -114,7 +122,7 @@ export class QqbotFf14ClientService { region?: string; world?: string; }): Promise { - const marketTarget = this.resolveMarketTarget(params); + const marketTarget = await this.resolveMarketTarget(params); const item = await this.resolveItem(params); if (item.isUntradable) { return { @@ -127,9 +135,9 @@ export class QqbotFf14ClientService { } const url = new URL( - `${this.universalisBaseUrl}/${encodeURIComponent( - marketTarget.target, - )}/${item.itemId}`, + `${this.universalisBaseUrl}/${encodeURIComponent(marketTarget.target)}/${ + item.itemId + }`, ); url.searchParams.set('entries', '10'); url.searchParams.set('listings', '10'); @@ -211,7 +219,9 @@ export class QqbotFf14ClientService { const total = item.total || price * quantity; const retainerName = item.retainerName || '未知雇员'; const worldName = item.worldName || result.world; - return `[${hq}]${this.formatPrice(price)} x ${quantity} = ${this.formatPrice( + return `[${hq}]${this.formatPrice( + price, + )} x ${quantity} = ${this.formatPrice( total, )} ${retainerName} (${worldName})`; }) @@ -255,25 +265,45 @@ export class QqbotFf14ClientService { return Math.round(value).toLocaleString('en-US'); } - private normalizeWorld(world?: string) { - const raw = - `${world || this.configService.get('FF14_DEFAULT_WORLD') || '中国'}`.trim(); + private normalizeWorld(world?: string, fallback?: string) { + const raw = `${ + world || + this.configService.get('FF14_DEFAULT_WORLD') || + fallback || + '' + }`.trim(); return raw; } - private resolveMarketTarget(params: { + private async resolveMarketTarget(params: { dataCenter?: string; region?: string; world?: string; }) { - return resolveQqbotFf14MarketTarget({ + const catalog = await this.getFf14MarketCatalog(); + return resolveQqbotFf14MarketTarget(catalog, { dataCenter: params.dataCenter, - fallback: this.normalizeWorld(params.world), + fallback: this.normalizeWorld(params.world, catalog.defaultRegion), region: params.region, world: params.world, }); } + private async getFf14MarketCatalog() { + const [regions, dataCenters, worlds] = await Promise.all([ + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.region), + this.dictService.getDictItemsByKey( + QQBOT_FF14_MARKET_DICT_CODES.dataCenter, + ), + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.world), + ]); + return buildQqbotFf14MarketCatalog({ + dataCenters, + regions, + worlds, + }); + } + private normalizeXivapiLanguage(language?: string) { const value = `${language || 'chs'}`.trim().toLowerCase(); if (['zh', 'zh-cn', 'zh_hans', 'cn', 'chs'].includes(value)) return 'chs'; @@ -328,8 +358,7 @@ export class QqbotFf14ClientService { 'XIVAPI 物品解析', ); return (data.results || []).filter( - (result) => - result.sheet === 'Item' || result.fields?.Name || result.name, + (result) => result.sheet === 'Item' || result.fields?.Name || result.name, ); } @@ -400,9 +429,7 @@ export class QqbotFf14ClientService { }); response.on('end', () => { if ((response.statusCode || 500) >= 400) { - reject( - new Error(`${context}失败:${response.statusCode}`), - ); + reject(new Error(`${context}失败:${response.statusCode}`)); return; } try { diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts new file mode 100644 index 0000000..2db4364 --- /dev/null +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts @@ -0,0 +1,37 @@ +import { + buildQqbotFf14MarketCatalog, + resolveQqbotFf14MarketTarget, +} from './qqbot-ff14-worlds'; + +describe('qqbot ff14 market worlds', () => { + const catalog = buildQqbotFf14MarketCatalog({ + dataCenters: [ + { childrenCode: '中国', label: '猫小胖', value: '猫小胖' }, + { childrenCode: '中国', label: '陆行鸟', value: '陆行鸟' }, + ], + regions: [{ label: '中国', value: '中国' }], + worlds: [ + { childrenCode: '猫小胖', label: '琥珀原', value: '琥珀原' }, + { childrenCode: '陆行鸟', label: '红玉海', value: '红玉海' }, + ], + }); + + it('resolves world labels from dict catalog', () => { + expect(resolveQqbotFf14MarketTarget(catalog, { world: '琥珀原' })).toEqual({ + dataCenter: '猫小胖', + label: '中国 / 猫小胖 / 琥珀原', + region: '中国', + target: '琥珀原', + world: '琥珀原', + }); + }); + + it('rejects world and data center mismatch from dict catalog', () => { + expect(() => + resolveQqbotFf14MarketTarget(catalog, { + dataCenter: '陆行鸟', + world: '琥珀原', + }), + ).toThrow('服务器 琥珀原 不属于大区 陆行鸟'); + }); +}); diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts index 5c9b943..a0d3406 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts @@ -1,9 +1,17 @@ +import type { AdminDictItem } 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; @@ -12,80 +20,76 @@ export type QqbotFf14MarketTarget = { world?: string; }; -export const QQBOT_FF14_DEFAULT_REGION = '中国'; +export const QQBOT_FF14_MARKET_DICT_CODES = { + dataCenter: 'FF14_MARKET_DATA_CENTER', + region: 'FF14_MARKET_REGION', + world: 'FF14_MARKET_WORLD', +}; -export const QQBOT_FF14_CHINA_DATA_CENTERS: QqbotFf14DataCenter[] = [ - { - name: '陆行鸟', - region: QQBOT_FF14_DEFAULT_REGION, - worlds: [ - '红玉海', - '神意之地', - '拉诺西亚', - '幻影群岛', - '萌芽池', - '宇宙和音', - '沃仙曦染', - '晨曦王座', - ], - }, - { - name: '莫古力', - region: QQBOT_FF14_DEFAULT_REGION, - worlds: [ - '白银乡', - '白金幻象', - '神拳痕', - '潮风亭', - '旅人栈桥', - '拂晓之间', - '龙巢神殿', - '梦羽宝境', - ], - }, - { - name: '猫小胖', - region: QQBOT_FF14_DEFAULT_REGION, - worlds: [ - '紫水栈桥', - '延夏', - '静语庄园', - '摩杜纳', - '海猫茶屋', - '柔风海湾', - '琥珀原', - ], - }, - { - name: '豆豆柴', - region: QQBOT_FF14_DEFAULT_REGION, - worlds: ['水晶塔', '银泪湖', '太阳海岸', '伊修加德', '红茶川'], - }, -]; +export function buildQqbotFf14MarketCatalog(input: { + dataCenters: AdminDictItem[]; + regions: AdminDictItem[]; + worlds: AdminDictItem[]; +}): QqbotFf14MarketCatalog { + const regions = input.regions.map(getDictDisplayValue).filter(Boolean); + const defaultRegion = regions[0]; + const dataCenters = input.dataCenters + .map((item) => { + const name = getDictDisplayValue(item); + if (!name) return null; + return { + name, + region: + normalizeQqbotFf14WorldValue(item.childrenCode) || defaultRegion, + worlds: input.worlds + .filter(({ childrenCode }) => childrenCode === getDictRawValue(item)) + .map(getDictDisplayValue) + .filter(Boolean), + }; + }) + .filter((item): item is QqbotFf14DataCenter => !!item); -export function isQqbotFf14DataCenterName(value?: string) { + return { + dataCenters, + defaultRegion, + regions, + }; +} + +export function isQqbotFf14DataCenterName( + catalog: QqbotFf14MarketCatalog, + value?: string, +) { const name = normalizeQqbotFf14WorldValue(value); - return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) => item.name === name); + return catalog.dataCenters.some((item) => item.name === name); } -export function isQqbotFf14RegionName(value?: string) { - return normalizeQqbotFf14WorldValue(value) === QQBOT_FF14_DEFAULT_REGION; -} - -export function isQqbotFf14WorldName(value?: string) { +export function isQqbotFf14RegionName( + catalog: QqbotFf14MarketCatalog, + value?: string, +) { const name = normalizeQqbotFf14WorldValue(value); - return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) => - item.worlds.includes(name), - ); + return catalog.regions.includes(name); } -export function isQqbotFf14LocationName(value?: string) { +export function isQqbotFf14WorldName( + catalog: QqbotFf14MarketCatalog, + value?: string, +) { + const name = normalizeQqbotFf14WorldValue(value); + return catalog.dataCenters.some((item) => item.worlds.includes(name)); +} + +export function isQqbotFf14LocationName( + catalog: QqbotFf14MarketCatalog, + value?: string, +) { const name = normalizeQqbotFf14WorldValue(value); const path = splitQqbotFf14WorldPath(name); return ( - isQqbotFf14RegionName(name) || - isQqbotFf14DataCenterName(name) || - isQqbotFf14WorldName(name) || + isQqbotFf14RegionName(catalog, name) || + isQqbotFf14DataCenterName(catalog, name) || + isQqbotFf14WorldName(catalog, name) || (!!path.dataCenter && !!path.world) ); } @@ -114,19 +118,24 @@ export function splitQqbotFf14WorldPath(value?: string) { }; } -export function findQqbotFf14DataCenterByWorld(world?: string) { +export function findQqbotFf14DataCenterByWorld( + catalog: QqbotFf14MarketCatalog, + world?: string, +) { const worldName = normalizeQqbotFf14WorldValue(world); - return QQBOT_FF14_CHINA_DATA_CENTERS.find((item) => - item.worlds.includes(worldName), - ); + return catalog.dataCenters.find((item) => item.worlds.includes(worldName)); } -export function resolveQqbotFf14MarketTarget(params: { - dataCenter?: string; - fallback?: string; - region?: string; - world?: string; -}): QqbotFf14MarketTarget { +export function resolveQqbotFf14MarketTarget( + catalog: QqbotFf14MarketCatalog, + params: { + dataCenter?: string; + fallback?: string; + region?: string; + world?: string; + }, +): QqbotFf14MarketTarget { + const defaultRegion = catalog.defaultRegion || ''; const fallback = normalizeQqbotFf14WorldValue(params.fallback); const path = splitQqbotFf14WorldPath(params.world); const region = normalizeQqbotFf14WorldValue(params.region || path.region); @@ -134,10 +143,8 @@ export function resolveQqbotFf14MarketTarget(params: { params.dataCenter || path.dataCenter, ); const rawWorld = normalizeQqbotFf14WorldValue(path.world || params.world); - const world = - dataCenter && rawWorld === QQBOT_FF14_DEFAULT_REGION ? '' : rawWorld; - const raw = - world || dataCenter || region || fallback || QQBOT_FF14_DEFAULT_REGION; + const world = dataCenter && rawWorld === defaultRegion ? '' : rawWorld; + const raw = world || dataCenter || region || fallback || defaultRegion; if (region && dataCenter && (!world || world === region)) { return { @@ -148,16 +155,16 @@ export function resolveQqbotFf14MarketTarget(params: { }; } - if (raw === QQBOT_FF14_DEFAULT_REGION) { + if (raw && raw === defaultRegion) { return { - label: QQBOT_FF14_DEFAULT_REGION, - region: QQBOT_FF14_DEFAULT_REGION, - target: QQBOT_FF14_DEFAULT_REGION, + label: defaultRegion, + region: defaultRegion, + target: defaultRegion, }; } if (dataCenter && world && world !== dataCenter) { - const matchedDataCenter = QQBOT_FF14_CHINA_DATA_CENTERS.find( + const matchedDataCenter = catalog.dataCenters.find( (item) => item.name === dataCenter, ); if (matchedDataCenter && !matchedDataCenter.worlds.includes(world)) { @@ -174,7 +181,7 @@ export function resolveQqbotFf14MarketTarget(params: { }; } - const matchedWorldDataCenter = findQqbotFf14DataCenterByWorld(raw); + const matchedWorldDataCenter = findQqbotFf14DataCenterByWorld(catalog, raw); if (matchedWorldDataCenter) { return { dataCenter: matchedWorldDataCenter.name, @@ -185,11 +192,11 @@ export function resolveQqbotFf14MarketTarget(params: { }; } - if (isQqbotFf14DataCenterName(raw)) { + if (isQqbotFf14DataCenterName(catalog, raw)) { return { dataCenter: raw, - label: `${QQBOT_FF14_DEFAULT_REGION} / ${raw}`, - region: QQBOT_FF14_DEFAULT_REGION, + label: defaultRegion ? `${defaultRegion} / ${raw}` : raw, + region: defaultRegion, target: raw, }; } @@ -200,6 +207,14 @@ export function resolveQqbotFf14MarketTarget(params: { }; } -function normalizeQqbotFf14WorldValue(value?: string) { +function getDictDisplayValue(item: AdminDictItem) { + return normalizeQqbotFf14WorldValue(item.label || item.value); +} + +function getDictRawValue(item: AdminDictItem) { + return normalizeQqbotFf14WorldValue(item.value || item.label); +} + +function normalizeQqbotFf14WorldValue(value?: string | null) { return `${value || ''}`.trim(); } diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts new file mode 100644 index 0000000..038439c --- /dev/null +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts @@ -0,0 +1,69 @@ +jest.mock( + '@/common', + () => ({ + ensureSnowflakeId: jest.fn(), + setDictDecodeCache: jest.fn(), + }), + { virtual: true }, +); + +import { ConfigService } from '@nestjs/config'; +import { DictService } from '../../../admin/dict/dict.service'; +import { QqbotFflogsClientService } from './qqbot-fflogs-client.service'; + +describe('QqbotFflogsClientService', () => { + const dicts = { + FFLOGS_ENCOUNTER_LABEL: [ + { label: 'M9S Vamp Fatale', value: 'vampfatale' }, + { label: 'M12S P2 Lindwurm II', value: 'lindwurmii' }, + ], + FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }], + FFLOGS_METRIC_LABEL: [{ label: 'DPS', value: 'dps' }], + FFLOGS_ROLE_LABEL: [], + FFLOGS_SERVER_REGION_LABEL: [{ label: '国服', value: 'cn' }], + }; + + const service = new QqbotFflogsClientService( + { + get: jest.fn(), + } as unknown as ConfigService, + { + getDictByKey: jest.fn( + async (dictKey: keyof typeof dicts) => dicts[dictKey] || [], + ), + } as unknown as DictService, + ); + + it('formats character rankings with dict labels', async () => { + const localizationMaps = await (service as any).getLocalizationMaps(); + const replyText = (service as any).buildReplyText({ + allStarText: '全明星:318分 / 排名第1857', + characterId: 20962075, + characterName: 'Kwi柊司', + localizationMaps, + metric: 'DPS', + rankings: [ + { + bestAmount: 36635, + encounter: { name: 'Vamp Fatale' }, + rankPercent: 72.4, + spec: 'Machinist', + }, + { + bestAmount: 0, + encounter: { name: 'Lindwurm II' }, + rankPercent: 0, + }, + ], + serverName: '琥珀原', + serverRegion: 'CN', + url: 'https://cn.fflogs.com/character/cn/example/Kwi', + }); + + expect(replyText).toContain('FFLogs 战绩:Kwi柊司 @ 琥珀原(国服)'); + expect(replyText).toContain( + '1. M9S Vamp Fatale:72.4% | DPS 36,635 | 机工士', + ); + expect(replyText).toContain('2. M12S P2 Lindwurm II:暂无有效排名'); + }); +}); diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts index 48b1503..2bdcd08 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; 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'; @@ -35,6 +36,19 @@ type FflogsCharacterSummaryResponse = { type FflogsRankingItem = Record; +const FFLOGS_LOCALIZATION_DICT_CODES = { + encounter: 'FFLOGS_ENCOUNTER_LABEL', + job: 'FFLOGS_JOB_LABEL', + metric: 'FFLOGS_METRIC_LABEL', + role: 'FFLOGS_ROLE_LABEL', + serverRegion: 'FFLOGS_SERVER_REGION_LABEL', +}; + +type FflogsLocalizationMaps = Record< + keyof typeof FFLOGS_LOCALIZATION_DICT_CODES, + Map +>; + export type QqbotFflogsCharacterSummaryInput = { character?: string; characterName?: string; @@ -74,7 +88,10 @@ export class QqbotFflogsClientService { private readonly tokenUrl: string; private readonly webBaseUrl: string; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly dictService: DictService, + ) { this.baseUrl = this.normalizeBaseUrl( this.configService.get('FFLOGS_BASE_URL') || 'https://www.fflogs.com', @@ -196,6 +213,7 @@ export class QqbotFflogsClientService { character.server?.slug || serverSlug, character.name || characterName, ); + const localizationMaps = await this.getLocalizationMaps(); return { allStarText, @@ -206,7 +224,9 @@ export class QqbotFflogsClientService { allStarText, characterId: character.id, characterName: character.name || characterName, + metric: variables.metric || 'DPS', rankings, + localizationMaps, serverName, serverRegion, url, @@ -327,29 +347,51 @@ export class QqbotFflogsClientService { allStarText?: string; characterId?: number; characterName: string; + localizationMaps: FflogsLocalizationMaps; + metric: string; rankings: FflogsRankingItem[]; serverName: string; serverRegion: string; url: string; }) { - const header = `FFLogs:${params.characterName} - ${params.serverName} (${params.serverRegion})`; - const idText = params.characterId ? `角色 ID:${params.characterId}` : ''; + const region = this.localizeServerRegion( + params.serverRegion, + params.localizationMaps, + ); + const header = `FFLogs 战绩:${params.characterName} @ ${params.serverName}(${region})`; + const idText = params.characterId ? `角色ID:${params.characterId}` : ''; const rankingText = params.rankings.length - ? params.rankings - .map((item, index) => this.formatRanking(item, index)) - .join('\n') - : '暂无公开排名数据'; + ? [ + '公开排名:', + ...params.rankings.map((item, index) => + this.formatRanking( + item, + index, + params.metric, + params.localizationMaps, + ), + ), + ].join('\n') + : '公开排名:暂无公开排名数据'; return [header, idText, params.allStarText, rankingText, params.url] .filter(Boolean) .join('\n'); } - private formatRanking(item: FflogsRankingItem, index: number) { - const encounter = this.pickText( - item.encounter?.name, - item.encounterName, - item.name, - `记录 ${index + 1}`, + private formatRanking( + item: FflogsRankingItem, + index: number, + fallbackMetric: string, + localizationMaps: FflogsLocalizationMaps, + ) { + const encounter = this.localizeEncounter( + this.pickText( + item.encounter?.name, + item.encounterName, + item.name, + `记录 ${index + 1}`, + ), + localizationMaps, ); const percent = this.pickNumber( item.rankPercent, @@ -358,16 +400,29 @@ export class QqbotFflogsClientService { item.historicalPercent, ); const amount = this.pickNumber(item.bestAmount, item.amount, item.total); - const spec = this.pickText(item.spec, item.specName, item.class, item.role); + const spec = this.localizeSpec( + this.pickText(item.spec, item.specName, item.class, item.role), + localizationMaps, + ); const rank = this.pickText(item.rank, item.regionRank, item.serverRank); + + if (!this.hasMeaningfulRanking(percent, amount)) { + return `${index + 1}. ${encounter}:暂无有效排名`; + } + + const metric = this.localizeMetric( + this.pickText(item.metric, item.metricName, fallbackMetric), + localizationMaps, + ); const parts = [ - `${index + 1}. ${encounter}`, - percent !== undefined ? `${this.formatNumber(percent)}%` : '', - amount !== undefined ? this.formatNumber(amount) : '', + `${index + 1}. ${encounter}:${ + percent !== undefined ? `${this.formatNumber(percent)}%` : '百分位暂无' + }`, + amount !== undefined ? `${metric} ${this.formatNumber(amount)}` : '', spec, - rank ? `Rank ${rank}` : '', + rank ? this.formatRank(rank) : '', ].filter(Boolean); - return parts.join(' / '); + return parts.join(' | '); } private pickRankings(payload: any): FflogsRankingItem[] { @@ -399,8 +454,8 @@ export class QqbotFflogsClientService { allStars.serverRank, ); const parts = [ - points !== undefined ? `全明星分:${this.formatNumber(points)}` : '', - rank ? `名次:${rank}` : '', + points !== undefined ? `全明星:${this.formatNumber(points)}分` : '', + rank ? this.formatRank(rank) : '', ].filter(Boolean); return parts.length ? parts.join(' / ') : undefined; } @@ -495,12 +550,98 @@ export class QqbotFflogsClientService { private formatNumber(value: number) { const digits = Math.abs(value) >= 100 ? 0 : 1; - return value.toLocaleString('en-US', { + return value.toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: 0, }); } + private formatRank(value: string) { + const rank = value.replace(/^#/, '').trim(); + if (!rank) return ''; + if (rank.startsWith('第') || rank.endsWith('名')) return `排名${rank}`; + return `排名第${rank}`; + } + + private hasMeaningfulRanking(percent?: number, amount?: number) { + return ( + (percent !== undefined && percent > 0) || + (amount !== undefined && amount > 0) + ); + } + + private async getLocalizationMaps(): Promise { + const [encounter, job, metric, role, serverRegion] = await Promise.all([ + this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.encounter), + this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.job), + this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric), + this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role), + this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.serverRegion), + ]); + + return { + encounter, + job, + metric, + role, + serverRegion, + }; + } + + private async getNormalizedDictMap(dictCode: string) { + const dicts = await this.dictService.getDictByKey(dictCode); + return new Map( + dicts.map(({ label, value }) => [ + this.normalizeLookupKey(`${value}`), + `${label}`, + ]), + ); + } + + private localizeEncounter( + value: string, + localizationMaps: FflogsLocalizationMaps, + ) { + return ( + localizationMaps.encounter.get(this.normalizeLookupKey(value)) || value + ); + } + + private localizeMetric( + value: string, + localizationMaps: FflogsLocalizationMaps, + ) { + return ( + localizationMaps.metric.get(this.normalizeLookupKey(value)) || + value || + 'DPS' + ); + } + + private localizeServerRegion( + value: string, + localizationMaps: FflogsLocalizationMaps, + ) { + return ( + localizationMaps.serverRegion.get(this.normalizeLookupKey(value)) || + value.toUpperCase() + ); + } + + private localizeSpec( + value: string, + localizationMaps: FflogsLocalizationMaps, + ) { + const key = this.normalizeLookupKey(value); + return ( + localizationMaps.job.get(key) || localizationMaps.role.get(key) || value + ); + } + + private normalizeLookupKey(value: string) { + return `${value || ''}`.toLowerCase().replace(/[^a-z0-9]/g, ''); + } + private removeUndefined(input: Record) { return Object.entries(input).reduce>( (result, [key, value]) => { diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index fcaffdc..5d12ef9 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module'; +import { DictModule } from '@/admin/dict/dict.module'; import { QqbotAccountController } from './account/qqbot-account.controller'; import { QqbotAccountAbility } from './account/qqbot-account-ability.entity'; import { QqbotAccount } from './account/qqbot-account.entity'; @@ -55,6 +56,7 @@ import { QqbotSendService } from './send/qqbot-send.service'; imports: [ ConfigModule, AdminAuthGuardModule, + DictModule, TypeOrmModule.forFeature([ QqbotAccount, QqbotAccountAbility,