feat: QQBot映射改为字典维护

This commit is contained in:
sunlei 2026-06-03 21:53:32 +08:00
parent c73084b4d1
commit cf2c20ba22
10 changed files with 581 additions and 151 deletions

View File

@ -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),

View File

@ -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<Dict[]> {
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<AdminDictItem[]> {
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,
}));
}

View File

@ -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,14 +128,13 @@ 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;
}
}
throw new Error('未匹配到命令');
}
private buildReplyText(
command: QqbotCommand,
@ -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;

View File

@ -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<string, string | true>();
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;

View File

@ -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<string>('FF14_XIVAPI_BASE_URL') ||
'https://v2.xivapi.com/api';
@ -114,7 +122,7 @@ export class QqbotFf14ClientService {
region?: string;
world?: string;
}): Promise<QqbotFf14PriceResult> {
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<string>('FF14_DEFAULT_WORLD') || '中国'}`.trim();
private normalizeWorld(world?: string, fallback?: string) {
const raw = `${
world ||
this.configService.get<string>('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 {

View File

@ -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('服务器 琥珀原 不属于大区 陆行鸟');
});
});

View File

@ -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: {
export function resolveQqbotFf14MarketTarget(
catalog: QqbotFf14MarketCatalog,
params: {
dataCenter?: string;
fallback?: string;
region?: string;
world?: string;
}): QqbotFf14MarketTarget {
},
): 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();
}

View File

@ -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 Fatale72.4% DPS 36,635 机工士',
);
expect(replyText).toContain('2. M12S P2 Lindwurm II暂无有效排名');
});
});

View File

@ -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<string, any>;
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<string, string>
>;
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<string>('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 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(
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<FflogsLocalizationMaps> {
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<string, any>) {
return Object.entries(input).reduce<Record<string, any>>(
(result, [key, value]) => {

View File

@ -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,