From a019137c16f4179cda18bccac6a0c7b46b57aa93 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 24 Jul 2026 03:49:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7QQBot=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/refactor-v3/99-verify.sql | 74 +++- .../message-push/qqbot-message-push.types.ts | 48 ++- .../qqbot-message-push-persistence.spec.ts | 242 +++++++++---- .../qqbot-message-push-sql.spec.ts | 340 ++++++++++++++---- 4 files changed, 556 insertions(+), 148 deletions(-) diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index 14946a7..db103da 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -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 diff --git a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts index afea1e7..77554dd 100644 --- a/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts +++ b/src/modules/qqbot/core/contract/message-push/qqbot-message-push.types.ts @@ -25,18 +25,62 @@ export type SystemMessageDeliveryReadiness = export interface SystemMessageSourceAdapter { readonly definition: SystemMessageSourceDefinition; + /** + * 检查订阅配置当前是否仍可用。 + * @param config - 待检查的来源配置。 + * @returns 有效性、稳定错误码与可展示的来源摘要;不写入状态。 + */ inspectSubscription(config: Record): Promise<{ invalidReasonCode: null | string; sourceSummary: string; valid: boolean; }>; + /** + * 列出创建或编辑订阅时可选择的来源资源。 + * @returns 来源专属的候选项快照;不创建订阅或事务状态。 + */ listSubscriptionOptions(): Promise>; + /** + * 规范化并验证用户提交的订阅配置。 + * @param input - 未信任的订阅配置输入。 + * @returns 可持久化的规范配置、资源键及来源摘要;调用方在事务中保存返回值。 + */ normalizeSubscriptionConfig(input: unknown): Promise<{ canonicalConfig: Record; resourceKey: string; sourceSummary: string; }>; + /** + * 根据事件和订阅配置重新计算一次投递就绪状态。 + * @param input - 冻结的事件载荷及当前订阅配置。 + * @returns 可发送变量、等待 DDNS 或取消/被取代状态;不直接发送消息。 + */ resolveDelivery(input: { eventPayload: Record; subscriptionConfig: Record; }): Promise; + /** + * 校验并收窄生产者事件载荷至允许的标量值。 + * @param payload - 来自系统事件的未验证载荷。 + * @returns 可安全写入 Outbox 的规范化标量载荷;非法输入抛出稳定契约错误。 + */ validateEventPayload(payload: Record): Record; } export interface SystemMessageEventInput { eventId: string; occurredAt: string; payload: Record; 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; requestDrain(): void; } +export interface SystemMessageDeliveryCoordinator { + /** + * 在 DDNS 同步提交后通知协调器重新评估等待中的投递。 + * @param input - 已应用地址及对应 DDNS 记录 ID。 + * @returns 唤醒请求已被接收;实现应在提交后异步合并 drain,不在调用事务内发送消息。 + */ + notifyDdnsSynced(input: { appliedAddress: string; ddnsRecordId: string; }): Promise; + /** + * 请求一次可合并的异步投递 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; sourceKey: string; } diff --git a/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts index c09b4ad..a0390fd 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push-persistence.spec.ts @@ -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([]); } }); }); diff --git a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts index 3150ec2..deb3487 100644 --- a/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts +++ b/test/modules/qqbot/message-push/qqbot-message-push-sql.spec.ts @@ -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'); }); });