fix: 收紧QQBot消息推送持久化校验

This commit is contained in:
sunlei 2026-07-24 03:49:16 +08:00
parent 5b34a4d8b1
commit a019137c16
4 changed files with 556 additions and 148 deletions

View File

@ -38,13 +38,73 @@ WHERE id = 2041700000000200601
AND enabled = 1
AND is_deleted = 0;
SELECT 'seed_qqbot_message_push_menu' AS check_name, COUNT(*) AS matched_rows
FROM admin_menu
WHERE auth_code IN (
'QqBot:MessageSubscription:List', 'QqBot:MessageSubscription:Create', 'QqBot:MessageSubscription:Update', 'QqBot:MessageSubscription:Delete', 'QqBot:MessageSubscription:Toggle',
'QqBot:MessageTemplate:List', 'QqBot:MessageTemplate:Create', 'QqBot:MessageTemplate:Update', 'QqBot:MessageTemplate:Delete', 'QqBot:MessageTemplate:Toggle', 'QqBot:MessageTemplate:Preview',
'QqBot:Account:MessagePush:List', 'QqBot:Account:MessagePush:Create', 'QqBot:Account:MessagePush:Update', 'QqBot:Account:MessagePush:Delete', 'QqBot:Account:MessagePush:Toggle'
) AND status = 1 AND is_deleted = 0;
WITH expected_menu AS (
SELECT 2041700000000100413 AS id, 2041700000000100400 AS pid, 'QqBotMessageSubscription' AS name, 'QqBot:MessageSubscription:List' AS auth_code, 10 AS sort
UNION ALL SELECT 2041700000000100414, 2041700000000100400, 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List', 11
UNION ALL SELECT 2041700000000120461, 2041700000000100413, 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List', 0
UNION ALL SELECT 2041700000000120462, 2041700000000100413, 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create', 0
UNION ALL SELECT 2041700000000120463, 2041700000000100413, 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update', 0
UNION ALL SELECT 2041700000000120464, 2041700000000100413, 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete', 0
UNION ALL SELECT 2041700000000120465, 2041700000000100413, 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle', 0
UNION ALL SELECT 2041700000000120471, 2041700000000100414, 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List', 0
UNION ALL SELECT 2041700000000120472, 2041700000000100414, 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create', 0
UNION ALL SELECT 2041700000000120473, 2041700000000100414, 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update', 0
UNION ALL SELECT 2041700000000120474, 2041700000000100414, 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete', 0
UNION ALL SELECT 2041700000000120475, 2041700000000100414, 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle', 0
UNION ALL SELECT 2041700000000120476, 2041700000000100414, 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview', 0
UNION ALL SELECT 2041700000000120481, 2041700000000100410, 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List', 0
UNION ALL SELECT 2041700000000120482, 2041700000000100410, 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create', 0
UNION ALL SELECT 2041700000000120483, 2041700000000100410, 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update', 0
UNION ALL SELECT 2041700000000120484, 2041700000000100410, 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete', 0
UNION ALL SELECT 2041700000000120485, 2041700000000100410, 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', 0
)
SELECT 'seed_qqbot_message_push_menu_mismatch' AS check_name,
expected.id, expected.pid, expected.name, expected.auth_code, expected.sort,
actual.id AS actual_id, actual.pid AS actual_pid, actual.name AS actual_name,
actual.auth_code AS actual_auth_code, actual.sort AS actual_sort,
actual.status AS actual_status, actual.is_deleted AS actual_is_deleted
FROM expected_menu expected
LEFT JOIN admin_menu actual ON actual.id = expected.id
WHERE actual.id IS NULL
OR actual.name <> expected.name
OR actual.auth_code <> expected.auth_code
OR actual.pid <> expected.pid
OR actual.sort <> expected.sort
OR actual.status <> 1
OR actual.is_deleted <> 0;
WITH expected_menu AS (
SELECT 2041700000000100413 AS id UNION ALL SELECT 2041700000000100414 UNION ALL SELECT 2041700000000120461
UNION ALL SELECT 2041700000000120462 UNION ALL SELECT 2041700000000120463 UNION ALL SELECT 2041700000000120464
UNION ALL SELECT 2041700000000120465 UNION ALL SELECT 2041700000000120471 UNION ALL SELECT 2041700000000120472
UNION ALL SELECT 2041700000000120473 UNION ALL SELECT 2041700000000120474 UNION ALL SELECT 2041700000000120475
UNION ALL SELECT 2041700000000120476 UNION ALL SELECT 2041700000000120481 UNION ALL SELECT 2041700000000120482
UNION ALL SELECT 2041700000000120483 UNION ALL SELECT 2041700000000120484 UNION ALL SELECT 2041700000000120485
)
SELECT 'seed_qqbot_message_push_menu_cardinality' AS check_name,
COUNT(*) AS expected_count, COUNT(actual.id) AS actual_count,
COUNT(*) - COUNT(actual.id) AS missing_count
FROM expected_menu expected
LEFT JOIN admin_menu actual ON actual.id = expected.id
AND actual.status = 1
AND actual.is_deleted = 0;
WITH expected_menu AS (
SELECT 2041700000000100413 AS id UNION ALL SELECT 2041700000000100414 UNION ALL SELECT 2041700000000120461
UNION ALL SELECT 2041700000000120462 UNION ALL SELECT 2041700000000120463 UNION ALL SELECT 2041700000000120464
UNION ALL SELECT 2041700000000120465 UNION ALL SELECT 2041700000000120471 UNION ALL SELECT 2041700000000120472
UNION ALL SELECT 2041700000000120473 UNION ALL SELECT 2041700000000120474 UNION ALL SELECT 2041700000000120475
UNION ALL SELECT 2041700000000120476 UNION ALL SELECT 2041700000000120481 UNION ALL SELECT 2041700000000120482
UNION ALL SELECT 2041700000000120483 UNION ALL SELECT 2041700000000120484 UNION ALL SELECT 2041700000000120485
)
SELECT 'seed_qqbot_message_push_menu_role_grant_missing' AS check_name,
role.role_code, expected.id AS menu_id
FROM admin_role role CROSS JOIN expected_menu expected
LEFT JOIN admin_role_menu role_menu ON role_menu.role_id = role.id AND role_menu.menu_id = expected.id
WHERE role.role_code IN ('super', 'admin')
AND role.status = 1
AND role.is_deleted = 0
AND role_menu.menu_id IS NULL;
SELECT 'qqbot_napcat_webui_gateway_audit table exists' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.tables

