feat: 定义消息推送前端契约

This commit is contained in:
sunlei 2026-07-24 14:53:11 +08:00
parent 25a5bc3499
commit edb77bd67c
6 changed files with 794 additions and 0 deletions

View File

@ -391,4 +391,57 @@ describe('core menu api', () => {
},
]);
});
it('keeps every supported message-push menu name once while filtering unknown nodes', async () => {
const messagePushNames = [
'QqBotMessageSubscription',
'QqBotMessageTemplate',
'QqBotMessageSubscriptionList',
'QqBotMessageSubscriptionCreate',
'QqBotMessageSubscriptionUpdate',
'QqBotMessageSubscriptionDelete',
'QqBotMessageSubscriptionToggle',
'QqBotMessageTemplateList',
'QqBotMessageTemplateCreate',
'QqBotMessageTemplateUpdate',
'QqBotMessageTemplateDelete',
'QqBotMessageTemplateToggle',
'QqBotMessageTemplatePreview',
'QqBotAccountMessagePushList',
'QqBotAccountMessagePushCreate',
'QqBotAccountMessagePushUpdate',
'QqBotAccountMessagePushDelete',
'QqBotAccountMessagePushToggle',
];
requestClientGet.mockResolvedValue([
{
name: 'QqBot',
path: '/qqbot',
children: [
{
name: 'QqBotAccount',
path: '/qqbot/account',
},
...messagePushNames.map((name) => ({
authCode: `QqBot:${name}`,
name,
type: 'button',
})),
{
name: 'UnsupportedMessagePushNode',
type: 'button',
},
],
},
]);
const { getAllMenusApi } = await import('./menu');
const menus = await getAllMenusApi();
const qqbot = menus.find((menu) => menu.name === 'QqBot');
const retainedNames = qqbot?.children?.map((child) => child.name) ?? [];
expect(retainedNames).toEqual(['QqBotAccount', ...messagePushNames]);
expect(new Set(retainedNames).size).toBe(retainedNames.length);
expect(retainedNames).not.toContain('UnsupportedMessagePushNode');
});
});

View File

