diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 152fab5..97f099e 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -141,6 +141,118 @@ CREATE TABLE IF NOT EXISTS `napcat_runtime_cleanup` ( KEY `idx_napcat_runtime_cleanup_session` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `napcat_runtime_profile` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `container_id` bigint DEFAULT NULL, + `device_identity_id` bigint DEFAULT NULL, + `profile_version` varchar(64) NOT NULL, + `image_ref` varchar(255) NOT NULL, + `image_digest` varchar(255) DEFAULT NULL, + `base_image_digest` varchar(255) DEFAULT NULL, + `desktop_profile_version` varchar(64) DEFAULT NULL, + `locale_available` tinyint(1) NOT NULL DEFAULT 0, + `fontconfig_evidence` json DEFAULT NULL, + `timezone_evidence` json DEFAULT NULL, + `runtime_uid` int DEFAULT NULL, + `runtime_gid` int DEFAULT NULL, + `shm_size` varchar(32) DEFAULT NULL, + `locale` varchar(64) DEFAULT NULL, + `xdg_config_home` varchar(255) DEFAULT NULL, + `xdg_cache_home` varchar(255) DEFAULT NULL, + `xdg_data_home` varchar(255) DEFAULT NULL, + `persist_cache` tinyint(1) NOT NULL DEFAULT 1, + `persist_local_share` tinyint(1) NOT NULL DEFAULT 1, + `persist_logs` tinyint(1) NOT NULL DEFAULT 1, + `hostname_strategy` varchar(64) NOT NULL, + `mac_strategy` varchar(64) NOT NULL, + `migrate_device_identity` tinyint(1) NOT NULL DEFAULT 0, + `profile_status` varchar(32) NOT NULL, + `last_check_evidence` json DEFAULT NULL, + `last_checked_at` datetime DEFAULT 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), + PRIMARY KEY (`id`), + KEY `idx_napcat_runtime_profile_account` (`account_id`), + KEY `idx_napcat_runtime_profile_container` (`container_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_protocol_profile` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `container_id` bigint DEFAULT NULL, + `profile_version` varchar(64) NOT NULL, + `packet_backend` varchar(64) NOT NULL, + `packet_server` varchar(255) NOT NULL DEFAULT '', + `o3_hook_mode` int NOT NULL DEFAULT 1, + `o3_hook_gray_enabled` tinyint(1) NOT NULL DEFAULT 0, + `onebot_config_hash` varchar(128) DEFAULT NULL, + `onebot_config_json` json DEFAULT NULL, + `napcat_config_hash` varchar(128) DEFAULT NULL, + `napcat_config_json` json DEFAULT NULL, + `profile_status` varchar(32) NOT NULL, + `last_check_evidence` json DEFAULT NULL, + `last_checked_at` datetime DEFAULT 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), + PRIMARY KEY (`id`), + KEY `idx_napcat_protocol_profile_account` (`account_id`), + KEY `idx_napcat_protocol_profile_container` (`container_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_session_behavior_profile` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `profile_version` varchar(64) NOT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT 1, + `cold_start_until` datetime DEFAULT NULL, + `housekeeping_enabled` tinyint(1) NOT NULL DEFAULT 0, + `housekeeping_interval_ms` int DEFAULT NULL, + `next_housekeeping_at` datetime DEFAULT NULL, + `last_housekeeping_at` datetime DEFAULT NULL, + `last_housekeeping_result` json DEFAULT NULL, + `presence_enabled` tinyint(1) NOT NULL DEFAULT 0, + `presence_strategy` varchar(64) DEFAULT NULL, + `last_presence_event_at` datetime DEFAULT NULL, + `next_presence_event_at` datetime DEFAULT NULL, + `auto_capability_stage` varchar(32) NOT NULL, + `last_behavior_evidence` json DEFAULT 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), + PRIMARY KEY (`id`), + KEY `idx_napcat_session_behavior_profile_account` (`account_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_login_event` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `container_id` bigint DEFAULT NULL, + `event_kind` varchar(64) NOT NULL, + `event_source` varchar(32) NOT NULL, + `event_status` varchar(32) NOT NULL, + `evidence` json DEFAULT 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), + PRIMARY KEY (`id`), + KEY `idx_napcat_login_event_account` (`account_id`, `create_time`), + KEY `idx_napcat_login_event_container` (`container_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `napcat_risk_mode` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `risk_mode` varchar(32) NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `source_event` varchar(64) DEFAULT NULL, + `expires_at` datetime DEFAULT NULL, + `last_evidence` json DEFAULT 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), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_napcat_risk_mode_account` (`account_id`), + KEY `idx_napcat_risk_mode_mode` (`risk_mode`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `qqbot_config` ( `id` bigint NOT NULL, `config_key` varchar(120) NOT NULL, diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index 237a638..60bc22b 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -1016,3 +1016,110 @@ CREATE TABLE IF NOT EXISTS napcat_runtime_cleanup ( update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_napcat_runtime_cleanup_session (session_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS napcat_runtime_profile ( + id BIGINT NOT NULL PRIMARY KEY, + account_id BIGINT NOT NULL, + container_id BIGINT NULL, + device_identity_id BIGINT NULL, + profile_version VARCHAR(64) NOT NULL, + image_ref VARCHAR(255) NOT NULL, + image_digest VARCHAR(255) NULL, + base_image_digest VARCHAR(255) NULL, + desktop_profile_version VARCHAR(64) NULL, + locale_available TINYINT NOT NULL DEFAULT 0, + fontconfig_evidence JSON NULL, + timezone_evidence JSON NULL, + runtime_uid INT NULL, + runtime_gid INT NULL, + shm_size VARCHAR(32) NULL, + locale VARCHAR(64) NULL, + xdg_config_home VARCHAR(255) NULL, + xdg_cache_home VARCHAR(255) NULL, + xdg_data_home VARCHAR(255) NULL, + persist_cache TINYINT NOT NULL DEFAULT 1, + persist_local_share TINYINT NOT NULL DEFAULT 1, + persist_logs TINYINT NOT NULL DEFAULT 1, + hostname_strategy VARCHAR(64) NOT NULL, + mac_strategy VARCHAR(64) NOT NULL, + migrate_device_identity TINYINT NOT NULL DEFAULT 0, + profile_status VARCHAR(32) NOT NULL, + last_check_evidence JSON NULL, + last_checked_at DATETIME NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_napcat_runtime_profile_account (account_id), + KEY idx_napcat_runtime_profile_container (container_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS napcat_protocol_profile ( + id BIGINT NOT NULL PRIMARY KEY, + account_id BIGINT NOT NULL, + container_id BIGINT NULL, + profile_version VARCHAR(64) NOT NULL, + packet_backend VARCHAR(64) NOT NULL, + packet_server VARCHAR(255) NOT NULL DEFAULT '', + o3_hook_mode INT NOT NULL DEFAULT 1, + o3_hook_gray_enabled TINYINT NOT NULL DEFAULT 0, + onebot_config_hash VARCHAR(128) NULL, + onebot_config_json JSON NULL, + napcat_config_hash VARCHAR(128) NULL, + napcat_config_json JSON NULL, + profile_status VARCHAR(32) NOT NULL, + last_check_evidence JSON NULL, + last_checked_at DATETIME NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_napcat_protocol_profile_account (account_id), + KEY idx_napcat_protocol_profile_container (container_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS napcat_session_behavior_profile ( + id BIGINT NOT NULL PRIMARY KEY, + account_id BIGINT NOT NULL, + profile_version VARCHAR(64) NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + cold_start_until DATETIME NULL, + housekeeping_enabled TINYINT NOT NULL DEFAULT 0, + housekeeping_interval_ms INT NULL, + next_housekeeping_at DATETIME NULL, + last_housekeeping_at DATETIME NULL, + last_housekeeping_result JSON NULL, + presence_enabled TINYINT NOT NULL DEFAULT 0, + presence_strategy VARCHAR(64) NULL, + last_presence_event_at DATETIME NULL, + next_presence_event_at DATETIME NULL, + auto_capability_stage VARCHAR(32) NOT NULL, + last_behavior_evidence JSON NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_napcat_session_behavior_profile_account (account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS napcat_login_event ( + id BIGINT NOT NULL PRIMARY KEY, + account_id BIGINT NOT NULL, + container_id BIGINT NULL, + event_kind VARCHAR(64) NOT NULL, + event_source VARCHAR(32) NOT NULL, + event_status VARCHAR(32) NOT NULL, + evidence JSON NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_napcat_login_event_account (account_id, create_time), + KEY idx_napcat_login_event_container (container_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS napcat_risk_mode ( + id BIGINT NOT NULL PRIMARY KEY, + account_id BIGINT NOT NULL, + risk_mode VARCHAR(32) NOT NULL, + reason VARCHAR(255) NULL, + source_event VARCHAR(64) NULL, + expires_at DATETIME NULL, + last_evidence JSON NULL, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_napcat_risk_mode_account (account_id), + KEY idx_napcat_risk_mode_mode (risk_mode) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index e6d97cc..98ffad8 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -11,6 +11,11 @@ SELECT 'napcat_account_binding' AS table_name, COUNT(*) AS row_count FROM napcat SELECT 'napcat_login_session' AS table_name, COUNT(*) AS row_count FROM napcat_login_session; SELECT 'napcat_login_challenge' AS table_name, COUNT(*) AS row_count FROM napcat_login_challenge; SELECT 'napcat_runtime_cleanup' AS table_name, COUNT(*) AS row_count FROM napcat_runtime_cleanup; +SELECT 'napcat_runtime_profile' AS table_name, COUNT(*) AS row_count FROM napcat_runtime_profile; +SELECT 'napcat_protocol_profile' AS table_name, COUNT(*) AS row_count FROM napcat_protocol_profile; +SELECT 'napcat_session_behavior_profile' AS table_name, COUNT(*) AS row_count FROM napcat_session_behavior_profile; +SELECT 'napcat_login_event' AS table_name, COUNT(*) AS row_count FROM napcat_login_event; +SELECT 'napcat_risk_mode' AS table_name, COUNT(*) AS row_count FROM napcat_risk_mode; SELECT 'qqbot_plugin_task' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task; SELECT 'qqbot_plugin_task_run' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task_run; @@ -111,6 +116,60 @@ WHERE table_schema = DATABASE() AND table_name = 'napcat_runtime_cleanup' AND index_name = 'idx_napcat_runtime_cleanup_session'; +SELECT 'index_napcat_runtime_profile_account' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_runtime_profile' + AND index_name = 'idx_napcat_runtime_profile_account'; + +SELECT 'index_napcat_runtime_profile_container' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_runtime_profile' + AND index_name = 'idx_napcat_runtime_profile_container'; + +SELECT 'index_napcat_protocol_profile_account' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_protocol_profile' + AND index_name = 'idx_napcat_protocol_profile_account'; + +SELECT 'index_napcat_protocol_profile_container' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_protocol_profile' + AND index_name = 'idx_napcat_protocol_profile_container'; + +SELECT 'index_napcat_session_behavior_profile_account' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_session_behavior_profile' + AND index_name = 'idx_napcat_session_behavior_profile_account'; + +SELECT 'index_napcat_login_event_account' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_login_event' + AND index_name = 'idx_napcat_login_event_account'; + +SELECT 'index_napcat_login_event_container' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_login_event' + AND index_name = 'idx_napcat_login_event_container'; + +SELECT 'index_napcat_risk_mode_account' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_risk_mode' + AND index_name = 'uk_napcat_risk_mode_account'; + +SELECT 'index_napcat_risk_mode_mode' AS check_name, COUNT(*) AS matched_rows +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'napcat_risk_mode' + AND index_name = 'idx_napcat_risk_mode_mode'; + SELECT 'column_napcat_login_challenge_session_id_varchar' AS check_name, COUNT(*) AS matched_rows FROM information_schema.columns WHERE table_schema = DATABASE() diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts index e97527a..bedba2d 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/index.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/index.ts @@ -2,8 +2,13 @@ import { NapcatAccountBinding } from './napcat-account-binding.entity'; import { NapcatContainer } from './napcat-container.entity'; import { NapcatDeviceIdentity } from './napcat-device-identity.entity'; import { NapcatLoginChallengeEntity } from './napcat-login-challenge.entity'; +import { NapcatLoginEvent } from './napcat-login-event.entity'; import { NapcatLoginSession } from './napcat-login-session.entity'; +import { NapcatProtocolProfile } from './napcat-protocol-profile.entity'; +import { NapcatRiskMode } from './napcat-risk-mode.entity'; import { NapcatRuntimeCleanup } from './napcat-runtime-cleanup.entity'; +import { NapcatRuntimeProfile } from './napcat-runtime-profile.entity'; +import { NapcatSessionBehaviorProfile } from './napcat-session-behavior-profile.entity'; export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = { tables: [ @@ -13,6 +18,11 @@ export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = { 'napcat_login_session', 'napcat_login_challenge', 'napcat_runtime_cleanup', + 'napcat_runtime_profile', + 'napcat_protocol_profile', + 'napcat_session_behavior_profile', + 'napcat_login_event', + 'napcat_risk_mode', ], } as const; @@ -23,4 +33,17 @@ export const NAPCAT_RUNTIME_ENTITIES = [ NapcatLoginChallengeEntity, NapcatLoginSession, NapcatRuntimeCleanup, + NapcatRuntimeProfile, + NapcatProtocolProfile, + NapcatSessionBehaviorProfile, + NapcatLoginEvent, + NapcatRiskMode, ]; + +export { + NapcatLoginEvent, + NapcatProtocolProfile, + NapcatRiskMode, + NapcatRuntimeProfile, + NapcatSessionBehaviorProfile, +}; diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-event.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-event.entity.ts new file mode 100644 index 0000000..49b10b6 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-event.entity.ts @@ -0,0 +1,76 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatLoginEventKind = + | 'captcha_required' + | 'container_recreate' + | 'container_restart' + | 'manual_qr_created' + | 'manual_qr_scanned' + | 'new_device_required' + | 'password_attempt' + | 'quick_attempt' + | 'recovery_suspended'; + +export type NapcatLoginEventSource = + | 'admin' + | 'runtime' + | 'system' + | 'watchdog'; + +export type NapcatLoginEventStatus = + | 'blocked' + | 'failed' + | 'pending' + | 'skipped' + | 'success'; + +@Entity('napcat_login_event') +@Index('idx_napcat_login_event_account', ['accountId', 'createTime']) +@Index('idx_napcat_login_event_container', ['containerId', 'createTime']) +export class NapcatLoginEvent { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ + default: null, + name: 'container_id', + nullable: true, + type: 'bigint', + }) + containerId: null | string; + + @Column({ length: 64, name: 'event_kind' }) + eventKind: NapcatLoginEventKind; + + @Column({ length: 32, name: 'event_source' }) + eventSource: NapcatLoginEventSource; + + @Column({ length: 32, name: 'event_status' }) + eventStatus: NapcatLoginEventStatus; + + @Column({ default: null, nullable: true, type: 'json' }) + evidence: null | Record; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + /** + * Assigns a stable Snowflake id before persisting a login-side risk event. + */ + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-protocol-profile.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-protocol-profile.entity.ts new file mode 100644 index 0000000..ceac9b7 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-protocol-profile.entity.ts @@ -0,0 +1,112 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatProtocolProfileStatus = + | 'drifted' + | 'failed' + | 'pending' + | 'synced'; + +@Entity('napcat_protocol_profile') +@Index('idx_napcat_protocol_profile_account', ['accountId']) +@Index('idx_napcat_protocol_profile_container', ['containerId']) +export class NapcatProtocolProfile { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ + default: null, + name: 'container_id', + nullable: true, + type: 'bigint', + }) + containerId: null | string; + + @Column({ length: 64, name: 'profile_version' }) + profileVersion: string; + + @Column({ length: 64, name: 'packet_backend' }) + packetBackend: string; + + @Column({ default: '', length: 255, name: 'packet_server' }) + packetServer: string; + + @Column({ default: 1, name: 'o3_hook_mode', type: 'int' }) + o3HookMode: number; + + @Column({ default: false, name: 'o3_hook_gray_enabled' }) + o3HookGrayEnabled: boolean; + + @Column({ + default: null, + length: 128, + name: 'onebot_config_hash', + nullable: true, + }) + onebotConfigHash: null | string; + + @Column({ + default: null, + name: 'onebot_config_json', + nullable: true, + type: 'json', + }) + onebotConfigJson: null | Record; + + @Column({ + default: null, + length: 128, + name: 'napcat_config_hash', + nullable: true, + }) + napcatConfigHash: null | string; + + @Column({ + default: null, + name: 'napcat_config_json', + nullable: true, + type: 'json', + }) + napcatConfigJson: null | Record; + + @Column({ length: 32, name: 'profile_status' }) + profileStatus: NapcatProtocolProfileStatus; + + @Column({ + default: null, + name: 'last_check_evidence', + nullable: true, + type: 'json', + }) + lastCheckEvidence: null | Record; + + @KtDateTimeColumn({ + default: null, + name: 'last_checked_at', + nullable: true, + }) + lastCheckedAt: KtDateTime | null; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + /** + * Assigns a stable Snowflake id before persisting protocol-profile evidence. + */ + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-risk-mode.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-risk-mode.entity.ts new file mode 100644 index 0000000..f2b8235 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-risk-mode.entity.ts @@ -0,0 +1,60 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatRiskModeValue = 'cooldown' | 'manual_only' | 'normal'; + +@Entity('napcat_risk_mode') +@Index('uk_napcat_risk_mode_account', ['accountId'], { unique: true }) +@Index('idx_napcat_risk_mode_mode', ['riskMode']) +export class NapcatRiskMode { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ length: 32, name: 'risk_mode' }) + riskMode: NapcatRiskModeValue; + + @Column({ default: null, length: 255, nullable: true }) + reason: null | string; + + @Column({ + default: null, + length: 64, + name: 'source_event', + nullable: true, + }) + sourceEvent: null | string; + + @KtDateTimeColumn({ default: null, name: 'expires_at', nullable: true }) + expiresAt: KtDateTime | null; + + @Column({ + default: null, + name: 'last_evidence', + nullable: true, + type: 'json', + }) + lastEvidence: null | Record; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + /** + * Assigns a stable Snowflake id before persisting account risk-mode state. + */ + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-profile.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-profile.entity.ts new file mode 100644 index 0000000..f9f7118 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-profile.entity.ts @@ -0,0 +1,171 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatRuntimeProfileStatus = + | 'drifted' + | 'failed' + | 'pending' + | 'synced'; + +@Entity('napcat_runtime_profile') +@Index('idx_napcat_runtime_profile_account', ['accountId']) +@Index('idx_napcat_runtime_profile_container', ['containerId']) +export class NapcatRuntimeProfile { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ + default: null, + name: 'container_id', + nullable: true, + type: 'bigint', + }) + containerId: null | string; + + @Column({ + default: null, + name: 'device_identity_id', + nullable: true, + type: 'bigint', + }) + deviceIdentityId: null | string; + + @Column({ length: 64, name: 'profile_version' }) + profileVersion: string; + + @Column({ length: 255, name: 'image_ref' }) + imageRef: string; + + @Column({ default: null, length: 255, name: 'image_digest', nullable: true }) + imageDigest: null | string; + + @Column({ + default: null, + length: 255, + name: 'base_image_digest', + nullable: true, + }) + baseImageDigest: null | string; + + @Column({ + default: null, + length: 64, + name: 'desktop_profile_version', + nullable: true, + }) + desktopProfileVersion: null | string; + + @Column({ default: false, name: 'locale_available' }) + localeAvailable: boolean; + + @Column({ + default: null, + name: 'fontconfig_evidence', + nullable: true, + type: 'json', + }) + fontconfigEvidence: null | Record; + + @Column({ + default: null, + name: 'timezone_evidence', + nullable: true, + type: 'json', + }) + timezoneEvidence: null | Record; + + @Column({ default: null, name: 'runtime_uid', nullable: true, type: 'int' }) + runtimeUid: null | number; + + @Column({ default: null, name: 'runtime_gid', nullable: true, type: 'int' }) + runtimeGid: null | number; + + @Column({ default: null, length: 32, name: 'shm_size', nullable: true }) + shmSize: null | string; + + @Column({ default: null, length: 64, nullable: true }) + locale: null | string; + + @Column({ + default: null, + length: 255, + name: 'xdg_config_home', + nullable: true, + }) + xdgConfigHome: null | string; + + @Column({ + default: null, + length: 255, + name: 'xdg_cache_home', + nullable: true, + }) + xdgCacheHome: null | string; + + @Column({ + default: null, + length: 255, + name: 'xdg_data_home', + nullable: true, + }) + xdgDataHome: null | string; + + @Column({ default: true, name: 'persist_cache' }) + persistCache: boolean; + + @Column({ default: true, name: 'persist_local_share' }) + persistLocalShare: boolean; + + @Column({ default: true, name: 'persist_logs' }) + persistLogs: boolean; + + @Column({ length: 64, name: 'hostname_strategy' }) + hostnameStrategy: string; + + @Column({ length: 64, name: 'mac_strategy' }) + macStrategy: string; + + @Column({ default: false, name: 'migrate_device_identity' }) + migrateDeviceIdentity: boolean; + + @Column({ length: 32, name: 'profile_status' }) + profileStatus: NapcatRuntimeProfileStatus; + + @Column({ + default: null, + name: 'last_check_evidence', + nullable: true, + type: 'json', + }) + lastCheckEvidence: null | Record; + + @KtDateTimeColumn({ + default: null, + name: 'last_checked_at', + nullable: true, + }) + lastCheckedAt: KtDateTime | null; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + /** + * Assigns a stable Snowflake id before persisting runtime-profile evidence. + */ + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-session-behavior-profile.entity.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-session-behavior-profile.entity.ts new file mode 100644 index 0000000..885d3b0 --- /dev/null +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-session-behavior-profile.entity.ts @@ -0,0 +1,120 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtDateTimeColumn, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatAutoCapabilityStage = + | 'automatic' + | 'image_commands' + | 'manual_only' + | 'text_commands'; + +@Entity('napcat_session_behavior_profile') +@Index('idx_napcat_session_behavior_profile_account', ['accountId']) +export class NapcatSessionBehaviorProfile { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ length: 64, name: 'profile_version' }) + profileVersion: string; + + @Column({ default: true }) + enabled: boolean; + + @KtDateTimeColumn({ + default: null, + name: 'cold_start_until', + nullable: true, + }) + coldStartUntil: KtDateTime | null; + + @Column({ default: false, name: 'housekeeping_enabled' }) + housekeepingEnabled: boolean; + + @Column({ + default: null, + name: 'housekeeping_interval_ms', + nullable: true, + type: 'int', + }) + housekeepingIntervalMs: null | number; + + @KtDateTimeColumn({ + default: null, + name: 'next_housekeeping_at', + nullable: true, + }) + nextHousekeepingAt: KtDateTime | null; + + @KtDateTimeColumn({ + default: null, + name: 'last_housekeeping_at', + nullable: true, + }) + lastHousekeepingAt: KtDateTime | null; + + @Column({ + default: null, + name: 'last_housekeeping_result', + nullable: true, + type: 'json', + }) + lastHousekeepingResult: null | Record; + + @Column({ default: false, name: 'presence_enabled' }) + presenceEnabled: boolean; + + @Column({ + default: null, + length: 64, + name: 'presence_strategy', + nullable: true, + }) + presenceStrategy: null | string; + + @KtDateTimeColumn({ + default: null, + name: 'last_presence_event_at', + nullable: true, + }) + lastPresenceEventAt: KtDateTime | null; + + @KtDateTimeColumn({ + default: null, + name: 'next_presence_event_at', + nullable: true, + }) + nextPresenceEventAt: KtDateTime | null; + + @Column({ length: 32, name: 'auto_capability_stage' }) + autoCapabilityStage: NapcatAutoCapabilityStage; + + @Column({ + default: null, + name: 'last_behavior_evidence', + nullable: true, + type: 'json', + }) + lastBehaviorEvidence: null | Record; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + /** + * Assigns a stable Snowflake id before persisting session-behavior profile state. + */ + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/schema/README.md b/src/modules/qqbot/napcat/schema/README.md index e0aba07..d5611d9 100644 --- a/src/modules/qqbot/napcat/schema/README.md +++ b/src/modules/qqbot/napcat/schema/README.md @@ -1,7 +1,8 @@ # QQBot NapCat Schema NapCat owns container runtime, account binding, device identity, login session, -challenge, and cleanup persistence through the v3 `napcat_*` tables. +challenge, cleanup, runtime/profile evidence, login-event, and risk-mode +persistence through the v3 `napcat_*` tables. - `napcat_container` - `napcat_device_identity` @@ -9,6 +10,11 @@ challenge, and cleanup persistence through the v3 `napcat_*` tables. - `napcat_login_session` - `napcat_login_challenge` - `napcat_runtime_cleanup` +- `napcat_runtime_profile` +- `napcat_protocol_profile` +- `napcat_session_behavior_profile` +- `napcat_login_event` +- `napcat_risk_mode` Verification SQL: @@ -16,4 +22,9 @@ Verification SQL: SELECT COUNT(*) FROM napcat_container WHERE is_deleted = 0; SELECT COUNT(*) FROM napcat_device_identity; SELECT COUNT(*) FROM napcat_account_binding WHERE is_deleted = 0; +SELECT COUNT(*) FROM napcat_runtime_profile; +SELECT COUNT(*) FROM napcat_protocol_profile; +SELECT COUNT(*) FROM napcat_session_behavior_profile; +SELECT COUNT(*) FROM napcat_login_event; +SELECT COUNT(*) FROM napcat_risk_mode; ``` diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts new file mode 100644 index 0000000..c3bbea3 --- /dev/null +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -0,0 +1,90 @@ +import { getMetadataArgsStorage } from 'typeorm'; +import { + NapcatLoginEvent, + NapcatProtocolProfile, + NapcatRiskMode, + NapcatRuntimeProfile, + NapcatSessionBehaviorProfile, + NAPCAT_RUNTIME_DOMAIN_CONTRACT, + NAPCAT_RUNTIME_ENTITIES, +} from '../../../../src/modules/qqbot/napcat'; +import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; + +type EntityClass = new (...args: never[]) => unknown; + +/** + * Reads a TypeORM entity table name from decorator metadata. + * @param entity - Entity class selected by the test to compare against SQL schema ownership. + */ +const getEntityTableName = (entity: EntityClass) => + getMetadataArgsStorage().tables.find((table) => table.target === entity) + ?.name; + +/** + * Reads entity column names as they are persisted in MySQL. + * @param entity - Entity class whose decorator column metadata must match refactor-v3 SQL. + */ +const getEntityColumnNames = (entity: EntityClass) => + getMetadataArgsStorage() + .columns.filter((column) => column.target === entity) + .map((column) => `${column.options.name || column.propertyName}`); + +describe('NapCat runtime and protocol profile persistence', () => { + const schema = readRefactorV3SqlSchema(); + + it('declares runtime profile tables as NapCat-owned domain tables', () => { + expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual( + expect.arrayContaining([ + 'napcat_runtime_profile', + 'napcat_protocol_profile', + 'napcat_session_behavior_profile', + 'napcat_login_event', + 'napcat_risk_mode', + ]), + ); + }); + + it.each([ + [NapcatRuntimeProfile, 'napcat_runtime_profile'], + [NapcatProtocolProfile, 'napcat_protocol_profile'], + [NapcatSessionBehaviorProfile, 'napcat_session_behavior_profile'], + [NapcatLoginEvent, 'napcat_login_event'], + [NapcatRiskMode, 'napcat_risk_mode'], + ])('maps %p to %s in the v3 SQL schema', (entity, tableName) => { + expect(NAPCAT_RUNTIME_ENTITIES).toContain(entity); + expect(getEntityTableName(entity)).toBe(tableName); + schema.expectTableColumns(tableName, getEntityColumnNames(entity)); + }); + + it('keeps login-event fields separate from send budget fields', () => { + const loginEventColumns = getEntityColumnNames(NapcatLoginEvent); + expect(loginEventColumns).toEqual( + expect.arrayContaining([ + 'account_id', + 'container_id', + 'event_kind', + 'event_source', + 'event_status', + 'evidence', + ]), + ); + expect(loginEventColumns.join(' ')).not.toMatch( + /hour|daily|quota|budget|limit_count/i, + ); + }); + + it('keeps risk mode separate from account send budgets', () => { + const riskColumns = getEntityColumnNames(NapcatRiskMode); + expect(riskColumns).toEqual( + expect.arrayContaining([ + 'account_id', + 'risk_mode', + 'reason', + 'source_event', + 'expires_at', + 'last_evidence', + ]), + ); + expect(riskColumns.join(' ')).not.toMatch(/daily|hour|budget|quota/i); + }); +});