View File

@ -25,18 +25,62 @@ export type SystemMessageDeliveryReadiness =
export interface SystemMessageSourceAdapter {
readonly definition: SystemMessageSourceDefinition;
/**
*
* @param config -
* @returns
*/
inspectSubscription(config: Record<string, unknown>): Promise<{ invalidReasonCode: null | string; sourceSummary: string; valid: boolean; }>;
/**
*
* @returns
*/
listSubscriptionOptions(): Promise<Record<string, unknown>>;
/**
*
* @param input -
* @returns
*/
normalizeSubscriptionConfig(input: unknown): Promise<{ canonicalConfig: Record<string, string>; resourceKey: string; sourceSummary: string; }>;
/**
*
* @param input -
* @returns DDNS /
*/
resolveDelivery(input: { eventPayload: Record<string, SystemMessageScalar>; subscriptionConfig: Record<string, unknown>; }): Promise<SystemMessageDeliveryReadiness>;
/**
*
* @param payload -
* @returns Outbox
*/
validateEventPayload(payload: Record<string, unknown>): Record<string, SystemMessageScalar>;
}
export interface SystemMessageEventInput { eventId: string; occurredAt: string; payload: Record<string, SystemMessageScalar>; resourceKey: string; sourceKey: string; }
export const SYSTEM_MESSAGE_EVENT_STAGER = Symbol('SYSTEM_MESSAGE_EVENT_STAGER');
export interface SystemMessageEventStager { stage(manager: EntityManager, input: SystemMessageEventInput): Promise<'accepted' | 'duplicate'>; }
export interface SystemMessageEventStager {
/**
* Outbox
* @param manager -
* @param input -
* @returns `accepted` ID `duplicate`
*/
stage(manager: EntityManager, input: SystemMessageEventInput): Promise<'accepted' | 'duplicate'>;
}
export const SYSTEM_MESSAGE_DELIVERY_COORDINATOR = Symbol('SYSTEM_MESSAGE_DELIVERY_COORDINATOR');
export interface SystemMessageDeliveryCoordinator { notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise<void>; requestDrain(): void; }
export interface SystemMessageDeliveryCoordinator {
/**
* DDNS
* @param input - DDNS ID
* @returns drain
*/
notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise<void>;
/**
* drain
* @returns worker
*/
requestDrain(): void;
}
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; }

View File