@ -33,6 +33,11 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'QqBotAccountDelete',
'QqBotAccountEdit',
'QqBotAccountKick',
'QqBotAccountMessagePushCreate',
'QqBotAccountMessagePushDelete',
'QqBotAccountMessagePushList',
'QqBotAccountMessagePushToggle',
'QqBotAccountMessagePushUpdate',
'QqBotAccountNapcatWebui',
'QqBotAccountRefreshLogin',
'QqBotAccountWebUI',
@ -45,6 +50,19 @@ const SUPPORTED_ADMIN_MENU_NAMES = new Set([
'QqBotConversation',
'QqBotDashboard',
'QqBotMessage',
'QqBotMessageSubscription',
'QqBotMessageSubscriptionCreate',
'QqBotMessageSubscriptionDelete',
'QqBotMessageSubscriptionList',
'QqBotMessageSubscriptionToggle',
'QqBotMessageSubscriptionUpdate',
'QqBotMessageTemplate',
'QqBotMessageTemplateCreate',
'QqBotMessageTemplateDelete',
'QqBotMessageTemplateList',
'QqBotMessageTemplatePreview',
'QqBotMessageTemplateToggle',
'QqBotMessageTemplateUpdate',
'QqBotPermission',
'QqBotPermissionCreate',
'QqBotPermissionDelete',

View File

@ -0,0 +1,224 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { requestClient } from '#/api/request';
import * as messagePushApi from './message-push';
import {
createAccountMessagePushBinding,
createMessageSubscription,
createMessageTemplate,
deleteAccountMessagePushBinding,
deleteMessageSubscription,
deleteMessageTemplate,
getAccountMessagePushBindings,
getAccountMessagePushTargets,
getMessagePushSourceDetail,
getMessagePushSources,
getMessageSubscriptionList,
getMessageTemplateList,
getStunMappingPortChangedOptions,
previewMessageTemplate,
setAccountMessagePushBindingEnabled,
setMessageSubscriptionEnabled,
setMessageTemplateEnabled,
updateAccountMessagePushBinding,
updateMessageSubscription,
updateMessageTemplate,
} from './message-push';
vi.mock('#/api/request', () => ({
requestClient: {
delete: vi.fn(),
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
},
}));
describe('qqbot message push api', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('owns the exact source, subscription, and template callers', async () => {
const sourceKey = 'network.stun/a b';
const id = '10000000000000001';
const previewContent = '端口 $' + '{{port}}';
const subscriptionInput = {
enabled: true,
name: 'STUN 端口变更',
sourceConfig: {
ddnsRecordId: '10000000000000003',
portForwardId: '10000000000000002',
},
sourceKey: 'network.stun.mapping-port-changed',
};
const templateInput = {
content: previewContent,
enabled: true,
name: '端口通知',
sourceKey: 'network.stun.mapping-port-changed',
};
const subscriptionQuery = {
enabled: true,
name: 'STUN',
pageNo: 1,
pageSize: 10,
sourceKey: 'network.stun.mapping-port-changed',
};
const templateQuery = {
enabled: false,
name: '通知',
pageNo: 2,
pageSize: 20,
sourceKey: 'network.stun.mapping-port-changed',
};
await getMessagePushSources();
await getMessagePushSourceDetail(sourceKey);
await getStunMappingPortChangedOptions();
await getMessageSubscriptionList(subscriptionQuery);
await createMessageSubscription(subscriptionInput);
await updateMessageSubscription('id/a b', subscriptionInput);
await setMessageSubscriptionEnabled(id, false);
await deleteMessageSubscription('id/a b');
await getMessageTemplateList(templateQuery);
await createMessageTemplate(templateInput);
await updateMessageTemplate('template/a b', templateInput);
await setMessageTemplateEnabled(id, false);
await deleteMessageTemplate('template/a b');
await previewMessageTemplate({
content: previewContent,
sourceKey: 'network.stun.mapping-port-changed',
});
expect(requestClient.get).toHaveBeenNthCalledWith(
1,
'/qqbot/message-push/sources',
);
expect(requestClient.get).toHaveBeenNthCalledWith(
2,
'/qqbot/message-push/sources/network.stun%2Fa%20b',
);
expect(requestClient.get).toHaveBeenNthCalledWith(
3,
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
);
expect(requestClient.get).toHaveBeenNthCalledWith(
4,
'/qqbot/message-push/subscriptions',
{ params: subscriptionQuery },
);
expect(requestClient.get).toHaveBeenNthCalledWith(
5,
'/qqbot/message-push/templates',
{ params: templateQuery },
);
expect(requestClient.post).toHaveBeenNthCalledWith(
1,
'/qqbot/message-push/subscriptions',
subscriptionInput,
);
expect(requestClient.post).toHaveBeenNthCalledWith(
2,
'/qqbot/message-push/templates',
templateInput,
);
expect(requestClient.post).toHaveBeenNthCalledWith(
3,
'/qqbot/message-push/templates/preview',
{
content: previewContent,
sourceKey: 'network.stun.mapping-port-changed',
},
);
expect(requestClient.put).toHaveBeenNthCalledWith(
1,
'/qqbot/message-push/subscriptions/id%2Fa%20b',
subscriptionInput,
);
expect(requestClient.put).toHaveBeenNthCalledWith(
2,
'/qqbot/message-push/subscriptions/10000000000000001/enabled',
{ enabled: false },
);
expect(requestClient.put).toHaveBeenNthCalledWith(
3,
'/qqbot/message-push/templates/template%2Fa%20b',
templateInput,
);
expect(requestClient.put).toHaveBeenNthCalledWith(
4,
'/qqbot/message-push/templates/10000000000000001/enabled',
{ enabled: false },
);
expect(requestClient.delete).toHaveBeenNthCalledWith(
1,
'/qqbot/message-push/subscriptions/id%2Fa%20b',
);
expect(requestClient.delete).toHaveBeenNthCalledWith(
2,
'/qqbot/message-push/templates/template%2Fa%20b',
);
});
it('keeps account IDs as strings and encodes every account path segment', async () => {
const selfId = '10000000000000001/a b';
const bindingId = 'binding/a b';
const bindingInput = {
enabled: true,
subscriptionId: '10000000000000002',
targets: [
{
targetId: '10000000000000003',
targetName: '测试群',
targetType: 'group' as const,
},
],
templateId: '10000000000000004',
};
await getAccountMessagePushBindings(selfId);
await createAccountMessagePushBinding(selfId, bindingInput);
await updateAccountMessagePushBinding(selfId, bindingId, bindingInput);
await setAccountMessagePushBindingEnabled(selfId, bindingId, false);
await deleteAccountMessagePushBinding(selfId, bindingId);
await getAccountMessagePushTargets(selfId);
const accountPath =
'/qqbot/accounts/10000000000000001%2Fa%20b/message-push';
expect(requestClient.get).toHaveBeenNthCalledWith(
1,
`${accountPath}/bindings`,
);
expect(requestClient.get).toHaveBeenNthCalledWith(
2,
`${accountPath}/targets`,
);
expect(requestClient.post).toHaveBeenCalledWith(
`${accountPath}/bindings`,
bindingInput,
);
expect(requestClient.put).toHaveBeenNthCalledWith(
1,
`${accountPath}/bindings/binding%2Fa%20b`,
bindingInput,
);
expect(requestClient.put).toHaveBeenNthCalledWith(
2,
`${accountPath}/bindings/binding%2Fa%20b/enabled`,
{ enabled: false },
);
expect(requestClient.delete).toHaveBeenCalledWith(
`${accountPath}/bindings/binding%2Fa%20b`,
);
});
it('does not expose internal publish, event, delivery, or worker callers', () => {
expect(Object.keys(messagePushApi)).not.toEqual(
expect.arrayContaining([
expect.stringMatching(/delivery|event|publish|worker/i),
]),
);
});
});

View File

@ -0,0 +1,447 @@
import { requestClient } from '#/api/request';
export namespace QqbotMessagePushApi {
export type SystemMessageScalar = boolean | null | number | string;
export type QqbotMessagePushTargetType = 'group' | 'private';
export interface SystemMessageSourceVariableDefinition {
description: string;
example: string;
key: string;
label: string;
type: 'boolean' | 'number' | 'string';
}
export interface SystemMessageSourceFieldDefinition {
dependsOn?: string;
key: string;
label: string;
optionCollection: 'ddnsRecords' | 'portForwards';
required: true;
type: 'select';
}
export interface SystemMessageSourceDefinition {
description: string;
displayName: string;
sourceKey: string;
subscriptionFields: SystemMessageSourceFieldDefinition[];
variables: SystemMessageSourceVariableDefinition[];
version: 1;
}
export interface StunMappingPortChangedSubscriptionConfig {
ddnsRecordId: string;
portForwardId: string;
}
export interface StunMappingPortChangedOptionsResponse {
ddnsRecords: Array<{
disabledReasonCode: null | string;
eligible: boolean;
fqdn: string;
id: string;
name: string;
portForwardId: string;
}>;
portForwards: Array<{
disabledReasonCode: null | string;
eligible: boolean;
externalPort: number;
id: string;
internalPort: number;
name: string;
protocol: 'tcp' | 'udp';
}>;
}
export interface MessageSubscriptionView {
createTime: string;
enabled: boolean;
id: string;
invalidReasonCode: null | string;
name: string;
remark: null | string;
sourceConfig: StunMappingPortChangedSubscriptionConfig;
sourceKey: string;
sourceName: string;
sourceSummary: string;
updateTime: string;
valid: boolean;
}
export interface MessageSubscriptionListQuery {
enabled?: boolean;
name?: string;
pageNo?: number;
pageSize?: number;
sourceKey?: string;
}
export interface MessageSubscriptionInput {
enabled: boolean;
name: string;
remark?: string;
sourceConfig: Record<string, unknown>;
sourceKey: string;
}
export interface MessageTemplateView {
content: string;
createTime: string;
enabled: boolean;
id: string;
name: string;
referenceCount: number;
remark: null | string;
sourceKey: string;
sourceName: string;
updateTime: string;
}
export interface MessageTemplateListQuery {
enabled?: boolean;
name?: string;
pageNo?: number;
pageSize?: number;
sourceKey?: string;
}
export interface MessageTemplateInput {
content: string;
enabled: boolean;
name: string;
remark?: string;
sourceKey: string;
}
export interface MessageTemplatePreviewInput {
content: string;
sourceKey: string;
}
export interface MessageTemplatePreview {
renderedMessage: string;
variables: Record<string, boolean | number | string>;
}
export interface QqbotMessagePublishTargetInput {
targetId: string;
targetName?: string;
targetType: QqbotMessagePushTargetType;
}
export interface QqbotMessagePublishBindingInput {
enabled: boolean;
subscriptionId: string;
targets: QqbotMessagePublishTargetInput[];
templateId: string;
}
export interface QqbotMessagePushTargetOption {
label: string;
targetId: string;
targetType: QqbotMessagePushTargetType;
}
export interface QqbotMessagePushTargetOptionsResponse {
available: boolean;
options: QqbotMessagePushTargetOption[];
reasonCode: null | string;
}
export interface QqbotMessagePublishTargetView {
enabled: boolean;
id: string;
targetId: string;
targetName: null | string;
targetType: QqbotMessagePushTargetType;
}
export interface QqbotMessagePublishBindingView {
available: boolean;
createTime: string;
enabled: boolean;
id: string;
invalidReasonCode: null | string;
sourceKey: string;
sourceName: string;
subscriptionId: string;
subscriptionName: string;
targets: QqbotMessagePublishTargetView[];
templateId: string;
templateName: string;
updateTime: string;
}
export interface PageResult<T> {
items: T[];
total: number;
}
}
/**
* Lists registered system message sources available to the Admin UI.
* @returns Public source definitions without internal adapter details.
*/
export function getMessagePushSources() {
return requestClient.get<QqbotMessagePushApi.SystemMessageSourceDefinition[]>(
'/qqbot/message-push/sources',
);
}
/**
* Loads one public source definition by its stable source key.
* @param sourceKey - String source identity, encoded before becoming a URL segment.
* @returns The requested source definition.
*/
export function getMessagePushSourceDetail(sourceKey: string) {
return requestClient.get<QqbotMessagePushApi.SystemMessageSourceDefinition>(
`/qqbot/message-push/sources/${encodeURIComponent(sourceKey)}`,
);
}
/**
* Loads server-evaluated options for the built-in STUN port-change source.
* @returns Eligible and disabled port-forward and DDNS options.
*/
export function getStunMappingPortChangedOptions() {
return requestClient.get<QqbotMessagePushApi.StunMappingPortChangedOptionsResponse>(
'/qqbot/message-push/sources/network.stun.mapping-port-changed/options',
);
}
/**
* Pages global message subscriptions using independent list filters.
* @param params - Pagination, source, name, and enabled-state filters.
* @returns A strict `{ items, total }` subscription page.
*/
export function getMessageSubscriptionList(
params: QqbotMessagePushApi.MessageSubscriptionListQuery,
) {
return requestClient.get<
QqbotMessagePushApi.PageResult<QqbotMessagePushApi.MessageSubscriptionView>
>('/qqbot/message-push/subscriptions', { params });
}
/**
* Creates one source-scoped global message subscription.
* @param data - Complete subscription name, source, configuration, and enabled state.
* @returns The persisted subscription view.
*/
export function createMessageSubscription(
data: QqbotMessagePushApi.MessageSubscriptionInput,
) {
return requestClient.post<QqbotMessagePushApi.MessageSubscriptionView>(
'/qqbot/message-push/subscriptions',
data,
);
}
/**
* Replaces one global subscription without coercing its string ID.
* @param id - Stable subscription ID, encoded before becoming a URL segment.
* @param data - Complete replacement subscription input.
* @returns The updated subscription view.
*/
export function updateMessageSubscription(
id: string,
data: QqbotMessagePushApi.MessageSubscriptionInput,
) {
return requestClient.put<QqbotMessagePushApi.MessageSubscriptionView>(
`/qqbot/message-push/subscriptions/${encodeURIComponent(id)}`,
data,
);
}
/**
* Changes whether a subscription matches future source events.
* @param id - Stable subscription ID, encoded before becoming a URL segment.
* @param enabled - Requested future enabled state.
* @returns The updated subscription view.
*/
export function setMessageSubscriptionEnabled(id: string, enabled: boolean) {
return requestClient.put<QqbotMessagePushApi.MessageSubscriptionView>(
`/qqbot/message-push/subscriptions/${encodeURIComponent(id)}/enabled`,
{ enabled },
);
}
/**
* Soft-deletes one global message subscription.
* @param id - Stable subscription ID, encoded before becoming a URL segment.
* @returns Whether the subscription was deleted.
*/
export function deleteMessageSubscription(id: string) {
return requestClient.delete<boolean>(
`/qqbot/message-push/subscriptions/${encodeURIComponent(id)}`,
);
}
/**
* Pages global message templates using independent list filters.
* @param params - Pagination, source, name, and enabled-state filters.
* @returns A strict `{ items, total }` template page.
*/
export function getMessageTemplateList(
params: QqbotMessagePushApi.MessageTemplateListQuery,
) {
return requestClient.get<
QqbotMessagePushApi.PageResult<QqbotMessagePushApi.MessageTemplateView>
>('/qqbot/message-push/templates', { params });
}
/**
* Creates one source-scoped global message template.
* @param data - Complete template content, source, metadata, and enabled state.
* @returns The persisted template view.
*/
export function createMessageTemplate(
data: QqbotMessagePushApi.MessageTemplateInput,
) {
return requestClient.post<QqbotMessagePushApi.MessageTemplateView>(
'/qqbot/message-push/templates',
data,
);
}
/**
* Replaces one global message template without coercing its string ID.
* @param id - Stable template ID, encoded before becoming a URL segment.
* @param data - Complete replacement template input.
* @returns The updated template view.
*/
export function updateMessageTemplate(
id: string,
data: QqbotMessagePushApi.MessageTemplateInput,
) {
return requestClient.put<QqbotMessagePushApi.MessageTemplateView>(
`/qqbot/message-push/templates/${encodeURIComponent(id)}`,
data,
);
}
/**
* Changes whether a template can be selected for future bindings and events.
* @param id - Stable template ID, encoded before becoming a URL segment.
* @param enabled - Requested future enabled state.
* @returns The updated template view.
*/
export function setMessageTemplateEnabled(id: string, enabled: boolean) {
return requestClient.put<QqbotMessagePushApi.MessageTemplateView>(
`/qqbot/message-push/templates/${encodeURIComponent(id)}/enabled`,
{ enabled },
);
}
/**
* Soft-deletes one global message template when the backend permits it.
* @param id - Stable template ID, encoded before becoming a URL segment.
* @returns Whether the template was deleted.
*/
export function deleteMessageTemplate(id: string) {
return requestClient.delete<boolean>(
`/qqbot/message-push/templates/${encodeURIComponent(id)}`,
);
}
/**
* Renders a source-scoped message template using server-controlled example data.
* @param data - Template content and source identity only.
* @returns Safe rendered text and the example variables used.
*/
export function previewMessageTemplate(
data: QqbotMessagePushApi.MessageTemplatePreviewInput,
) {
return requestClient.post<QqbotMessagePushApi.MessageTemplatePreview>(
'/qqbot/message-push/templates/preview',
data,
);
}
/**
* Lists message-push bindings belonging to exactly one QQBot account.
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
* @returns Account-scoped binding views with their configured targets.
*/
export function getAccountMessagePushBindings(selfId: string) {
return requestClient.get<
QqbotMessagePushApi.QqbotMessagePublishBindingView[]
>(`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/bindings`);
}
/**
* Creates one account-scoped binding from a global subscription and template.
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
* @param data - Subscription, template, target, and enabled-state input.
* @returns The persisted account binding view.
*/
export function createAccountMessagePushBinding(
selfId: string,
data: QqbotMessagePushApi.QqbotMessagePublishBindingInput,
) {
return requestClient.post<QqbotMessagePushApi.QqbotMessagePublishBindingView>(
`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/bindings`,
data,
);
}
/**
* Replaces one binding for exactly one QQBot account.
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
* @param id - Stable binding ID, encoded before becoming a URL segment.
* @param data - Subscription, template, target, and enabled-state input.
* @returns The updated account binding view.
*/
export function updateAccountMessagePushBinding(
selfId: string,
id: string,
data: QqbotMessagePushApi.QqbotMessagePublishBindingInput,
) {
return requestClient.put<QqbotMessagePushApi.QqbotMessagePublishBindingView>(
`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/bindings/${encodeURIComponent(id)}`,
data,
);
}
/**
* Changes whether one account binding creates future delivery work.
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
* @param id - Stable binding ID, encoded before becoming a URL segment.
* @param enabled - Requested future enabled state.
* @returns The updated account binding view.
*/
export function setAccountMessagePushBindingEnabled(
selfId: string,
id: string,
enabled: boolean,
) {
return requestClient.put<QqbotMessagePushApi.QqbotMessagePublishBindingView>(
`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/bindings/${encodeURIComponent(id)}/enabled`,
{ enabled },
);
}
/**
* Soft-deletes one binding from exactly one QQBot account.
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
* @param id - Stable binding ID, encoded before becoming a URL segment.
* @returns Whether the account binding was deleted.
*/
export function deleteAccountMessagePushBinding(selfId: string, id: string) {
return requestClient.delete<boolean>(
`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/bindings/${encodeURIComponent(id)}`,
);
}
/**
* Lists group and private-message target choices for exactly one QQBot account.
* @param selfId - QQBot self ID kept as a string and encoded in the URL.
* @returns Availability state plus currently discoverable string-ID targets.
*/
export function getAccountMessagePushTargets(selfId: string) {
return requestClient.get<QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse>(
`/qqbot/accounts/${encodeURIComponent(selfId)}/message-push/targets`,
);
}

View File

@ -0,0 +1,34 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
describe('qqbot routes', () => {
it('adds two flat message-push route children without loading future pages', () => {
const source = readFileSync(
'apps/web-antdv-next/src/router/routes/modules/qqbot.ts',
'utf8',
);
for (const [name, path, componentPath, title] of [
[
'QqBotMessageSubscription',
'/qqbot/message-subscription',
'#/views/qqbot/message-subscription/list',
'消息订阅',
],
[
'QqBotMessageTemplate',
'/qqbot/message-template',
'#/views/qqbot/message-template/list',
'消息模板',
],
] as const) {
expect(source).toMatch(
new RegExp(
String.raw`\{\s*component: \(\) => import\('${componentPath}'\),\s*meta: \{\s*icon: 'lucide:[^']+',\s*title: '${title}',\s*\},\s*name: '${name}',\s*path: '${path}',\s*\}`,
's',
),
);
}
});
});

View File

@ -112,6 +112,24 @@ const routes: RouteRecordRaw[] = [
name: 'QqBotSendLog',
path: '/qqbot/sendLog',
},
{
component: () => import('#/views/qqbot/message-subscription/list'),
meta: {
icon: 'lucide:bell-ring',
title: '消息订阅',
},
name: 'QqBotMessageSubscription',
path: '/qqbot/message-subscription',
},
{
component: () => import('#/views/qqbot/message-template/list'),
meta: {
icon: 'lucide:message-square-plus',
title: '消息模板',
},
name: 'QqBotMessageTemplate',
path: '/qqbot/message-template',
},
{
component: () => import('#/views/qqbot/permission/list'),
meta: {