fix: 对齐QQBot页面权限与插件元数据

This commit is contained in:
sunlei 2026-06-16 06:38:50 +08:00
parent b549e30dd5
commit 788aba00a8
12 changed files with 435 additions and 52 deletions

View File

@ -4,9 +4,11 @@ import { requestClient } from '#/api/request';
import {
cancelQqbotAccountScan,
getNapcatLoginProgressLabel,
getNapcatNewDeviceStatusMessage,
getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus,
mergeNapcatAccountScanResult,
NAPCAT_LOGIN_PROGRESS_LABELS,
refreshQqbotAccountScanQrcode,
resolveNapcatLoginDisplayQrcode,
@ -63,6 +65,78 @@ describe('napcat login display helpers', () => {
);
});
it('preserves pending captcha and new-device evidence when later polls omit URLs', () => {
expect(
mergeNapcatAccountScanResult(
{
captchaUrl: 'https://captcha.qq.com/proof',
mode: 'refresh',
sessionId: 'scan-1',
status: 'pending',
},
{
mode: 'refresh',
sessionId: 'scan-1',
status: 'pending',
},
),
).toMatchObject({
captchaUrl: 'https://captcha.qq.com/proof',
status: 'pending',
});
expect(
mergeNapcatAccountScanResult(
{
deviceVerifyUrl: 'https://ti.qq.com/device',
mode: 'refresh',
newDeviceQrcode: 'data:image/png;base64,new-device',
newDeviceStatus: 'qr-pending',
sessionId: 'scan-1',
status: 'pending',
},
{
mode: 'refresh',
newDeviceStatus: 'scanned',
sessionId: 'scan-1',
status: 'pending',
},
),
).toMatchObject({
deviceVerifyUrl: 'https://ti.qq.com/device',
newDeviceQrcode: 'data:image/png;base64,new-device',
newDeviceStatus: 'scanned',
status: 'pending',
});
});
it('clears stale verification evidence when scan reaches a terminal state', () => {
expect(
mergeNapcatAccountScanResult(
{
captchaUrl: 'https://captcha.qq.com/proof',
deviceVerifyUrl: 'https://ti.qq.com/device',
mode: 'refresh',
newDeviceQrcode: 'data:image/png;base64,new-device',
newDeviceStatus: 'confirming',
sessionId: 'scan-1',
status: 'pending',
},
{
mode: 'refresh',
sessionId: 'scan-1',
status: 'success',
},
),
).toMatchObject({
captchaUrl: undefined,
deviceVerifyUrl: undefined,
newDeviceQrcode: undefined,
newDeviceStatus: undefined,
status: 'success',
});
});
it('keeps the complete new-device progress key set in caller helpers', () => {
expect(
Object.keys(NAPCAT_LOGIN_PROGRESS_LABELS)
@ -77,6 +151,21 @@ describe('napcat login display helpers', () => {
]);
});
it('falls back to Chinese progress labels by SSE step key', () => {
expect(
getNapcatLoginProgressLabel({
message: 'Need new device',
step: 'new-device-required',
}),
).toBe('需要新设备验证二维码');
expect(
getNapcatLoginProgressLabel({
message: '',
step: 'new-device-confirming',
}),
).toBe('新设备确认中');
});
it('owns create, refresh, status, QR refresh, captcha, cancel, and SSE caller routes', async () => {
const scanResult = {
mode: 'refresh' as const,

View File

@ -49,6 +49,21 @@ export type NapcatLoginDisplayQrcodeSource = {
qrcode?: string;
};
export type NapcatLoginScanStateSnapshot = Partial<
Pick<
QqbotNapcatApi.AccountScanResult,
| 'captchaUrl'
| 'deviceVerifyUrl'
| 'newDeviceQrcode'
| 'newDeviceStatus'
| 'qrcode'
>
> & {
mode: QqbotNapcatApi.AccountScanResult['mode'];
sessionId?: string;
status: 'idle' | QqbotNapcatApi.AccountScanResult['status'];
};
export const NAPCAT_LOGIN_PROGRESS_LABELS = {
'captcha-submit': '验证码已提交,等待确认',
'login-failed': '登录失败',
@ -75,6 +90,43 @@ export function resolveNapcatLoginDisplayQrcode(
return source.newDeviceQrcode || source.qrcode || '';
}
export function mergeNapcatAccountScanResult(
current: NapcatLoginScanStateSnapshot,
result: QqbotNapcatApi.AccountScanResult,
): QqbotNapcatApi.AccountScanResult {
if (result.status !== 'pending') {
return {
...result,
captchaUrl: result.captchaUrl,
deviceVerifyUrl: result.deviceVerifyUrl,
newDeviceQrcode: result.newDeviceQrcode,
newDeviceStatus: result.newDeviceStatus,
};
}
const hasCaptcha = !!result.captchaUrl;
const hasNewDevice =
!!result.newDeviceQrcode ||
!!result.newDeviceStatus ||
!!result.deviceVerifyUrl;
return {
...result,
captchaUrl: hasNewDevice
? undefined
: result.captchaUrl || current.captchaUrl,
deviceVerifyUrl: hasCaptcha
? undefined
: result.deviceVerifyUrl || current.deviceVerifyUrl,
newDeviceQrcode: hasCaptcha
? undefined
: result.newDeviceQrcode || current.newDeviceQrcode,
newDeviceStatus: hasCaptcha
? undefined
: result.newDeviceStatus || current.newDeviceStatus,
};
}
export function getNapcatNewDeviceStatusMessage(
status?: NapcatLoginNewDeviceStatus,
) {
@ -86,6 +138,16 @@ export function getNapcatNewDeviceStatusMessage(
return '新设备二维码待扫码';
}
export function getNapcatLoginProgressLabel(
event: Pick<QqbotNapcatApi.AccountScanEvent, 'message' | 'step'>,
) {
const label =
NAPCAT_LOGIN_PROGRESS_LABELS[
event.step as keyof typeof NAPCAT_LOGIN_PROGRESS_LABELS
];
return label || event.message || event.step || '登录处理中';
}
export function startQqbotAccountScanCreate() {
return requestClient.post<QqbotNapcatApi.AccountScanResult>(
'/qqbot/account/scan/create',

View File

@ -59,7 +59,10 @@ describe('qqbot plugin API wrappers', () => {
it('owns plugin-platform management caller routes', async () => {
const manifest = { capabilities: [], key: 'demo' };
const packageBody = { manifest, packagePath: '/tmp/demo.zip' };
const packageBody = {
packageHash: 'sha256-demo',
packagePath: '.kt-workspace/qqbot-plugin-packages/demo.qqbot-plugin.json',
};
vi.mocked(requestClient.get).mockResolvedValue([]);
vi.mocked(requestClient.post).mockResolvedValue({});

View File

@ -26,6 +26,12 @@ export namespace QqbotPluginPlatformApi {
valid: boolean;
}
export interface PackageValidationResult extends ManifestValidationResult {
packageHash: string;
packagePath: string;
packageSizeBytes?: number;
}
export interface Installation {
createTime?: string;
id: string;
@ -61,10 +67,13 @@ export namespace QqbotPluginPlatformApi {
value?: any;
}
export interface PackageBody {
export interface ManifestBody {
manifest: Recordable<any>;
}
export interface PackageBody {
packageHash?: string;
packagePath?: string;
packagePath: string;
}
}
@ -131,14 +140,14 @@ export function getQqbotPluginPlatformInstallations() {
export function uploadQqbotPluginPackage(
data: QqbotPluginPlatformApi.PackageBody,
) {
return requestClient.post<QqbotPluginPlatformApi.ManifestValidationResult>(
return requestClient.post<QqbotPluginPlatformApi.PackageValidationResult>(
'/qqbot/plugin-platform/upload',
data,
);
}
export function validateQqbotPluginManifest(
manifest: QqbotPluginPlatformApi.PackageBody['manifest'],
manifest: QqbotPluginPlatformApi.ManifestBody['manifest'],
) {
return requestClient.post<QqbotPluginPlatformApi.ManifestValidationResult>(
'/qqbot/plugin-platform/validate',

View File

@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { refreshAccessCodes } from './access-codes';
describe('router access code refresh', () => {
it('overwrites persisted access codes with the latest backend codes', async () => {
const setAccessCodes = vi.fn();
const loadAccessCodes = vi
.fn()
.mockResolvedValue(['QqBot:Command:Test', 'QqBot:Account:RefreshLogin']);
await expect(
refreshAccessCodes({
loadAccessCodes,
setAccessCodes,
}),
).resolves.toEqual(['QqBot:Command:Test', 'QqBot:Account:RefreshLogin']);
expect(loadAccessCodes).toHaveBeenCalledOnce();
expect(setAccessCodes).toHaveBeenCalledWith([
'QqBot:Command:Test',
'QqBot:Account:RefreshLogin',
]);
});
});

View File

@ -0,0 +1,13 @@
interface RefreshAccessCodesOptions {
loadAccessCodes: () => Promise<string[]>;
setAccessCodes: (codes: string[]) => void;
}
export async function refreshAccessCodes({
loadAccessCodes,
setAccessCodes,
}: RefreshAccessCodesOptions) {
const accessCodes = await loadAccessCodes();
setAccessCodes(accessCodes);
return accessCodes;
}

View File

@ -5,10 +5,12 @@ import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { startProgress, stopProgress } from '@vben/utils';
import { getAccessCodesApi } from '#/api';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import { generateAccess } from './access';
import { refreshAccessCodes } from './access-codes';
function decodeRedirect(redirect?: string) {
if (!redirect) return null;
@ -131,7 +133,13 @@ function setupAccessGuard(router: Router) {
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const [userInfo] = await Promise.all([
userStore.userInfo || authStore.fetchUserInfo(),
refreshAccessCodes({
loadAccessCodes: getAccessCodesApi,
setAccessCodes: (codes) => accessStore.setAccessCodes(codes),
}),
]);
const userRoles = userInfo.roles ?? [];
// 生成菜单和路由

View File

@ -38,9 +38,11 @@ import {
} from '#/api/qqbot';
import {
cancelQqbotAccountScan,
getNapcatLoginProgressLabel,
getNapcatNewDeviceStatusMessage,
getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus,
mergeNapcatAccountScanResult,
refreshQqbotAccountScanQrcode,
resolveNapcatLoginDisplayQrcode,
startQqbotAccountScanCreate,
@ -134,7 +136,7 @@ export default defineComponent({
scanEvents.value.map((event) => ({
description: formatEventTime(event.createdAt),
status: getScanStepStatus(event.status),
title: event.message,
title: getNapcatLoginProgressLabel(event),
})),
);
const scanProgressCurrent = computed(() =>
@ -440,20 +442,21 @@ export default defineComponent({
result: QqbotNapcatApi.AccountScanResult,
options: { reloadQrcode?: boolean } = {},
) {
scanState.captchaUrl = result.captchaUrl;
scanState.containerId = result.containerId;
scanState.containerName = result.containerName;
scanState.deviceVerifyUrl = result.deviceVerifyUrl;
scanState.errorMessage = result.errorMessage;
scanState.expiresAt = result.expiresAt;
scanState.mode = result.mode;
scanState.newDeviceQrcode = result.newDeviceQrcode;
scanState.newDeviceStatus = result.newDeviceStatus;
scanState.selfId = result.selfId;
scanState.sessionId = result.sessionId;
scanState.status = result.status;
scanState.webuiPort = result.webuiPort;
const nextQrcode = resolveNapcatLoginDisplayQrcode(result);
const nextState = mergeNapcatAccountScanResult(scanState, result);
scanState.captchaUrl = nextState.captchaUrl;
scanState.containerId = nextState.containerId;
scanState.containerName = nextState.containerName;
scanState.deviceVerifyUrl = nextState.deviceVerifyUrl;
scanState.errorMessage = nextState.errorMessage;
scanState.expiresAt = nextState.expiresAt;
scanState.mode = nextState.mode;
scanState.newDeviceQrcode = nextState.newDeviceQrcode;
scanState.newDeviceStatus = nextState.newDeviceStatus;
scanState.selfId = nextState.selfId;
scanState.sessionId = nextState.sessionId;
scanState.status = nextState.status;
scanState.webuiPort = nextState.webuiPort;
const nextQrcode = resolveNapcatLoginDisplayQrcode(nextState);
const qrcodeChanged = nextQrcode !== scanQrcodeText.value;
if (qrcodeChanged) {
scanQrcodeImageFailed.value = false;

View File

@ -1,8 +1,11 @@
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { Modal } from 'antdv-next';
import { Input, Modal } from 'antdv-next';
const AModal = Modal as any;
const AInput = Input as any;
export default defineComponent({
name: 'QqBotPluginManifestModal',
@ -15,6 +18,18 @@ export default defineComponent({
default: false,
type: Boolean,
},
mode: {
default: 'validate',
type: String as PropType<'install' | 'upload' | 'validate'>,
},
packageHash: {
default: '',
type: String,
},
packagePath: {
default: '',
type: String,
},
title: {
default: '',
type: String,
@ -24,7 +39,13 @@ export default defineComponent({
type: String,
},
},
emits: ['close', 'submit', 'update:value'],
emits: [
'close',
'submit',
'update:packageHash',
'update:packagePath',
'update:value',
],
setup(props, { emit }) {
return () => (
<AModal
@ -35,6 +56,7 @@ export default defineComponent({
title={props.title}
width={760}
>
{props.mode === 'validate' ? (
<textarea
class="w-full resize-y rounded border border-solid border-gray-200 p-3 font-mono text-sm outline-none"
onInput={(event) => {
@ -43,6 +65,30 @@ export default defineComponent({
rows={18}
value={props.value}
/>
) : (
<div class="space-y-3">
<label class="block">
<span class="mb-1 block text-sm text-gray-600"></span>
<AInput
onUpdate:value={(value: string) => {
emit('update:packagePath', value);
}}
placeholder=".kt-workspace/qqbot-plugin-packages/demo.qqbot-plugin.json"
value={props.packagePath}
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-gray-600"> Hash</span>
<AInput
onUpdate:value={(value: string) => {
emit('update:packageHash', value);
}}
placeholder="上传校验可留空,安装时用于校验包内容"
value={props.packageHash}
/>
</label>
</div>
)}
</AModal>
);
},

View File

@ -32,6 +32,7 @@ import { useDict } from '#/hooks/useDict';
import PluginManifestModal from './components/PluginManifestModal';
import PluginPlatformStateDrawer from './components/PluginPlatformStateDrawer';
import { loadQqbotPluginMetadata } from './metadata';
const AKtTable = KtTable as any;
const QQBOT_PLUGIN_TRIGGER_MODE_DICT = 'QQBOT_PLUGIN_TRIGGER_MODE';
@ -78,6 +79,8 @@ export default defineComponent({
const manifestMode = ref<'install' | 'upload' | 'validate'>('validate');
const manifestModalOpen = ref(false);
const manifestText = ref(JSON.stringify(defaultManifest, null, 2));
const packageHashText = ref('');
const packagePathText = ref('');
const pluginOptions = ref<Array<{ label: string; value: string }>>([]);
const pluginMap = ref<Record<string, QqbotApi.Plugin>>({});
const platformLoading = ref(false);
@ -88,8 +91,8 @@ export default defineComponent({
return '插件安装记录';
});
const manifestModalTitle = computed(() => {
if (manifestMode.value === 'install') return '安装插件 Manifest';
if (manifestMode.value === 'upload') return '上传插件 Manifest';
if (manifestMode.value === 'install') return '本地安装插件包';
if (manifestMode.value === 'upload') return '上传插件';
return '校验插件 Manifest';
});
const {
@ -208,24 +211,20 @@ export default defineComponent({
});
async function loadMetadata() {
const [plugins] = await Promise.all([
getQqbotPluginList(),
reloadTriggerModeDict(),
]);
const nextPluginMap: Record<string, QqbotApi.Plugin> = {};
for (const item of plugins) {
nextPluginMap[item.key] = item;
}
pluginMap.value = nextPluginMap;
pluginOptions.value = plugins.map((item) => ({
label: `${item.name} (${item.key} / ${getTriggerModeLabel(item.triggerMode, '-')})`,
value: item.key,
}));
const metadata = await loadQqbotPluginMetadata({
labelOf: getTriggerModeLabel,
loadPlugins: () => getQqbotPluginList(),
reloadTriggerModes: () => reloadTriggerModeDict(),
});
pluginMap.value = metadata.pluginMap;
pluginOptions.value = metadata.pluginOptions;
}
function openManifestModal(mode: typeof manifestMode.value) {
manifestMode.value = mode;
manifestText.value = JSON.stringify(defaultManifest, null, 2);
packageHashText.value = '';
packagePathText.value = '';
manifestModalOpen.value = true;
}
@ -239,19 +238,26 @@ export default defineComponent({
}
async function submitManifest() {
const manifest = parseManifestText();
if (!manifest) return;
platformLoading.value = true;
try {
if (manifestMode.value === 'upload') {
await uploadQqbotPluginPackage({ manifest });
message.success('插件包上传校验通过');
const body = parsePackageBody();
if (!body) return;
const result = await uploadQqbotPluginPackage(body);
message.success(
result.packageHash
? `插件包上传校验通过:${result.packageHash.slice(0, 12)}`
: '插件包上传校验通过',
);
} else if (manifestMode.value === 'install') {
await installLocalQqbotPluginPackage({ manifest });
const body = parsePackageBody();
if (!body) return;
await installLocalQqbotPluginPackage(body);
message.success('插件已安装');
await loadInstallations(false);
} else {
const manifest = parseManifestText();
if (!manifest) return;
await validateQqbotPluginManifest(manifest);
message.success('Manifest 校验通过');
}
@ -261,6 +267,21 @@ export default defineComponent({
}
}
function parsePackageBody():
| QqbotPluginPlatformApi.PackageBody
| undefined {
const packagePath = packagePathText.value.trim();
const packageHash = packageHashText.value.trim();
if (!packagePath) {
message.error('请输入受控插件包路径');
return undefined;
}
return {
...(packageHash ? { packageHash } : {}),
packagePath,
};
}
async function loadInstallations(openDrawer = true) {
installations.value = await getQqbotPluginPlatformInstallations();
drawerMode.value = 'installations';
@ -329,14 +350,23 @@ export default defineComponent({
/>
<PluginManifestModal
loading={platformLoading.value}
mode={manifestMode.value}
onClose={() => {
manifestModalOpen.value = false;
}}
onSubmit={() => void submitManifest()}
onUpdate:packageHash={(value: string) => {
packageHashText.value = value;
}}
onUpdate:packagePath={(value: string) => {
packagePathText.value = value;
}}
onUpdate:value={(value: string) => {
manifestText.value = value;
}}
open={manifestModalOpen.value}
packageHash={packageHashText.value}
packagePath={packagePathText.value}
title={manifestModalTitle.value}
value={manifestText.value}
/>

View File

@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { loadQqbotPluginMetadata } from './metadata';
describe('qqbot plugin page metadata', () => {
it('keeps plugin page metadata loading from rejecting when plugin list fails', async () => {
const error = { code: 500, msg: 'plugin list failed' };
const onError = vi.fn();
await expect(
loadQqbotPluginMetadata({
labelOf: (value, fallback) => fallback ?? String(value),
loadPlugins: () => Promise.reject(error),
onError,
reloadTriggerModes: () =>
Promise.resolve([{ label: '命令', value: 'command' }]),
}),
).resolves.toEqual({
pluginMap: {},
pluginOptions: [],
});
expect(onError).toHaveBeenCalledWith(error);
});
it('builds plugin map and options after trigger mode dictionary is reloaded', async () => {
const plugin = {
description: 'BangDream commands',
key: 'bangdream',
name: 'BangDream',
operationCount: 12,
triggerMode: 'command',
version: '1.0.0',
} as const;
await expect(
loadQqbotPluginMetadata({
labelOf: (value) => (value === 'command' ? '命令' : String(value)),
loadPlugins: () => Promise.resolve([plugin]),
reloadTriggerModes: () =>
Promise.resolve([{ label: '命令', value: 'command' }]),
}),
).resolves.toEqual({
pluginMap: {
bangdream: plugin,
},
pluginOptions: [
{
label: 'BangDream (bangdream / 命令)',
value: 'bangdream',
},
],
});
});
});

View File

@ -0,0 +1,40 @@
import type { QqbotApi } from '#/api/qqbot';
export interface QqbotPluginMetadata {
pluginMap: Record<string, QqbotApi.Plugin>;
pluginOptions: Array<{ label: string; value: string }>;
}
export interface LoadQqbotPluginMetadataOptions {
labelOf: (value: unknown, fallback?: string) => string;
loadPlugins: () => Promise<QqbotApi.Plugin[]>;
onError?: (error: unknown) => void;
reloadTriggerModes: () => Promise<unknown>;
}
export async function loadQqbotPluginMetadata(
options: LoadQqbotPluginMetadataOptions,
): Promise<QqbotPluginMetadata> {
const [plugins] = await Promise.all([
options.loadPlugins().catch((error: unknown) => {
options.onError?.(error);
return [];
}),
options.reloadTriggerModes().catch((error: unknown) => {
options.onError?.(error);
return [];
}),
]);
const pluginMap: Record<string, QqbotApi.Plugin> = {};
for (const item of plugins) {
pluginMap[item.key] = item;
}
return {
pluginMap,
pluginOptions: plugins.map((item) => ({
label: `${item.name} (${item.key} / ${options.labelOf(item.triggerMode, '-')})`,
value: item.key,
})),
};
}