@ -1,7 +1,5 @@
import { getMetadataArgsStorage } from 'typeorm';
import {
QQBOT_CORE_ENTITIES,
} from '../../../../src/modules/qqbot/core/qqbot-core.module';
import { QQBOT_CORE_ENTITIES } from '../../../../src/modules/qqbot/core/qqbot-core.module';
import { QqbotMessageDelivery } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-delivery.entity';
import { QqbotMessageEvent } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-event.entity';
import { QqbotMessagePublishBinding } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-publish-binding.entity';
@ -9,75 +7,193 @@ import { QqbotMessagePublishTarget } from '../../../../src/modules/qqbot/core/in
import { QqbotMessageSubscription } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
import { QqbotMessageTemplate } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-template.entity';
const messagePushEntities = [
QqbotMessageSubscription,
QqbotMessageTemplate,
QqbotMessagePublishBinding,
QqbotMessagePublishTarget,
QqbotMessageEvent,
QqbotMessageDelivery,
type EntityClass = new (...args: never[]) => unknown;
type ColumnContract = {
default: boolean | number | string | null;
length: number | null;
name: string;
nullable: boolean;
precision: number | null;
primary: boolean;
propertyName: string;
type: string;
unsigned: boolean;
};
/** Builds an expected varchar metadata tuple without consulting production contracts. */
const varchar = (propertyName: string, length: number, nullable = false): ColumnContract => ({ default: null, length, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable, precision: null, primary: false, propertyName, type: 'varchar', unsigned: false });
/** Builds an expected bigint metadata tuple without consulting production contracts. */
const bigint = (propertyName: string, nullable = false, primary = false): ColumnContract => ({ default: null, length: null, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable, precision: null, primary, propertyName, type: 'bigint', unsigned: false });
/** Builds an expected datetime(6) metadata tuple without consulting production contracts. */
const datetime = (propertyName: string, nullable = false): ColumnContract => ({ default: null, length: null, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable, precision: 6, primary: false, propertyName, type: 'datetime', unsigned: false });
/** Builds an expected tinyint boolean metadata tuple without consulting production contracts. */
const boolean = (propertyName: string, defaultValue: boolean): ColumnContract => ({ default: defaultValue, length: null, name: propertyName.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`), nullable: false, precision: null, primary: false, propertyName, type: 'tinyint', unsigned: false });
const persistenceContract: ReadonlyArray<{
columns: readonly ColumnContract[];
entity: EntityClass;
indexes: ReadonlyArray<{ columns: readonly string[]; name: string; unique: boolean }>;
table: string;
}> = [
{
entity: QqbotMessageSubscription,
table: 'qqbot_message_subscription',
columns: [
bigint('id', false, true), varchar('name', 100), varchar('sourceKey', 128),
{ ...varchar('sourceConfig', 0), length: null, type: 'json' },
{ ...varchar('sourceConfigDigest', 64), type: 'char' }, varchar('activeKey', 255, true),
boolean('enabled', true), varchar('remark', 500, true), boolean('isDeleted', false),
datetime('createTime'), datetime('updateTime'),
],
indexes: [{ name: 'uk_qqbot_message_subscription_active_key', columns: ['activeKey'], unique: true }],
},
{
entity: QqbotMessageTemplate,
table: 'qqbot_message_template',
columns: [
bigint('id', false, true), varchar('name', 100), varchar('sourceKey', 128),
{ ...varchar('content', 0), length: null, type: 'text' }, boolean('enabled', true),
varchar('remark', 500, true), boolean('isDeleted', false), datetime('createTime'), datetime('updateTime'),
],
indexes: [],
},
{
entity: QqbotMessagePublishBinding,
table: 'qqbot_message_publish_binding',
columns: [
bigint('id', false, true), bigint('subscriptionId'), bigint('accountId'), varchar('selfId', 64),
bigint('templateId'), varchar('activeKey', 255, true), boolean('enabled', true),
boolean('isDeleted', false), datetime('createTime'), datetime('updateTime'),
],
indexes: [{ name: 'uk_qqbot_message_publish_binding_active_key', columns: ['activeKey'], unique: true }],
},
{
entity: QqbotMessagePublishTarget,
table: 'qqbot_message_publish_target',
columns: [
bigint('id', false, true), bigint('bindingId'), varchar('targetType', 16), varchar('targetId', 64),
varchar('targetName', 120, true), varchar('activeKey', 300, true), boolean('enabled', true),
boolean('isDeleted', false), datetime('createTime'), datetime('updateTime'),
],
indexes: [{ name: 'uk_qqbot_message_publish_target_active_key', columns: ['activeKey'], unique: true }],
},
{
entity: QqbotMessageEvent,
table: 'qqbot_message_event',
columns: [
bigint('id', false, true), varchar('eventId', 128), varchar('sourceKey', 128), varchar('resourceKey', 128),
datetime('occurredAt'), { ...varchar('payload', 0), length: null, type: 'json' },
{ ...varchar('fanoutStatus', 32), default: 'accepted' },
{ ...varchar('fanoutAttemptCount', 0), default: 0, length: null, type: 'int', unsigned: true },
datetime('nextFanoutAt', true), datetime('fanoutLeaseUntil', true), varchar('lastErrorCode', 64, true),
varchar('lastErrorMessage', 500, true), datetime('createTime'), datetime('updateTime'),
],
indexes: [
{ name: 'uk_qqbot_message_event_event_id', columns: ['eventId'], unique: true },
{ name: 'idx_qqbot_message_event_dispatch', columns: ['fanoutStatus', 'nextFanoutAt'], unique: false },
{ name: 'idx_qqbot_message_event_lease', columns: ['fanoutLeaseUntil'], unique: false },
],
},
{
entity: QqbotMessageDelivery,
table: 'qqbot_message_delivery',
columns: [
bigint('id', false, true), bigint('messageEventId'), bigint('publishTargetId'), bigint('bindingId'),
bigint('subscriptionId'), varchar('selfId', 64), varchar('targetType', 16), varchar('targetId', 64),
bigint('templateId'), { ...varchar('templateContent', 0), length: null, type: 'text' },
{ ...varchar('variableSnapshot', 0), length: null, type: 'json' }, { ...varchar('renderedMessage', 0), length: null, type: 'text' },
varchar('status', 32), { ...varchar('attemptCount', 0), default: 0, length: null, type: 'int', unsigned: true },
datetime('nextAttemptAt', true), datetime('processingLeaseUntil', true), bigint('sendLogId', true),
varchar('lastErrorCode', 64, true), varchar('lastErrorMessage', 500, true), datetime('expiresAt'),
datetime('createTime'), datetime('updateTime'),
],
indexes: [
{ name: 'uk_qqbot_message_delivery_event_target', columns: ['messageEventId', 'publishTargetId'], unique: true },
{ name: 'idx_qqbot_message_delivery_dispatch', columns: ['status', 'nextAttemptAt'], unique: false },
{ name: 'idx_qqbot_message_delivery_lease', columns: ['processingLeaseUntil'], unique: false },
{ name: 'idx_qqbot_message_delivery_history', columns: ['subscriptionId', 'messageEventId'], unique: false },
],
},
];
const expectedColumns = {
qqbot_message_delivery: [
'id', 'message_event_id', 'publish_target_id', 'binding_id',
'subscription_id', 'self_id', 'target_type', 'target_id', 'template_id',
'template_content', 'variable_snapshot', 'rendered_message', 'status',
'attempt_count', 'next_attempt_at', 'processing_lease_until',
'send_log_id', 'last_error_code', 'last_error_message', 'expires_at',
'create_time', 'update_time',
],
qqbot_message_event: [
'id', 'event_id', 'source_key', 'resource_key', 'occurred_at', 'payload',
'fanout_status', 'fanout_attempt_count', 'next_fanout_at',
'fanout_lease_until', 'last_error_code', 'last_error_message',
'create_time', 'update_time',
],
qqbot_message_publish_binding: [
'id', 'subscription_id', 'account_id', 'self_id', 'template_id',
'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time',
],
qqbot_message_publish_target: [
'id', 'binding_id', 'target_type', 'target_id', 'target_name',
'active_key', 'enabled', 'is_deleted', 'create_time', 'update_time',
],
qqbot_message_subscription: [
'id', 'name', 'source_key', 'source_config', 'source_config_digest',
'active_key', 'enabled', 'remark', 'is_deleted', 'create_time',
'update_time',
],
qqbot_message_template: [
'id', 'name', 'source_key', 'content', 'enabled', 'remark',
'is_deleted', 'create_time', 'update_time',
],
} as const;
/** Converts TypeORM's constructor shorthand into its SQL-facing type name. */
const normalizeColumnType = (type: unknown) => {
if (type === String) return 'varchar';
if (type === Boolean) return 'tinyint';
return `${type}`.toLowerCase();
};
/** Reads the table name declared by a TypeORM entity. */
const tableNameOf = (entity: object) =>
getMetadataArgsStorage().tables.find((table) => table.target === entity)
?.name;
/** Reads an entity's TypeORM column metadata into a stable comparison tuple. */
const getColumns = (entity: EntityClass): ColumnContract[] => getMetadataArgsStorage()
.columns.filter((column) => column.target === entity)
.map((column) => ({
default: column.options.default ?? null,
length: column.options.length === undefined ? null : Number(column.options.length),
name: `${column.options.name || column.propertyName}`,
nullable: column.options.nullable === true,
precision: column.options.precision === undefined ? null : Number(column.options.precision),
primary: column.options.primary === true,
propertyName: `${column.propertyName}`,
type: column.options.type === undefined && ['createDate', 'updateDate'].includes(column.mode)
? 'datetime'
: normalizeColumnType(column.options.type),
unsigned: column.options.unsigned === true,
}));
/** Reads the database column names declared by a TypeORM entity. */
const columnNamesOf = (entity: object) =>
getMetadataArgsStorage()
.columns.filter((column) => column.target === entity)
.map((column) => `${column.options.name || column.propertyName}`);
/** Reads an entity's TypeORM index definitions into exact name/column/unique tuples. */
const getIndexes = (entity: EntityClass) => getMetadataArgsStorage().indices
.filter((index) => index.target === entity)
.map((index) => ({
columns: Array.isArray(index.columns) ? index.columns.map((column) => `${column}`) : [],
name: index.name,
unique: index.unique === true,
}))
.sort((left, right) => left.name.localeCompare(right.name));
describe('QQBot message-push persistence contract', () => {
it('registers the six message-push entities in QQBot Core', () => {
expect(QQBOT_CORE_ENTITIES).toEqual(
expect.arrayContaining(messagePushEntities),
);
it('registers exactly the six message-push entities in QQBot Core', () => {
expect(QQBOT_CORE_ENTITIES).toEqual(expect.arrayContaining(persistenceContract.map(({ entity }) => entity)));
});
it('maps every required message-push column with string Snowflake IDs', () => {
for (const entity of messagePushEntities) {
const tableName = tableNameOf(entity);
it('maps every table to its independent exact column metadata matrix', () => {
for (const { columns, entity, table } of persistenceContract) {
const metadataTable = getMetadataArgsStorage().tables.find((entry) => entry.target === entity);
expect(metadataTable?.name).toBe(table);
expect(getColumns(entity)).toEqual(columns);
}
});
expect(tableName).toBeTruthy();
expect(columnNamesOf(entity)).toEqual(
expect.arrayContaining([...expectedColumns[tableName as keyof typeof expectedColumns]]),
);
it('marks create and update timestamps as generated TypeORM timestamp columns', () => {
for (const { entity } of persistenceContract) {
const timestamps = getMetadataArgsStorage().columns
.filter((column) => column.target === entity)
.filter((column) => ['createTime', 'updateTime'].includes(`${column.propertyName}`))
.map((column) => ({
mode: column.mode,
precision: column.options.precision,
type: column.options.type === undefined ? 'datetime' : normalizeColumnType(column.options.type),
}));
expect(timestamps).toEqual([
{ mode: 'createDate', precision: 6, type: 'datetime' },
{ mode: 'updateDate', precision: 6, type: 'datetime' },
]);
}
});
it('keeps all unique, dispatch, lease, and history indexes as exact tuples', () => {
for (const { entity, indexes } of persistenceContract) {
expect(getIndexes(entity)).toEqual([...indexes].sort((left, right) => left.name.localeCompare(right.name)));
}
});
it('keeps every identifier property as a string and declares no ORM relations or foreign keys', () => {
const metadata = getMetadataArgsStorage();
for (const { columns, entity } of persistenceContract) {
for (const { propertyName } of columns.filter(({ propertyName }) => propertyName === 'id' || propertyName.endsWith('Id'))) {
expect(Reflect.getMetadata('design:type', entity.prototype, propertyName)).toBe(String);
}
expect(metadata.relations.filter((relation) => relation.target === entity)).toEqual([]);
expect((metadata.foreignKeys || []).filter((foreignKey) => foreignKey.target === entity)).toEqual([]);
}
});
});

View File

@ -1,42 +1,208 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
const tables = [
'qqbot_message_subscription',
'qqbot_message_template',
'qqbot_message_publish_binding',
'qqbot_message_publish_target',
'qqbot_message_event',
'qqbot_message_delivery',
] as const;
type MenuEntry = readonly [
id: string,
pid: string,
name: string,
authCode: string,
sort: string,
];
const menuEntries = [
['2041700000000100413', 'QqBotMessageSubscription', 'QqBot:MessageSubscription:List'],
['2041700000000100414', 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List'],
['2041700000000120461', 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List'],
['2041700000000120462', 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create'],
['2041700000000120463', 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update'],
['2041700000000120464', 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete'],
['2041700000000120465', 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle'],
['2041700000000120471', 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List'],
['2041700000000120472', 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create'],
['2041700000000120473', 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update'],
['2041700000000120474', 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete'],
['2041700000000120475', 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle'],
['2041700000000120476', 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview'],
['2041700000000120481', 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List'],
['2041700000000120482', 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create'],
['2041700000000120483', 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update'],
['2041700000000120484', 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete'],
['2041700000000120485', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle'],
] as const;
const messagePushTableDefinitions = {
qqbot_message_subscription: {
columns: [
'id bigint not null', 'name varchar(100) not null',
'source_key varchar(128) not null', 'source_config json not null',
'source_config_digest char(64) not null', 'active_key varchar(255) null',
'enabled tinyint(1) not null default 1', 'remark varchar(500) null',
'is_deleted tinyint(1) not null default 0',
'create_time datetime(6) not null default current_timestamp(6)',
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
],
indexes: ['primary key (id)', 'unique key uk_qqbot_message_subscription_active_key (active_key)'],
},
qqbot_message_template: {
columns: [
'id bigint not null', 'name varchar(100) not null',
'source_key varchar(128) not null', 'content text not null',
'enabled tinyint(1) not null default 1', 'remark varchar(500) null',
'is_deleted tinyint(1) not null default 0',
'create_time datetime(6) not null default current_timestamp(6)',
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
],
indexes: ['primary key (id)'],
},
qqbot_message_publish_binding: {
columns: [
'id bigint not null', 'subscription_id bigint not null',
'account_id bigint not null', 'self_id varchar(64) not null',
'template_id bigint not null', 'active_key varchar(255) null',
'enabled tinyint(1) not null default 1', 'is_deleted tinyint(1) not null default 0',
'create_time datetime(6) not null default current_timestamp(6)',
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
],
indexes: ['primary key (id)', 'unique key uk_qqbot_message_publish_binding_active_key (active_key)'],
},
qqbot_message_publish_target: {
columns: [
'id bigint not null', 'binding_id bigint not null',
'target_type varchar(16) not null', 'target_id varchar(64) not null',
'target_name varchar(120) null', 'active_key varchar(300) null',
'enabled tinyint(1) not null default 1', 'is_deleted tinyint(1) not null default 0',
'create_time datetime(6) not null default current_timestamp(6)',
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
],
indexes: ['primary key (id)', 'unique key uk_qqbot_message_publish_target_active_key (active_key)'],
},
qqbot_message_event: {
columns: [
'id bigint not null', 'event_id varchar(128) not null',
'source_key varchar(128) not null', 'resource_key varchar(128) not null',
'occurred_at datetime(6) not null', 'payload json not null',
"fanout_status varchar(32) not null default 'accepted'", 'fanout_attempt_count int unsigned not null default 0',
'next_fanout_at datetime(6) null', 'fanout_lease_until datetime(6) null',
'last_error_code varchar(64) null', 'last_error_message varchar(500) null',
'create_time datetime(6) not null default current_timestamp(6)',
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
],
indexes: [
'primary key (id)', 'unique key uk_qqbot_message_event_event_id (event_id)',
'key idx_qqbot_message_event_dispatch (fanout_status, next_fanout_at)',
'key idx_qqbot_message_event_lease (fanout_lease_until)',
],
},
qqbot_message_delivery: {
columns: [
'id bigint not null', 'message_event_id bigint not null',
'publish_target_id bigint not null', 'binding_id bigint not null',
'subscription_id bigint not null', 'self_id varchar(64) not null',
'target_type varchar(16) not null', 'target_id varchar(64) not null',
'template_id bigint not null', 'template_content text not null',
'variable_snapshot json not null', 'rendered_message text not null',
'status varchar(32) not null', 'attempt_count int unsigned not null default 0',
'next_attempt_at datetime(6) null', 'processing_lease_until datetime(6) null',
'send_log_id bigint null', 'last_error_code varchar(64) null',
'last_error_message varchar(500) null', 'expires_at datetime(6) not null',
'create_time datetime(6) not null default current_timestamp(6)',
'update_time datetime(6) not null default current_timestamp(6) on update current_timestamp(6)',
],
indexes: [
'primary key (id)', 'unique key uk_qqbot_message_delivery_event_target (message_event_id, publish_target_id)',
'key idx_qqbot_message_delivery_dispatch (status, next_attempt_at)',
'key idx_qqbot_message_delivery_lease (processing_lease_until)',
'key idx_qqbot_message_delivery_history (subscription_id, message_event_id)',
],
},
} as const;
/** Reads SQL with case and quote differences removed for contract assertions. */
const menuEntries: readonly MenuEntry[] = [
['2041700000000100413', '2041700000000100400', 'QqBotMessageSubscription', 'QqBot:MessageSubscription:List', '10'],
['2041700000000100414', '2041700000000100400', 'QqBotMessageTemplate', 'QqBot:MessageTemplate:List', '11'],
['2041700000000120461', '2041700000000100413', 'QqBotMessageSubscriptionList', 'QqBot:MessageSubscription:List', '0'],
['2041700000000120462', '2041700000000100413', 'QqBotMessageSubscriptionCreate', 'QqBot:MessageSubscription:Create', '0'],
['2041700000000120463', '2041700000000100413', 'QqBotMessageSubscriptionUpdate', 'QqBot:MessageSubscription:Update', '0'],
['2041700000000120464', '2041700000000100413', 'QqBotMessageSubscriptionDelete', 'QqBot:MessageSubscription:Delete', '0'],
['2041700000000120465', '2041700000000100413', 'QqBotMessageSubscriptionToggle', 'QqBot:MessageSubscription:Toggle', '0'],
['2041700000000120471', '2041700000000100414', 'QqBotMessageTemplateList', 'QqBot:MessageTemplate:List', '0'],
['2041700000000120472', '2041700000000100414', 'QqBotMessageTemplateCreate', 'QqBot:MessageTemplate:Create', '0'],
['2041700000000120473', '2041700000000100414', 'QqBotMessageTemplateUpdate', 'QqBot:MessageTemplate:Update', '0'],
['2041700000000120474', '2041700000000100414', 'QqBotMessageTemplateDelete', 'QqBot:MessageTemplate:Delete', '0'],
['2041700000000120475', '2041700000000100414', 'QqBotMessageTemplateToggle', 'QqBot:MessageTemplate:Toggle', '0'],
['2041700000000120476', '2041700000000100414', 'QqBotMessageTemplatePreview', 'QqBot:MessageTemplate:Preview', '0'],
['2041700000000120481', '2041700000000100410', 'QqBotAccountMessagePushList', 'QqBot:Account:MessagePush:List', '0'],
['2041700000000120482', '2041700000000100410', 'QqBotAccountMessagePushCreate', 'QqBot:Account:MessagePush:Create', '0'],
['2041700000000120483', '2041700000000100410', 'QqBotAccountMessagePushUpdate', 'QqBot:Account:MessagePush:Update', '0'],
['2041700000000120484', '2041700000000100410', 'QqBotAccountMessagePushDelete', 'QqBot:Account:MessagePush:Delete', '0'],
['2041700000000120485', '2041700000000100410', 'QqBotAccountMessagePushToggle', 'QqBot:Account:MessagePush:Toggle', '0'],
];
/** Normalizes SQL formatting while preserving the exact statement content. */
const normalizeSql = (sql: string) => sql
.toLowerCase()
.replace(/`/g, '')
.replace(/\s+/g, ' ')
.trim();
/** Reads a repository SQL file as normalized text. */
const readNormalizedSql = (relativePath: string) =>
readFileSync(resolve(process.cwd(), relativePath), 'utf8')
.toLowerCase()
.replace(/`/g, '')
.replace(/\s+/g, ' ');
normalizeSql(readFileSync(resolve(process.cwd(), relativePath), 'utf8'));
/** Extracts one CREATE TABLE block so assertions cannot match another table. */
const extractCreateTableBlock = (sql: string, table: string) => {
const escapedTable = table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = sql.match(new RegExp(
`create table if not exists ${escapedTable} \\((.*?)\\) engine=innodb default charset=utf8mb4 collate=utf8mb4_unicode_ci;`,
's',
));
expect(match).toBeTruthy();
return match?.[0] || '';
};
/** Splits a SQL VALUES tuple without treating quoted JSON or text commas as separators. */
const splitSqlTuple = (tuple: string) => {
const values: string[] = [];
let current = '';
let quoted = false;
for (let index = 0; index < tuple.length; index += 1) {
const character = tuple[index];
if (character === "'" && tuple[index - 1] !== '\\') quoted = !quoted;
if (character === ',' && !quoted) {
values.push(current.trim());
current = '';
continue;
}
current += character;
}
values.push(current.trim());
return values;
};
/** Removes SQL string delimiters from one already-split VALUES field. */
const unquoteSqlValue = (value: string) => value.replace(/^'|'$/g, '');
/** Extracts top-level SQL VALUES tuples while preserving quoted payloads. */
const extractSqlTuples = (values: string) => {
const tuples: string[] = [];
let current = '';
let depth = 0;
let quoted = false;
for (let index = 0; index < values.length; index += 1) {
const character = values[index];
if (character === "'" && values[index - 1] !== '\\') quoted = !quoted;
if (!quoted && character === '(') {
depth += 1;
if (depth === 1) current = '';
else current += character;
continue;
}
if (!quoted && character === ')') {
depth -= 1;
if (depth === 0) tuples.push(current);
else current += character;
continue;
}
if (depth > 0) current += character;
}
return tuples;
};
/** Extracts the message-push menu VALUES rows from the matching seed statement. */
const extractMessagePushMenuRows = (sql: string) => {
const statements = [...sql.matchAll(/insert into admin_menu \([^)]*\) values (.*?) on duplicate key update/gs)];
const statement = statements.find((candidate) => candidate[1].includes(menuEntries[0][0]));
expect(statement).toBeTruthy();
const values = statement?.[1] || '';
const tuples = extractSqlTuples(values);
return tuples
.map(splitSqlTuple)
.map((fields) => [
unquoteSqlValue(fields[0]), unquoteSqlValue(fields[1]),
unquoteSqlValue(fields[2]), unquoteSqlValue(fields[6]),
unquoteSqlValue(fields[10]),
] as MenuEntry)
.filter(([id]) => menuEntries.some(([expectedId]) => expectedId === id));
};
describe('QQBot message-push SQL contract', () => {
const bootstrapSql = readNormalizedSql('sql/qqbot-init.sql');
@ -45,53 +211,75 @@ describe('QQBot message-push SQL contract', () => {
const verifySql = readNormalizedSql('sql/refactor-v3/99-verify.sql');
const vbenSql = readNormalizedSql('sql/vben-admin-init.sql');
it.each(tables)('declares %s in current and bootstrap schema SQL', (table) => {
expect(bootstrapSql).toContain(`create table if not exists ${table}`);
expect(schemaSql).toContain(`create table if not exists ${table}`);
});
it('keeps six-table indexes, datetime precision, and JSON fields aligned', () => {
for (const sql of [bootstrapSql, schemaSql]) {
expect(sql).toContain('uk_qqbot_message_subscription_active_key');
expect(sql).toContain('uk_qqbot_message_publish_binding_active_key');
expect(sql).toContain('uk_qqbot_message_publish_target_active_key');
expect(sql).toContain('uk_qqbot_message_event_event_id');
expect(sql).toContain('uk_qqbot_message_delivery_event_target');
expect(sql).toContain('idx_qqbot_message_event_dispatch');
expect(sql).toContain('idx_qqbot_message_event_lease');
expect(sql).toContain('idx_qqbot_message_delivery_dispatch');
expect(sql).toContain('idx_qqbot_message_delivery_lease');
expect(sql).toContain('idx_qqbot_message_delivery_history');
expect(sql).toContain('source_config json');
expect(sql).toContain('payload json');
expect(sql).toContain('variable_snapshot json');
expect(sql).toContain('datetime(6)');
}
});
it('seeds the default template and every stable menu node idempotently', () => {
for (const sql of [bootstrapSql, seedSql]) {
expect(sql).toContain('2041700000000200601');
expect(sql).toContain('network.stun.mapping-port-changed');
expect(sql).toContain('当前stun的端口已变更为${{endpoint}}');
expect(sql).toContain('where not exists');
}
for (const [id, name, authCode] of menuEntries) {
for (const sql of [bootstrapSql, seedSql, vbenSql]) {
expect(sql).toContain(id);
expect(sql).toContain(name.toLowerCase());
expect(sql).toContain(authCode.toLowerCase());
it('keeps each table DDL and index tuple exact in current and refactor schemas', () => {
for (const [table, expected] of Object.entries(messagePushTableDefinitions)) {
for (const sql of [bootstrapSql, schemaSql]) {
const block = extractCreateTableBlock(sql, table);
let expectedColumns = sql === bootstrapSql
? expected.columns.map((column) => column.endsWith(' null') && !column.endsWith('not null')
? column.replace(/ null$/, ' default null')
: column)
: expected.columns;
const expectedIndexes = [...expected.indexes];
if (sql === schemaSql) {
expectedColumns = expectedColumns.map((column, index) => index === 0
? `${column} primary key`
: column);
expectedIndexes.shift();
}
const expectedBlock = normalizeSql([
`create table if not exists ${table} (`,
[...expectedColumns, ...expectedIndexes].join(', '),
') engine=innodb default charset=utf8mb4 collate=utf8mb4_unicode_ci;',
].join(' '));
expect(block).toBe(expectedBlock);
}
}
});
it('verifies every persisted message-push table and seeded menu contract', () => {
for (const table of tables) {
expect(verifySql).toContain(table);
it('seeds the exact default template natural key, content, stable ID, and absence guard', () => {
const expectedTemplate = normalizeSql(`
insert into qqbot_message_template (id, name, source_key, content, enabled, remark, is_deleted)
select 2041700000000200601, 'STUN 映射端口变更默认模板',
'network.stun.mapping-port-changed', '当前STUN的端口已变更为\${{endpoint}}', 1, '系统默认模板', 0
where not exists (
select 1 from qqbot_message_template
where source_key = 'network.stun.mapping-port-changed'
and name = 'STUN 映射端口变更默认模板' and is_deleted = 0
);
`);
for (const sql of [bootstrapSql, seedSql]) expect(sql).toContain(expectedTemplate);
});
it('keeps every stable menu tuple together and exactly once in every seed file', () => {
for (const sql of [bootstrapSql, seedSql, vbenSql]) {
const rows = extractMessagePushMenuRows(sql);
expect(rows).toHaveLength(18);
expect(new Set(rows.map(([id]) => id)).size).toBe(18);
expect(rows).toEqual(menuEntries.map((entry) => entry.map((value) => value.toLowerCase())));
}
expect(verifySql).toContain('2041700000000200601');
expect(verifySql).toContain('qqbot:messagesubscription:list');
expect(verifySql).toContain('qqbot:account:messagepush:toggle');
});
it('uses an inline exact menu expected set to verify rows, cardinality, and enabled-role grants', () => {
for (const [index, [id, pid, name, authCode, sort]] of menuEntries.entries()) {
expect(verifySql).toContain(normalizeSql(
`${index === 0 ? 'select' : 'union all select'} ${id}${index === 0 ? ' as id' : ''}, ${pid}${index === 0 ? ' as pid' : ''}, '${name}'${index === 0 ? ' as name' : ''}, '${authCode}'${index === 0 ? ' as auth_code' : ''}, ${sort}${index === 0 ? ' as sort' : ''}`,
));
}
expect(verifySql).toContain('left join admin_menu actual on actual.id = expected.id');
expect(verifySql).toContain('actual.name <> expected.name');
expect(verifySql).toContain('actual.auth_code <> expected.auth_code');
expect(verifySql).toContain('actual.pid <> expected.pid');
expect(verifySql).toContain('actual.sort <> expected.sort');
expect(verifySql).toContain('actual.status <> 1');
expect(verifySql).toContain('actual.is_deleted <> 0');
expect(verifySql).toContain('count(*) as expected_count');
expect(verifySql).toContain('count(actual.id) as actual_count');
expect(verifySql).toContain('from admin_role role cross join expected_menu expected');
expect(verifySql).toContain('left join admin_role_menu role_menu');
expect(verifySql).toContain("role.role_code in ('super', 'admin')");
expect(verifySql).toContain('role.status = 1');
expect(verifySql).toContain('role.is_deleted = 0');
expect(verifySql).toContain('role_menu.menu_id is null');
});
});