diff --git a/docs/admin-adfahi-research/screenshots/00-menu-overview.png b/docs/admin-adfahi-research/screenshots/00-menu-overview.png new file mode 100644 index 0000000..5299a52 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/00-menu-overview.png differ diff --git a/docs/admin-adfahi-research/screenshots/01-dashboard.png b/docs/admin-adfahi-research/screenshots/01-dashboard.png new file mode 100644 index 0000000..cb9baf9 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/01-dashboard.png differ diff --git a/docs/admin-adfahi-research/screenshots/02-user-management.png b/docs/admin-adfahi-research/screenshots/02-user-management.png new file mode 100644 index 0000000..77ed862 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/02-user-management.png differ diff --git a/docs/admin-adfahi-research/screenshots/03-user-groups.png b/docs/admin-adfahi-research/screenshots/03-user-groups.png new file mode 100644 index 0000000..746df5f Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/03-user-groups.png differ diff --git a/docs/admin-adfahi-research/screenshots/04-group-management.png b/docs/admin-adfahi-research/screenshots/04-group-management.png new file mode 100644 index 0000000..39f5997 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/04-group-management.png differ diff --git a/docs/admin-adfahi-research/screenshots/05-group-members.png b/docs/admin-adfahi-research/screenshots/05-group-members.png new file mode 100644 index 0000000..79bbc9f Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/05-group-members.png differ diff --git a/docs/admin-adfahi-research/screenshots/06-invite-codes.png b/docs/admin-adfahi-research/screenshots/06-invite-codes.png new file mode 100644 index 0000000..5326ca0 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/06-invite-codes.png differ diff --git a/docs/admin-adfahi-research/screenshots/07-direct-messages.png b/docs/admin-adfahi-research/screenshots/07-direct-messages.png new file mode 100644 index 0000000..73e2966 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/07-direct-messages.png differ diff --git a/docs/admin-adfahi-research/screenshots/08-group-messages.png b/docs/admin-adfahi-research/screenshots/08-group-messages.png new file mode 100644 index 0000000..40f37fb Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/08-group-messages.png differ diff --git a/docs/admin-adfahi-research/screenshots/09-sensitive-words.png b/docs/admin-adfahi-research/screenshots/09-sensitive-words.png new file mode 100644 index 0000000..14a3f0f Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/09-sensitive-words.png differ diff --git a/docs/admin-adfahi-research/screenshots/10-files.png b/docs/admin-adfahi-research/screenshots/10-files.png new file mode 100644 index 0000000..e0410d9 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/10-files.png differ diff --git a/docs/admin-adfahi-research/screenshots/11-feedback.png b/docs/admin-adfahi-research/screenshots/11-feedback.png new file mode 100644 index 0000000..af91a13 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/11-feedback.png differ diff --git a/docs/admin-adfahi-research/screenshots/12-operation-logs.png b/docs/admin-adfahi-research/screenshots/12-operation-logs.png new file mode 100644 index 0000000..a0c0e8c Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/12-operation-logs.png differ diff --git a/docs/admin-adfahi-research/screenshots/13-user-account-audit.png b/docs/admin-adfahi-research/screenshots/13-user-account-audit.png new file mode 100644 index 0000000..816c9a8 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/13-user-account-audit.png differ diff --git a/docs/admin-adfahi-research/screenshots/14-group-event-audit.png b/docs/admin-adfahi-research/screenshots/14-group-event-audit.png new file mode 100644 index 0000000..03be1cd Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/14-group-event-audit.png differ diff --git a/docs/admin-adfahi-research/screenshots/15-admin-login.png b/docs/admin-adfahi-research/screenshots/15-admin-login.png new file mode 100644 index 0000000..703bee6 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/15-admin-login.png differ diff --git a/docs/admin-adfahi-research/screenshots/16-user-login.png b/docs/admin-adfahi-research/screenshots/16-user-login.png new file mode 100644 index 0000000..2d25c57 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/16-user-login.png differ diff --git a/docs/admin-adfahi-research/screenshots/17-ip-account-stats.png b/docs/admin-adfahi-research/screenshots/17-ip-account-stats.png new file mode 100644 index 0000000..3a1e44e Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/17-ip-account-stats.png differ diff --git a/docs/admin-adfahi-research/screenshots/18-login-failures.png b/docs/admin-adfahi-research/screenshots/18-login-failures.png new file mode 100644 index 0000000..80b4788 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/18-login-failures.png differ diff --git a/docs/admin-adfahi-research/screenshots/19-private-domain-settings.png b/docs/admin-adfahi-research/screenshots/19-private-domain-settings.png new file mode 100644 index 0000000..73af3bf Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/19-private-domain-settings.png differ diff --git a/docs/admin-adfahi-research/screenshots/20-security-policy.png b/docs/admin-adfahi-research/screenshots/20-security-policy.png new file mode 100644 index 0000000..4d975a5 Binary files /dev/null and b/docs/admin-adfahi-research/screenshots/20-security-policy.png differ diff --git a/docs/admin-adfahi-research/sql/Admin-Server改造.sql b/docs/admin-adfahi-research/sql/Admin-Server改造.sql new file mode 100644 index 0000000..3d30b4b --- /dev/null +++ b/docs/admin-adfahi-research/sql/Admin-Server改造.sql @@ -0,0 +1,350 @@ +-- Admin Server schema changes for the GedaDa management-console adaptation. +-- Target: MySQL 8.0+, control database `hotgo`. +-- Scope: control-plane configuration only. Do not create tenant business data here. +-- Review before execution. DDL statements auto-commit in MySQL. + +SET NAMES utf8mb4; +USE `hotgo`; + +-- ============================================================ +-- 1. User policy groups. +-- status: 1=enabled, 2=disabled. +-- policy_json action values: allow, deny, challenge, audit_only. +-- Friend entry values: search, profile_card, group_member, qrcode, +-- invite_auto_add, recommendation. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_user_policy_groups` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '0 means global default', + `group_code` varchar(64) NOT NULL COMMENT 'Stable policy group code', + `group_name` varchar(128) NOT NULL COMMENT 'Policy group name', + `priority` int NOT NULL DEFAULT 0 COMMENT 'Higher value has higher priority', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `policy_json` json NOT NULL COMMENT 'Login, friend, message, group and file policies', + `ip_whitelist` json DEFAULT NULL COMMENT 'IPv4/IPv6 or CIDR list', + `ip_blacklist` json DEFAULT NULL COMMENT 'IPv4/IPv6 or CIDR list', + `version` int unsigned NOT NULL DEFAULT 1 COMMENT 'Optimistic-lock/config version', + `remark` varchar(500) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_policy_group_code` (`tenant_id`, `group_code`), + KEY `idx_im_user_policy_group_list` (`tenant_id`, `status`, `priority`, `id`), + KEY `idx_im_user_policy_group_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM user policy groups'; + +CREATE TABLE IF NOT EXISTS `im_user_policy_group_members` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0, + `group_id` bigint NOT NULL, + `user_id` bigint NOT NULL COMMENT 'User ID in the tenant database', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `valid_from` datetime(3) DEFAULT NULL, + `valid_until` datetime(3) DEFAULT NULL, + `remark` varchar(500) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_policy_group_member` (`group_id`, `user_id`), + KEY `idx_im_user_policy_member_user` (`tenant_id`, `user_id`, `status`), + KEY `idx_im_user_policy_member_validity` (`status`, `valid_from`, `valid_until`), + KEY `idx_im_user_policy_member_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM user-to-policy-group assignments'; + +-- ============================================================ +-- 2. Sensitive word dictionary. +-- match_type: exact, contains, regex, pinyin. +-- action: replace, block, audit_only, manual_review. +-- risk_level: low, medium, high. +-- scene_json values include private_message, group_message, user_profile, +-- group_profile, feedback, report. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_sensitive_word_groups` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '0 means global dictionary', + `group_code` varchar(64) NOT NULL, + `group_name` varchar(128) NOT NULL, + `priority` int NOT NULL DEFAULT 0, + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `remark` varchar(500) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_sensitive_word_group_code` (`tenant_id`, `group_code`), + KEY `idx_im_sensitive_word_group_list` (`tenant_id`, `status`, `priority`, `id`), + KEY `idx_im_sensitive_word_group_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM sensitive word groups'; + +CREATE TABLE IF NOT EXISTS `im_sensitive_words` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '0 means global dictionary', + `group_id` bigint NOT NULL, + `word` varchar(255) NOT NULL, + `word_hash` char(64) NOT NULL COMMENT 'SHA-256 of normalized word', + `match_type` varchar(24) NOT NULL DEFAULT 'contains', + `scene_json` json NOT NULL COMMENT 'Enabled scenes', + `action` varchar(24) NOT NULL DEFAULT 'block', + `replacement` varchar(255) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `priority` int NOT NULL DEFAULT 0, + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `remark` varchar(500) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_sensitive_word_normalized` (`tenant_id`, `group_id`, `word_hash`), + KEY `idx_im_sensitive_word_list` (`tenant_id`, `status`, `risk_level`, `id`), + KEY `idx_im_sensitive_word_group` (`group_id`, `status`, `priority`), + KEY `idx_im_sensitive_word_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM sensitive words'; + +-- ============================================================ +-- 3. Versioned security policy. +-- publish_status: draft, published, archived, failed. +-- The latest published row is the source of truth. The summary switches in +-- im_tenant_config are updated in the same application transaction on publish. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_security_policy` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '0 means global fallback policy', + `policy_version` int unsigned NOT NULL, + `policy_name` varchar(128) NOT NULL DEFAULT '', + `publish_status` varchar(16) NOT NULL DEFAULT 'draft', + `password_policy` json DEFAULT NULL, + `login_rate_limit` json DEFAULT NULL, + `sensitive_word_policy` json DEFAULT NULL, + `file_review_policy` json DEFAULT NULL, + `realname_policy` json DEFAULT NULL, + `risk_challenge_policy` json DEFAULT NULL, + `global_friend_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Backend master switch for every add-friend entry', + `friend_disabled_policy` json DEFAULT NULL COMMENT 'Profile masking, QR and group-member restrictions', + `message_curfew_enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Backend master switch for message curfew', + `remark` varchar(500) NOT NULL DEFAULT '', + `published_at` datetime(3) DEFAULT NULL, + `published_by` bigint DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_security_policy_version` (`tenant_id`, `policy_version`), + KEY `idx_im_security_policy_publish` (`tenant_id`, `publish_status`, `published_at`), + KEY `idx_im_security_policy_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Versioned IM security policies'; + +CREATE TABLE IF NOT EXISTS `im_message_curfew_rules` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0, + `policy_id` bigint NOT NULL, + `rule_code` varchar(64) NOT NULL, + `rule_name` varchar(128) NOT NULL, + `date_type` varchar(16) NOT NULL DEFAULT 'daily' COMMENT 'daily, weekday, weekend, custom', + `custom_dates` json DEFAULT NULL COMMENT 'YYYY-MM-DD values for custom date type', + `weekdays` json DEFAULT NULL COMMENT 'ISO weekdays 1 through 7', + `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Shanghai', + `start_time` time NOT NULL, + `end_time` time NOT NULL COMMENT 'Less than start_time means crossing midnight', + `scope` varchar(24) NOT NULL DEFAULT 'private_message' COMMENT 'private_message or group_message', + `whitelist_user_ids` json DEFAULT NULL, + `whitelist_group_ids` json DEFAULT NULL, + `message` varchar(255) NOT NULL DEFAULT '当前时段暂不能发送消息', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `sort` int NOT NULL DEFAULT 0, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_curfew_rule` (`policy_id`, `rule_code`), + KEY `idx_im_message_curfew_policy` (`tenant_id`, `policy_id`, `status`, `sort`), + KEY `idx_im_message_curfew_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Message curfew rules'; + +CREATE TABLE IF NOT EXISTS `im_security_ip_rules` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL DEFAULT 0, + `rule_type` varchar(16) NOT NULL COMMENT 'whitelist or blacklist', + `ip_cidr` varchar(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR', + `action` varchar(24) NOT NULL DEFAULT 'block' COMMENT 'allow, challenge, block, audit_only', + `scene_json` json NOT NULL COMMENT 'login, register, message, upload and other scenes', + `risk_level` varchar(16) NOT NULL DEFAULT 'high', + `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 enabled, 2 disabled', + `expires_at` datetime(3) DEFAULT NULL, + `remark` varchar(500) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT 0, + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_security_ip_rule` (`tenant_id`, `rule_type`, `ip_cidr`), + KEY `idx_im_security_ip_rule_list` (`tenant_id`, `status`, `rule_type`, `id`), + KEY `idx_im_security_ip_rule_expiry` (`status`, `expires_at`), + KEY `idx_im_security_ip_rule_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM IP whitelist and blacklist rules'; + +-- ============================================================ +-- 4. Lightweight published-policy summary in im_tenant_config. +-- These columns are read optimizations, not the policy source of truth. +-- The conditional ALTER blocks are safe to rerun on MySQL 8.0. +-- ============================================================ + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'default_policy_group_id') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `default_policy_group_id` BIGINT DEFAULT NULL COMMENT ''Default user policy group'' AFTER `max_group_members`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'security_policy_version') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `security_policy_version` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''Published security policy version'' AFTER `default_policy_group_id`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'global_friend_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `global_friend_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''Published global friend switch'' AFTER `security_policy_version`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'message_curfew_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `message_curfew_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''Published message curfew switch'' AFTER `global_friend_enabled`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- ============================================================ +-- 5. Invitation-code compatibility for the 6-digit target format. +-- Existing 5-letter codes remain stored and valid during the application +-- rollout. New code generation must switch to exactly six ASCII digits, while +-- Admin and IM Server temporarily accept both legacy 5-letter and new 6-digit +-- values. max_uses=0 preserves the current unlimited-use behavior. +-- ============================================================ + +SET @im_sql = IF( + (SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_registration_invite_codes' + AND COLUMN_NAME = 'code') = 5, + 'ALTER TABLE `im_registration_invite_codes` MODIFY COLUMN `code` CHAR(6) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_registration_invite_codes' + AND COLUMN_NAME = 'max_uses') = 0, + 'ALTER TABLE `im_registration_invite_codes` ADD COLUMN `max_uses` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''0 means unlimited'' AFTER `expires_at`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_registration_invite_batches' + AND COLUMN_NAME = 'max_uses_per_code') = 0, + 'ALTER TABLE `im_registration_invite_batches` ADD COLUMN `max_uses_per_code` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''0 means unlimited'' AFTER `quantity`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- ============================================================ +-- 6. Safe global fallback policy. +-- Tenant-specific published policies override tenant_id=0. +-- This keeps existing behavior permissive until an administrator publishes +-- the first tenant policy. +-- ============================================================ + +INSERT INTO `im_security_policy` ( + `tenant_id`, `policy_version`, `policy_name`, `publish_status`, + `global_friend_enabled`, `friend_disabled_policy`, + `message_curfew_enabled`, `remark`, `published_at`, `published_by`, + `created_by`, `updated_by` +) +SELECT + 0, 1, '全局默认安全策略', 'published', + 1, + JSON_OBJECT( + 'hide_profile', TRUE, + 'block_qrcode', TRUE, + 'block_group_member_entry', TRUE, + 'blocked_entries', JSON_ARRAY( + 'search', 'profile_card', 'group_member', + 'qrcode', 'invite_auto_add', 'recommendation' + ), + 'message', '当前时段暂不允许添加好友' + ), + 0, '系统初始化:保持原有好友和消息行为', CURRENT_TIMESTAMP(3), 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `im_security_policy` + WHERE `tenant_id` = 0 AND `publish_status` = 'published' AND `deleted_at` IS NULL +); + +UPDATE `im_security_policy` +SET `friend_disabled_policy` = JSON_SET( + `friend_disabled_policy`, + '$.blocked_entries', + JSON_ARRAY('search', 'profile_card', 'group_member', 'qrcode', 'invite_auto_add', 'recommendation') +) +WHERE `tenant_id` = 0 + AND `publish_status` = 'published' + AND `deleted_at` IS NULL + AND JSON_CONTAINS_PATH(`friend_disabled_policy`, 'one', '$.blocked_entries') = 0; + +-- Post-deployment checks (read-only): +-- SELECT tenant_id, group_code, status, priority FROM im_user_policy_groups; +-- SELECT tenant_id, policy_version, publish_status, +-- global_friend_enabled, message_curfew_enabled +-- FROM im_security_policy ORDER BY tenant_id, policy_version DESC; +-- SELECT tenant_id, policy_id, rule_code, date_type, start_time, end_time, scope +-- FROM im_message_curfew_rules WHERE deleted_at IS NULL; diff --git a/docs/admin-adfahi-research/sql/IM-Server改造.sql b/docs/admin-adfahi-research/sql/IM-Server改造.sql new file mode 100644 index 0000000..bb383eb --- /dev/null +++ b/docs/admin-adfahi-research/sql/IM-Server改造.sql @@ -0,0 +1,464 @@ +-- IM Server schema changes for the GedaDa management-console adaptation. +-- Target: MySQL 8.0+, one independent tenant business database. +-- Default local tenant database: `tinode`. +-- IMPORTANT: execute this migration once for every tenant database. Tenant +-- business tables intentionally do not contain tenant_id. +-- Review before execution. DDL statements auto-commit in MySQL. + +SET NAMES utf8mb4; +USE `tinode`; + +-- ============================================================ +-- 0. User account number. +-- account_no is the short user number shown in Admin. The real primary key +-- im_users.id remains the internal Tinode/Snowflake user ID. +-- Existing users are backfilled from 1000 by created time; new users are +-- assigned by IM Server in the user creation transaction. +-- ============================================================ + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_users' AND COLUMN_NAME = 'account_no') = 0, + 'ALTER TABLE `im_users` ADD COLUMN `account_no` int unsigned NOT NULL DEFAULT 0 COMMENT ''用户编号'' AFTER `id`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @im_user_account_no := ( + SELECT GREATEST(COALESCE(MAX(`account_no`), 999), 999) + FROM `im_users` + WHERE `account_no` > 0 +); + +UPDATE `im_users` AS u +JOIN ( + SELECT ordered.`id`, (@im_user_account_no := @im_user_account_no + 1) AS next_no + FROM ( + SELECT `id` + FROM `im_users` + WHERE `account_no` = 0 + ORDER BY `createdat`, `id` + ) AS ordered +) AS seq ON seq.`id` = u.`id` +SET u.`account_no` = seq.next_no +WHERE u.`account_no` = 0; + +SET @sql := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_users' AND INDEX_NAME = 'uk_im_users_account_no') = 0, + 'CREATE UNIQUE INDEX `uk_im_users_account_no` ON `im_users` (`account_no`)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ============================================================ +-- 1. Applied runtime policy snapshots. +-- policy_type: security, user_policy_groups, sensitive_words. +-- The application uses the latest row with apply_status=applied and also keeps +-- an in-memory cache. This table makes restart and Kafka replay deterministic. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_runtime_policy_snapshots` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `policy_type` varchar(32) NOT NULL, + `policy_version` bigint unsigned NOT NULL, + `payload` json NOT NULL, + `payload_sha256` char(64) NOT NULL, + `source_event_id` varchar(96) DEFAULT NULL COMMENT 'NULL for a manual bootstrap snapshot', + `apply_status` varchar(16) NOT NULL DEFAULT 'applied' COMMENT 'applied or failed', + `apply_error` varchar(1000) NOT NULL DEFAULT '', + `applied_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_runtime_policy_version` (`policy_type`, `policy_version`), + UNIQUE KEY `uk_im_runtime_policy_event` (`source_event_id`), + KEY `idx_im_runtime_policy_latest` (`policy_type`, `apply_status`, `policy_version` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Last applied Admin policy payloads'; + +-- ============================================================ +-- 2. User login history and login failure history. +-- Never store plaintext passwords, verification codes, access tokens or refresh +-- tokens. account_hash is SHA-256 of the normalized login account; account_mask +-- is the value shown in Admin. +-- login_type: password, sms_code, sso, qr_code, token_refresh. +-- event_type: login, refresh, logout, kicked, session_expired. +-- failure_type: password_error, code_error, account_not_found, +-- account_disabled, rate_limited, risk_blocked. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_user_login_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `username` varchar(64) NOT NULL DEFAULT '', + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `login_type` varchar(24) NOT NULL, + `event_type` varchar(24) NOT NULL DEFAULT 'login', + `session_id` varchar(96) NOT NULL DEFAULT '' COMMENT 'Opaque session identifier, never an auth token', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'low', + `request_id` varchar(64) NOT NULL DEFAULT '', + `login_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `logout_at` datetime(3) DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_user_login_user_time` (`user_id`, `login_at` DESC, `id` DESC), + KEY `idx_im_user_login_ip_time` (`ip`, `login_at` DESC, `id` DESC), + KEY `idx_im_user_login_device_time` (`device_id`(64), `login_at` DESC), + KEY `idx_im_user_login_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='User login and session event history'; + +CREATE TABLE IF NOT EXISTS `im_login_failure_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint DEFAULT NULL COMMENT 'NULL when the account cannot be resolved', + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `failure_type` varchar(32) NOT NULL, + `reason` varchar(96) NOT NULL DEFAULT '' COMMENT 'Stable backend reason code', + `detail` varchar(500) NOT NULL DEFAULT '' COMMENT 'Sanitized detail without credentials', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `request_id` varchar(64) NOT NULL DEFAULT '', + `attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_login_failure_account_time` (`account_hash`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_user_time` (`user_id`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_ip_time` (`ip`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_type_time` (`failure_type`, `attempt_at` DESC, `id` DESC), + KEY `idx_im_login_failure_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Authentication failure and risk-block history'; + +-- ============================================================ +-- 3. Message administration actions. +-- Messages remain in im_messages/im_group_messages. This table is an immutable +-- action overlay so hide/delete can be audited and restored. +-- conversation_type: private, group. +-- action: hide, unhide, delete, restore. +-- action_status: pending, success, failed, dead. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_message_admin_actions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `action_id` varchar(96) NOT NULL, + `command_id` varchar(96) NOT NULL, + `conversation_type` varchar(16) NOT NULL, + `topic` varchar(64) NOT NULL, + `seqid` bigint NOT NULL, + `sender_user_id` bigint DEFAULT NULL, + `action` varchar(16) NOT NULL, + `action_status` varchar(16) NOT NULL DEFAULT 'pending', + `reason` varchar(500) NOT NULL DEFAULT '', + `actor_admin_id` bigint NOT NULL DEFAULT 0, + `request_id` varchar(64) NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `failure_reason` varchar(96) NOT NULL DEFAULT '', + `failure_message` varchar(1000) NOT NULL DEFAULT '', + `processed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_admin_action_id` (`action_id`), + UNIQUE KEY `uk_im_message_admin_command` (`command_id`), + KEY `idx_im_message_admin_target` (`conversation_type`, `topic`, `seqid`, `id` DESC), + KEY `idx_im_message_admin_status` (`action_status`, `created_at`, `id`), + KEY `idx_im_message_admin_actor` (`actor_admin_id`, `created_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Auditable Admin message actions'; + +-- ============================================================ +-- 4. Sensitive-word hits and policy decisions. +-- Store only a digest and a short masked excerpt. Do not duplicate complete +-- private messages in log tables. +-- policy decision reasons include FRIEND_DISABLED and MESSAGE_CURFEW_ACTIVE. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_sensitive_word_hits` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `word_id` bigint NOT NULL DEFAULT 0 COMMENT 'Admin control-database word ID', + `word_group_id` bigint NOT NULL DEFAULT 0, + `word_snapshot` varchar(255) NOT NULL DEFAULT '' COMMENT 'Masked word snapshot', + `scene` varchar(32) NOT NULL, + `user_id` bigint NOT NULL DEFAULT 0, + `target_user_id` bigint DEFAULT NULL, + `topic` varchar(64) NOT NULL DEFAULT '', + `seqid` bigint DEFAULT NULL, + `content_sha256` char(64) NOT NULL DEFAULT '', + `matched_excerpt` varchar(500) NOT NULL DEFAULT '', + `match_type` varchar(24) NOT NULL DEFAULT 'contains', + `action` varchar(24) NOT NULL, + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `request_id` varchar(64) NOT NULL DEFAULT '', + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_sensitive_hit_scene_time` (`scene`, `created_at` DESC, `id` DESC), + KEY `idx_im_sensitive_hit_user_time` (`user_id`, `created_at` DESC, `id` DESC), + KEY `idx_im_sensitive_hit_target` (`topic`, `seqid`, `id` DESC), + KEY `idx_im_sensitive_hit_word` (`word_id`, `created_at` DESC), + KEY `idx_im_sensitive_hit_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Sensitive-word match events'; + +CREATE TABLE IF NOT EXISTS `im_policy_decision_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `policy_type` varchar(32) NOT NULL COMMENT 'security or user_policy_group', + `policy_version` bigint unsigned NOT NULL DEFAULT 0, + `policy_group_id` bigint NOT NULL DEFAULT 0, + `scene` varchar(64) NOT NULL COMMENT 'friend_request, qrcode_resolve, private_message and so on', + `action` varchar(24) NOT NULL COMMENT 'deny, challenge or audit_only', + `reason_code` varchar(96) NOT NULL COMMENT 'For example FRIEND_DISABLED or MESSAGE_CURFEW_ACTIVE', + `user_id` bigint NOT NULL DEFAULT 0, + `target_user_id` bigint DEFAULT NULL, + `topic` varchar(64) NOT NULL DEFAULT '', + `request_id` varchar(64) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_policy_decision_user_time` (`user_id`, `created_at` DESC, `id` DESC), + KEY `idx_im_policy_decision_reason_time` (`reason_code`, `created_at` DESC, `id` DESC), + KEY `idx_im_policy_decision_scene_time` (`scene`, `created_at` DESC, `id` DESC), + KEY `idx_im_policy_decision_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Runtime policy deny/challenge decisions'; + +-- ============================================================ +-- 5. Group event history and daily IP/account statistics. +-- im_group_events_outbox remains the delivery table; this log is the queryable +-- business history after the operation result is known. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `im_group_event_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `topic` varchar(64) NOT NULL, + `group_id` bigint DEFAULT NULL, + `event_type` varchar(64) NOT NULL, + `actor_user_id` bigint NOT NULL DEFAULT 0, + `target_user_id` bigint DEFAULT NULL, + `result` varchar(16) NOT NULL DEFAULT 'success', + `reason` varchar(500) NOT NULL DEFAULT '', + `request_id` varchar(64) NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_event_log_event` (`event_id`), + KEY `idx_im_group_event_topic_time` (`topic`, `created_at` DESC, `id` DESC), + KEY `idx_im_group_event_type_time` (`event_type`, `created_at` DESC, `id` DESC), + KEY `idx_im_group_event_actor_time` (`actor_user_id`, `created_at` DESC, `id` DESC), + KEY `idx_im_group_event_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Queryable group event audit history'; + +CREATE TABLE IF NOT EXISTS `im_ip_account_stats_daily` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `stat_date` date NOT NULL, + `ip` varchar(64) NOT NULL, + `region` varchar(128) NOT NULL DEFAULT '', + `account_count` int unsigned NOT NULL DEFAULT 0, + `user_count` int unsigned NOT NULL DEFAULT 0, + `login_success_count` int unsigned NOT NULL DEFAULT 0, + `login_failure_count` int unsigned NOT NULL DEFAULT 0, + `first_seen_at` datetime(3) DEFAULT NULL, + `last_seen_at` datetime(3) DEFAULT NULL, + `risk_level` varchar(16) NOT NULL DEFAULT 'low', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `sample_user_ids` json DEFAULT NULL COMMENT 'Bounded sample only, not an unlimited array', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_ip_account_stats_day_ip` (`stat_date`, `ip`), + KEY `idx_im_ip_account_stats_ip_day` (`ip`, `stat_date` DESC), + KEY `idx_im_ip_account_stats_risk_day` (`risk_level`, `stat_date` DESC, `id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Daily IP and account aggregation'; + +-- ============================================================ +-- 6. Compatible enhancements to existing tables. +-- Every ALTER is guarded by information_schema and can be rerun. +-- ============================================================ + +-- Message management queries need topic/time and sender/time indexes. +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_messages' + AND INDEX_NAME = 'idx_im_messages_topic_created') = 0, + 'ALTER TABLE `im_messages` ADD INDEX `idx_im_messages_topic_created` (`topic`, `createdat`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_messages' + AND INDEX_NAME = 'idx_im_messages_from_created') = 0, + 'ALTER TABLE `im_messages` ADD INDEX `idx_im_messages_from_created` (`from`, `createdat`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- Extend the existing Tinode device record for device and login-history views. +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'firstseen') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `firstseen` DATETIME(3) DEFAULT NULL AFTER `platform`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'lastip') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `lastip` VARCHAR(64) NOT NULL DEFAULT '''' AFTER `lastseen`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'useragent') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `useragent` VARCHAR(512) NOT NULL DEFAULT '''' AFTER `lastip`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'appversion') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `appversion` VARCHAR(32) NOT NULL DEFAULT '''' AFTER `useragent`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_devices' + AND COLUMN_NAME = 'status') = 0, + 'ALTER TABLE `im_devices` ADD COLUMN `status` VARCHAR(16) NOT NULL DEFAULT ''active'' AFTER `appversion`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- Feedback handling fields required by the Admin processing workflow. +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'assigned_to') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `assigned_to` BIGINT DEFAULT NULL AFTER `status`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'handled_by') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `handled_by` BIGINT DEFAULT NULL AFTER `assigned_to`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'handled_at') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `handled_at` DATETIME(3) DEFAULT NULL AFTER `handled_by`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'resolution') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `resolution` VARCHAR(1000) NOT NULL DEFAULT '''' AFTER `handled_at`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND COLUMN_NAME = 'admin_remark') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD COLUMN `admin_remark` VARCHAR(1000) NOT NULL DEFAULT '''' AFTER `resolution`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_feedback_submissions' + AND INDEX_NAME = 'idx_im_feedback_status_time') = 0, + 'ALTER TABLE `im_feedback_submissions` ADD INDEX `idx_im_feedback_status_time` (`status`, `created_at`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_audit_logs' + AND INDEX_NAME = 'idx_im_audit_logs_ip_time') = 0, + 'ALTER TABLE `im_audit_logs` ADD INDEX `idx_im_audit_logs_ip_time` (`ip`, `created_at`, `id`)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- Post-deployment checks (read-only): +-- SELECT policy_type, policy_version, apply_status, applied_at +-- FROM im_runtime_policy_snapshots ORDER BY policy_type, policy_version DESC; +-- SELECT reason_code, scene, COUNT(*) AS deny_count +-- FROM im_policy_decision_logs +-- WHERE created_at >= CURRENT_DATE +-- GROUP BY reason_code, scene; +-- SELECT stat_date, ip, account_count, login_failure_count, risk_level +-- FROM im_ip_account_stats_daily ORDER BY stat_date DESC, login_failure_count DESC; diff --git a/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql b/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql new file mode 100644 index 0000000..cb0d033 --- /dev/null +++ b/docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql @@ -0,0 +1,1618 @@ +-- IM tenant business database initialization script. +-- Source baseline: local MySQL database `tinode` on 2026-09-13. +-- Purpose: create a clean business database for a new IM tenant. +-- +-- Usage: +-- 1. Replace placeholders before execution: +-- __TENANT_DB__ -> new tenant business database, for example `tinode_acme`. +-- __TENANT_ID__ -> hotgo.im_tenant.id assigned to this tenant. +-- __TENANT_CODE__ -> public enterprise code, for example `acme`. +-- __TENANT_NAME__ -> tenant display name. +-- 2. Execute this SQL with a MySQL account that can CREATE DATABASE and write hotgo.im_tenant. +-- 3. Keep business tables empty; do not copy users, topics, messages, files, Kafka logs, or audit data from `tinode`. +-- +-- Notes: +-- - This script intentionally seeds only stable metadata/default configuration. +-- - It does not copy default tenant's test users, groups, messages, login logs, or outbox rows. +-- - The IM Server must enable tenant_db_routing=true and use hotgo as tenant_registry_db when registry and tenant DB are separated. + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE DATABASE IF NOT EXISTS `__TENANT_DB__` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE `__TENANT_DB__`; + +-- ============================================================ +-- 1. Tenant business schema. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `im_abuse_reasons` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `target_type` varchar(16) NOT NULL, + `reason_code` varchar(64) NOT NULL, + `label` varchar(128) NOT NULL, + `requires_description` tinyint NOT NULL DEFAULT '0', + `allows_evidence` tinyint NOT NULL DEFAULT '1', + `sort_order` int NOT NULL DEFAULT '0', + `enabled` tinyint NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_abuse_reasons_type_code` (`target_type`,`reason_code`), + KEY `idx_im_abuse_reasons_type_order` (`target_type`,`enabled`,`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_abuse_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `reporter_id` bigint NOT NULL, + `target_type` varchar(16) NOT NULL, + `target_user_id` bigint NOT NULL DEFAULT '0', + `target_topic` varchar(32) NOT NULL DEFAULT '', + `target_seq` bigint NOT NULL DEFAULT '0', + `reason_code` varchar(64) NOT NULL, + `description` text NOT NULL, + `evidence_file_ids` json DEFAULT NULL, + `snapshot` json DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_abuse_reports_report` (`report_id`), + UNIQUE KEY `uk_im_abuse_reports_idempotency` (`reporter_id`,`idempotency_key`), + KEY `idx_im_abuse_reports_target` (`target_type`,`target_topic`,`target_seq`,`target_user_id`), + KEY `idx_im_abuse_reports_reporter` (`reporter_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_action_tokens` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `token_id` varchar(64) NOT NULL, + `action` varchar(64) NOT NULL, + `target` varchar(128) NOT NULL DEFAULT '', + `token_hash` char(64) NOT NULL, + `state` smallint NOT NULL, + `expires_at` datetime(3) NOT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_action_tokens_token` (`token_id`), + KEY `idx_im_action_tokens_user_action` (`user_id`,`action`,`target`,`state`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_app_releases` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `platform` varchar(32) NOT NULL, + `channel` varchar(32) NOT NULL DEFAULT 'stable', + `latest_version` varchar(64) NOT NULL, + `min_supported_version` varchar(64) NOT NULL, + `update_type` varchar(16) NOT NULL DEFAULT 'none', + `release_notes` text NOT NULL, + `store_url` varchar(512) NOT NULL DEFAULT '', + `package_url` varchar(512) NOT NULL DEFAULT '', + `checksum` varchar(128) NOT NULL DEFAULT '', + `remind_after_seconds` int NOT NULL DEFAULT '0', + `published_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_app_releases_platform_channel` (`platform`,`channel`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_appeals` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `appeal_id` varchar(64) NOT NULL, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `appeal_type` varchar(64) NOT NULL, + `reason` varchar(512) NOT NULL, + `target_action` varchar(32) NOT NULL DEFAULT '', + `target_time` datetime(3) DEFAULT NULL, + `attachment_file_ids` json DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `version` int NOT NULL DEFAULT '1', + `idempotency_key` varchar(128) NOT NULL, + `submitted_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_appeals_appeal` (`appeal_id`), + UNIQUE KEY `uk_im_attendance_appeals_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_attendance_appeals_record` (`record_id`), + KEY `idx_im_attendance_appeals_user_status` (`user_id`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_checkins` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `checkin_id` varchar(64) NOT NULL, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `record_date` date NOT NULL, + `action` varchar(32) NOT NULL, + `rule_id` varchar(64) NOT NULL, + `shift_id` varchar(64) NOT NULL, + `client_time` datetime(3) NOT NULL, + `server_time` datetime(3) NOT NULL, + `result` varchar(32) NOT NULL, + `location` json DEFAULT NULL, + `wifi` json DEFAULT NULL, + `device` json DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_checkins_checkin` (`checkin_id`), + UNIQUE KEY `uk_im_attendance_checkins_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_attendance_checkins_record` (`record_id`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_records` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `record_date` date NOT NULL, + `rule_id` varchar(64) NOT NULL, + `shift_id` varchar(64) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'not_checked', + `exceptions` json DEFAULT NULL, + `appeal_status` varchar(32) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_records_record` (`record_id`), + UNIQUE KEY `uk_im_attendance_records_user_date_shift` (`user_id`,`record_date`,`shift_id`), + KEY `idx_im_attendance_records_user_id` (`user_id`,`id` DESC), + KEY `idx_im_attendance_records_date_status` (`record_date`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_rules` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `rule_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `workday` tinyint NOT NULL DEFAULT '1', + `location_required` tinyint NOT NULL DEFAULT '0', + `wifi_required` tinyint NOT NULL DEFAULT '0', + `allow_outside_checkin` tinyint NOT NULL DEFAULT '1', + `appeal_allowed` tinyint NOT NULL DEFAULT '1', + `effective_from` date DEFAULT NULL, + `effective_to` date DEFAULT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_rules_rule` (`rule_id`), + KEY `idx_im_attendance_rules_state` (`state`,`effective_from`,`effective_to`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_shifts` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `shift_id` varchar(64) NOT NULL, + `rule_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `work_start` time NOT NULL, + `work_end` time NOT NULL, + `checkin_start` time NOT NULL, + `checkout_end` time NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `sort_order` int NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_shifts_shift` (`shift_id`), + KEY `idx_im_attendance_shifts_rule` (`rule_id`,`state`,`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_audit_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `actor_uid` bigint NOT NULL DEFAULT '0', + `actor_role` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `action` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `target_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `target_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `reason` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `request_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `ip` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `user_agent` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_audit_logs_actor` (`actor_uid`,`id` DESC), + KEY `idx_im_audit_logs_action` (`action`,`id` DESC), + KEY `idx_im_audit_logs_target` (`target_type`,`target_id`,`id` DESC), + KEY `idx_im_audit_logs_request` (`request_id`), + KEY `idx_im_audit_logs_created` (`created_at`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM租户审计日志'; + +CREATE TABLE IF NOT EXISTS `im_auth` ( + `id` int NOT NULL AUTO_INCREMENT, + `userid` bigint NOT NULL, + `authlvl` int NOT NULL, + `secret` varchar(255) NOT NULL, + `expires` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `auth_userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_business_events_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(64) NOT NULL, + `target_type` varchar(16) NOT NULL, + `target` varchar(128) NOT NULL, + `actor_user_id` bigint NOT NULL DEFAULT '0', + `event_type` varchar(64) NOT NULL, + `payload` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `delivered_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_business_events_outbox_event` (`event_id`), + KEY `idx_im_business_events_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_business_events_outbox_target` (`target_type`,`target`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_call_records` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `call_id` varchar(64) NOT NULL, + `topic` char(25) NOT NULL, + `seq_id` bigint NOT NULL, + `caller_id` bigint NOT NULL, + `callee_id` bigint NOT NULL, + `call_type` varchar(16) NOT NULL, + `status` varchar(32) NOT NULL, + `end_reason` varchar(64) NOT NULL DEFAULT '', + `duration_ms` bigint NOT NULL DEFAULT '0', + `started_at` datetime(3) NOT NULL, + `accepted_at` datetime(3) DEFAULT NULL, + `finished_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_call_records_call` (`call_id`), + UNIQUE KEY `uk_im_call_records_topic_seq` (`topic`,`seq_id`), + KEY `idx_im_call_records_caller` (`caller_id`,`id` DESC), + KEY `idx_im_call_records_callee` (`callee_id`,`id` DESC), + KEY `idx_im_call_records_status` (`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_conversation_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `topic` varchar(128) NOT NULL, + `pinned` tinyint NOT NULL DEFAULT '0', + `muted` tinyint NOT NULL DEFAULT '0', + `remark` varchar(128) NOT NULL DEFAULT '', + `message_retention` varchar(32) NOT NULL DEFAULT 'default', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_conversation_settings_user_topic` (`user_id`,`topic`), + KEY `idx_im_conversation_settings_user_pinned` (`user_id`,`pinned`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_credentials` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `method` varchar(16) NOT NULL, + `value` varchar(128) NOT NULL, + `synthetic` varchar(192) NOT NULL, + `userid` bigint NOT NULL, + `resp` varchar(255) DEFAULT NULL, + `done` tinyint NOT NULL DEFAULT '0', + `retries` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_daily_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `author_id` bigint NOT NULL, + `report_date` date NOT NULL, + `content` text NOT NULL, + `attachments` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'draft', + `read_state` varchar(16) NOT NULL DEFAULT 'unread', + `version` int NOT NULL DEFAULT '1', + `submitted_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_daily_reports_report` (`report_id`), + UNIQUE KEY `uk_im_daily_reports_author_date` (`author_id`,`report_date`), + KEY `idx_im_daily_reports_author_id` (`author_id`,`id` DESC), + KEY `idx_im_daily_reports_date_status` (`report_date`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_dellog` ( + `id` int NOT NULL AUTO_INCREMENT, + `topic` char(25) NOT NULL, + `deletedfor` bigint NOT NULL DEFAULT '0', + `delid` int NOT NULL, + `low` int NOT NULL, + `hi` int NOT NULL, + PRIMARY KEY (`id`), + KEY `dellog_topic_delid_deletedfor` (`topic`,`delid`,`deletedfor`), + KEY `dellog_topic_deletedfor_low_hi` (`topic`,`deletedfor`,`low`,`hi`), + KEY `dellog_deletedfor` (`deletedfor`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_device_push_tokens` ( + `id` bigint NOT NULL, + `device_id` varchar(128) NOT NULL, + `user_id` bigint NOT NULL, + `provider` varchar(32) NOT NULL, + `token` varchar(512) NOT NULL, + `app_version` varchar(64) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_device_push_tokens_provider_token` (`provider`,`token`), + UNIQUE KEY `uk_im_device_push_tokens_device` (`device_id`), + KEY `idx_im_device_push_tokens_user` (`user_id`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_devices` ( + `id` int NOT NULL AUTO_INCREMENT, + `userid` bigint NOT NULL, + `hash` char(16) NOT NULL, + `deviceid` text NOT NULL, + `platform` varchar(32) DEFAULT NULL, + `lastseen` datetime NOT NULL, + `lang` varchar(8) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_favorites` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `favorite_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `item_type` varchar(16) NOT NULL, + `source_topic` char(25) NOT NULL, + `source_seq` bigint NOT NULL, + `snapshot` json DEFAULT NULL, + `attachment_refs` json DEFAULT NULL, + `availability` varchar(16) NOT NULL DEFAULT 'available', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `collected_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_favorites_favorite` (`favorite_id`), + UNIQUE KEY `uk_im_favorites_source` (`user_id`,`source_topic`,`source_seq`), + UNIQUE KEY `uk_im_favorites_idempotency` (`user_id`,(nullif(`idempotency_key`,_utf8mb4''))), + KEY `idx_im_favorites_inbox` (`user_id`,`id` DESC), + KEY `idx_im_favorites_type` (`user_id`,`item_type`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_feedback_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `categories` json DEFAULT NULL, + `max_length` int NOT NULL DEFAULT '1000', + `attachment_policy` json DEFAULT NULL, + `allow_contact` tinyint NOT NULL DEFAULT '1', + `diagnostics_notice` text NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_feedback_submissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `feedback_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `category` varchar(64) NOT NULL, + `content` text NOT NULL, + `contact` varchar(128) NOT NULL DEFAULT '', + `attachment_file_ids` json DEFAULT NULL, + `client_context` json DEFAULT NULL, + `include_diagnostics` tinyint NOT NULL DEFAULT '0', + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_feedback_submissions_feedback` (`feedback_id`), + UNIQUE KEY `uk_im_feedback_submissions_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_feedback_submissions_user` (`user_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_file_review_tasks` ( + `id` bigint NOT NULL, + `task_id` varchar(64) NOT NULL, + `file_id` bigint NOT NULL, + `user_id` bigint NOT NULL DEFAULT '0', + `task_type` varchar(32) NOT NULL, + `provider` varchar(64) NOT NULL, + `storage_provider` varchar(32) NOT NULL DEFAULT '', + `object_name` varchar(2048) NOT NULL DEFAULT '', + `mime_type` varchar(255) NOT NULL DEFAULT '', + `file_size` bigint NOT NULL DEFAULT '0', + `provider_task_id` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `risk_level` varchar(32) NOT NULL DEFAULT '', + `risk_labels` json DEFAULT NULL, + `result_json` json DEFAULT NULL, + `error_code` varchar(64) NOT NULL DEFAULT '', + `error_message` varchar(512) NOT NULL DEFAULT '', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_file_review_tasks_task` (`task_id`), + UNIQUE KEY `uk_im_file_review_tasks_file_provider` (`file_id`,`task_type`,`provider`), + KEY `idx_im_file_review_tasks_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_file_review_tasks_file` (`file_id`), + KEY `idx_im_file_review_tasks_provider_task` (`provider`,`provider_task_id`), + KEY `idx_im_file_review_tasks_object` (`storage_provider`,`object_name`(191)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_filemsglinks` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `fileid` bigint NOT NULL, + `msgid` int DEFAULT NULL, + `topic` char(25) DEFAULT NULL, + `userid` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fileid` (`fileid`), + KEY `msgid` (`msgid`), + KEY `topic` (`topic`), + KEY `userid` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_fileuploads` ( + `id` bigint NOT NULL, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `userid` bigint DEFAULT NULL, + `status` int NOT NULL, + `mimetype` varchar(255) NOT NULL, + `size` bigint NOT NULL, + `etag` varchar(128) DEFAULT NULL, + `location` varchar(2048) NOT NULL, + PRIMARY KEY (`id`), + KEY `fileuploads_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_friend_requests` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `request_id` varchar(64) NOT NULL, + `applicant_id` bigint NOT NULL, + `target_id` bigint NOT NULL, + `message` varchar(200) NOT NULL DEFAULT '', + `source` varchar(32) NOT NULL DEFAULT '', + `source_topic` varchar(64) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `expires_at` datetime(3) NOT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_friend_requests_request` (`request_id`), + UNIQUE KEY `uk_im_friend_requests_idempotency` (`applicant_id`,`idempotency_key`), + KEY `idx_im_friend_requests_pair` (`applicant_id`,`target_id`,`status`,`created_at`), + KEY `idx_im_friend_requests_inbox` (`target_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_friendships` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `peer_id` bigint NOT NULL, + `status` varchar(16) NOT NULL, + `remark` varchar(64) NOT NULL DEFAULT '', + `topic` char(25) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_friendships_pair` (`user_id`,`peer_id`), + KEY `idx_im_friendships_peer` (`peer_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_admin_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `log_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `operator_id` bigint NOT NULL, + `target_user_id` bigint DEFAULT NULL, + `action` varchar(64) NOT NULL, + `object_type` varchar(32) NOT NULL DEFAULT '', + `object_id` varchar(64) NOT NULL DEFAULT '', + `before_data` json DEFAULT NULL, + `after_data` json DEFAULT NULL, + `request_id` varchar(128) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_admin_logs_log` (`log_id`), + KEY `idx_im_group_admin_logs_topic_time` (`topic`,`id` DESC), + KEY `idx_im_group_admin_logs_operator` (`operator_id`,`id` DESC), + KEY `idx_im_group_admin_logs_object` (`object_type`,`object_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_announcement_reads` ( + `id` bigint NOT NULL, + `announcement_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `read_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_announcement_reads_announcement_user` (`announcement_id`,`user_id`), + KEY `idx_im_group_announcement_reads_topic_user` (`topic`,`user_id`,`read_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_announcements` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `announcement_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `author_user_id` bigint NOT NULL, + `content` text NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'current', + `version` int NOT NULL DEFAULT '1', + `published_at` datetime(3) NOT NULL, + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_announcements_announcement` (`announcement_id`), + UNIQUE KEY `uk_im_group_announcements_idempotency` (`topic`,`author_user_id`,`idempotency_key`), + KEY `idx_im_group_announcements_topic_status` (`topic`,`status`,`version` DESC,`id` DESC), + KEY `idx_im_group_announcements_topic_time` (`topic`,`published_at` DESC,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_events_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `actor_user_id` bigint NOT NULL DEFAULT '0', + `event_type` varchar(64) NOT NULL, + `payload` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `delivered_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_events_outbox_event` (`event_id`), + KEY `idx_im_group_events_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_group_events_outbox_topic` (`topic`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_files` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `group_file_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `file_id` varchar(64) NOT NULL, + `source_topic` varchar(32) NOT NULL DEFAULT '', + `source_seq` bigint NOT NULL DEFAULT '0', + `uploader_id` bigint NOT NULL, + `file_name` varchar(255) NOT NULL DEFAULT '', + `mime_type` varchar(128) NOT NULL DEFAULT '', + `file_type` varchar(32) NOT NULL DEFAULT 'file', + `file_size` bigint NOT NULL DEFAULT '0', + `status` varchar(16) NOT NULL DEFAULT 'active', + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_files_group_file` (`group_file_id`), + UNIQUE KEY `uk_im_group_files_topic_file` (`topic`,`file_id`), + UNIQUE KEY `uk_im_group_files_idempotency` (`topic`,`uploader_id`,`idempotency_key`), + KEY `idx_im_group_files_topic_type` (`topic`,`status`,`file_type`,`id` DESC), + KEY `idx_im_group_files_topic_name` (`topic`,`status`,`file_name`), + KEY `idx_im_group_files_uploader` (`uploader_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_idempotency` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `method` varchar(8) NOT NULL, + `route` varchar(256) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `body_hash` char(64) NOT NULL, + `result_code` int NOT NULL DEFAULT '0', + `result_body` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_idempotency_request` (`user_id`,`method`,`route`,`idempotency_key`), + KEY `idx_im_group_idempotency_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_invitations` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `invitation_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `inviter_user_id` bigint NOT NULL, + `target_user_id` bigint NOT NULL, + `message` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `result` varchar(32) NOT NULL DEFAULT '', + `join_request_id` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `expires_at` datetime(3) NOT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_invitations_invitation` (`invitation_id`), + UNIQUE KEY `uk_im_group_invitations_idempotency` (`inviter_user_id`,`idempotency_key`), + KEY `idx_im_group_invitations_target` (`target_user_id`,`status`,`expires_at`), + KEY `idx_im_group_invitations_topic_status` (`topic`,`status`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_join_requests` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `request_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `applicant_user_id` bigint NOT NULL, + `inviter_user_id` bigint DEFAULT NULL, + `source` varchar(32) NOT NULL DEFAULT 'manual', + `qr_token_hash` char(64) NOT NULL DEFAULT '', + `message` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `reject_reason` varchar(256) NOT NULL DEFAULT '', + `handled_by` bigint DEFAULT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `expires_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_join_requests_request` (`request_id`), + UNIQUE KEY `uk_im_group_join_requests_idempotency` (`applicant_user_id`,`idempotency_key`), + KEY `idx_im_group_join_requests_topic_status` (`topic`,`status`,`id`), + KEY `idx_im_group_join_requests_applicant` (`applicant_user_id`,`topic`,`status`,`id`), + KEY `idx_im_group_join_requests_expire` (`status`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_members` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `role` varchar(16) NOT NULL DEFAULT 'member', + `member_status` varchar(16) NOT NULL DEFAULT 'active', + `nickname` varchar(64) NOT NULL DEFAULT '', + `pinned` tinyint NOT NULL DEFAULT '0', + `mute_notifications` tinyint NOT NULL DEFAULT '0', + `join_source` varchar(32) NOT NULL DEFAULT 'manual', + `joined_by` bigint DEFAULT NULL, + `joined_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `left_at` datetime(3) DEFAULT NULL, + `removed_by` bigint DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_members_topic_user` (`topic`,`user_id`), + KEY `idx_im_group_members_user_status` (`user_id`,`member_status`,`updated_at`), + KEY `idx_im_group_members_topic_role` (`topic`,`role`,`member_status`,`id`), + KEY `idx_im_group_members_topic_status` (`topic`,`member_status`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_messages` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `topic` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '群 Topic,如 grp_xxx', + `group_id` bigint NOT NULL COMMENT '业务群 ID,便于分区、治理和统计', + `seqid` bigint NOT NULL COMMENT '群内消息序号,由 Redis 原子发号', + `from_user` bigint NOT NULL COMMENT '发送人用户 ID', + `client_msg_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户端幂等消息 ID', + `msg_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked', + `content` json DEFAULT NULL COMMENT '消息正文或 Drafty 内容', + `head` json DEFAULT NULL COMMENT '消息头,包含 @、引用、客户端能力等', + `mentioned_user_ids` json DEFAULT NULL COMMENT '@ 用户列表,便于提醒和检索', + `mention_all` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否 @ 全体', + `reply_seqid` bigint DEFAULT NULL COMMENT '回复的群消息 seq', + `root_seqid` bigint DEFAULT NULL COMMENT '线程或合并上下文根消息 seq', + `file_count` int NOT NULL DEFAULT '0' COMMENT '附件数量', + `content_size` int NOT NULL DEFAULT '0' COMMENT '正文 JSON 序列化后的字节数', + `has_risk` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否命中风控或内容安全', + `audit_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject', + `version` int NOT NULL DEFAULT '1' COMMENT '编辑版本', + `recalled_at` datetime(3) DEFAULT NULL, + `recalled_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `expires_at` datetime(3) DEFAULT NULL COMMENT '可选保留期,便于过期归档或清理', + `created_at` datetime(3) NOT NULL, + `updated_at` datetime(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_messages_group_seq` (`group_id`,`seqid`), + UNIQUE KEY `uk_im_group_messages_topic_seq` (`topic`,`seqid`), + UNIQUE KEY `uk_im_group_messages_topic_sender_client_mid` (`topic`,`from_user`,`client_msg_id`), + KEY `idx_im_group_messages_group_created` (`group_id`,`created_at`), + KEY `idx_im_group_messages_group_status_seq` (`group_id`,`status`,`seqid`), + KEY `idx_im_group_messages_topic_created` (`topic`,`created_at`), + KEY `idx_im_group_messages_topic_from_seq` (`topic`,`from_user`,`seqid`), + KEY `idx_im_group_messages_audit` (`audit_status`,`created_at`), + KEY `idx_im_group_messages_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `im_group_mutes` ( + `id` bigint NOT NULL, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `muted_by` bigint NOT NULL, + `muted_at` datetime(3) NOT NULL, + `expires_at` datetime(3) DEFAULT NULL, + `reason` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'active', + `revoked_by` bigint DEFAULT NULL, + `revoked_at` datetime(3) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_mutes_topic_user` (`topic`,`user_id`), + KEY `idx_im_group_mutes_topic_status` (`topic`,`status`,`expires_at`), + KEY `idx_im_group_mutes_user_status` (`user_id`,`status`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_qrcodes` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `token_id` varchar(64) NOT NULL, + `token_hash` char(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `created_by` bigint NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'active', + `expires_at` datetime(3) NOT NULL, + `revoked_by` bigint DEFAULT NULL, + `revoked_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_qrcodes_token` (`token_id`), + UNIQUE KEY `uk_im_group_qrcodes_hash` (`token_hash`), + KEY `idx_im_group_qrcodes_topic_status` (`topic`,`status`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_settings` ( + `id` bigint NOT NULL, + `topic` varchar(32) NOT NULL, + `owner_user_id` bigint NOT NULL, + `name` varchar(128) NOT NULL DEFAULT '', + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `description` varchar(512) NOT NULL DEFAULT '', + `group_status` varchar(16) NOT NULL DEFAULT 'active', + `join_approval` tinyint NOT NULL DEFAULT '0', + `mute_all` tinyint NOT NULL DEFAULT '0', + `member_upload_files` tinyint NOT NULL DEFAULT '1', + `member_private_chat` tinyint NOT NULL DEFAULT '1', + `member_invite` tinyint NOT NULL DEFAULT '1', + `max_member_count` int NOT NULL DEFAULT '0' COMMENT '0 means use tenant default or unlimited', + `max_admin_count` int NOT NULL DEFAULT '10', + `member_count` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `created_by` bigint NOT NULL, + `dissolved_by` bigint DEFAULT NULL, + `dissolved_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_settings_topic` (`topic`), + KEY `idx_im_group_settings_owner` (`owner_user_id`,`group_status`), + KEY `idx_im_group_settings_status_updated` (`group_status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_http_idempotency` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `method` varchar(8) NOT NULL, + `route` varchar(512) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `body_hash` char(64) NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'processing', + `response_status` int NOT NULL DEFAULT '0', + `response_body` mediumblob, + `expires_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_http_idempotency_scope` (`user_id`,`method`,`route`,`idempotency_key`), + KEY `idx_im_http_idempotency_expiry` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_consume_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `consumer_group` varchar(128) NOT NULL, + `source_topic` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL DEFAULT '', + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'processing', + `retry_count` int NOT NULL DEFAULT '0', + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `processing_deadline` datetime(3) DEFAULT NULL, + `succeeded_at` datetime(3) DEFAULT NULL, + `dead_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_consume_log_event_group` (`event_id`,`consumer_group`), + KEY `idx_im_kafka_consume_log_status` (`status`,`processing_deadline`), + KEY `idx_im_kafka_consume_log_biz` (`biz_type`,`biz_id`), + KEY `idx_im_kafka_consume_log_topic` (`source_topic`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_dead_letters` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `dead_id` varchar(96) NOT NULL, + `event_id` varchar(96) NOT NULL, + `parent_event_id` varchar(96) NOT NULL DEFAULT '', + `source_topic` varchar(128) NOT NULL, + `consumer_group` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL DEFAULT '', + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `envelope` json NOT NULL, + `retry_count` int NOT NULL DEFAULT '0', + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `status` varchar(24) NOT NULL DEFAULT 'dead', + `replay_count` int NOT NULL DEFAULT '0', + `resolved_by` varchar(64) NOT NULL DEFAULT '', + `resolved_at` datetime(3) DEFAULT NULL, + `resolve_note` varchar(1000) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_dead_letters_dead` (`dead_id`), + KEY `idx_im_kafka_dead_letters_event` (`event_id`), + KEY `idx_im_kafka_dead_letters_status` (`status`,`created_at`), + KEY `idx_im_kafka_dead_letters_biz` (`biz_type`,`biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `topic` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL, + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `key_value` varchar(256) NOT NULL DEFAULT '', + `envelope` json NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `sent_at` datetime(3) DEFAULT NULL, + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_outbox_event` (`event_id`), + KEY `idx_im_kafka_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_kafka_outbox_biz` (`biz_type`,`biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kvmeta` ( + `id` bigint NOT NULL, + `key` varchar(64) NOT NULL, + `createdat` datetime(3) DEFAULT NULL, + `value` text, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kvmeta_key` (`key`), + KEY `kvmeta_createdat_key` (`createdat`,`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_login_failure_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint DEFAULT NULL, + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `failure_type` varchar(32) NOT NULL, + `reason` varchar(96) NOT NULL DEFAULT '', + `detail` varchar(500) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `request_id` varchar(64) NOT NULL DEFAULT '', + `attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_login_failure_account_time` (`account_hash`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_user_time` (`user_id`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_ip_time` (`ip`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_type_time` (`failure_type`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_mailbox_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `enabled` tinyint NOT NULL DEFAULT '1', + `allow_anonymous` tinyint NOT NULL DEFAULT '1', + `required_fields` json DEFAULT NULL, + `max_length` int NOT NULL DEFAULT '5000', + `attachment_policy` json DEFAULT NULL, + `notice_title` varchar(128) NOT NULL DEFAULT '', + `notice_content` text NOT NULL, + `notice_updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_mailbox_submissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `submission_id` varchar(64) NOT NULL, + `sender_id` bigint NOT NULL, + `subject` varchar(200) NOT NULL, + `content` text NOT NULL, + `anonymous` tinyint NOT NULL DEFAULT '0', + `attachment_file_ids` json DEFAULT NULL, + `notice_version` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `submitted_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_mailbox_submissions_submission` (`submission_id`), + UNIQUE KEY `uk_im_mailbox_submissions_idempotency` (`sender_id`,`idempotency_key`), + KEY `idx_im_mailbox_submissions_sender` (`sender_id`,`id` DESC), + KEY `idx_im_mailbox_submissions_status` (`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meeting_note_files` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `note_file_id` varchar(64) NOT NULL, + `meeting_id` varchar(64) NOT NULL, + `file_id` varchar(64) NOT NULL, + `uploader_id` bigint NOT NULL, + `file_name` varchar(255) NOT NULL DEFAULT '', + `mime_type` varchar(128) NOT NULL DEFAULT '', + `file_size` bigint NOT NULL DEFAULT '0', + `file_etag` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'active', + `preview_status` varchar(16) NOT NULL DEFAULT 'ready', + `preview_mode` varchar(16) NOT NULL DEFAULT 'native', + `preview_file_id` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_meeting_note_files_id` (`note_file_id`), + UNIQUE KEY `uk_im_meeting_note_files_idempotency` (`meeting_id`,`uploader_id`,`idempotency_key`), + KEY `idx_im_meeting_note_files_file` (`meeting_id`,`file_id`,`status`), + KEY `idx_im_meeting_note_files_meeting` (`meeting_id`,`status`,`id` DESC), + KEY `idx_im_meeting_note_files_uploader` (`uploader_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meeting_participants` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `meeting_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `response` varchar(16) NOT NULL DEFAULT 'pending', + `response_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_meeting_participants_user` (`meeting_id`,`user_id`), + KEY `idx_im_meeting_participants_user_time` (`user_id`,`meeting_id`), + KEY `idx_im_meeting_participants_response` (`meeting_id`,`response`), + CONSTRAINT `fk_im_meeting_participants_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `im_meetings` (`meeting_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meetings` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `meeting_id` varchar(64) NOT NULL, + `organizer_id` bigint NOT NULL, + `recorder_user_id` bigint NOT NULL DEFAULT '0', + `title` varchar(200) NOT NULL, + `start_at` datetime(3) NOT NULL, + `end_at` datetime(3) NOT NULL, + `location` varchar(256) NOT NULL DEFAULT '', + `state` varchar(16) NOT NULL DEFAULT 'scheduled', + `reminder_minutes` json DEFAULT NULL, + `idempotency_key` varchar(128) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `canceled_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_meetings_meeting` (`meeting_id`), + UNIQUE KEY `uk_im_meetings_idempotency` (`organizer_id`,`idempotency_key`), + KEY `idx_im_meetings_organizer` (`organizer_id`,`start_at`), + KEY `idx_im_meetings_state_time` (`state`,`start_at`), + KEY `idx_im_meetings_recorder` (`recorder_user_id`,`end_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_message_admin_actions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `action_id` varchar(96) NOT NULL, + `command_id` varchar(96) NOT NULL, + `conversation_type` varchar(16) NOT NULL, + `topic` varchar(64) NOT NULL, + `seqid` bigint NOT NULL, + `sender_user_id` bigint DEFAULT NULL, + `action` varchar(16) NOT NULL, + `action_status` varchar(16) NOT NULL DEFAULT 'pending', + `reason` varchar(500) NOT NULL DEFAULT '', + `actor_admin_id` bigint NOT NULL DEFAULT '0', + `request_id` varchar(64) NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `failure_reason` varchar(96) NOT NULL DEFAULT '', + `failure_message` varchar(1000) NOT NULL DEFAULT '', + `processed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_admin_action_id` (`action_id`), + UNIQUE KEY `uk_im_message_admin_command` (`command_id`), + KEY `idx_im_message_admin_target` (`conversation_type`,`topic`,`seqid`,`id` DESC), + KEY `idx_im_message_admin_status` (`action_status`,`created_at`,`id`), + KEY `idx_im_message_admin_actor` (`actor_admin_id`,`created_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_message_revisions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `topic` char(25) NOT NULL, + `seqid` bigint NOT NULL, + `version` int NOT NULL, + `actor` bigint NOT NULL, + `head` json DEFAULT NULL, + `content` json DEFAULT NULL, + `reason` varchar(256) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_revisions_version` (`topic`,`seqid`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_messages` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `delid` int DEFAULT '0', + `seqid` bigint NOT NULL, + `topic` char(25) NOT NULL, + `from` bigint NOT NULL, + `client_msg_id` varchar(64) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `recalled_at` datetime(3) DEFAULT NULL, + `recalled_by` bigint DEFAULT NULL, + `head` json DEFAULT NULL, + `content` json DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_monthly_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `author_id` bigint NOT NULL, + `report_month` char(7) NOT NULL, + `work_summary` text NOT NULL, + `next_plan` text NOT NULL, + `attachments` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'draft', + `read_state` varchar(16) NOT NULL DEFAULT 'unread', + `version` int NOT NULL DEFAULT '1', + `submitted_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_monthly_reports_report` (`report_id`), + UNIQUE KEY `uk_im_monthly_reports_author_month` (`author_id`,`report_month`), + KEY `idx_im_monthly_reports_author_id` (`author_id`,`id` DESC), + KEY `idx_im_monthly_reports_month_status` (`report_month`,`status`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_departments` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `department_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `parent_id` varchar(64) NOT NULL DEFAULT 'root', + `sort_order` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `state` varchar(16) NOT NULL DEFAULT 'active', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_org_departments_department` (`department_id`), + KEY `idx_im_org_departments_parent` (`parent_id`,`state`,`sort_order`,`id`), + KEY `idx_im_org_departments_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_members` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `department_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `display_name` varchar(128) NOT NULL DEFAULT '', + `position` varchar(128) NOT NULL DEFAULT '', + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `mobile` varchar(32) NOT NULL DEFAULT '', + `email` varchar(128) NOT NULL DEFAULT '', + `employment_state` varchar(16) NOT NULL DEFAULT 'active', + `contactable` tinyint NOT NULL DEFAULT '1', + `sort_order` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_org_members_department_user` (`department_id`,`user_id`), + KEY `idx_im_org_members_department` (`department_id`,`employment_state`,`sort_order`,`id`), + KEY `idx_im_org_members_user` (`user_id`), + KEY `idx_im_org_members_display_name` (`display_name`), + CONSTRAINT `fk_im_org_members_department` FOREIGN KEY (`department_id`) REFERENCES `im_org_departments` (`department_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_visibility_overrides` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `visibility_scope` varchar(16) NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `remark` varchar(256) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_org_visibility_user` (`user_id`), + KEY `idx_im_org_visibility_scope` (`visibility_scope`,`state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_realname_appeals` ( + `id` bigint NOT NULL, + `appeal_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `reason` varchar(500) NOT NULL DEFAULT '', + `attachments_json` json DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `review_comment` varchar(500) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_realname_appeals_appeal` (`appeal_id`), + KEY `idx_im_realname_appeals_user` (`user_id`,`created_at`), + KEY `idx_im_realname_appeals_status` (`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_realname_verifications` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `submission_id` varchar(64) NOT NULL, + `status` varchar(16) NOT NULL, + `name_cipher` text NOT NULL, + `id_type` varchar(32) NOT NULL, + `id_number_cipher` text NOT NULL, + `id_number_hash` char(64) NOT NULL, + `masked_name` varchar(64) NOT NULL, + `masked_id_number` varchar(64) NOT NULL, + `review_reason` varchar(256) DEFAULT NULL, + `verified_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_realname_submission` (`submission_id`), + KEY `idx_im_realname_user_latest` (`user_id`,`created_at`), + KEY `idx_im_realname_id_hash` (`id_number_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_registration_invite_uses` ( + `id` bigint NOT NULL, + `invite_code_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `request_id` varchar(64) NOT NULL, + `used_at` datetime(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_registration_invite_use_user` (`user_id`), + KEY `idx_im_registration_invite_use_request` (`request_id`), + KEY `idx_im_registration_invite_use_code` (`invite_code_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_risk_challenges` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `challenge_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `action` varchar(64) NOT NULL, + `level` varchar(16) NOT NULL, + `notice_code` varchar(64) NOT NULL, + `notice_params` json DEFAULT NULL, + `state` varchar(16) NOT NULL DEFAULT 'pending', + `expires_at` datetime(3) NOT NULL, + `decision` varchar(16) NOT NULL DEFAULT '', + `action_token_id` varchar(64) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_risk_challenges_challenge` (`challenge_id`), + KEY `idx_im_risk_challenges_user_state` (`user_id`,`state`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_subscriptions` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `userid` bigint NOT NULL, + `topic` char(25) NOT NULL, + `delid` int DEFAULT '0', + `recvseqid` bigint DEFAULT '0', + `readseqid` bigint DEFAULT '0', + `modewant` char(8) DEFAULT NULL, + `modegiven` char(8) DEFAULT NULL, + `private` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `subscriptions_topic` (`topic`), + KEY `subscriptions_deletedat` (`deletedat`), + KEY `subscriptions_userid_topic_deletedat` (`userid`,`topic`,`deletedat`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_system_notifications` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `event_id` varchar(64) NOT NULL, + `event_type` varchar(64) NOT NULL, + `title` varchar(128) NOT NULL, + `summary` varchar(512) NOT NULL, + `action` json DEFAULT NULL, + `read_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_system_notifications_event` (`user_id`,`event_id`), + KEY `idx_im_system_notifications_inbox` (`user_id`,`id` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topic_drafts` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `topic` varchar(128) NOT NULL, + `content` text, + `entities_json` json DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_topic_drafts_user_topic` (`user_id`,`topic`), + KEY `idx_im_topic_drafts_user_updated` (`user_id`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topics` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `state` smallint NOT NULL DEFAULT '0', + `stateat` datetime(3) DEFAULT NULL, + `touchedat` datetime(3) DEFAULT NULL, + `name` char(25) NOT NULL, + `usebt` tinyint DEFAULT '0', + `owner` bigint NOT NULL DEFAULT '0', + `access` json DEFAULT NULL, + `seqid` bigint NOT NULL DEFAULT '0', + `delid` int DEFAULT '0', + `subcnt` int DEFAULT '0', + `public` json DEFAULT NULL, + `trusted` json DEFAULT NULL, + `tags` json DEFAULT NULL, + `aux` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `topics_owner` (`owner`), + KEY `topics_state_stateat` (`state`,`stateat`), + KEY `topics_name_state_seqid` (`name`,`state`,`seqid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topictags` ( + `id` int NOT NULL AUTO_INCREMENT, + `topic` char(25) NOT NULL, + `tag` varchar(96) NOT NULL, + PRIMARY KEY (`id`), + KEY `topictags_tag` (`tag`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_blocks` ( + `id` bigint NOT NULL, + `blocker_id` bigint NOT NULL, + `blocked_id` bigint NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_blocks_pair` (`blocker_id`,`blocked_id`), + KEY `idx_im_user_blocks_blocked` (`blocked_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_ip_whitelists` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `ip_cidr` varchar(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR', + `status` tinyint NOT NULL DEFAULT '1' COMMENT '1 enabled, 2 disabled', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT '0', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT '0', + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_ip_whitelist` (`user_id`,`ip_cidr`), + KEY `idx_im_user_ip_whitelist_user` (`user_id`,`status`), + KEY `idx_im_user_ip_whitelist_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Per-user login IP whitelist'; + +CREATE TABLE IF NOT EXISTS `im_user_login_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `username` varchar(64) NOT NULL DEFAULT '', + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `login_type` varchar(24) NOT NULL, + `event_type` varchar(24) NOT NULL DEFAULT 'login', + `session_id` varchar(96) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'low', + `request_id` varchar(64) NOT NULL DEFAULT '', + `login_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `logout_at` datetime(3) DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_user_login_user_time` (`user_id`,`login_at` DESC,`id` DESC), + KEY `idx_im_user_login_ip_time` (`ip`,`login_at` DESC,`id` DESC), + KEY `idx_im_user_login_device_time` (`device_id`(64),`login_at` DESC), + KEY `idx_im_user_login_request` (`request_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_privacy_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `add_me_methods` json DEFAULT NULL, + `profile_visibility` varchar(32) NOT NULL DEFAULT 'friends', + `last_seen_visibility` varchar(32) NOT NULL DEFAULT 'friends', + `read_receipts` tinyint NOT NULL DEFAULT '1', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_privacy_settings_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_profiles` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `nickname` varchar(64) NOT NULL DEFAULT '', + `birth_date` date DEFAULT NULL, + `signature` varchar(256) NOT NULL DEFAULT '', + `gender` varchar(16) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `email` varchar(128) NOT NULL DEFAULT '', + `public_account` varchar(64) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_profiles_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_security` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `auth_version` int NOT NULL DEFAULT '1', + `password_updated_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_security_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_users` ( + `id` bigint NOT NULL, + `account_no` int unsigned NOT NULL DEFAULT '0' COMMENT '用户编号', + `username` varchar(32) NOT NULL, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `state` smallint NOT NULL DEFAULT '0', + `stateat` datetime(3) DEFAULT NULL, + `access` json DEFAULT NULL, + `lastseen` datetime DEFAULT NULL, + `useragent` varchar(255) DEFAULT '', + `phone` varchar(32) DEFAULT NULL, + `public` json DEFAULT NULL, + `trusted` json DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `users_username` (`username`), + UNIQUE KEY `uk_im_users_account_no` (`account_no`), + UNIQUE KEY `users_phone` (`phone`), + KEY `users_state_stateat` (`state`,`stateat`), + KEY `users_lastseen_updatedat` (`lastseen`,`updatedat`), + KEY `idx_im_users_account_no` (`account_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_verification_codes` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `challenge_id` varchar(64) NOT NULL, + `scene` varchar(32) NOT NULL, + `action` varchar(64) NOT NULL DEFAULT '', + `phone_hash` char(64) NOT NULL, + `phone_mask` varchar(32) NOT NULL, + `target_user` bigint DEFAULT NULL, + `code_hash` char(64) NOT NULL, + `verification_token_hash` char(64) DEFAULT NULL, + `state` smallint NOT NULL, + `attempts` int NOT NULL DEFAULT '0', + `max_attempts` int NOT NULL, + `expires_at` datetime(3) NOT NULL, + `resend_after` datetime(3) NOT NULL, + `verified_at` datetime(3) DEFAULT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_verification_challenge` (`challenge_id`), + KEY `idx_im_verification_scene_phone_state` (`scene`,`phone_hash`,`state`,`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_workbench_apps` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `code` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `icon_file_id` varchar(64) NOT NULL DEFAULT '', + `route` varchar(128) NOT NULL, + `sort_order` int NOT NULL DEFAULT '0', + `unread_key` varchar(64) NOT NULL DEFAULT '', + `enabled` tinyint NOT NULL DEFAULT '1', + `permission` varchar(64) NOT NULL DEFAULT 'enabled', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_workbench_apps_code` (`code`), + KEY `idx_im_workbench_apps_enabled_order` (`enabled`,`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_youth_mode_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `purpose` varchar(64) NOT NULL, + `password_hash` varbinary(255) NOT NULL, + `enabled` tinyint NOT NULL DEFAULT '0', + `policy_version` int NOT NULL DEFAULT '1', + `restrictions` json DEFAULT NULL, + `enabled_at` datetime(3) DEFAULT NULL, + `failed_attempts` int NOT NULL DEFAULT '0', + `locked_until` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_youth_mode_settings_user_purpose` (`user_id`,`purpose`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 2. Tenant business seed data. +-- ============================================================ + +-- Tinode schema version expected by the current MySQL adapter. +INSERT INTO `im_kvmeta` (`id`, `key`, `createdat`, `value`) +VALUES (1, 'version', CURRENT_TIMESTAMP(3), 123) +ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); + +-- Workbench default apps. These are product navigation entries, not tenant business data. +INSERT INTO `im_workbench_apps` (`code`, `name`, `route`, `sort_order`, `unread_key`, `enabled`, `permission`) +VALUES + ('attendance', '打卡', '/attendance', 10, 'attendance', 1, 'enabled'), + ('monthly_reports', '月报', '/reports/monthly', 20, 'monthly_reports', 1, 'enabled'), + ('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), + ('organization', '部门框架', '/organization', 40, 'organization', 1, 'enabled'), + ('executive_mailbox', '举报信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled'), + ('meetings', '会议通知', '/meetings', 60, 'meetings', 1, 'enabled'), + ('meeting_notes', '会议笔记', '/meeting-notes', 70, '', 1, 'enabled') +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `route` = VALUES(`route`), + `sort_order` = VALUES(`sort_order`), + `unread_key` = VALUES(`unread_key`), + `enabled` = VALUES(`enabled`), + `permission` = VALUES(`permission`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default attendance settings. Tenants can edit or replace them later. +INSERT INTO `im_attendance_rules` (`rule_id`, `name`, `workday`, `location_required`, `wifi_required`, `allow_outside_checkin`, `appeal_allowed`) +VALUES ('att_rule_default', '默认考勤规则', 1, 0, 0, 1, 1) +ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `im_attendance_shifts` (`shift_id`, `rule_id`, `name`, `work_start`, `work_end`, `checkin_start`, `checkout_end`, `sort_order`) +VALUES ('shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:30:00', '20:00:00', 10) +ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default mailbox configuration. +INSERT INTO `im_mailbox_config` (`id`, `enabled`, `allow_anonymous`, `required_fields`, `max_length`, `attachment_policy`, `notice_title`, `notice_content`) +VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') +ON DUPLICATE KEY UPDATE + `enabled` = VALUES(`enabled`), + `allow_anonymous` = VALUES(`allow_anonymous`), + `required_fields` = VALUES(`required_fields`), + `max_length` = VALUES(`max_length`), + `attachment_policy` = VALUES(`attachment_policy`), + `notice_title` = VALUES(`notice_title`), + `notice_content` = VALUES(`notice_content`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default abuse report reasons. +INSERT INTO `im_abuse_reasons` (`target_type`, `reason_code`, `label`, `requires_description`, `allows_evidence`, `sort_order`) +VALUES + ('user', 'fraud', '诈骗欺诈', 0, 1, 10), + ('user', 'harassment', '骚扰辱骂', 0, 1, 20), + ('user', 'illegal', '违法违规', 1, 1, 30), + ('group', 'fraud', '诈骗欺诈', 0, 1, 10), + ('group', 'spam', '垃圾广告', 0, 1, 20), + ('group', 'illegal', '违法违规', 1, 1, 30), + ('message', 'fraud', '诈骗欺诈', 0, 1, 10), + ('message', 'spam', '垃圾广告', 0, 1, 20), + ('message', 'harassment', '骚扰辱骂', 0, 1, 30), + ('message', 'illegal', '违法违规', 1, 1, 40) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `requires_description` = VALUES(`requires_description`), + `allows_evidence` = VALUES(`allows_evidence`), + `sort_order` = VALUES(`sort_order`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default feedback configuration. +INSERT INTO `im_feedback_config` (`id`, `categories`, `max_length`, `attachment_policy`, `allow_contact`, `diagnostics_notice`) +VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。') +ON DUPLICATE KEY UPDATE + `categories` = VALUES(`categories`), + `max_length` = VALUES(`max_length`), + `attachment_policy` = VALUES(`attachment_policy`), + `allow_contact` = VALUES(`allow_contact`), + `diagnostics_notice` = VALUES(`diagnostics_notice`), + `updated_at` = CURRENT_TIMESTAMP(3); + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- 3. Control database registry seed. +-- ============================================================ +-- Keep this section in the same change set as tenant DB initialization. +-- If the tenant id is generated by Admin Server, replace __TENANT_ID__ with the generated id. + +INSERT INTO `hotgo`.`im_tenant` (`id`, `code`, `name`, `tenant_desc`, `db_name`, `db_dsn`, `state`, `created_by`, `updated_by`) +VALUES (__TENANT_ID__, '__TENANT_CODE__', '__TENANT_NAME__', NULL, '__TENANT_DB__', '', 1, 0, 0) +ON DUPLICATE KEY UPDATE + `code` = VALUES(`code`), + `name` = VALUES(`name`), + `db_name` = VALUES(`db_name`), + `state` = VALUES(`state`), + `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `hotgo`.`im_tenant_config` (`tenant_id`, `max_users`, `max_groups`, `max_group_members`, `created_by`, `updated_by`) +VALUES (__TENANT_ID__, 0, 0, 0, 0, 0) +ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); diff --git a/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md b/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md new file mode 100644 index 0000000..db8f507 --- /dev/null +++ b/docs/admin-adfahi-research/服务端消息加密与内容审核改造文档.md @@ -0,0 +1,570 @@ +# 服务端消息加密与内容审核改造文档 + +> 文档状态:待开发 +> 编制日期:2026-09-13 +> 适用项目:`im_manager`(Admin Server)、`im_server`(IM Server) +> 本文档是后续数据库设计、后端开发、联调、迁移和验收的共同依据。 + +## 1. 建设目标 + +本次改造同时建设两项能力: + +1. 内容审核:IM Server 在保存或修改内容前,根据商户策略执行本地敏感词检查和阿里云文本内容审核。 +2. 消息加密存储:审核通过的消息在 IM Server 内部使用 AES-256-GCM 加密,国内 IM 业务库只保存密文;经授权的后台查询由 IM Server 解密后返回明文。 + +本次不做以下内容: + +- 不做客户端 AES 二次加密。 +- 不做端到端加密(E2EE)。 +- 不改变现有 HTTPS/WSS 传输协议。 +- 不允许 Admin Server 直接读取 IM 数据库并自行解密。 +- 不在日志、Kafka 事件、死信或监控标签中保存消息明文。 + +## 2. 安全边界和部署前提 + +### 2.1 部署分工 + +| 区域 | 系统 | 保存数据 | +| --- | --- | --- | +| 国外 | HotGo / Admin Server | 商户审核策略、阿里云配置、字典根密钥、商户消息密钥 | +| 国内 | IM Server | 用户、会话、消息密文、审核结果和处置记录 | + +该方案主要防护目标是:任意一侧数据库单独泄露时,不能直接恢复消息明文。 + +已知边界:HotGo 数据库、IM 数据库和完整代码同时泄露不在本方案的主要防护范围内。 + +### 2.2 传输安全 + +- 客户端调用必须使用 `https://` 和 `wss://`。 +- 阿里云审核请求必须使用 HTTPS。 +- Admin Server 与 IM Server 的内部配置和查询接口必须使用 HTTPS,并保留现有签名鉴权;跨公网部署时建议叠加 mTLS 或固定出口 IP 白名单。 +- TLS 只解决传输保护,AES-256-GCM 解决消息落库后的静态保护。 + +## 3. 总体架构 + +```text +客户端 + | HTTPS/WSS明文业务数据(TLS通道内不可见) + v +IM Server + |-- 实时权限、宵禁、用户状态等校验 + |-- 获取商户审核策略 + |-- 本地敏感词补充检查 + |-- 阿里云 TextModerationPlus 审核 + |-- AES-256-GCM 加密 + `-- 密文写入国内IM库 + +Admin Server(国外HotGo) + |-- 管理阿里云配置 + |-- 管理商户审核策略 + |-- 管理商户消息密钥 + |-- 通过内部接口下发配置/密钥 + `-- 通过IM内部查询接口查看解密后消息 +``` + +## 4. 内容审核设计 + +### 4.1 审核范围 + +| 场景 | 阿里云 Service | 审核时机 | +| --- | --- | --- | +| 用户昵称 | `nickname_detection_pro` | 更新用户资料前 | +| 群昵称 | `nickname_detection_pro` | 更新群成员资料前 | +| 群名称 | `nickname_detection_pro` | 创建群或修改群资料前 | +| 用户签名 | `nickname_detection_pro` | 更新用户资料前 | +| 单聊文本 | `chat_detection_pro` | 消息入库及广播前 | +| 群聊文本 | `chat_detection_pro` | 消息入库及广播前 | +| 编辑后消息 | `chat_detection_pro` | 新版本入库前 | +| 群公告 | `chat_detection_pro` | 发布或修改前 | + +图片、视频和文件继续走文件审核链路,不属于本文本审核接口范围。 + +### 4.2 商户范围 + +- 第一阶段只为 `tenant.code=default` 的上架默认商户开启阿里云内容审核。 +- 其他团队商户默认关闭,不调用阿里云,不产生审核费用。 +- 数据模型保留按商户开启的能力,但只有平台管理员可变更。 +- 商户端管理员不得看到阿里云 AK/SK,不得修改平台级审核服务配置。 + +### 4.3 后台配置项 + +#### 4.3.1 阿里云服务配置 + +复用 `im_external_config` 的 `service=moderation`,由平台管理员维护: + +| 配置 | 说明 | +| --- | --- | +| `provider` | 固定为 `aliyun_green` | +| `endpoint` | 默认 `green-cip.cn-shanghai.aliyuncs.com` | +| `enabled` | 平台总开关 | +| `config_json.region_id` | 默认 `cn-shanghai` | +| `config_json.nickname_service` | 默认 `nickname_detection_pro` | +| `config_json.chat_service` | 默认 `chat_detection_pro` | +| `config_json.timeout_ms` | 单次请求超时,建议 1000~2000 ms | +| `config_json.failure_mode` | `allow` 或 `block` | +| `secret_json.access_key_id` | 只写不回显 | +| `secret_json.access_key_secret` | 只写不回显 | + +“检查配置”必须真实调用阿里云测试文本,不能只检查字段非空。返回仅包含成功与否、请求耗时、RequestID 和脱敏错误。 + +#### 4.3.2 商户审核策略 + +可在 `im_security_policy.sensitive_word_policy` 中保存,避免新增重复策略表。JSON 结构固定为: + +```json +{ + "enabled": true, + "aliyun_enabled": true, + "local_words_enabled": true, + "scenes": { + "user_nickname": true, + "user_signature": true, + "group_name": true, + "group_nickname": true, + "private_message": true, + "group_message": true, + "group_announcement": true + }, + "risk_actions": { + "high": "block", + "medium": "block", + "low": "audit_only", + "none": "allow" + }, + "failure_mode": "allow", + "block_message": "内容不符合平台规范,请修改后重试" +} +``` + +策略发布后使用现有 `im.config.event` 发布配置事件,IM Server 按商户清理本地缓存。 + +### 4.4 文本提取 + +审核输入不能直接对任意 JSON 执行字符串化,应按 Drafty 或消息类型提取用户可见文本: + +- 普通文本:提取完整文本。 +- Drafty 富文本:提取 `txt` 及按钮、链接等用户可见标题。 +- 图片/文件/语音:审核文件名、说明和附带文本,文件本体走文件审核。 +- 系统消息、音视频信令和已签名服务端消息不调用文本审核。 +- 空文本直接放行。 + +### 4.5 阿里云结果处置 + +IM Server 统一映射为: + +| 内部结果 | 动作 | +| --- | --- | +| `allow` | 继续加密、入库和广播 | +| `block` | 不入库、不广播,返回统一业务错误 | +| `audit_only` | 允许发送,但写入审核命中日志 | +| `retryable_error` | 根据 `failure_mode` 放行或拦截 | + +不只依赖 `RiskLevel/Label` 一个字段,需保留与验证阿里云原始结果中的扩展判断,针对引流、诈骗等样本建立回归测试集。 + +### 4.6 超时与降级 + +- 阿里云请求不得无限等待。 +- 建议默认 `failure_mode=allow`,阿里云异常时允许发送并记录告警,防止第三方故障造成 IM 全局不可用。 +- 连续失败达到阈值后进入短时熔断;熔断期间按失败策略处理。 +- 阿里云调用不得在数据库事务中执行。 + +## 5. 消息加密设计 + +### 5.1 算法 + +- 算法:AES-256-GCM。 +- DEK 长度:32 字节。 +- Nonce:每次加密使用 `crypto/rand` 生成的独立 12 字节随机值。 +- GCM 标签:使用 Go 标准库默认 16 字节认证标签。 +- 明文编码:将原 `content` JSON 规范化序列化为 UTF-8 字节。 +- 不允许固定 Nonce,不允许 AES-ECB,不使用未认证的 AES-CBC/CTR。 + +### 5.2 AAD + +每条消息加密时绑定以下 AAD: + +```text +tenant_id | topic | seqid | from | client_msg_id | key_version | content +``` + +`content` 是字面标识,用于与未来可能加密的 `head` 区分。AAD 按固定字段顺序和长度前缀编码,禁止使用可产生拼接歧义的简单字符串。 + +### 5.3 密钥层级 + +```text +HotGo字典根密钥(KEK) + -> 加密每个商户、每个版本的DEK + -> DEK加密该商户消息 +``` + +根密钥使用 HotGo `hg_sys_dict_data` 保存,但必须满足: + +1. 使用独立的内部字典类型,不出现在通用字典管理页。 +2. 后端字典列表、选项和导出接口必须对该类型做服务端拒绝,不能仅依赖菜单隐藏。 +3. 根密钥为 32 字节强随机值的 Base64 编码,不使用口令、UUID 或可读文本。 +4. 根密钥只由 Admin Server 密钥服务读取,不通过通用配置接口返回。 +5. 根密钥不写日志,不在后台页面回显,不进入 Kafka 消息。 + +### 5.4 商户密钥管理表 + +在 HotGo 库新增 `im_message_encryption_keys`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | bigint | 主键 | +| `tenant_id` | bigint | 商户 ID | +| `key_version` | int | 密钥版本,从 1 递增 | +| `algorithm` | varchar(32) | 固定 `AES-256-GCM` | +| `encrypted_dek` | blob | KEK 加密后的 DEK | +| `dek_nonce` | varbinary(12) | 加密 DEK 时的随机 Nonce | +| `status` | varchar(16) | `pending` / `active` / `retired` / `disabled` | +| `created_at` | datetime | 创建时间 | +| `activated_at` | datetime | 启用时间 | +| `retired_at` | datetime | 退役时间 | +| `created_by` | bigint | 操作人 | +| `remark` | varchar(255) | 备注 | + +约束: + +- 唯一键:`tenant_id + key_version`。 +- 每个商户同时只能有一把 `active` 密钥。 +- `retired` 密钥仍可用于解密历史消息,不得再用于新消息加密。 +- `disabled` 只用于安全事故应急,禁用前必须完成历史消息转密钥。 + +### 5.5 IM 消息表改造 + +`im_messages` 和 `im_message_revisions` 新增: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `content_ciphertext` | longblob | 消息密文,包含 GCM 认证标签 | +| `content_nonce` | varbinary(12) | 本条消息随机 Nonce | +| `key_version` | int | 商户 DEK 版本 | +| `encryption_alg` | varchar(32) | `AES-256-GCM` | +| `encrypted` | tinyint | `0` 明文兼容,`1` 密文 | + +迁移完成前保留现有 `content JSON`,规则如下: + +- 新写入:`content=NULL`、`encrypted=1`,只保存密文。 +- 旧数据:`content IS NOT NULL`、`encrypted=0`,按原逻辑读取。 +- 解密成功后还原为原 `any` JSON 结构,不修改对客户端的协议。 +- `head` 第一阶段不加密,但禁止在 `head` 中放置消息正文、联系方式等敏感明文。 + +`im_group_messages` 是群消息派生查询表,不得另存一份明文。实现时二选一: + +1. 推荐:仅保存索引、状态和统计字段,通过 `topic + seqid` 关联 `im_messages` 解密。 +2. 兼容:同步增加密文字段,但加密输入和密文必须与主消息表保持一致。 + +不允许 `im_messages` 已加密而 `im_group_messages.content` 仍保留明文。 + +### 5.6 加密和解密时机 + +#### 新消息 + +```text +校验消息 + -> 提取审核文本 + -> 内容审核 + -> 分配seqid/组装AAD + -> 序列化content + -> 使用当前active DEK加密 + -> 事务写入密文和消息元数据 + -> 向在线订阅者广播原内存明文 +``` + +#### 历史消息和离线同步 + +```text +从数据库读取密文 + -> 按tenant_id + key_version获取DEK + -> 重建AAD + -> GCM验签并解密 + -> JSON反序列化 + -> 按原Tinode协议返回 +``` + +任何验签失败均必须当作数据篡改或密钥错误,禁止返回部分内容或尝试忽略错误。 + +### 5.7 密钥获取和缓存 + +- IM Server 不得每发送一条消息都跨境请求 HotGo。 +- IM Server 按 `tenant_id + key_version` 在内存中缓存已解密 DEK。 +- 新消息使用当前 `active` 密钥,历史消息按记录中的 `key_version` 取密钥。 +- 密钥变更发布 `message.encryption.key.updated` 配置事件,IM Server 按商户刷新。 +- HotGo 短时不可用时继续使用内存密钥。 +- IM Server 重启后如无法取得当前密钥,该商户禁止写入新消息,不得降级为明文落库。 + +### 5.8 密钥轮换 + +1. Admin Server 生成新 DEK,使用 KEK 加密后以 `pending` 状态入库。 +2. 通知 IM Server 拉取并验证新密钥。 +3. 验证成功后,在事务中将旧密钥设为 `retired`,新密钥设为 `active`。 +4. 新消息立即使用新版本,旧消息仍按原版本解密。 +5. 历史转密钥作为单独低速任务执行,不阻塞即时通信。 + +## 6. Admin Server 改造 + +### 6.1 内容审核页面 + +将现有“审核插件”配置补齐为可用功能: + +- 阿里云基础配置。 +- 真实连通性检查。 +- 商户审核开关和场景开关。 +- 风险等级与处置动作映射。 +- 异常降级策略。 +- 配置发布版本和生效时间。 + +非默认商户显示“未开启内容审核”,不显示阿里云密钥编辑项。 + +### 6.2 消息加密管理页面 + +增加平台级“消息加密”页面,只允许最高权限平台管理员使用: + +- 查看商户加密状态、当前密钥版本和最后轮换时间。 +- 初始化商户密钥。 +- 启用/暂停商户消息加密。 +- 轮换密钥。 +- 查看历史迁移进度。 +- 密钥明文、根密钥和 `encrypted_dek` 均不展示。 + +### 6.3 内部接口 + +建议增加: + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/internal/im-config/tenants/{code}/moderation-policy` | IM Server 拉取商户审核策略 | +| GET | `/internal/im-config/moderation/provider` | IM Server 拉取阿里云配置 | +| GET | `/internal/im-config/tenants/{code}/message-keys/active` | 获取当前消息 DEK | +| GET | `/internal/im-config/tenants/{code}/message-keys/{version}` | 获取指定版本 DEK | +| POST | `/internal/im-config/tenants/{code}/message-keys/ack` | IM Server 确认新密钥可用 | + +密钥接口返回的是服务端使用的 DEK,不允许从浏览器或通用 Admin API 调用。接口必须校验服务身份、时间窗、签名、请求 ID 和重放。 + +### 6.4 后台消息查询 + +- Admin Server 继续调用 IM Server internal admin-query,不直连 IM 业务库解密。 +- IM Server 在商户数据权限通过后批量解密当页消息。 +- 每次查看明文记录管理员 ID、商户、IP、查询条件、返回数量、时间和 TraceID。 +- 列表只解密当页,禁止一次加载全部历史消息。 + +## 7. IM Server 改造 + +### 7.1 新增内部模块 + +```text +server/moderation/ + provider.go 审核Provider接口 + policy.go 商户策略和缓存 + text.go 文本提取与结果映射 + aliyun/text.go TextModerationPlus实现 + +server/messagecrypto/ + cipher.go AES-256-GCM加解密 + key_provider.go 密钥获取接口 + key_cache.go tenant/version内存缓存 + serializer.go content规范序列化 +``` + +不将加密和阿里云调用直接写进 MySQL adapter;adapter 只保存已加密的存储模型和解密所需元数据。 + +### 7.2 消息发送接入点 + +在 `Topic.handlePubBroadcast` 中,于 `saveAndBroadcastMessage` 之前执行: + +1. 过滤 WebRTC/系统信令等不审核内容。 +2. 提取文本。 +3. 根据商户策略执行审核。 +4. 拦截时通过 Tinode ctrl 回复业务错误,不进入保存和广播。 +5. 放行时进入加密保存。 + +进程内广播使用本次请求已有明文,不为广播执行“加密后立即解密”。 + +### 7.3 资料修改接入点 + +用户昵称/签名、群名称/公告、群昵称的 HTTP 和 Tinode meta 修改通道都必须接入同一 moderation service,不能只修改某一个 HTTP API 而留下协议绕过路径。 + +### 7.4 存储层改造 + +- `types.Message` 和 `types.MessageRevision` 增加密文及密钥元数据字段。 +- MySQL 的普通库和共享库/多商户 adapter 必须同时改造。 +- 新建库 schema、启动 ensure 迁移和手工 SQL 脚本必须保持一致。 +- Save、GetOne、GetAll、编辑、撤回、删除、历史修订、管理后台查询均需覆盖。 +- 解密层应在业务存储服务边界统一执行,避免上层遗漏。 + +### 7.5 缓存与多 Pod + +- 每个 Pod 保持本地密钥和审核策略缓存。 +- Kafka 配置事件使所有 Pod 同时失效。 +- 事件处理必须幂等,重复消息不得导致重复创建密钥。 +- 新旧密钥切换期间,所有 Pod 必须同时具备旧版本解密和新版本加密能力。 + +## 8. 审核和解密日志 + +在 IM 业务库增加 `im_content_moderation_logs`: + +| 字段 | 说明 | +| --- | --- | +| `id` | 主键 | +| `request_id` | 审核请求 ID | +| `scene` | 昵称、单聊、群聊等 | +| `topic` / `seqid` | 关联消息,拦截时 `seqid` 可为空 | +| `actor_id` | 内容提交人 | +| `provider` | `local` / `aliyun_green` | +| `provider_request_id` | 第三方 RequestID | +| `risk_level` | 统一风险等级 | +| `risk_labels` | 命中标签 JSON | +| `action` | `allow` / `block` / `audit_only` | +| `content_hash` | SHA-256 摘要,不保存明文 | +| `latency_ms` | 审核耗时 | +| `error_code` | 脱敏错误码 | +| `created_at` | 创建时间 | + +后台明文查看继续写入统一审计事件,必须区分“列表解密”、“详情解密”和“导出解密”。默认不提供明文批量导出;需要开放时必须增加独立权限和审批。 + +## 9. 关键词查询限制 + +消息加密后,MySQL 无法对密文执行原有 `LIKE '%keyword%'`。第一阶段采用: + +1. 后台查询必须先提供商户、时间范围,并尽量提供用户或群号。 +2. IM Server 先用明文元数据缩小候选集。 +3. 对有上限的候选集批量解密,再在内存中匹配关键词。 +4. 限制查询最大时间范围和候选消息数,超限返回明确提示。 + +不建议在第一阶段建设明文分词索引,否则会降低加密保护效果。若后续必须支持全库高性能检索,再单独评审 HMAC 盲索引方案。 + +## 10. 历史数据迁移 + +### 10.1 发布顺序 + +1. 发布 HotGo 表结构、根密钥和密钥管理能力。 +2. 为默认商户初始化第 1 版 DEK,但不开启加密。 +3. 发布 IM Server 双读兼容版本,验证密钥获取、加解密和内容审核。 +4. 开启新消息加密写入,保持旧数据明文可读。 +5. 启动历史数据分批迁移。 +6. 对比数量、抽样解密和 GCM 验签,完成后清空明文 `content`。 +7. 观察期结束后,再决定是否删除原明文字段。 + +### 10.2 迁移任务要求 + +- 按主键或 `topic + seqid` 分批,不长时锁表。 +- 每批使用独立事务,记录游标、成功数、失败数和最后错误。 +- 重复执行必须幂等,`encrypted=1` 的记录直接跳过。 +- 先写入密文并验证可解密,再将明文字段置空。 +- 失败记录保留明文并进入重试列表,不得丢失原数据。 +- 迁移速率可配置,并监控数据库负载和消息延迟。 + +## 11. 错误码和客户端兼容 + +客户端不参与加解密,不修改消息协议。新增业务错误建议: + +| 错误 | 说明 | 客户端处理 | +| --- | --- | --- | +| `CONTENT_REJECTED` | 内容审核拒绝 | 保留未发送文本并提示修改 | +| `CONTENT_REVIEW_UNAVAILABLE` | 审核不可用且策略要求拦截 | 提示稍后重试 | +| `MESSAGE_ENCRYPTION_UNAVAILABLE` | 未取得加密密钥 | 提示服务暂时不可用 | +| `MESSAGE_DECRYPT_FAILED` | 历史消息验签/解密失败 | 展示“消息暂时无法加载”,不展示乱码 | + +不向客户端返回阿里云原始错误、风险标签详情、密钥版本内部信息或解密堆栈。 + +## 12. 监控指标 + +### 12.1 内容审核 + +- 按商户/场景统计请求量、放行数、拦截数、降级数。 +- 阿里云耗时 P50/P95/P99。 +- 超时率、错误率、熔断状态。 +- 各风险等级和标签命中数。 + +### 12.2 消息加密 + +- 加密/解密成功数、失败数和耗时。 +- 各商户当前密钥版本。 +- 密钥获取成功率、缓存命中率和缓存最后刷新时间。 +- 明文历史记录剩余数、迁移速率和迁移失败数。 +- GCM 验签失败必须产生高优先级告警。 + +监控标签不得使用消息正文、密文、Nonce、DEK 或 KEK。 + +## 13. 验收标准 + +### 13.1 审核验收 + +- `default` 商户开启后,单聊、群聊、用户昵称、群名称和群昵称均实际调用对应阿里云 Service。 +- 其他商户关闭时不调用阿里云。 +- 拦截内容不进入 `im_messages` 和 `im_group_messages`,也不向订阅者广播。 +- 本地敏感词与阿里云开关可独立启停。 +- 阿里云超时严格按 `failure_mode` 处理。 +- 配置发布后所有 IM Pod 在规定时间内生效。 + +### 13.2 加密验收 + +- 开启商户的新消息在数据库中不存在可读正文。 +- 相同内容连续发送两次,Nonce 和密文必须不同。 +- 修改密文、Nonce、AAD 关联字段的任意一处后,解密必须失败。 +- 在线发送、离线同步、多端漫游、编辑、回复、撤回、删除保持原有业务行为。 +- Admin 有权限账号可查看解密明文,无权限账号被拒绝。 +- 查看明文必须形成完整审计记录。 +- 旧密钥退役后仍可解密旧版本消息,新消息只使用新版本。 +- HotGo 暂时断开时,已有内存密钥的 IM Pod 仍可工作;无密钥时绝不允许明文降级落库。 + +### 13.3 性能验收 + +- 纯 AES 加密不得成为消息链路主要瓶颈。 +- 密钥缓存命中时不发生跨境 HotGo 请求。 +- 审核关闭的商户不增加阿里云网络耗时。 +- 审核开启时应分别统计本地处理、阿里云调用和消息入库耗时。 +- 历史迁移期间即时消息发送和数据库负载不得超过上线阈值。 + +## 14. 开发分批建议 + +### 批次一:配置和密钥基础 + +- Admin 阿里云内容审核配置、商户策略、密钥表和密钥管理页。 +- 字典根密钥隔离和专用密钥服务。 +- Admin internal config/key API 及 Kafka 失效事件。 + +### 批次二:IM 文本审核 + +- 阿里云 `TextModerationPlus` Go 客户端。 +- 策略缓存、文本提取、消息和资料修改接入。 +- 审核日志、错误码、告警和回归测试。 + +### 批次三:新消息加密 + +- AES-256-GCM 模块、密钥缓存和存储模型。 +- 新消息加密写入和历史消息解密读取。 +- 单聊、群聊、修订、后台查询的一致性验证。 + +### 批次四:历史迁移和轮换 + +- 历史明文分批加密。 +- 迁移仪表盘、少量失败重试和数据对账。 +- 密钥轮换和历史转密钥测试。 +- 完成观察后清理明文。 + +## 15. 开发前必须确认的决策 + +| 决策 | 文档默认值 | +| --- | --- | +| 阿里云异常时放行还是拦截 | 放行并告警 | +| 是否审核系统消息 | 不审核 | +| 是否加密 `head` | 第一阶段不加密 | +| 第一阶段启用商户 | 仅 `code=default` | +| 是否允许无密钥时明文降级 | 不允许 | +| 后台是否允许明文导出 | 默认不允许 | +| 历史消息是否迁移 | 是,分批迁移 | +| 根密钥位置 | HotGo 内部字典数据,严禁通用接口暴露 | + +## 16. 现有实现对照 + +| 能力 | 现状 | 本次改造 | +| --- | --- | --- | +| 阿里云文本审核 | 文档已人工验证,IM Server 无 Go 调用实现 | 增加 TextModerationPlus 客户端和业务接入 | +| 本地敏感词 | Admin 已有 CRUD,IM Server 未消费 | 作为可选补充审核 | +| 审核配置 | `im_external_config` 已有 `moderation` 占位 | 增加真实检查、商户策略和发布 | +| 配置同步 | 已有 internal config 客户端、Kafka 配置事件和缓存失效 | 复用并扩展新的策略/密钥类型 | +| 消息存储 | `im_messages.content` 和修订表 `content` 为 JSON 明文 | 改为 AES-256-GCM 密文且双读迁移 | +| 群消息派生表 | `im_group_messages.content` 为 JSON 明文 | 去明文副本或同步密文化 | +| 后台消息查询 | IM internal-query 返回库内内容 | 增加权限校验、解密和查看审计 | diff --git a/docs/admin-adfahi-research/测试用例文档.md b/docs/admin-adfahi-research/测试用例文档.md new file mode 100644 index 0000000..92e1ec7 --- /dev/null +++ b/docs/admin-adfahi-research/测试用例文档.md @@ -0,0 +1,460 @@ +# 鸽达达管理后台改造测试用例 + +> 依据:`需求文档.md`、`Admin-Server改造文档.md`、`IM-Server改造文档.md`及当前 Admin Server、IM Server 代码。 +> 测试环境:`https://admin.118im.com/admin#/dashboard/console` +> 文档日期:2026-09-07 + +## 1. 测试目标与范围 + +本测试用于验证鸽达达管理后台改造后的页面操作、Admin Server 接口、IM Server 内部查询与命令执行是否一致,并重点验证以下闭环: + +1. 后台查询条件、表格字段、详情、导出及权限控制正确。 +2. Admin Server 能正确访问 IM Server,真实展示概览、消息和登录审计数据。 +3. 消息隐藏、删除经 Kafka 下发后由 IM Server 幂等执行并记录结果。 +4. 全局禁止加好友发布后,移动端所有加好友入口均受后端控制,不能绕过。 +5. 宵禁发布后,命中规则的单聊/私聊不能发送消息;配置群聊规则时群聊也必须受限。 +6. 登录成功、失败及退出均产生安全审计数据,且账号、口令和验证码等敏感信息不泄露。 + +### 1.1 本轮交付范围 + +| 页面/能力 | 本轮状态 | 测试结论口径 | +|---|---|---| +| 3.1 数据概览 | 已改造 | 本轮必测,需联调 Admin 与 IM | +| 3.3 用户分组 | Admin 已改造为用户策略分组 | 后台 CRUD 本轮必测;IM 策略消费待补齐 | +| 3.7 单聊消息 | 已改造 | 本轮必测,需验证 Kafka 处置闭环 | +| 3.8 群聊消息 | 已改造 | 本轮必测,需验证 Kafka 处置闭环 | +| 3.9 敏感词库 | Admin 管理端已改造 | 后台 CRUD 本轮必测;IM 运行时拦截待补齐 | +| 3.16 用户登录历史 | 已改造 | 本轮必测,数据来自 IM Server | +| 3.18 登录失败日志 | 已改造 | 本轮必测,包含加入 IP 黑名单、封禁用户 | +| 3.20 安全策略 | Admin 管理与发布已改造,IM 执行端未完成 | P0 验收目标;当前不得判定端到端通过 | +| 其余 3.2、3.4-3.6、3.10-3.15、3.17、3.19 | 原系统能力/后续改造 | 执行基础回归,不作为本轮新增功能全部交付的判定依据 | + +### 1.2 代码核查结论 + +本节是对当前代码的静态核查结论,不等同于测试环境已经执行通过: + +| 服务 | 已实现内容 | 当前缺口/测试关注点 | +|---|---|---| +| Admin Server | 数据概览、单聊/群聊消息查询与处置、用户登录历史、登录失败日志;用户策略分组、敏感词、安全策略和 IP 规则的管理页面、接口、权限与数据表 | 对 IM 的依赖异常、异步命令最终状态、租户隔离仍需按本文实测 | +| IM Server | 概览/趋势、单聊/群聊消息、登录成功/失败日志的 Internal Query;`message.hide`、`message.delete` 消费、幂等处置及动作落库;真实登录成功、失败和退出审计 | 未找到 `security.policy.updated`、用户策略分组、敏感词策略的运行时消费;未找到全局禁加好友、资料隐藏、二维码拦截和消息宵禁在 IM 请求链路的执行代码 | +| 联调结论 | 消息治理、概览和登录审计具备端到端测试基础 | 安全策略页面“发布成功”目前只能证明 Admin 保存并发出事件,不能证明移动端已生效;`FRIEND-*`、`CURFEW-*`、敏感词运行时用例预计失败或阻塞,须补齐 IM 实现后回归 | + +因此,测试报告必须分别记录“Admin 配置成功”“事件发布成功”“IM 接收成功”“移动端行为生效”四个结果,禁止合并为一个“发布成功”。 + +### 1.3 优先级与结果 + +| 标识 | 说明 | +|---|---| +| P0 | 核心链路或安全控制,失败时禁止上线 | +| P1 | 主要页面功能,失败时原则上阻断上线 | +| P2 | 辅助功能、兼容性或易用性问题 | +| 通过 | 实际结果与预期完全一致,接口、数据库及异步结果无异常 | +| 失败 | 页面、接口、数据库或 IM 最终行为任一不符合预期 | +| 阻塞 | 环境、账号、数据或依赖不可用,无法执行 | + +## 2. 测试准备 + +### 2.1 账号、权限与数据 + +准备以下数据,所有测试数据使用唯一前缀 `QA_YYYYMMDD_`: + +| 数据 | 用途 | +|---|---| +| 超级管理员 A | 验证全部菜单与操作权限 | +| 只读管理员 B | 仅授予列表、详情权限,验证写按钮隐藏和接口拒绝 | +| 租户管理员 C | 验证租户隔离,不得查看或操作其他租户数据 | +| 移动端用户 U1、U2、U3 | U1/U2 为普通用户,U3 用作好友或宵禁白名单 | +| 群组 G1 | U1、U2、U3 入群,用于群消息及群成员入口测试 | +| 消息样本 | 文本、图片、视频、语音、文件、名片、系统消息各至少 1 条 | +| 登录样本 | 密码成功、密码错误、账号不存在、账号禁用、退出各至少 1 条 | + +执行前确认: + +- Admin Server 的 IM Internal API 地址与共享密钥配置正确。 +- Admin Server 和 IM Server 的 Kafka broker、topic 和鉴权配置一致。 +- Admin 库已存在策略、敏感词、策略分组及消息命令相关表。 +- IM 库已存在 `im_user_login_logs`、`im_login_failure_logs`、`im_message_admin_actions`。 +- 浏览器清理旧缓存后重新登录,确认当前构建版本与待测提交一致。 + +### 2.2 通用检查 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| COM-001 | P0 | 使用超级管理员登录并展开 IM 管理菜单 | 已交付的 8 个功能域可见,页面可打开,无 404、白屏和控制台致命错误 | +| COM-002 | P0 | 使用只读管理员打开各页面 | 可查询和查看;新增、编辑、删除、发布、隐藏、封禁等无权限按钮不可见或禁用;直接请求写接口返回无权限 | +| COM-003 | P0 | 使用租户管理员查询列表、详情和导出 | 后端强制按当前租户过滤,修改 `tenantId` 不能越权读取或操作其他租户数据 | +| COM-004 | P1 | 对列表执行查询、重置、翻页、切换每页条数 | 查询条件生效;重置恢复默认值;总数和分页正确;每页超过 200 时接口明确报错 | +| COM-005 | P1 | 查询无匹配数据、模拟 IM 超时或不可用 | 空数据展示为空列表而非报错;依赖异常有明确提示,不把历史缓存伪装成实时成功 | +| COM-006 | P1 | 连续快速点击保存、发布、删除等按钮 | 提交期间按钮有加载/防重复状态,不产生重复记录或重复处置 | +| COM-007 | P1 | 检查新增写操作后的操作日志 | 操作者、租户、目标对象、动作、时间、结果和原因可追溯,不记录密码、验证码、Token 或完整敏感账号 | + +## 3. 页面测试用例 + +### 3.1 数据概览 + +当前按钮和字段:`刷新`,时间范围 `近7天/近14天/近30天`;指标包含总用户数、活跃用户、总群组数、总会话数、总消息数、今日消息;趋势表包含日期、新增用户、新增消息。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| DASH-001 | P0 | 打开数据概览 | 调用 `/imDashboard/overview` 和 `/imDashboard/trends`;六项指标展示真实数字,不显示 `undefined`、`NaN` 或静态占位值 | +| DASH-002 | P0 | 记录 IM 库统计结果后刷新页面 | 总用户、活跃用户、群组、会话、消息及今日消息与 IM Server 同口径统计一致 | +| DASH-003 | P1 | 分别选择近 7、14、30 天 | 趋势行数和日期区间正确,日期连续;切换后重新请求,新增用户和新增消息按日聚合 | +| DASH-004 | P1 | 在 IM 端新增用户并发送一条消息,再点刷新 | 对应总量和当日趋势增加;刷新按钮显示加载状态且不会重复并发请求 | +| DASH-005 | P1 | 检查跨天边界和无数据日期 | 按 `Asia/Shanghai` 自然日统计;无数据日期补 0,不遗漏日期 | +| DASH-006 | P0 | 使用租户管理员查看概览 | 只统计本租户数据;不能通过修改请求参数获得全局或其他租户数据 | + +### 3.2 用户管理 + +本页属于既有能力回归。重点回归查询、详情、状态修改、重置密码、IP 白名单、强制下线、注销、批量封禁、按 IP 封禁和导入。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| USER-001 | P1 | 按用户 ID、用户名、昵称、手机号、状态和时间查询 | 条件可组合,表格字段与详情一致,手机号等敏感字段按权限脱敏 | +| USER-002 | P1 | 新增或编辑用户,切换启用/禁用 | 校验必填项和唯一性;状态写入成功,禁用用户不能继续登录 | +| USER-003 | P1 | 执行重置密码、强制下线、注销 | 均需二次确认;成功后客户端会话失效;操作日志记录动作但不记录明文密码 | +| USER-004 | P1 | 执行批量封禁及按 IP 封禁 | 仅命中目标租户和目标用户;返回成功、跳过、失败数量,重复提交保持幂等 | + +### 3.3 用户分组 + +当前页面为“用户策略分组”,按钮包括新增/编辑、成员、启用/禁用、删除;成员弹窗支持添加成员、移除成员。表格重点字段:分组编码、分组名称、优先级、状态、版本、成员数、备注、更新时间。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| UPG-001 | P1 | 按租户、编码/名称关键字、状态查询并重置 | 条件生效;状态 `1=启用、2=禁用` 显示字典标签;成员数与成员关系表一致 | +| UPG-002 | P1 | 新增分组,填写合法编码、名称、优先级、策略 JSON、IP 白/黑名单 | 保存成功并生成版本;编码以字母开头且仅含字母、数字、下划线、中划线,长度 2-64 | +| UPG-003 | P1 | 输入数字开头、空名称、非法 JSON、超过 128 字名称 | 前后端拒绝保存并给出明确中文错误,数据库不产生脏数据 | +| UPG-004 | P1 | 编辑分组策略后保存 | 原记录更新,版本按实现规则变化;详情与列表一致;其他租户同编码记录不受影响 | +| UPG-005 | P1 | 点击状态开关切换启用/禁用 | 调用 `/imUserPolicyGroup/status`;页面状态即时刷新,非法状态值被后端拒绝 | +| UPG-006 | P1 | 打开成员列表,添加 U1,设置有效开始/结束时间 | 成员成功加入,成员数 +1;结束时间早于开始时间时拒绝保存 | +| UPG-007 | P1 | 重复添加同一用户,然后移除成员 | 不产生重复有效关系;移除后成员列表和成员数同步更新 | +| UPG-008 | P1 | 删除含成员的分组 | 显示二次确认;按业务约束拒绝删除或级联解除关系,不能遗留指向不存在分组的成员关系 | + +### 3.4 群组管理 + +本页属于既有能力回归,验证查询、详情、封禁/解封、解散、成员、文件和快速建群。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| GROUP-001 | P1 | 按群 ID、群号、群名、群主、状态查询 | 列表与 IM 群数据一致,分页及租户隔离正确 | +| GROUP-002 | P1 | 封禁 G1 后尝试群内发消息,再解封 | 封禁后发送被 IM Server 拒绝;解封后恢复;后台和客户端状态一致 | +| GROUP-003 | P1 | 查看成员、文件并删除一个群文件 | 只展示 G1 数据;删除需确认且客户端不可再访问已删除文件 | +| GROUP-004 | P1 | 快速创建群组并解散 | 成员、群主校验正确;解散后群不可继续使用,操作有审计记录 | + +### 3.5 群成员管理 + +本页属于既有能力回归,重点验证踢出、禁言、解除禁言,以及全局禁加好友时的群成员隐私联动。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| GMEM-001 | P1 | 查询 G1 成员并按用户筛选 | 角色、入群时间、禁言状态正确,只返回该群成员 | +| GMEM-002 | P1 | 对 U2 执行禁言、解除禁言、踢出 | 对应客户端行为立即生效;群主/管理员等受保护角色遵守业务限制 | +| GMEM-003 | P0 | 发布“全局禁止加好友”策略后,U1 在群成员列表点击 U2 | 不显示可识别的个人详情或个人名片,不显示加好友入口;抓包直接访问资料/加好友接口也被后端拒绝 | + +### 3.6 邀请码管理 + +本页属于既有能力回归。邀请码创建、删除、使用记录必须与全局好友开关联动。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| INV-001 | P1 | 创建邀请码并设置有效期、次数、状态 | 校验正确,创建成功后可查询,过期或耗尽后不可继续使用 | +| INV-002 | P1 | 查看使用记录并删除未使用邀请码 | 使用人、时间、状态正确;删除需确认,已删除邀请码失效 | +| INV-003 | P0 | 全局禁止加好友期间通过邀请码注册或自动加好友 | 注册能力按私域设置执行,但 `invite_auto_add` 入口必须被后端阻断,不能自动建立好友关系 | + +### 3.7 单聊消息 + +筛选项:租户、会话 Topic、发送用户 ID、接收用户 ID、内容类型、消息状态、内容关键词、发送时间。表格字段:Topic、消息序号、发送人、接收人、类型、内容摘要、状态、发送时间;按钮:详情、隐藏、删除、导出。 + +内容类型枚举:`text` 文本、`image` 图片、`video` 视频、`audio/voice` 语音、`file` 文件、`card` 名片、`system` 系统。状态枚举:`normal` 正常、`deleted` 后台删除、`recalled` 用户撤回、`blocked` 风控拦截、`hidden` 已隐藏。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| PM-001 | P0 | 打开页面并点击查询 | `/imPrivateMessage/list` 返回真实 IM 消息;单聊双方、Topic、Seq、类型、摘要、状态和时间与 IM 库一致 | +| PM-002 | P1 | 组合发送人、接收人、类型、状态、关键词和时间查询 | 所有条件正确下传并组合生效;非法类型、非法状态、每页大于 200 被后端拒绝 | +| PM-003 | P1 | 点击详情 | `/imPrivateMessage/view` 返回消息头、完整内容、元数据、回复序号、@用户等;空 Topic 或 `seqId<=0` 被拒绝 | +| PM-004 | P0 | 点击隐藏,输入处置原因并确认 | 原因必填且不超过 500 字;Admin 生成唯一 command/event,返回待处理状态,Kafka 发布 `message.hide` | +| PM-005 | P0 | 等待隐藏命令完成后刷新并在移动端查看 | Admin 命令状态变为成功;IM `im_message_admin_actions` 有一条成功记录;列表状态为 `hidden`,普通客户端不再展示该消息 | +| PM-006 | P0 | 点击删除,确认后等待执行 | Kafka 发布 `message.delete`;IM 幂等落库并清除相关缓存;列表状态为 `deleted`,详情/客户端不能再获得可展示原文 | +| PM-007 | P0 | 使用同一 command/event 重放隐藏或删除命令 | 不重复执行、不新增重复有效记录,最终状态保持一致,并可追踪重复消费结果 | +| PM-008 | P1 | Kafka 不可用时执行隐藏/删除 | 页面明确提示失败或命令进入失败/待重试状态,不显示为已成功;恢复后可按设计重试 | +| PM-009 | P1 | 按当前筛选条件导出 | 导出字段含 Topic、Seq、双方 ID、类型、摘要、状态、发送时间;数据范围与筛选及租户权限一致 | + +### 3.8 群聊消息 + +筛选项:租户、群 Topic、发送用户 ID、内容类型、消息状态、关键词、发送时间。表格除公共消息字段外还应展示群名称、群号;按钮与单聊一致。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| GM-001 | P0 | 按 G1 Topic 查询 | `/imGroupMessage/list` 仅返回群消息,群名称、群号、发送人、Seq、类型、摘要和状态正确 | +| GM-002 | P1 | 按发送人、类型、状态、关键词、时间组合查询并查看详情 | 条件准确,详情完整;不能用单聊 Topic 越界取得群消息 | +| GM-003 | P0 | 隐藏一条群消息并在多个群成员客户端刷新 | `message.hide` 执行成功;所有普通成员均不可见,后台状态和 IM 动作记录一致 | +| GM-004 | P0 | 删除一条群消息 | `message.delete` 执行成功;群消息缓存被清除,各客户端不能继续通过历史缓存看到原内容 | +| GM-005 | P1 | 导出群消息 | 导出包含群名、Topic、Seq、发送人、类型、摘要、状态、时间,范围与查询一致 | +| GM-006 | P0 | 重放相同命令、乱序投递隐藏与删除命令 | IM Server 幂等处理;删除是最终不可见状态,不因迟到的隐藏命令恢复内容 | + +### 3.9 敏感词库 + +按钮:新增、编辑、启用/禁用、删除、批量导入、分组管理。筛选字段:租户、分组、敏感词、匹配类型、命中动作、风险等级、状态。 + +匹配枚举:`exact` 精确、`contains` 包含、`regex` 正则、`pinyin` 拼音。动作枚举:`replace` 替换、`block` 拦截、`audit_only` 仅审计、`manual_review` 人工审核。风险等级:`low/medium/high`。场景:单聊、群聊、用户资料、群资料、反馈、举报。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| SW-001 | P1 | 新建敏感词分组 | 编码格式、名称 1-128 字和状态校验正确;同租户编码保持唯一 | +| SW-002 | P1 | 新增敏感词并逐项选择匹配方式、动作、风险、场景 | 保存后列表字典显示正确;敏感词长度 1-255;至少选择一个生效场景 | +| SW-003 | P1 | 动作选择“替换”但不填替换文本 | 前后端均拒绝并提示“替换动作必须填写替换文本” | +| SW-004 | P1 | 输入非法匹配类型、动作、风险、状态或无效正则 | 后端拒绝;无效正则不得发布到 IM 运行时 | +| SW-005 | P1 | 切换启用/禁用并发送命中消息 | 禁用后不执行该词规则;启用后按所选场景和动作处理,后台状态与 IM 行为一致 | +| SW-006 | P1 | 批量导入含空行、重复词和已存在词的数据 | 单次仅允许 1-5000 条;返回 `inserted` 和 `skipped`,重复数据不产生多条记录 | +| SW-007 | P1 | 删除敏感词后再次发送命中内容 | 删除需确认;运行时规则及时失效,且操作有审计记录 | + +### 3.10 文件管理 + +本页属于既有能力回归。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| FILE-001 | P1 | 按上传人、文件类型、大小、来源、审核状态、时间查询 | 表格元数据、下载地址、引用对象与存储记录一致,租户隔离正确 | +| FILE-002 | P1 | 预览、下载、删除文件 | 权限校验正确;删除需确认,引用中的已删除文件不能继续匿名访问 | +| FILE-003 | P1 | 执行审核通过/拒绝及批量处置 | 状态流转正确,拒绝原因必填,相关消息或群文件同步更新 | + +### 3.11 用户反馈 + +本页属于既有能力回归。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| FB-001 | P1 | 按反馈类型、状态、优先级、用户和时间查询 | 查询、分页、详情及附件展示正确 | +| FB-002 | P1 | 修改处理状态、优先级和回复内容 | 状态流转符合约束,处理人和处理时间自动记录 | +| FB-003 | P2 | 批量处理并导出 | 成功/失败数量准确,导出范围与当前筛选一致 | + +### 3.12 操作日志 + +本页属于既有能力回归,也是所有后台写操作的审计入口。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| OPLOG-001 | P0 | 完成策略发布、消息删除、用户封禁后查询日志 | 三类操作均可按模块、操作者、目标和时间检索,结果、原因、请求 ID 完整 | +| OPLOG-002 | P0 | 检查请求参数与响应摘要 | 密码、验证码、Token、共享密钥、完整手机号/邮箱等不以明文出现 | +| OPLOG-003 | P1 | 只读管理员尝试越权写接口 | 记录拒绝结果及必要上下文,不记录敏感正文,不改变业务数据 | + +### 3.13 用户账号审计 + +本页属于既有能力回归。动作至少覆盖修改资料、修改密码、注销、管理员改用户名/状态、重置密码、更新安全权限、强制下线、强制注销、调整策略分组、批量封禁、按 IP 封禁。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| UAUD-001 | P1 | 分别触发用户自助和管理员账号变更 | 动作枚举显示中文,操作者类型、目标用户、变更前后摘要和时间正确 | +| UAUD-002 | P0 | 执行重置密码并查看审计详情 | 仅记录“已重置”等安全摘要,绝不保存新旧明文密码 | +| UAUD-003 | P1 | 调整 U1 策略分组 | 产生 `assign_policy_group` 审计,旧分组、新分组及管理员可追溯 | + +### 3.14 群事件审计 + +本页属于既有能力回归。事件包括建群、加入/退出/踢出、禁言/解禁、转让群主、增删管理员、改群名、封禁/解封和解散。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| GAUD-001 | P1 | 在 G1 触发踢人、禁言、改群名 | 对应事件各产生一条审计,操作人、目标人、群、时间和原因正确 | +| GAUD-002 | P1 | 系统执行群封禁或解散 | 操作人显示系统或实际管理员,事件类型与最终群状态一致 | + +### 3.15 管理员登录历史 + +本页属于 HotGo 既有能力回归。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| ALOGIN-001 | P1 | 管理员成功登录、失败登录、退出后查询 | 账号、IP、浏览器、系统、结果和时间正确;失败原因不泄露密码 | +| ALOGIN-002 | P1 | 使用日期、账号、IP、状态筛选并导出 | 列表和导出范围一致,普通管理员不能查看超出权限的数据 | + +### 3.16 用户登录历史 + +筛选项:租户、用户 ID、脱敏账号、登录方式、事件类型、客户端平台、IP、登录时间。表格字段:ID、用户、脱敏账号、登录方式、事件、客户端、版本、IP、地区、风险等级、登录时间、退出时间。 + +登录方式:`password` 密码、`sms_code` 短信验证码、`sso` 单点登录、`qr_code` 二维码、`token_refresh` 令牌刷新。事件:`login` 登录、`refresh` 刷新会话、`logout` 主动退出、`kicked` 被踢下线、`session_expired` 会话过期。平台:iOS、Android、Web、桌面端、未知。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| ULOGIN-001 | P0 | U1 密码登录成功后在后台查询 | IM `im_user_login_logs` 新增成功记录;后台展示用户、脱敏账号、会话、设备、平台、IP、请求 ID 和登录时间 | +| ULOGIN-002 | P0 | U1 主动退出后刷新列表 | 对应会话 `logout_at` 被更新或产生退出事件;退出时间不早于登录时间 | +| ULOGIN-003 | P1 | 分别模拟刷新会话、被踢、会话过期 | 事件字典和发生时间正确,同一 session 可串联追踪 | +| ULOGIN-004 | P1 | 按全部筛选项组合查询 | Admin 参数正确透传到 IM Internal API;结果按登录时间倒序,分页不重复不漏项 | +| ULOGIN-005 | P0 | 检查列表、详情、Admin/IM 日志及数据库 | 只保存账号掩码和不可逆哈希;密码、短信验证码、Token、Cookie 不得出现 | +| ULOGIN-006 | P1 | 导出当前筛选结果 | 含 ID、用户、脱敏账号、事件、平台、IP、地区、风险、原因、时间;租户隔离和脱敏规则不因导出而放宽 | + +### 3.17 IP 账号统计 + +本页属于后续改造/回归能力。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| IPSTAT-001 | P1 | 按 IP、账号数、风险和时间查询 | 同一 IP 关联账号数、成功/失败次数和最近时间统计准确 | +| IPSTAT-002 | P1 | 查看关联用户并按 IP 封禁 | 关联列表受租户隔离;封禁需确认并记录命中用户数、成功数和失败数 | + +### 3.18 登录失败日志 + +筛选项:租户、用户 ID、脱敏账号、失败类型、平台、IP、尝试时间。表格字段:ID、用户、脱敏账号、失败类型、原因、IP、地区、客户端、版本、风险等级、风控动作、尝试时间;按钮:详情、加入 IP 黑名单、封禁用户、导出。 + +失败类型:`password_error` 密码错误、`code_error` 验证码错误、`account_not_found` 账号不存在、`account_disabled` 账号已禁用、`rate_limited` 访问频率受限、`risk_blocked` 风控拦截。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| LFAIL-001 | P0 | 使用错误密码登录 U1 | IM `im_login_failure_logs` 新增 `password_error`;后台展示脱敏账号、IP、设备、平台、风险及尝试时间 | +| LFAIL-002 | P0 | 分别触发验证码错误、账号不存在、账号禁用、限频和风控拦截 | 失败类型、原因码、详情、风险动作正确;未知账号场景不泄露账号是否真实存在 | +| LFAIL-003 | P1 | 点击详情 | 根据日志 ID 返回完整安全上下文;`id<=0` 或跨租户 ID 被拒绝 | +| LFAIL-004 | P0 | 点击“加入 IP 黑名单”并确认 | IP 必填;在安全策略 IP 黑名单生成规则,不重复插入相同有效规则,后续登录按场景被阻断 | +| LFAIL-005 | P0 | 对有关联用户的记录点击“封禁用户”,填写原因 | 用户 ID 和原因必填;用户状态变为禁用,已有会话按设计失效,账号审计和操作日志可追溯 | +| LFAIL-006 | P1 | 对账号不存在、无关联用户的记录点击封禁 | 按钮不可用或接口明确提示无法关联用户,不得误封其他用户 | +| LFAIL-007 | P1 | 导出失败日志 | 结果与筛选一致,账号继续脱敏,不包含提交的密码、验证码和完整鉴权数据 | + +### 3.19 私域设置 + +本页属于既有/后续能力回归,重点验证注册与验证码开关不绕过安全策略。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| TENANT-001 | P1 | 修改短信验证码、邀请码注册等开关并保存 | 保存后重新进入值保持一致,移动端登录/注册入口按租户配置变化 | +| TENANT-002 | P0 | 关闭短信验证码并尝试短信登录、忘记密码、验证码注册 | 后端拒绝被关闭的流程,不能仅靠隐藏移动端按钮 | +| TENANT-003 | P0 | 全局禁加好友期间通过注册流程触发自动加好友 | 注册成功与否按私域设置判断,但任何自动加好友均被安全策略统一拒绝 | + +### 3.20 安全策略 + +页面包含“策略版本”和“IP 黑白名单”两个页签。策略按钮:新建草稿、编辑/查看、发布、恢复默认;策略内含好友控制、消息宵禁和其他 JSON 策略。IP 规则按钮:新增、编辑、删除。 + +发布状态:`draft` 草稿、`published` 已发布、`archived` 已归档、`failed` 发布失败。IP 规则类型:白名单/黑名单;动作:`allow` 放行、`challenge` 二次验证、`block` 阻断、`audit_only` 仅审计;场景:登录、注册、发消息、上传文件;风险:低、中、高。 + +#### 3.20.1 策略版本与发布 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| SEC-001 | P0 | 点击新建草稿,填写策略名称并保存 | `/imSecurityPolicy/save` 创建 `draft`;只有草稿可编辑,已发布版本只读 | +| SEC-002 | P0 | 修改草稿但不发布,在移动端验证旧策略 | 草稿不影响 IM 运行时,移动端继续执行最后一个已发布版本 | +| SEC-003 | P0 | 点击发布并确认 | 发布成功后当前版本为 `published`,原已发布版本归档;IM Server 收到策略更新并按新版本执行 | +| SEC-004 | P0 | 模拟 Kafka/IM 策略应用失败 | 不得提示完全成功;版本进入失败或可重试状态,运行时继续使用上一个有效版本 | +| SEC-005 | P1 | 点击恢复默认 | 仅创建一个默认草稿,不直接发布、不改变当前生效策略 | +| SEC-006 | P1 | 输入非法 JSON、空策略名或超过 500 字发布原因 | 前后端拒绝提交并明确指出错误字段 | + +#### 3.20.2 全局禁止加好友 + +关闭“允许加好友”时,后端必须强制设置:隐藏个人信息、禁止二维码、禁止群成员入口,并封锁 `search`、`profile_card`、`group_member`、`qrcode`、`invite_auto_add`、`recommendation` 六类入口。该限制必须由后端执行,移动端隐藏按钮不能作为唯一控制。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| FRIEND-001 | P0 | 关闭“允许加好友”并观察表单 | 隐藏资料、禁止二维码、禁止群成员入口自动开启且不可取消;六类入口全部选中 | +| FRIEND-002 | P0 | 仅保存草稿,不发布;U1 从用户搜索添加 U2 | 仍按旧策略执行,证明草稿不直接生效 | +| FRIEND-003 | P0 | 发布禁加好友策略;U1 从用户搜索添加 U2 | 页面不展示添加入口或提示“当前时段暂不允许添加好友”;直接调用加好友接口同样被拒绝 | +| FRIEND-004 | P0 | U1 打开 U2 个人名片并尝试添加 | 个人资料按策略隐藏,不展示可用个人名片及加好友入口;直接请求资料/好友接口不可绕过 | +| FRIEND-005 | P0 | U1 从 G1 群成员列表点击 U2 | 不显示 U2 可识别的个人信息、个人名片和加好友入口 | +| FRIEND-006 | P0 | U1 扫描 U2 二维码并构造二维码加好友请求 | 二维码入口不可用,后端拒绝建立好友关系 | +| FRIEND-007 | P0 | 使用邀请码注册并触发自动添加,再查看推荐联系人 | `invite_auto_add` 和 `recommendation` 均被阻断,不产生好友关系 | +| FRIEND-008 | P0 | 修改客户端请求绕过 UI、伪造入口类型或不传入口类型 | 后端按全局开关统一拒绝,不能因缺失/伪造入口字段放行 | +| FRIEND-009 | P0 | 将 U3 配置为例外用户(若本版本支持)并重复六入口测试 | 仅 U3 按明确白名单规则放行,U1/U2 仍被阻断;未实现例外时页面和接口不得暗示支持 | +| FRIEND-010 | P0 | 重新开启并发布,再从六入口验证 | 限制解除,资料展示及加好友恢复为常规权限;已被拒绝的历史请求不会自动补建好友关系 | + +#### 3.20.3 消息宵禁 + +规则字段:规则编码、名称、日期类型、作用范围、开始/结束时间、时区、状态、提示语、白名单用户 ID;支持每天、工作日、周末、自定义日期,范围为单聊/私聊或群聊。默认时区为 `Asia/Shanghai`,支持跨午夜时间段。 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| CURFEW-001 | P0 | 开启宵禁但不添加规则并保存 | 后端拒绝;开启时至少存在一条规则,且至少一条启用的单聊/私聊规则 | +| CURFEW-002 | P0 | 新增工作日 18:00-09:00、范围单聊、状态启用的规则并发布 | 保存和发布成功,规则编码合法且唯一,IM Server 使用 `Asia/Shanghai` 解释跨午夜区间 | +| CURFEW-003 | P0 | 命中宵禁时段,U1 向 U2 发送文本、图片、语音、文件和名片 | 所有单聊/私聊消息均由后端拒绝,不写入可投递消息;客户端显示配置的宵禁提示语 | +| CURFEW-004 | P0 | 在开始时刻前 1 秒、开始时刻、结束时刻前 1 秒、结束时刻发送 | 边界按统一规则执行;开始时刻进入限制,结束时刻解除,Admin 与 IM 时间口径一致 | +| CURFEW-005 | P0 | 分别验证每天、工作日、周末、自定义日期 | 仅命中日期生效;自定义日期为空或格式非法时拒绝保存 | +| CURFEW-006 | P0 | 将 U3 加入白名单,在命中时段由 U3 与 U1 发消息 | 白名单方向规则符合产品定义且保持一致;非白名单 U1/U2 仍被拒绝 | +| CURFEW-007 | P0 | 增加群聊范围规则并发布,在 G1 发消息 | 命中时群消息被后端拒绝;只有单聊规则时群聊不受影响 | +| CURFEW-008 | P0 | 禁用规则或关闭宵禁并发布 | 消息恢复发送;旧策略缓存及时失效,不需重启 IM Server | +| CURFEW-009 | P0 | 仅保存新的时间规则但不发布 | IM Server 继续执行最后已发布规则,不能提前生效 | +| CURFEW-010 | P0 | 客户端修改本地时间、时区或直接调用发消息接口 | 后端以策略时区和服务器可信时间判定,无法绕过宵禁 | + +#### 3.20.4 IP 黑白名单 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果及核验点 | +|---|---|---|---| +| IPRULE-001 | P1 | 新增黑名单规则,填写单 IP、阻断、登录场景、高风险 | 保存成功,列表字段及字典显示正确;登录请求从该 IP 发起时被阻断 | +| IPRULE-002 | P1 | 新增 CIDR 白名单并选择多个场景 | CIDR 校验正确,命中范围内 IP 按白名单优先级执行,其他场景不受影响 | +| IPRULE-003 | P1 | 输入非法 IP/CIDR、空场景、非法动作/风险/状态 | 后端拒绝且不产生记录 | +| IPRULE-004 | P1 | 编辑规则后重新触发对应场景 | 新规则生效,旧匹配缓存失效;详情和列表一致 | +| IPRULE-005 | P1 | 删除规则并确认 | 记录删除且运行时规则失效;取消确认不产生变化 | +| IPRULE-006 | P0 | 从登录失败日志加入同一 IP 黑名单 | 安全策略页可查询到规则;重复操作不产生冲突规则,审计来源可追踪 | + +## 4. Admin Server 与 IM Server 联调核验 + +### 4.1 接口映射 + +| 后台操作 | Admin Server | IM Server Internal / Kafka | +|---|---|---| +| 概览指标 | `GET /imDashboard/overview` | `GET /internal/admin-query/tenants/{tenant}/dashboard/overview` | +| 趋势数据 | `GET /imDashboard/trends` | `GET /internal/admin-query/tenants/{tenant}/dashboard/trends` | +| 单聊列表 | `GET /imPrivateMessage/list` | `GET /internal/admin-query/tenants/{tenant}/private-messages` | +| 群聊列表 | `GET /imGroupMessage/list` | `GET /internal/admin-query/tenants/{tenant}/group-messages` | +| 消息详情 | `GET /imPrivateMessage/view` 或 `/imGroupMessage/view` | `GET /internal/admin-query/tenants/{tenant}/messages/{topic}/{seq}` | +| 隐藏/删除消息 | `POST .../hide`、`POST .../delete` | Kafka `message.hide`、`message.delete` | +| 用户登录历史 | `GET /imUserLoginHistory/list` | `GET /internal/admin-query/tenants/{tenant}/user-login-logs` | +| 登录失败列表/详情 | `GET /imLoginFailureLog/list|view` | `GET /internal/admin-query/tenants/{tenant}/login-failure-logs[/{id}]` | + +### 4.2 数据库抽查 + +以下 SQL 仅用于测试环境核验,查询条件必须带测试租户或测试数据标识: + +```sql +-- IM 登录成功/退出审计 +SELECT id, tenant_id, user_id, account_mask, login_type, event_type, + session_id, platform, ip, risk_level, login_at, logout_at +FROM im_user_login_logs +WHERE tenant_id = ? AND user_id = ? +ORDER BY id DESC LIMIT 20; + +-- IM 登录失败审计 +SELECT id, tenant_id, user_id, account_mask, failure_type, reason, + ip, platform, risk_level, risk_action, attempt_at +FROM im_login_failure_logs +WHERE tenant_id = ? AND ip = ? +ORDER BY id DESC LIMIT 20; + +-- IM 消息后台处置结果 +SELECT tenant_id, command_id, event_id, action, topic, seq_id, + status, reason, created_at, updated_at +FROM im_message_admin_actions +WHERE tenant_id = ? AND topic = ? AND seq_id = ? +ORDER BY id DESC; +``` + +核验要求:同一幂等键不得产生多条有效执行;失败必须保留错误状态和可追踪信息;表中不得出现密码、验证码、Token 或完整认证请求。 + +## 5. 非功能与异常测试 + +| 用例 ID | 优先级 | 操作步骤 | 预期结果 | +|---|---|---|---| +| NFT-001 | P1 | 列表准备 10 万级数据并执行常用筛选 | 首屏和翻页在项目约定时间内返回,无全表扫描导致的持续高负载 | +| NFT-002 | P1 | 并发 20 次概览、消息、登录审计查询 | 服务稳定,连接池无耗尽,租户数据不串读 | +| NFT-003 | P0 | 伪造 Internal API 请求、错误共享密钥或重放过期请求 | IM Server 拒绝未授权请求并记录安全日志,不返回业务数据 | +| NFT-004 | P0 | 在 Topic、关键词、原因、备注中输入 SQL/XSS 字符串 | 使用参数化查询;页面转义展示,不执行脚本,不污染导出文件 | +| NFT-005 | P1 | Admin 与 IM 短时网络中断后恢复 | 查询可恢复;异步命令状态可追踪,不丢命令、不重复执行 | +| NFT-006 | P1 | 检查 PC 常用分辨率及窄屏 | 搜索区、表格、弹窗和操作列无重叠;横向滚动可用,按钮文本不被截断为不可辨识状态 | + +## 6. 上线验收门槛 + +满足以下条件方可通过本轮验收: + +1. 所有 P0 用例通过,尤其是 `FRIEND-*`、`CURFEW-*`、消息处置、登录审计和租户隔离。 +2. P1 无阻断主流程的问题;遗留问题有负责人、修复版本和回归日期。 +3. Admin Server、IM Server、Kafka 和两侧数据库结果一致,不以页面提示代替最终状态核验。 +4. 全局禁加好友和宵禁均通过“直接调用后端接口”的绕过测试。 +5. 数据库、应用日志、操作日志和导出文件均通过敏感信息检查。 +6. 其余未纳入本轮改造的页面必须完成基本回归,但不应据此宣称 20 个页面已全部完成重构。 + +## 7. 执行记录 + +| 字段 | 内容 | +|---|---| +| Admin Server 版本/提交 | `16cc456`(功能提交:`bb87d01`) | +| IM Server 版本/提交 | `b92154b`(功能提交:`6b20a63`) | +| 前端版本/构建时间 | | +| 测试环境 | `https://admin.118im.com` | +| 测试租户 | | +| 测试人员 | | +| 执行日期 | | +| P0 通过/失败/阻塞 | | +| P1 通过/失败/阻塞 | | +| 缺陷链接 | | +| 最终结论 | 通过 / 有条件通过 / 不通过 | diff --git a/docs/admin-adfahi-research/测试问题记录-2026-09-07.md b/docs/admin-adfahi-research/测试问题记录-2026-09-07.md new file mode 100644 index 0000000..e75a6b2 --- /dev/null +++ b/docs/admin-adfahi-research/测试问题记录-2026-09-07.md @@ -0,0 +1,353 @@ +# 鸽达达管理后台测试问题记录 + +> 测试环境:`https://admin.118im.com/admin#/dashboard/console` +> 执行日期:2026-09-07 +> 参考用例:`测试用例文档.md` +> 测试方式:浏览器黑盒测试、HTTP 只读探测、测试库只读核验、当前代码静态核查 + +## 1. 执行摘要 + +| 项目 | 结果 | 说明 | +|---|---|---| +| 测试站点可达性 | 通过 | `/admin/` 返回 HTTP 200,响应约 0.2-0.5 秒 | +| 前端功能部署 | 通过 | 部署包包含 `imDashboard`、`imPrivateMessage`、`imGroupMessage`、`imUserLoginHistory`、`imLoginFailureLog`、`imSecurityPolicy`、`imSensitiveWord`、`imUserPolicyGroup` | +| Admin 新接口路由 | 通过 | 8 个列表/概览接口均已注册;无登录态请求统一返回业务码 `61`“登录身份已失效” | +| 浏览器页面操作 | 已恢复 | 已接管用户登录标签页,完成 8 个新增菜单及查询/刷新按钮的只读复测 | +| Admin 测试库结构 | 已修复 | 已在指定测试库 `hotgo` 创建 7 张策略相关表、8 个页面菜单及按钮权限 | +| IM 测试库结构 | 通过 | `tinode` 已存在 3 张新增审计/处置表 | +| IM 登录审计数据 | 部分通过 | IM Internal API 已恢复,成功登录历史返回 77 条;失败日志返回成功空列表,仍缺少失败场景验收数据 | +| Kafka Admin 命令 | 风险 | 13 条历史命令中 12 条长期待处理、1 条死信,需检查消费链路 | +| 消息隐藏/删除 | 未执行 | `im_message_admin_actions` 为 0;尚未准备专用消息数据,且 Kafka 命令链路存在长期积压风险 | +| 好友总开关/宵禁 | 未通过实现核查 | Admin 可配置和发布,但当前 IM Server 未找到运行时消费及拦截代码 | + +当前结论:**不通过。页面菜单和 Admin 本地策略表已可用,但 IM Internal 查询接口不可用,真实概览、消息和登录日志无法展示;IM 运行时策略执行也尚未完成。** + +## 2. 问题清单 + +### BUG-001 Admin 测试库缺少策略相关表 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 数据库部署/环境配置 | +| 状态 | 已修复并完成只读页面复测 | +| 关联用例 | `UPG-*`、`SW-*`、`SEC-*`、`FRIEND-*`、`CURFEW-*`、`IPRULE-*` | + +**操作步骤** + +1. 连接用户指定测试数据库 `64.118.137.182:53306`。 +2. 查询 `information_schema.tables` 中 `hotgo` 库的本轮新增表。 + +**实际结果** + +以下表均不存在: + +- `im_security_policy` +- `im_message_curfew_rules` +- `im_security_ip_rules` +- `im_sensitive_word_groups` +- `im_sensitive_words` +- `im_user_policy_groups` +- `im_user_policy_group_members` + +**预期结果** + +测试环境 Admin Server 所连接的数据库应包含全部新增表,并已执行初始化数据。 + +**影响** + +若线上 Admin Server 确实连接此 `hotgo` 库,用户策略分组、敏感词、安全策略、宵禁和 IP 规则页面会查询失败或无法保存。如果 Admin Server 实际连接其他数据库,则说明当前提供的测试库地址与部署配置不一致,需要明确真实连接库后重新核验。 + +**建议** + +核对 Admin Server 生产配置中的数据库 DSN;确认无误后执行已提交的 Admin 建表 SQL,并对表结构、索引和初始化数据做一次部署校验。 + +**处理记录(2026-09-07)** + +已在 `hotgo` 执行 `docs/admin-adfahi-research/sql/Admin-Server改造.sql`。7 张表均创建成功,`im_security_policy` 已初始化 1 条全局默认已发布策略,其余新增业务表初始为空。页面复测中,安全策略正确展示该默认策略,敏感词和用户分组正确展示空列表;写操作尚未执行。 + +### BUG-002 测试库缺少 8 个新增页面菜单记录 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 菜单/权限初始化 | +| 状态 | 已修复并完成超级管理员复测 | +| 关联用例 | `COM-001`、`COM-002` | + +**实际结果** + +在 `hotgo.hg_admin_menu` 中未查询到以下菜单: + +- `imDashboardIndex` +- `imPrivateMessageIndex` +- `imGroupMessageIndex` +- `imUserLoginHistoryIndex` +- `imLoginFailureLogIndex` +- `imUserPolicyGroupIndex` +- `imSensitiveWordIndex` +- `imSecurityPolicyIndex` + +部署前端包已经包含对应页面模块,后端接口也已注册,说明代码已部署但数据库菜单初始化可能未执行。 + +**预期结果** + +超级管理员登录后可在 IM 管理下看到 8 个页面;功能权限记录完整,普通角色按授权显示按钮。 + +**建议** + +确认部署库后执行: + +- `server/storage/data/generate/im_adfahi_batch2_menu.sql` +- `server/storage/data/generate/im_adfahi_policy_menu.sql` + +执行后分别用超级管理员、只读管理员和租户管理员回归菜单及按钮权限。 + +**处理记录(2026-09-07)** + +已执行 `im_adfahi_policy_menu.sql` 和 `im_adfahi_batch2_menu.sql`。8 个页面菜单均启用并挂载到 `imManager`(ID 2431)下;页面与按钮权限合计 43 条。用户需重新登录或刷新权限缓存后复测菜单显示。 + +**页面复测(2026-09-07)** + +超级管理员展开“IM管理”后,数据概览、用户分组、单聊消息、群聊消息、用户登录历史、登录失败日志、敏感词库、安全策略 8 个页面菜单均可见并可打开。`COM-001` 的菜单可见性通过;只读管理员和租户管理员权限仍需使用对应账号复测。 + +### BUG-003 Kafka Admin 命令存在长期 Pending 和 Dead 记录 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | Kafka/异步命令链路 | +| 状态 | 待处理 | +| 关联用例 | `PM-004` 至 `PM-008`、`GM-003` 至 `GM-006`、`NFT-005` | + +**实际结果** + +`hotgo.im_admin_commands` 共 13 条记录: + +| 事件类型 | 状态 | 数量 | +|---|---:|---:| +| `user.password.reset` | `pending` | 12 | +| `user.password.reset` | `dead` | 1 | + +最新死信记录时间为 2026-08-28,原因 `RESOURCE_NOT_FOUND`,消息 `not found`。其余 Pending 记录同样创建于 2026-08-28,长期未进入成功或失败终态。 + +**预期结果** + +命令应在约定时间内进入 `succeeded`、`failed` 或 `dead` 终态;Pending 不应长期积压。死信应可查看、重放、忽略或解决。 + +**影响** + +消息隐藏/删除、用户治理等 Kafka 命令可能出现后台提示已提交但 IM 不执行,属于治理能力失效风险。 + +**建议** + +检查 Admin outbox worker、Kafka topic、consumer group、IM consumer 启动配置及结果回传 topic;处理历史积压后再执行消息隐藏/删除端到端测试。 + +### BUG-004 IM Server 尚未执行安全策略、敏感词和用户策略分组 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 功能未完成 | +| 状态 | 待开发 | +| 关联用例 | `SW-005`、`SW-007`、`FRIEND-*`、`CURFEW-*`、`IPRULE-*` | + +**代码核查结果** + +Admin Server 已实现策略数据保存、发布和 `security.policy.updated` 等事件写出;当前 IM Server 仅发现 `message.hide`、`message.delete` 等 Admin 命令消费,未发现: + +- `security.policy.updated` 事件消费与策略缓存刷新。 +- 全局禁止加好友在用户搜索、个人名片、群成员、二维码、邀请自动添加、推荐联系人六类入口的后端校验。 +- 禁加好友时个人资料、个人名片的隐藏处理。 +- 消息发送路径中的宵禁日期、时区、跨午夜、范围和白名单判定。 +- 敏感词及用户策略分组在 IM 运行时的加载与执行。 + +**预期结果** + +后台发布后,IM Server 接收事件、加载对应版本,并在实际移动端请求链路强制执行;直接调用接口也不能绕过。 + +**影响** + +后台可以显示“发布成功”,但移动端行为不会变化。全局禁止加好友和宵禁属于明确要求,未完成时禁止上线。 + +### DATA-001 IM 测试数据不足,无法覆盖失败日志和消息处置 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P1 | +| 类型 | 测试数据 | +| 状态 | 待准备 | +| 关联用例 | `PM-*`、`GM-*`、`LFAIL-*` | + +**实际结果** + +- `tinode.im_user_login_logs`:43 条。 +- `tinode.im_login_failure_logs`:0 条。 +- `tinode.im_message_admin_actions`:0 条。 + +登录日志分布以 `password/login` 和 `token_refresh/login` 为主,尚不足以覆盖主动退出、被踢、会话过期等事件。账号掩码和 metadata 的只读抽查未发现手机号全明文或密码、验证码、Token 键值。 + +**建议** + +准备专用测试账号,分别触发密码错误、验证码错误、账号不存在、账号禁用、限频和风控拦截;准备单聊和群聊消息后执行隐藏、删除及幂等重放。 + +### BUG-005 IM Internal 查询接口返回 resource not found + +| 字段 | 内容 | +|---|---| +| 严重级别 | P0 | +| 类型 | 服务部署/路由联调 | +| 状态 | 已修复,接口复测通过 | +| 关联用例 | `DASH-001`、`DASH-002`、`PM-001`、`GM-001`、`ULOGIN-001`、`ULOGIN-004`、`LFAIL-001` | + +**操作步骤** + +1. 使用超级管理员进入“IM管理”。 +2. 打开数据概览并点击“刷新”。 +3. 分别进入单聊消息、群聊消息、用户登录历史、登录失败日志并点击“查询”。 + +**实际结果** + +- 数据概览六项指标全部显示 0,趋势表显示“无数据”,页面提示 `resource not found`。 +- 单聊消息、群聊消息、用户登录历史、登录失败日志均显示 0 条,查询后提示 `resource not found`。 +- 浏览器控制台在每次查询后记录同名接口错误。 +- 数据库已有 `tinode.im_user_login_logs` 43 条,因此用户登录历史页面的 0 条不是正常空数据。 + +**预期结果** + +Admin Server 应能调用 IM Server Internal API,概览、消息及登录日志返回真实数据;确实无数据的接口应返回成功的空列表,不能返回资源不存在。 + +**根因定位(2026-09-07)** + +- 当前 IM Server 源码已注册 `/internal/admin-query/`,并已实现 dashboard overview/trends、private-messages、group-messages、user-login-logs、login-failure-logs 路由,不是五个查询分别缺少实现。 +- `authorizeInternalAPI` 在 `internal_api.enabled=false` 时固定返回 HTTP 404、业务码 `30004`、消息 `resource not found`;该响应与页面错误完全一致。 +- 对测试环境 `https://api.118im.com/internal/admin-query/tenants/probe/dashboard/overview` 进行未签名只读探测,实际返回 HTTP 404、业务码 `30004`、消息 `resource not found`。若 Internal API 已启用,同一未签名请求应返回 HTTP 401、消息 `internal signature required`。 +- Gitea Actions 第 66 次部署(run 99,提交 `b92154b`)日志显示工作流调用 `deploy.sh` 时已经设置 `INTERNAL_API_ENABLED=true`,且部署成功切换到 green 实例;但部署后的公开实例仍表现为 disabled。 + +由此确认:提交 `c68c5ac` 将 Docker 配置模板中原本固定启用的 Internal API 改成了额外环境开关,但现有蓝绿部署并不会把这些新增变量注入容器,导致容器继承镜像默认值 `false`。这是新增启动配置造成的部署回归,不应要求部署机再增加一组开关配置。 + +**建议** + +已将 `docker/tinode/config.template` 恢复为改造前的启动方式:Internal API 固定开启、签名窗口固定 300 秒、调用方固定为 `hotgo-admin`,继续复用原有共享密钥配置;同时移除部署工作流中新增的开关、窗口和服务名变量。部署机无须增加 Compose 配置。 + +重新构建并部署 IM Server 后,先执行未签名探测:预期由当前的 HTTP 404 `resource not found` 变为 HTTP 401 `internal signature required`;再使用正确签名验证 overview、trends、private/group message、login history、login failure 六类接口,最后回归后台页面。 + +**接口复测(2026-09-07,IM `35da302`)** + +- Gitea Actions run 100 部署成功,线上未签名探测由 HTTP 404 变为预期的 HTTP 401 `internal signature required`。 +- 正确签名请求全部返回 HTTP 200、业务码 `0`:概览返回总用户 211、活跃用户 12、群组 95、会话 197、消息 2182、今日消息 167;7 日趋势返回 7 行。 +- 单聊列表返回 1528 条,群聊列表返回 654 条;分页、关键词筛选及首条消息详情均通过。 +- 用户登录历史返回 77 条;登录方式、事件、脱敏账号及组合筛选均通过。 +- 登录失败日志返回 HTTP 200 的空列表,总数 0,属于当前无测试数据,不再是接口错误。 +- 不存在租户正确返回 HTTP 404、业务码 `30010`、`tenant unavailable`。 +- Admin Server 的 IM 签名客户端和字段转换相关 Go 测试通过。 + +### BUG-006 新页面调用的字典 Store API 与部署版本不兼容 + +| 字段 | 内容 | +|---|---| +| 严重级别 | P1 | +| 类型 | 前端兼容性 | +| 状态 | 已发布,静态包复测通过,待登录态页面点击确认 | +| 关联用例 | `UPG-001`、`SW-002`、`SEC-001`、`COM-004` | + +**实际结果** + +- 用户分组页面加载及点击“查询”时抛出 `TypeError: D.getOptions is not a function`。 +- 敏感词库页面抛出 3 次 `c.getOptions is not a function`。 +- 安全策略页面抛出 `q.getOptions is not a function`。 +- 安全策略表格仍能显示 1 条默认策略,敏感词和用户分组仍能显示空列表,但字典筛选项和后续编辑表单存在失效风险。 + +**预期结果** + +页面应通过当前项目字典 Store 的公开方法加载选项,控制台无异常;状态、类型、动作、风险等级等枚举应正确展示和筛选。 + +**建议** + +已对照当前 `useDictStore` 实际 API,将用户分组、敏感词、安全策略页面统一改为 `dict.getOption()`;额外排查并修复 IM 文件策略同类调用,需过滤选项时使用 `dict.getOptionUnRef()`。源码扫描已无业务页面调用 `dict.getOptions()`,ESLint 和生产构建通过。重新发布 Admin 前端后回归筛选、编辑弹窗和字典标签。 + +**发布复测(2026-09-07,Admin `7839dce`)** + +- Gitea Actions run 103 发布成功,线上入口包已切换到 `index-Ch8uxbvK.js`。 +- 用户分组、敏感词、安全策略、文件策略四个线上动态页面包均可下载,且均不再包含 `getOptions` 调用,确认不是旧分包或 CDN 缓存。 +- 十条相关 Admin 路由的未登录请求均返回业务码 `61`,鉴权边界正常。 +- 本轮无法取得内置浏览器已登录标签页的会话控制,筛选下拉、编辑弹窗和写操作仍需登录态页面点击确认,不能仅凭静态包核对标记为完整通过。 + +### BLOCK-001 浏览器安全检查阻塞自动化页面测试 + +| 字段 | 内容 | +|---|---| +| 严重级别 | 环境阻塞,非产品缺陷 | +| 状态 | 已解除 | +| 关联用例 | 所有需要登录态和页面点击的用例 | + +已识别到 Codex 内置浏览器中的测试环境标签页,页面标题为“主控台”,URL 和登录状态由用户确认。但自动化接管域名时,Codex 浏览器安全检查返回“permission request could not complete”,因此未读取或复用登录 Token,也未绕过安全控制。 + +恢复浏览器授权后,应从 `COM-001` 开始继续,并优先执行只读查询。涉及删除消息、删除规则等操作时仅使用专用测试数据。 + +**复测记录(2026-09-07,建表后)** + +再次读取内置浏览器状态,测试标签页仍为“主控台”且 URL 正确;调用浏览器接管接口时仍返回 `Auto-review could not complete for https://admin.118im.com`。本次复测未绕过安全控制,页面用例继续标记为阻塞。 + +**解除记录(2026-09-07)** + +再次连接后浏览器授权恢复,已成功接管同一登录标签页并完成菜单展开、页面切换、查询/刷新和表格读取。该项不再阻塞后续自动化测试。 + +## 3. 已执行用例 + +| 执行 ID | 对应测试 | 结果 | 实际结果 | +|---|---|---|---| +| RUN-001 | 站点可达性 | 通过 | `/admin/` HTTP 200,HTML 和静态资源可加载 | +| RUN-002 | 前端部署完整性 | 通过 | 8 个新增页面模块均存在于部署 JS 包 | +| RUN-003 | 未登录接口鉴权 | 通过 | 8 条接口统一返回业务码 `61`,未泄露业务数据 | +| RUN-004 | COM-001 菜单检查 | 通过 | 超级管理员可见并可打开 8 个新增页面;其他角色权限待测 | +| RUN-005 | Admin 新表检查 | 失败 | 7 张策略相关表缺失 | +| RUN-006 | IM 新表检查 | 通过 | 3 张表均存在 | +| RUN-007 | ULOGIN-001 数据检查 | 失败 | 数据库有 43 条登录记录,后台查询为 0 条并提示 `resource not found` | +| RUN-008 | ULOGIN-005 敏感字段抽查 | 通过 | 当前 SQL 规则未发现可疑明文记录 | +| RUN-009 | LFAIL-001 至 LFAIL-007 | 部分阻塞 | 页面可打开和查询,但接口返回 `resource not found`;失败日志表为 0 条,缺少动作数据 | +| RUN-010 | PM/GM 消息处置 | 阻塞 | 动作表为 0 条,Kafka 另有长期积压风险 | +| RUN-011 | FRIEND/CURFEW | 失败(实现核查) | IM 运行时执行代码未完成 | +| RUN-012 | BUG-001 建表复测 | 通过 | 7 张 Admin 策略表均存在,默认安全策略 1 条 | +| RUN-013 | BUG-002 菜单复测 | 通过 | 8 个页面菜单、43 条页面/按钮权限存在且状态启用 | +| RUN-014 | BLOCK-001 浏览器接管复测 | 阻塞 | 建表后重试仍被 Codex 域名安全检查拒绝,未执行页面点击 | +| RUN-015 | BLOCK-001 浏览器接管再次复测 | 通过 | 授权恢复,已完成登录态页面操作 | +| RUN-016 | DASH-001、DASH-002 | 失败 | 刷新后六项指标均为 0,趋势无数据,接口返回 `resource not found` | +| RUN-017 | UPG-001 | 失败 | 表格正确显示 0 条,但查询触发 `getOptions is not a function` | +| RUN-018 | PM-001 | 失败 | 单聊消息显示 0 条,查询接口返回 `resource not found` | +| RUN-019 | GM-001 | 失败 | 群聊消息显示 0 条,查询接口返回 `resource not found` | +| RUN-020 | ULOGIN-001、ULOGIN-004 | 失败 | 数据库 43 条,页面为 0 条,查询接口返回 `resource not found` | +| RUN-021 | LFAIL-001 页面查询 | 失败 | 页面可操作,但查询接口返回 `resource not found`;暂无失败登录测试数据 | +| RUN-022 | SW 页面只读查询 | 部分通过 | 正确返回 0 条;控制台存在 3 个字典 `getOptions` 异常 | +| RUN-023 | SEC 页面只读查询 | 部分通过 | 正确展示默认已发布策略 1 条;控制台存在字典 `getOptions` 异常 | +| RUN-024 | BUG-005 Internal API 启用复测 | 通过 | 未签名请求返回预期的 HTTP 401,不再返回 `resource not found` | +| RUN-025 | DASH-001、DASH-002 接口复测 | 通过 | 概览返回 211 用户、95 群组、2182 消息;7 日趋势返回 7 行 | +| RUN-026 | PM-001、GM-001 接口复测 | 通过 | 单聊 1528 条、群聊 654 条;分页、关键词和详情查询通过 | +| RUN-027 | ULOGIN 接口复测 | 通过 | 登录历史 77 条;登录方式、事件、脱敏账号及组合筛选通过 | +| RUN-028 | LFAIL-001 接口复测 | 部分通过 | 接口成功返回空列表;仍需准备失败登录数据验证详情及处置按钮 | +| RUN-029 | BUG-006 代码复测 | 通过 | 4 个页面同类字典调用已修复,ESLint 和生产构建通过;待发布后页面复测 | +| RUN-030 | BUG-006 发布包复测 | 部分通过 | run 103 发布成功,4 个线上动态页面包无 `getOptions`;待登录态页面点击确认 | + +## 4. 后续复测顺序 + +1. 回归 BUG-005 对应后台页面,确认概览卡片和四个列表正确显示接口数据。 +2. 发布 BUG-006 前端修复并回归筛选、编辑弹窗和字典标签。 +3. 修复 Kafka Pending/Dead 链路,验证命令可进入终态。 +4. 补齐 IM Server 安全策略、好友入口、宵禁和敏感词运行时实现。 +5. 使用对应角色账号回归租户隔离、按钮权限、筛选、详情、分页和导出。 +6. 使用专用数据执行新增、编辑、发布、隐藏、删除、IP 黑名单和封禁用户。 +7. 使用移动端完成好友六入口、直接接口绕过、宵禁边界及白名单验收。 + +## 5. 复测记录 + +| 日期 | 版本 | 问题编号 | 复测结果 | 证据/备注 | 测试人 | +|---|---|---|---|---|---| +| 2026-09-07 | Admin `16cc456` | BUG-001 | 通过 | 7 张表存在,默认策略 1 条 | Codex | +| 2026-09-07 | Admin `16cc456` | BUG-002 | 通过 | 8 个菜单、43 条权限启用 | Codex | +| 2026-09-07 | Admin `16cc456` | BLOCK-001 | 阻塞 | 浏览器域名安全检查仍不可用 | Codex | +| 2026-09-07 | 测试环境部署版本 | BLOCK-001 | 通过 | 浏览器授权恢复并完成页面点击 | Codex | +| 2026-09-07 | 测试环境部署版本 | BUG-005 | 失败 | 5 类 IM 数据页面和概览统一返回 `resource not found` | Codex | +| 2026-09-07 | 测试环境部署版本 | BUG-006 | 失败 | 用户分组、敏感词、安全策略存在字典 Store API 异常 | Codex | +| 2026-09-07 | IM `35da302` / run 100 | BUG-005 | 通过 | 六类签名查询均为 HTTP 200;分页、筛选和消息详情通过 | Codex | +| 2026-09-07 | Admin 待发布版本 | BUG-006 | 代码通过 | 修复 4 个页面的字典 Store API 调用,生产构建通过 | Codex | +| 2026-09-07 | Admin `7839dce` / run 103 | BUG-006 | 部分通过 | 线上入口及 4 个动态页面包已更新,无错误方法调用;待登录态页面确认 | Codex | diff --git a/docs/admin-adfahi-research/需求文档.md b/docs/admin-adfahi-research/需求文档.md new file mode 100644 index 0000000..cd70c8e --- /dev/null +++ b/docs/admin-adfahi-research/需求文档.md @@ -0,0 +1,1462 @@ +# 鸽达达管理后台复刻需求文档 + +调研对象:https://admin.adfahi.cn +调研时间:2026-09-04 +当前登录账号视角:`heiyue` +目标:在本项目中复刻一套功能与页面结构一致的 IM 管理后台。 + +> 说明:本次通过内置浏览器逐项点击左侧菜单,进入页面后对有查询按钮的页面执行了“查询/查 询”,再采集当前可视区域截图、可见字段、表格列、操作按钮,并下载前端 bundle 反查接口路径。浏览器只读执行环境屏蔽了 `fetch/XMLHttpRequest`,因此未能直接读取接口 JSON body;本文档中的返回字段以页面查询后的渲染结果和前端接口定义反推。 + +## 0. 本次修订说明 + +根据补充要求,已做以下修订: + +- 截图从原先整页长图改为“当前可视区域截图”,减少大面积空白,更接近实际浏览器视图。 +- 每个列表页进入后均点击查询按钮;`群成员管理` 因群号必填,使用群组列表中已有群号 `68127154` 查询,已展示成员数据。 +- 单聊消息、群聊消息、文件管理、日志审计、用户登录、IP 统计、登录失败日志等页面查询后均展示了数据。 +- 邀请码管理已查询到数据;敏感词库、用户反馈当前查询结果为空,需求文档中按“需支持空态”记录。 +- 文档已在每个 `3.x` 页面章节内补充接口字段、查询参数、返回字段、按钮功能、表格字段、枚举、业务动作、危险操作确认、导入导出、分页和空态说明。 +- 新截图保存在 `docs/admin-adfahi-research/screenshots-queried/`。 + +## 1. 总体信息架构 + +左侧菜单分为 5 个一级模块,当前账号可见 20 个页面: + +| 一级模块 | 页面 | 前端路由 | +| --- | --- | --- | +| 数据概览 | 数据概览 | `/dashboard` | +| 用户与群组 | 用户管理 | `/users` | +| 用户与群组 | 用户分组 | `/user-policy-groups` | +| 用户与群组 | 群组管理 | `/groups` | +| 用户与群组 | 群成员管理 | `/group-members` | +| 用户与群组 | 邀请码管理 | `/invite-codes` | +| 内容审核 | 单聊消息 | `/private-messages` | +| 内容审核 | 群聊消息 | `/group-messages` | +| 内容审核 | 敏感词库 | `/sensitive-words` | +| 内容审核 | 文件管理 | `/file-records` | +| 内容审核 | 用户反馈 | `/feedbacks` | +| 日志审计 | 操作日志 | `/audit-logs` | +| 日志审计 | 用户账号审计 | `/user-account-audit` | +| 日志审计 | 群事件审计 | `/group-events` | +| 日志审计 | 管理员登录 | `/admin-login-history` | +| 日志审计 | 用户登录 | `/user-login-history` | +| 日志审计 | IP 账号统计 | `/ip-user-stats` | +| 日志审计 | 登录失败日志 | `/login-failure-logs` | +| 系统设置 | 私域设置 | `/tenant-settings` | +| 系统设置 | 安全策略 | `/security-settings` | + +菜单总览截图: + +![菜单总览](screenshots-queried/00-menu-overview.png) + +## 2. 全局布局与交互 + +后台采用典型管理系统布局: + +- 左侧固定菜单,支持一级菜单展开/收起。 +- 顶部显示当前账号头像、用户名、账户操作入口。 +- 主区域顶部有多标签页导航,打开过的页面会以 tab 形式保留,可关闭。 +- 列表页统一具备查询区、操作按钮区、表格、分页。 +- 详情、编辑、重置密码、IP 白名单、处理反馈、分组设置等操作预计以弹窗完成。 +- 危险操作需要二次确认,包括封禁、强制下线、强制注销、解散群、删除文件、删除敏感词、解锁账号等。 + +建议在本项目中按 HotGo 规范实现:后端 GoFrame 分层,前端 Vue 3 + Naive UI,枚举/字典由后端统一维护,前端不硬编码字典文本。 + +## 3. 页面需求 + +### 3.1 数据概览 + +路由:`/dashboard` +接口:`GET /admin/stats` + +页面展示统计卡片和趋势表: + +- 总用户数:16 +- 活跃用户:0 +- 总群组数:6 +- 总会话数:11 +- 总消息数:220 +- 今日消息:0 +- 用户增长趋势:日期、数量 +- 消息增长趋势:日期、数量 + +字段与数据口径: + +| 字段 | 接口字段建议 | 含义 | 展示/统计规则 | +| --- | --- | --- | --- | +| 总用户数 | `total_users` | 当前租户用户总量 | 建议统计未物理删除用户,可包含正常、封禁、注销 | +| 活跃用户 | `active_users` | 近期活跃用户 | 建议按当天或近 7 天登录/发消息口径统计 | +| 总群组数 | `total_groups` | 当前租户群组总量 | 建议包含正常和封禁群,已解散群是否计入需配置 | +| 总会话数 | `total_conversations` | 单聊和群聊会话总数 | 按会话去重统计 | +| 总消息数 | `total_messages` | 历史消息总量 | 包含文本、图片、视频、语音、文件、名片、系统消息 | +| 今日消息 | `today_messages` | 当日新增消息数 | 按服务器时区从 00:00 统计 | +| 用户增长趋势 | `user_growth[]` | 最近 7 天新增用户 | 元素包含 `date`、`count` | +| 消息增长趋势 | `message_growth[]` | 最近 7 天新增消息 | 元素包含 `date`、`count` | + +按钮与交互: + +- 本页无查询按钮,进入页面自动调用统计接口。 +- 统计卡片为空时数字显示 `0`,趋势表仍展示日期行,数量为 `0`。 +- 页面需支持后续扩展为折线图,但必须保留日期和数量明细。 + +截图: + +![数据概览](screenshots-queried/01-dashboard.png) + +### 3.2 用户管理 + +路由:`/users` +核心接口: + +- `GET /admin/users` +- `POST /admin/users` +- `GET /admin/users/{id}` +- `PATCH /admin/users/{id}/status` +- `POST /admin/users/{id}/reset_password` +- `POST /admin/users/{id}/ip-whitelist` +- `POST /admin/users/{id}/kick` +- `POST /admin/users/{id}/deactivate` +- `POST /admin/users/batch` +- `POST /admin/users/batch-ban` +- `POST /admin/users/ban-by-ip` +- `POST /admin/users/import-xlsx` + +查询条件: + +- 用户编号 +- 用户分组 +- 状态 +- 关键词:用户名、昵称、手机号 + +顶部操作: + +- 创建用户 +- 批量创建 +- 导入 Excel +- 下载模板 + +表格列: + +- 编号 +- 头像 +- 用户名 +- 昵称 +- 手机号 +- 注册方式 +- 状态 +- 用户分组 +- 最近登录 +- 创建时间 +- 操作 + +行操作: + +- 详情 +- 重置密码 +- IP 白名单 +- 调整分组 +- 封禁 +- 强制下线 +- 强制注销 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按条件筛选用户 | `GET /admin/users` | 点击后页码回到第 1 页;支持编号、分组、状态、关键词组合查询 | +| 重 置 | 清空筛选条件 | `GET /admin/users` | 清空编号、用户分组、状态、关键词后重新查询 | +| 创建用户 | 后台新增单个用户 | `POST /admin/users` | 弹窗填写昵称、手机号、密码、用户分组;平台账号还需选择租户;密码受安全策略限制 | +| 批量创建 | 批量生成用户 | `POST /admin/users/batch` | 支持批量数量、默认密码、默认分组;完成后展示成功/失败并支持下载结果 | +| 导入Excel | Excel 批量导入用户 | `POST /admin/users/import-xlsx` | 上传 `.xlsx`;模板列为昵称、手机号、密码;返回成功数、失败数、失败原因 | +| 下载模板 | 下载导入模板 | 静态生成或导出接口 | 模板示例字段:昵称、手机号、密码 | +| 详 情 | 查看用户详情 | `GET /admin/users/{id}` | 展示基础资料、状态、分组、最近登录、IP 白名单、安全权限等 | +| 重置密码 | 管理员重置密码 | `POST /admin/users/{id}/reset_password` | 弹窗输入新密码或生成密码;成功后写用户账号审计 | +| IP白名单 | 设置用户登录白名单 | `POST /admin/users/{id}/ip-whitelist` | 多行 IP/CIDR;用户白名单不覆盖全局登录 IP 黑名单 | +| 调整分组 | 修改用户策略组 | 用户分组分配接口 | 立即影响好友、建群、APP/Web 登录等权限 | +| 封 禁 / 解封 | 切换用户状态 | `PATCH /admin/users/{id}/status` | 封禁后撤销在线会话并禁止登录;需二次确认 | +| 强制下线 | 踢出在线终端 | `POST /admin/users/{id}/kick` | 不改变用户状态,仅撤销当前登录态 | +| 强制注销 | 注销用户账号 | `POST /admin/users/{id}/deactivate` | 高危操作,需二次确认;注销后按规则清理用户数据 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 编号 | `user_no` | 对外展示的用户编号 | 数字,支持精确查询 | +| 头像 | `avatar` | 用户头像 URL | 显示缩略图;无头像显示默认头像;支持点击查看 | +| 用户名 | `username` | 用户登录名/IM 账号 | 样例:`hy73074162` | +| 昵称 | `nickname` | 用户显示名 | 允许为空,空值显示 `--` | +| 手机号 | `mobile` / `phone` | 绑定手机号 | 建议脱敏展示 | +| 注册方式 | `register_type` | 用户来源 | 后台创建、手机号注册、邀请码注册 | +| 状态 | `status` | 账号状态 | 正常绿色、封禁红色、注销灰色 | +| 用户分组 | `policy_group_name` | 所属策略组 | 样例:默认用户组 | +| 最近登录 | `last_login_at` | 最近一次登录时间 | 无记录显示 `--` | +| 创建时间 | `created_at` | 用户创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 用户状态:`active` 正常、`disabled` 已封禁、`deleted` 已注销。 +- 注册方式:`admin` 后台创建、`mobile` 手机号注册、`invite_code` 邀请码注册。 +- 批量封禁结果:`success` 成功、`failed` 失败,失败项需返回 `username` 和 `reason`。 + +截图: + +![用户管理](screenshots-queried/02-user-management.png) + +### 3.3 用户分组 + +路由:`/user-policy-groups` +核心接口: + +- `GET /admin/user-policy-groups` +- `POST /admin/user-policy-groups` +- `PUT /admin/user-policy-groups/{id}` +- `DELETE /admin/user-policy-groups/{id}` +- `GET /admin/user-policy-groups/{id}/users` +- `GET/POST /admin/user-policy-groups/ip-whitelist` + +顶部操作: + +- 添加分组 +- IP 白名单 + +表格列: + +- 分组名称 +- 用户数 +- 允许加好友 +- 允许建群 +- 直接加好友 +- 好友上限 +- APP 登录 +- Web 登录 +- IP 白名单 +- 状态 +- 权重 +- 操作 + +编辑分组字段: + +- 分组名称 +- 默认用户组 +- 启用状态 +- 允许加好友 +- 允许建群 +- 直接添加好友 +- 允许 APP 登录 +- 允许 Web 登录 +- 启用 IP 白名单 +- 好友数量上限,0 表示不限 +- 排序权重 + +IP 白名单字段: + +- APP 登录白名单,每行一个 IPv4、IPv6 或 CIDR +- Web 登录白名单,每行一个 IPv4、IPv6 或 CIDR + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 添加分组 | 新建用户策略组 | `POST /admin/user-policy-groups` | 弹窗填写分组名称、开关权限、好友上限、排序权重 | +| IP 白名单 | 设置租户渠道白名单 | `GET/POST /admin/user-policy-groups/ip-whitelist` | 分 APP 登录白名单和 Web 登录白名单,每行一个 IP/CIDR | +| 编辑 | 修改策略组 | `PUT /admin/user-policy-groups/{id}` | 默认分组部分字段需禁用,避免系统无默认组 | +| 删除 | 删除策略组 | `DELETE /admin/user-policy-groups/{id}` | 默认用户组不可删除;分组下有用户时应阻止或要求迁移 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 分组名称 | `name` | 策略组名称 | 默认组显示“默认”标记 | +| 用户数 | `user_count` | 分组下用户数量 | 可扩展点击查看用户列表 | +| 允许加好友 | `allow_add_friend` | 是否允许主动加好友 | 允许/禁止 | +| 允许建群 | `allow_create_group` | 是否允许创建群 | 允许/禁止 | +| 直接加好友 | `direct_add_friend` | 加好友是否免确认 | 开启显示开启,关闭显示 `--` | +| 好友上限 | `max_friend_count` | 最大好友数量 | `0` 显示不限 | +| APP 登录 | `allow_app_login` | 是否允许 APP 登录 | 允许/禁止 | +| Web 登录 | `allow_web_login` | 是否允许 Web 登录 | 允许/禁止 | +| IP 白名单 | `ip_whitelist_enabled` | 是否启用分组白名单 | 已启用/未启用 | +| 状态 | `enabled` | 分组是否生效 | 启用/停用 | +| 权重 | `sort_order` | 排序或匹配优先级 | 数字 | + +枚举说明: + +- 布尔权限统一为 `true/false`,前端按字段语义显示为允许/禁止、启用/停用、开启/关闭。 +- 好友上限 `0` 代表不限。 +- IP 白名单启用后,APP/Web 登录需命中对应白名单;全局黑名单优先级高于白名单。 + +截图: + +![用户分组](screenshots-queried/03-user-groups.png) + +### 3.4 群组管理 + +路由:`/groups` +核心接口: + +- `GET /admin/groups` +- `GET /admin/groups/{id}` +- `POST /admin/groups/{id}/ban` +- `POST /admin/groups/{id}/unban` +- `DELETE /admin/groups/{id}` +- `GET /admin/groups/{id}/members` +- `GET /admin/groups/{id}/files` +- `DELETE /admin/groups/{id}/files/{fileId}` +- `POST /admin/groups/quick-create` + +查询条件: + +- 群号 +- 关键词:群名称 +- 状态 + +表格列: + +- 群组编号 +- 群号 +- 群组名称 +- 成员数 +- 群主昵称 +- 群主用户名 +- 状态 +- 创建时间 +- 操作 + +行操作: + +- 详情 +- 群文件 +- 封禁 +- 解散 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按群号、群名、状态筛选 | `GET /admin/groups` | 点击后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/groups` | 清空群号、关键词、状态后重新查询 | +| 详 情 | 查看群详情 | `GET /admin/groups/{id}` | 展示群号、群名、群主、成员数、状态、创建时间 | +| 群文件 | 查看群文件 | `GET /admin/groups/{id}/files` | 弹窗列表支持类型筛选、下载、删除 | +| 封 禁 / 解封 | 切换群状态 | `POST /admin/groups/{id}/ban`、`POST /admin/groups/{id}/unban` | 封禁后群内不可发送消息;需记录群事件审计 | +| 解 散 | 解散群聊 | `DELETE /admin/groups/{id}` | 高危操作,需二次确认;清理成员关系,保留历史记录 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 群组编号 | `id` | 群内部编号 | 数字 | +| 群号 | `group_no` / `group_code` | 用户端可搜索群号 | 支持精确查询 | +| 群组名称 | `name` | 群显示名称 | 样例:冯、冯氏家族 | +| 成员数 | `member_count` | 当前成员数量 | 数字 | +| 群主昵称 | `owner_nickname` | 群主昵称 | 空值显示 `--` | +| 群主用户名 | `owner_username` | 群主用户名 | 样例:`hy69089908` | +| 状态 | `status` | 群状态 | 正常/封禁/已解散 | +| 创建时间 | `created_at` | 群创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 群组状态:`normal` 正常、`banned` 已封禁、`dissolved` 已解散。 +- 群文件类型:图片、视频、音频、文档、其他。 + +截图: + +![群组管理](screenshots-queried/04-group-management.png) + +### 3.5 群成员管理 + +路由:`/group-members` +接口依赖: + +- `GET /admin/groups/{id}/members` +- `POST /admin/groups/{id}/members/kick` +- `POST /admin/groups/{id}/members/mute` +- `POST /admin/groups/{id}/members/unmute` + +查询条件: + +- 群号,必填 +- 角色 +- 关键词:用户名、昵称 + +表格列: + +- 编号 +- 用户名 +- 昵称 +- 群昵称 +- 角色 +- 禁言状态 +- 加入时间 +- 操作 + +顶部操作: + +- 查询 +- 重置 +- 刷新 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按群号查询成员 | `GET /admin/groups/{id}/members` | 群号必填;需先按群号解析群 ID | +| 重 置 | 清空群号、角色、关键词 | - | 清空后表格回到空态 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/groups/{id}/members` | 不清空已输入群号 | +| 禁 言 / 解除禁言 | 设置成员禁言状态 | `POST /admin/groups/{id}/members/mute`、`POST /admin/groups/{id}/members/unmute` | 需填写禁言时长/原因;写群事件审计 | +| 踢 出 | 将成员移出群 | `POST /admin/groups/{id}/members/kick` | 需二次确认;群主不可直接踢出 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 编号 | `user_no` | 成员用户编号 | 样例:3 | +| 用户名 | `username` | 成员用户名 | 样例:`hy69089908` | +| 昵称 | `nickname` | 成员昵称 | 样例:F | +| 群昵称 | `group_nickname` | 群内昵称 | 空值显示 `--` | +| 角色 | `role` | 群角色 | 群主/管理员/成员 | +| 禁言状态 | `mute_status` | 是否禁言 | 正常/已禁言 | +| 加入时间 | `joined_at` | 入群时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 群成员角色:`owner` 群主、`admin` 管理员、`member` 成员。 +- 禁言状态:`normal` 正常、`muted` 已禁言。 +- 本次查询样例使用群号 `68127154`,查询到成员 `hy69089908 / F`,角色为群主。 + +截图: + +![群成员管理](screenshots-queried/05-group-members.png) + +### 3.6 邀请码管理 + +路由:`/invite-codes` +核心接口: + +- `GET /admin/invite-codes` +- `POST /admin/invite-codes` +- `DELETE /admin/invite-codes/{id}` +- `GET /admin/invite-codes/{id}/usages` + +查询条件: + +- 状态 +- 邀请码,6 位数字 + +顶部操作: + +- 生成邀请码 + +业务说明: + +- 每次生成 1 个邀请码,可多次使用。 +- 可指定企业与用户。 +- 可勾选不限制时间,永久有效。 +- 可开启自动加好友,使用该码注册的用户直接成为指定用户好友。 +- 可查看使用该邀请码注册的用户列表。 + +表格列: + +- 邀请码 +- 指定用户 +- 自动加好友 +- 使用人数 +- 创建人 +- 状态 +- 过期时间 +- 创建时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按状态/邀请码筛选 | `GET /admin/invite-codes` | 邀请码为 6 位数字 | +| 重 置 | 清空状态和邀请码 | `GET /admin/invite-codes` | 回到全部列表 | +| 生成邀请码 | 创建邀请码 | `POST /admin/invite-codes` | 可指定用户、有效期、是否自动加好友;生成 6 位数字码 | +| 查 看 | 查看使用记录 | `GET /admin/invite-codes/{id}/usages` | 展示使用该码注册的用户列表 | +| 删 除 | 删除/停用邀请码 | `DELETE /admin/invite-codes/{id}` | 已使用邀请码建议标记停用,不物理删除 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 邀请码 | `code` | 6 位数字邀请码 | 样例:323441 | +| 指定用户 | `target_username` / `target_user_no` | 注册后绑定或自动加好友目标 | 样例:往事随风 (#2271) | +| 自动加好友 | `auto_add_friend` | 是否自动成为指定用户好友 | 是/否 | +| 使用人数 | `used_count` | 已使用注册人数 | 可点击查看详情 | +| 创建人 | `creator_name` / `creator_id` | 创建者 | 当前样例显示编号 16 | +| 状态 | `status` | 邀请码状态 | 启用/停用/过期 | +| 过期时间 | `expired_at` | 有效期截止时间 | 永久显示“不限制”或“永久” | +| 创建时间 | `created_at` | 创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 邀请码状态:`active` 启用、`disabled` 停用、`expired` 过期。 +- 自动加好友:`true` 是、`false` 否。 + +截图: + +![邀请码管理](screenshots-queried/06-invite-codes.png) + +### 3.7 单聊消息 + +路由:`/private-messages` +核心接口: + +- `GET /admin/messages/search` +- `GET /admin/messages/{id}` + +查询条件: + +- 消息类型:全部、文本、图片、视频、语音、文件、名片、系统 +- 发送人用户名 +- 接收人用户名 +- 关键词 +- 开始时间 +- 结束时间 + +表格列: + +- 发送人 +- 接收人 +- 类型 +- 内容 +- 状态 +- 发送时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按消息条件检索 | `GET /admin/messages/search` | 查询参数含 `conversation_type=private` | +| 重 置 | 清空筛选条件 | `GET /admin/messages/search` | 清空消息类型、发送人、接收人、关键词、时间 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/messages/search` | 不改变页码和筛选条件 | +| 删 除 | 删除或隐藏消息 | 消息删除接口 | 需二次确认;写内容审核/操作日志 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 发送人 | `sender_nickname`、`sender_username` | 发送方用户 | 两行展示昵称和用户名 | +| 接收人 | `receiver_nickname`、`receiver_username` | 接收方用户 | 两行展示昵称和用户名 | +| 类型 | `message_type` | 消息类型 | 文本/图片/视频/语音/文件/名片/系统 | +| 内容 | `content` | 消息摘要 | 文本直接展示,媒体显示预览/下载 | +| 状态 | `status` | 消息状态 | 正常/已删除/已撤回/拦截 | +| 发送时间 | `sent_at` | 消息发送时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 消息类型:`text` 文本、`image` 图片、`video` 视频、`voice/audio` 语音、`file` 文件、`card` 名片、`system` 系统。 +- 消息状态:`normal` 正常、`deleted` 后台删除、`recalled` 用户撤回、`blocked` 风控拦截。 +- 查询后样例:`F / hy69089908` 给 `冯仁飞 / hy91144536` 发送文本 `哈哈哈哈`。 + +截图: + +![单聊消息](screenshots-queried/07-direct-messages.png) + +### 3.8 群聊消息 + +路由:`/group-messages` +核心接口: + +- `GET /admin/messages/search` +- `GET /admin/messages/{id}` + +查询条件: + +- 消息类型:全部、文本、图片、视频、语音、文件、名片、系统 +- 发送人用户名 +- 关键词 +- 开始时间 +- 结束时间 + +表格列: + +- 发送人 +- 群名称 +- 类型 +- 内容 +- 状态 +- 发送时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按群聊消息条件检索 | `GET /admin/messages/search` | 查询参数含 `conversation_type=group` | +| 重 置 | 清空筛选条件 | `GET /admin/messages/search` | 清空消息类型、发送人、关键词、时间 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/messages/search` | 不改变页码和筛选条件 | +| 删 除 | 删除或隐藏群消息 | 消息删除接口 | 需二次确认;建议写群事件/内容审核日志 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 发送人 | `sender_nickname`、`sender_username` | 发送方用户 | 两行展示 | +| 群名称 | `group_name`、`group_no` | 消息所属群 | 两行展示群名和群号 | +| 类型 | `message_type` | 消息类型 | 文本/图片/视频/语音/文件/名片/系统 | +| 内容 | `content` | 消息摘要 | 文本展示内容,媒体展示预览/下载 | +| 状态 | `status` | 消息状态 | 正常/已删除/已撤回/拦截 | +| 发送时间 | `sent_at` | 发送时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 消息类型和消息状态同单聊消息。 +- 查询后样例:`F / hy69089908` 在群 `冯 / 68127154` 发送文本 `哈哈哈哈`。 + +截图: + +![群聊消息](screenshots-queried/08-group-messages.png) + +### 3.9 敏感词库 + +路由:`/sensitive-words` +核心接口: + +- `GET /admin/sensitive-words` +- `POST /admin/sensitive-words` +- `PUT /admin/sensitive-words/{id}` +- `DELETE /admin/sensitive-words/{id}` +- `POST /admin/sensitive-words/import` +- `GET /admin/sensitive-words/export` + +查询条件: + +- 状态 +- 等级 +- 关键词:敏感词 + +顶部操作: + +- 新增敏感词 +- 批量导入 +- 文件导入 +- 批量导出 + +表格列: + +- ID +- 敏感词 +- 等级 +- 替换词 +- 状态 +- 创建时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按状态、等级、关键词筛选 | `GET /admin/sensitive-words` | 点击后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/sensitive-words` | 回到全部词库 | +| 新增敏感词 | 新增单个词条 | `POST /admin/sensitive-words` | 弹窗填写敏感词、等级、替换词、状态 | +| 批量导入 | 粘贴多行导入 | `POST /admin/sensitive-words/import` | 返回成功、重复、失败数量 | +| 文件导入 | 上传文件导入 | `POST /admin/sensitive-words/import` | 支持 `.txt`、`.csv`、`.xlsx` | +| 批量导出 | 导出当前筛选词库 | `GET /admin/sensitive-words/export` | 导出 CSV/XLSX | +| 编辑 | 编辑词条 | `PUT /admin/sensitive-words/{id}` | 修改后刷新敏感词缓存 | +| 删除 | 删除词条 | `DELETE /admin/sensitive-words/{id}` | 需二次确认,建议软删除 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| ID | `id` | 敏感词编号 | 数字 | +| 敏感词 | `word` | 待匹配词条 | 必填,建议唯一 | +| 等级 | `level` | 风险等级 | 低/中/高 | +| 替换词 | `replace_word` | 命中替代文本 | 空值显示 `--` | +| 状态 | `status` | 是否生效 | 启用/停用 | +| 创建时间 | `created_at` | 创建时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 敏感词等级:`low` 低、`medium` 中、`high` 高。 +- 敏感词状态:`active` 启用、`disabled` 停用。 +- 处理动作建议扩展:`replace` 替换、`block` 阻断、`alert` 告警。 +- 当前页面查询结果为空,需支持“暂无数据”空态。 + +截图: + +![敏感词库](screenshots-queried/09-sensitive-words.png) + +### 3.10 文件管理 + +路由:`/file-records` +接口: + +- 复用 `GET /admin/messages/search` +- 文件删除可复用消息/群文件删除接口 + +查询条件: + +- 文件类型:全部、图片、视频、音频、文档、其他 +- 发送人用户名 +- 文件名 +- 开始时间 +- 结束时间 + +表格列: + +- 发送人 +- 接收方 +- 类型 +- 内容 +- 文件名 +- 文件大小 +- URL +- 状态 +- 发送时间 +- 操作 + +行操作: + +- 预览 +- 下载 +- 删除 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按文件条件检索 | `GET /admin/messages/search` | 仅筛选包含文件 URL 的消息 | +| 重 置 | 清空筛选条件 | `GET /admin/messages/search` | 清空类型、发送人、文件名、时间 | +| 刷 新 | 保持当前条件刷新 | `GET /admin/messages/search` | 不改变当前页 | +| 预览 | 查看图片/视频/音频 | 文件 URL | 只对可预览类型显示 | +| 下载 | 下载文件 | 文件 URL | URL 需鉴权或短期签名,避免泄露 | +| 删 除 | 删除文件记录/隐藏文件消息 | 消息或群文件删除接口 | 需二次确认;OSS 文件是否物理删除需配置 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 发送人 | `sender_nickname`、`sender_username` | 文件发送方 | 两行展示 | +| 接收方 | `receiver_name` / `group_name` | 单聊接收人或群 | 两行展示名称和编号 | +| 类型 | `file_type` / `message_type` | 文件类型 | 图片/视频/音频/文档/其他 | +| 内容 | `content` | 文件消息摘要 | 显示预览、下载快捷入口 | +| 文件名 | `file_name` | 原始文件名 | 空值显示 `--` | +| 文件大小 | `file_size` | 文件字节数 | 格式化为 B/KB/MB/GB | +| URL | `file_url` | 文件地址 | 表格省略展示,详情里显示完整 URL | +| 状态 | `status` | 文件消息状态 | 正常/已删除 | +| 发送时间 | `sent_at` | 上传或发送时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 文件分类:图片、视频、音频、文档、其他。 +- 图片扩展名:`jpg`、`jpeg`、`png`、`gif`、`bmp`、`webp`、`svg`。 +- 视频扩展名:`mp4`、`avi`、`mov`、`mkv`、`wmv`、`flv`、`webm`。 +- 音频扩展名:`mp3`、`wav`、`flac`、`aac`、`ogg`、`wma`。 +- 文档扩展名:`doc`、`docx`、`xls`、`xlsx`、`csv`、`ppt`、`pptx`、`pdf`、`txt`、`md`。 + +截图: + +![文件管理](screenshots-queried/10-files.png) + +### 3.11 用户反馈 + +路由:`/feedbacks` +核心接口: + +- `GET /admin/feedbacks` +- `PUT /admin/feedbacks/{id}` +- `POST /admin/feedbacks/{id}/priority` +- `POST /admin/feedbacks/batch-handle` +- `GET /admin/feedbacks/export` + +查询条件: + +- 用户 ID +- 状态 +- 优先级 +- 关键词:内容、联系方式 + +顶部操作: + +- 批量处理 +- 导出 CSV + +表格列: + +- 用户 +- 优先级 +- 内容 +- 状态 +- 处理人 +- 创建时间 +- 操作 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查 询 | 按用户、状态、优先级、关键词筛选 | `GET /admin/feedbacks` | 关键词匹配内容或联系方式 | +| 重 置 | 清空筛选条件 | `GET /admin/feedbacks` | 回到全部列表 | +| 批量处理 | 批量更新反馈处理状态 | `POST /admin/feedbacks/batch-handle` | 对选中反馈统一设置状态和处理备注 | +| 导出 CSV | 导出当前筛选结果 | `GET /admin/feedbacks/export` | 包含用户、联系方式、内容、状态、处理信息 | +| 处理 | 单条处理反馈 | `PUT /admin/feedbacks/{id}` | 填写处理状态、处理备注,记录处理人和处理时间 | +| 调整优先级 | 快速修改优先级 | `POST /admin/feedbacks/{id}/priority` | 修改后刷新列表 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 用户 | `user_id`、`username`、`nickname` | 反馈用户 | 支持按用户 ID 筛选 | +| 优先级 | `priority` | 处理优先级 | 低/中/高,颜色区分 | +| 内容 | `content` | 反馈正文 | 长文本省略,详情查看完整 | +| 状态 | `status` | 处理状态 | 待处理/处理中/已处理/已关闭 | +| 处理人 | `handler_name` | 最后处理管理员 | 未处理显示 `--` | +| 创建时间 | `created_at` | 反馈提交时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 优先级:`low` 低、`medium` 中、`high` 高。 +- 反馈状态:`pending` 待处理、`processing` 处理中、`resolved` 已处理、`closed` 已关闭。 +- 当前页面查询结果为空,需支持“暂无数据”空态。 + +截图: + +![用户反馈](screenshots-queried/11-feedback.png) + +### 3.12 操作日志 + +路由:`/audit-logs` +接口:`GET /admin/audit/logs` + +查询条件: + +- 动作 +- 目标类型 +- 目标用户 +- 目标群 +- 开始时间 +- 结束时间 + +顶部操作: + +- 查询 +- 重置 +- 导出 CSV + +表格列: + +- 管理员 +- 动作 +- 目标类型 +- 目标 +- 查询条件 +- IP +- 时间 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按动作、目标类型、目标用户、目标群、时间范围筛选 | `GET /admin/audit/logs` | 查询后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/audit/logs` | 回到全部日志 | +| 导出 CSV | 导出当前筛选日志 | 导出接口 | 用于审计留存 | +| 详情 | 查看操作详情 | `GET /admin/audit/logs` 或详情接口 | 展示请求参数、变更内容、管理员信息 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 管理员 | `admin_username` | 执行动作的后台账号 | 样例:heiyue | +| 动作 | `action` | 操作类型 | 创建用户、重置密码、封禁等 | +| 目标类型 | `target_type` | 操作对象类型 | 用户、群、消息、文件、设置 | +| 目标 | `target_summary` | 目标摘要 | 用户显示昵称和用户名 | +| 查询条件 | `query_condition` | 操作时使用的查询条件 | 无则显示 `--` | +| IP | `ip` | 管理员操作 IP | IPv4/IPv6 | +| 时间 | `created_at` | 操作发生时间 | `YYYY-MM-DD HH:mm:ss` | + +枚举说明: + +- 动作:创建用户、重置密码、修改用户名、更新状态、调整分组、强制下线、强制注销、批量封禁、按 IP 封禁、封禁群、解封群、解散群、删除消息、删除文件、保存设置等。 +- 目标类型:`user` 用户、`group` 群、`message` 消息、`file` 文件、`setting` 设置。 +- 查询后样例:`heiyue` 重置用户密码,目标为 `往事随风 / hy17716319`,IP 为 `141.164.107.33`。 + +截图: + +![操作日志](screenshots-queried/12-operation-logs.png) + +### 3.13 用户账号审计 + +路由:`/user-account-audit` +接口:`GET /admin/audit/user-account-logs` + +查询条件: + +- 用户编号 +- 用户名 +- 操作者 +- 操作 +- IP +- 时间范围 + +操作类型包括: + +- 修改昵称、修改头像、修改性别、修改签名、修改生日、修改密码 +- 主动注销 +- 管理员修改用户名、管理员更新状态、管理员重置密码、管理员更新安全权限 +- 管理员强制下线、管理员强制注销、管理员调整分组、管理员批量封禁、管理员按 IP 封禁 + +表格列: + +- 操作时间 +- 目标用户 +- 操作者 +- 操作类型 +- 变更摘要 +- IP / 归属地 +- 设备/客户端 + +按钮功能说明: + +- 查询:按用户编号、用户名、操作者、操作、IP、时间范围筛选用户账号审计日志。 +- 重置:清空全部筛选条件并重新查询。 +- 本页不提供写操作,只用于审计追踪。 + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 操作时间 | `created_at` | 账号变更时间 | 倒序排列 | +| 目标用户 | `target_username`、`target_user_no` | 被操作用户 | 两行展示用户名和编号 | +| 操作者 | `actor_type`、`actor_username` | 管理员或用户本人 | 标签区分管理员/用户本人 | +| 操作类型 | `action` | 账号审计动作 | 字典化为中文 | +| 变更摘要 | `details` / `before_data` / `after_data` | 变更内容 | 长内容 tooltip 展示 | +| IP / 归属地 | `ip`、`ip_location` | 操作来源 | IP 和归属地分行展示 | +| 设备/客户端 | `device_id` / `user_agent` | 客户端信息 | 长文本省略,tooltip 查看 | + +操作类型枚举: + +| 值 | 中文 | +| --- | --- | +| `change_nickname` | 修改昵称 | +| `change_avatar` | 修改头像 | +| `change_gender` | 修改性别 | +| `change_signature` | 修改签名 | +| `change_birthday` | 修改生日 | +| `change_password` | 修改密码 | +| `deactivate_account` | 主动注销 | +| `update_username` | 管理员修改用户名 | +| `update_status` | 管理员更新状态 | +| `reset_password` | 管理员重置密码 | +| `update_security_perm` | 管理员更新安全权限 | +| `kick_user` | 管理员强制下线 | +| `force_deactivate` | 管理员强制注销 | +| `assign_policy_group` | 管理员调整分组 | +| `batch_ban_users` | 管理员批量封禁 | +| `ban_users_by_ip` | 管理员按 IP 封禁 | + +截图: + +![用户账号审计](screenshots-queried/13-user-account-audit.png) + +### 3.14 群事件审计 + +路由:`/group-events` +接口:`GET /admin/groups/events` + +查询条件: + +- 群号 +- 操作人 +- 目标用户 +- 事件类型 +- 开始时间 +- 结束时间 + +表格列: + +- 群名称 +- 事件类型 +- 操作人 +- 目标用户 +- 可见范围 +- 旧值 +- 新值 +- 时间 + +按钮功能说明: + +- 查询:按群号、操作人、目标用户、事件类型、时间范围筛选。 +- 重置:清空筛选项重新查询。 +- 本页不提供写操作,数据由群创建、成员加入、踢出、禁言、解散等业务自动写入。 + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 群名称 | `group_name`、`group_no` | 事件所属群 | 两行展示群名和群号 | +| 事件类型 | `event_type` | 群事件 | 成员加入、群创建等 | +| 操作人 | `operator_username`、`operator_nickname` | 发起事件用户 | 系统事件显示系统 | +| 目标用户 | `target_username`、`target_nickname` | 被操作用户 | 无目标显示 `--` | +| 可见范围 | `visibility` | 事件对谁可见 | 全员可见、管理员可见、仅自己 | +| 旧值 | `old_value` | 变更前值 | 无则 `--` | +| 新值 | `new_value` | 变更后值 | 无则 `--` | +| 时间 | `created_at` | 事件时间 | `YYYY-MM-DD HH:mm:ss` | + +事件类型枚举建议: + +- `group_created` 群创建。 +- `member_joined` 成员加入。 +- `member_left` 成员退出。 +- `member_kicked` 成员被踢。 +- `member_muted` 成员禁言。 +- `member_unmuted` 解除禁言。 +- `owner_transferred` 群主转让。 +- `admin_added` 设置管理员。 +- `admin_removed` 取消管理员。 +- `group_name_changed` 修改群名。 +- `group_banned` 群封禁。 +- `group_unbanned` 群解封。 +- `group_dissolved` 群解散。 + +截图: + +![群事件审计](screenshots-queried/14-group-event-audit.png) + +### 3.15 管理员登录历史 + +路由:`/admin-login-history` +接口:`GET /admin/admin-login-history` + +查询条件: + +- 开始时间 +- 结束时间 + +表格列: + +- 用户名 +- 登录时间 +- User-Agent +- IP 地址 + +按钮功能说明: + +- 查询:按开始时间、结束时间筛选管理员登录历史。 +- 分页:默认 `20 条/页`。 +- 本页不提供写操作。 + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 用户名 | `username` | 管理员账号 | 样例:heiyue | +| 登录时间 | `login_at` | 登录成功时间 | 倒序排列 | +| User-Agent | `user_agent` | 浏览器 UA | 长文本横向滚动或 tooltip | +| IP 地址 | `ip` | 登录 IP | IPv4/IPv6 | + +截图: + +![管理员登录](screenshots-queried/15-admin-login.png) + +### 3.16 用户登录历史 + +路由:`/user-login-history` +接口:`GET /admin/user-login-history` + +查询条件: + +- 用户编号 +- 用户名 +- 开始时间 +- 结束时间 +- IP + +表格列: + +- 用户编号 +- 用户名 +- 当前状态 +- 登录时间 +- 设备类型 +- 设备型号 +- 系统版本 +- App 版本 +- IP 地址 +- 归属地 +- 操作 + +行操作: + +- 快速封禁 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按用户编号、用户名、时间、IP 筛选 | `GET /admin/user-login-history` | 查询后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/user-login-history` | 回到全部登录历史 | +| 快速封禁 | 从登录记录封禁用户 | `PATCH /admin/users/{id}/status` | 需二次确认;封禁后撤销在线会话 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 用户编号 | `user_no` | 用户对外编号 | 支持筛选 | +| 用户名 | `username` | 用户名 | 支持筛选 | +| 当前状态 | `status` | 用户当前状态 | 正常/已封禁/已注销 | +| 登录时间 | `login_at` | 本次登录时间 | 倒序排列 | +| 设备类型 | `device_type` | 登录终端 | web/android/ios | +| 设备型号 | `device_model` | 设备名称 | 样例:OPPO PCHM10 | +| 系统版本 | `os_version` | 操作系统版本 | 样例:Android 11 | +| App 版本 | `app_version` | 客户端版本 | web 可显示 `-` | +| IP 地址 | `ip` | 登录 IP | 支持筛选 | +| 归属地 | `ip_location` | IP 地理位置 | 无法解析显示未知 | + +枚举说明: + +- 设备类型:`web` Web 端、`android` Android APP、`ios` iOS APP、`unknown` 未知设备。 +- 用户状态同用户管理。 + +截图: + +![用户登录](screenshots-queried/16-user-login.png) + +### 3.17 IP 账号统计 + +路由:`/ip-user-stats` +核心接口: + +- `GET /admin/audit/ip-user-stats` +- `GET /admin/audit/ip-user-stats/users` +- `POST /admin/users/ban-by-ip` + +查询条件: + +- IP,支持 IPv4 或 IPv6 + +表格列: + +- IP 地址 +- 归属地 +- 关联用户 +- 注册用户 +- 登录用户 +- 正常 +- 已封禁 +- 最近活动 +- 操作 + +行操作: + +- 用户列表 +- 封禁 IP,平台级账号可见 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按 IP 筛选统计 | `GET /admin/audit/ip-user-stats` | 支持 IPv4/IPv6 | +| 重 置 | 清空 IP | `GET /admin/audit/ip-user-stats` | 回到全部统计 | +| 用户列表 | 查看该 IP 关联用户 | `GET /admin/audit/ip-user-stats/users` | 弹窗展示注册/登录命中的用户 | +| 封禁 IP | 封禁该 IP 关联用户 | `POST /admin/users/ban-by-ip` | 平台账号可见;返回命中数、成功数、失败数 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| IP 地址 | `ip` | 统计 IP | IPv4/IPv6 | +| 归属地 | `ip_location` | IP 地理位置 | 样例:浙江 绍兴 | +| 关联用户 | `user_count` | 注册或登录命中过该 IP 的用户数 | 大于 1 显示“多账号”标签 | +| 注册用户 | `register_user_count` | 使用该 IP 注册的用户数 | 数字 | +| 登录用户 | `login_user_count` | 使用该 IP 登录的用户数 | 数字 | +| 正常 | `active_user_count` | 关联用户中正常数量 | 数字 | +| 已封禁 | `disabled_user_count` | 关联用户中封禁数量 | 数字 | +| 最近活动 | `last_seen_at` | 最近注册或登录时间 | `YYYY-MM-DD HH:mm:ss` | + +关联用户弹窗字段: + +- 用户 ID,平台账号可见。 +- 用户编号、用户名、昵称。 +- 来源:注册、登录,可同时命中。 +- 状态:正常、已封禁。 +- 最近活动。 + +截图: + +![IP 账号统计](screenshots-queried/17-ip-account-stats.png) + +### 3.18 登录失败日志 + +路由:`/login-failure-logs` +核心接口: + +- `GET /admin/login-failure-logs` +- `POST /admin/security/unlock` + +查询条件: + +- 类型 +- 登录账号 +- IP +- 开始时间 +- 结束时间 + +表格列: + +- 类型 +- 登录账号 +- 失败原因 +- IP 地址 +- User-Agent +- 时间 +- 操作 + +行操作: + +- 解锁 + +按钮功能说明: + +| 按钮 | 对应功能 | 接口 | 详细说明 | +| --- | --- | --- | --- | +| 查询 | 按类型、账号、IP、时间筛选 | `GET /admin/login-failure-logs` | 查询后页码回到第 1 页 | +| 重 置 | 清空筛选条件 | `GET /admin/login-failure-logs` | 回到全部失败记录 | +| 解锁 | 解除账号或 IP 登录锁定 | `POST /admin/security/unlock` | 需二次确认 | + +表格字段说明: + +| 表格字段 | 接口字段建议 | 含义 | 展示规则 | +| --- | --- | --- | --- | +| 类型 | `type` | 登录失败账号类型 | 用户/管理员 | +| 登录账号 | `account` | 输入的账号 | 手机号、用户名或管理员账号 | +| 失败原因 | `reason` | 失败说明 | 样例:密码错误 | +| IP 地址 | `ip` | 失败来源 IP | 支持筛选 | +| User-Agent | `user_agent` | 客户端 UA | 长文本省略 | +| 时间 | `created_at` | 失败时间 | 倒序排列 | + +枚举说明: + +- 类型:`user` 用户、`admin` 管理员。 +- 失败原因:`password_error` 密码错误、`account_not_found` 账号不存在、`account_disabled` 账号被封禁、`captcha_error` 验证码错误、`ip_blocked` IP 被拦截、`too_many_attempts` 连续失败次数过多。 + +截图: + +![登录失败日志](screenshots-queried/18-login-failures.png) + +### 3.19 私域设置 + +路由:`/tenant-settings` +核心接口: + +- `GET /admin/tenant-settings` +- `PUT /admin/tenant-settings` + +配置项: + +- 消息字符上限:范围 1 到 5000,仅允许调低 +- 图片大小上限:范围 1 到 1000 MB +- 文件大小上限:范围 1 到 1000 MB,默认 50 MB +- 最大撤回消息时间:范围 1 到 1440 分钟;群主/管理员可撤回自己或他人消息,普通用户撤回自己的消息固定为 2 分钟 +- 是否开启手机验证码:关闭后,注册仅支持邀请码,短信登录与忘记密码不可用 + +操作: + +- 保存设置 + +按钮与字段说明: + +| 字段/按钮 | 接口字段建议 | 范围/枚举 | 业务影响 | +| --- | --- | --- | --- | +| 消息字符上限 | `message_char_limit` | 1 到 5000,仅允许调低 | 用户发送文本消息时校验长度 | +| 图片大小上限(MB) | `image_size_limit_mb` | 1 到 1000 | 图片上传前后端双校验 | +| 文件大小上限(MB) | `file_size_limit_mb` | 1 到 1000,默认 50 | 文件上传前后端双校验 | +| 最大撤回消息时间(分钟) | `max_recall_minutes` | 1 到 1440 | 群主/管理员撤回自己或他人消息的最长时限 | +| 是否开启手机验证码 | `sms_code_enabled` | 开启/关闭 | 关闭后注册仅支持邀请码,短信登录与忘记密码不可用 | +| 保存设置 | - | - | 提交当前租户配置,后端校验后清理租户配置缓存 | + +枚举说明: + +- `sms_code_enabled=true`:开启手机验证码,支持短信登录、忘记密码、手机号验证码注册。 +- `sms_code_enabled=false`:关闭手机验证码,仅支持邀请码注册。 + +截图: + +![私域设置](screenshots-queried/19-private-domain-settings.png) + +### 3.20 安全策略 + +路由:`/security-settings` +核心接口: + +- `GET /admin/security-settings` +- `PUT /admin/security-settings` +- `POST /admin/security/unlock` + +配置分组与字段: + +- 用户安全 + - 验证码触发次数:连续密码错误该次数后要求验证码,范围 1 到 10 + - 密码最小位数:范围 6 到 32 + - 强制包含字母 +- 注册登录管控 + - 用户注册功能 + - 修改密码功能 + - 用户登录功能 + - Web 端多端登录 +- 聊天消息管控 + - 全局好友功能 + - 全局群消息 + - 全局私聊消息 + - 群聊发言间隔:0 表示不限制;1 到 1000 表示同一群内两次发言的最小间隔 +- 群组功能管控 + - 群号搜索功能 + - 群创建功能 + - 群解散功能 + - 群管理员功能 + - 是否显示群人数 +- 其他功能开关 + - 允许用户注销账号 +- IP 访问控制 + - 注册 IP 黑名单:每行一个 IPv4、IPv6 或 CIDR,命中后无法注册及获取注册短信验证码 + - 登录 IP 黑名单:命中后无法密码登录、短信登录及刷新 Token;黑名单优先级高于用户分组登录白名单 +- 高级功能 + - 消息已读状态 + +操作: + +- 保存设置 + +按钮与字段说明: + +| 分组 | 字段 | 接口字段建议 | 范围/枚举 | 业务影响 | +| --- | --- | --- | --- | --- | +| 用户安全 | 验证码触发次数 | `captcha_trigger_count` | 1 到 10 | 连续密码错误达到次数后要求验证码 | +| 用户安全 | 密码最小位数 | `password_min_length` | 6 到 32 | 注册、修改密码、后台重置密码均生效 | +| 用户安全 | 强制包含字母 | `password_require_letter` | 开启/关闭 | 开启后密码必须包含英文字母 | +| 注册登录管控 | 用户注册功能 | `user_register_enabled` | 开启/关闭 | 关闭后用户端隐藏注册入口 | +| 注册登录管控 | 修改密码功能 | `password_change_enabled` | 开启/关闭 | 关闭后用户无法自行修改密码 | +| 注册登录管控 | 用户登录功能 | `user_login_enabled` | 开启/关闭 | 关闭后用户无法登录,不影响已登录用户 | +| 注册登录管控 | Web 端多端登录 | `web_multi_login_enabled` | 开启/关闭 | 开启后同账号可多浏览器同时登录 | +| 聊天消息管控 | 全局好友功能 | `global_friend_enabled` | 开启/关闭 | 关闭后无法搜索和添加好友 | +| 聊天消息管控 | 全局群消息 | `global_group_message_enabled` | 开启/关闭 | 关闭后所有群无法发送消息 | +| 聊天消息管控 | 全局私聊消息 | `global_private_message_enabled` | 开启/关闭 | 关闭后所有私聊无法发送消息 | +| 聊天消息管控 | 群聊发言间隔(秒) | `group_message_interval_seconds` | 0 到 1000 | 0 不限制,其他值限制同群发言间隔 | +| 群组功能管控 | 群号搜索功能 | `group_search_enabled` | 开启/关闭 | 关闭后隐藏群号和群搜索入口 | +| 群组功能管控 | 群创建功能 | `group_create_enabled` | 开启/关闭 | 关闭后所有人无法新建群聊 | +| 群组功能管控 | 群解散功能 | `group_dissolve_enabled` | 开启/关闭 | 关闭后用户端无法解散群,不影响后台 | +| 群组功能管控 | 群管理员功能 | `group_admin_enabled` | 开启/关闭 | 关闭后无法设置群管理员 | +| 群组功能管控 | 是否显示群人数 | `show_group_member_count` | 开启/关闭 | 关闭后用户端群资料不显示总人数 | +| 其他功能开关 | 允许用户注销账号 | `account_deactivate_enabled` | 开启/关闭 | 关闭后隐藏注销账号按钮 | +| IP 访问控制 | 注册 IP 黑名单 | `register_ip_blacklist` | 多行 IP/CIDR | 命中后无法注册和获取注册短信验证码 | +| IP 访问控制 | 登录 IP 黑名单 | `login_ip_blacklist` | 多行 IP/CIDR | 命中后无法密码登录、短信登录和刷新 Token | +| 高级功能 | 消息已读状态 | `message_read_status_enabled` | 开启/关闭 | 开启后显示消息已读/未读标记 | +| 保存设置 | - | - | 保存安全配置,写操作日志,刷新配置缓存 | + +枚举说明: + +- 所有开关均使用布尔值:`true` 开启,`false` 关闭。 +- IP 黑名单每行一个 IPv4、IPv6 或 CIDR,保存时去空行、去重、校验格式。 +- 登录 IP 黑名单优先于用户分组白名单。 + +截图: + +![安全策略](screenshots-queried/20-security-policy.png) + +## 4. 接口清单 + +从前端静态资源反查到的接口如下,建议后端按 REST 风格补齐: + +| 模块 | 接口 | +| --- | --- | +| 认证 | `/admin/auth/login`, `/admin/auth/profile`, `/admin/auth/password`, `/admin/auth/totp/status`, `/admin/auth/totp/setup`, `/admin/auth/totp/bind`, `/admin/auth/totp/unbind` | +| 统计 | `/admin/stats` | +| 用户 | `/admin/users`, `/admin/users/{id}`, `/admin/users/{id}/status`, `/admin/users/{id}/username`, `/admin/users/{id}/reset_password`, `/admin/users/{id}/ip-whitelist`, `/admin/users/{id}/friend-privilege`, `/admin/users/{id}/security-perm`, `/admin/users/{id}/kick`, `/admin/users/{id}/deactivate`, `/admin/users/batch`, `/admin/users/batch-ban`, `/admin/users/ban-by-ip`, `/admin/users/import-xlsx` | +| 用户分组 | `/admin/user-policy-groups`, `/admin/user-policy-groups/{id}`, `/admin/user-policy-groups/{id}/users`, `/admin/user-policy-groups/ip-whitelist` | +| 群组 | `/admin/groups`, `/admin/groups/{id}`, `/admin/groups/{id}/ban`, `/admin/groups/{id}/unban`, `/admin/groups/{id}/members`, `/admin/groups/{id}/members/kick`, `/admin/groups/{id}/members/mute`, `/admin/groups/{id}/members/unmute`, `/admin/groups/{id}/files`, `/admin/groups/{id}/files/{fileId}`, `/admin/groups/events`, `/admin/groups/quick-create` | +| 消息与文件 | `/admin/messages/search`, `/admin/messages/{id}` | +| 邀请码 | `/admin/invite-codes`, `/admin/invite-codes/{id}`, `/admin/invite-codes/{id}/usages` | +| 敏感词 | `/admin/sensitive-words`, `/admin/sensitive-words/{id}`, `/admin/sensitive-words/import`, `/admin/sensitive-words/export` | +| 反馈 | `/admin/feedbacks`, `/admin/feedbacks/{id}`, `/admin/feedbacks/{id}/priority`, `/admin/feedbacks/batch-handle`, `/admin/feedbacks/export` | +| 审计 | `/admin/audit/logs`, `/admin/audit/user-account-logs`, `/admin/audit/ip-user-stats`, `/admin/audit/ip-user-stats/users`, `/admin/control/audit-logs` | +| 登录日志 | `/admin/admin-login-history`, `/admin/user-login-history`, `/admin/login-failure-logs` | +| 设置 | `/admin/tenant-settings`, `/admin/security-settings`, `/admin/security/unlock` | +| 当前菜单未展示但 bundle 存在 | `/admin/tenants`, `/admin/admins`, `/admin/tenant-admins`, `/admin/roles`, `/admin/nodes`, `/admin/system/nodes-status`, `/admin/avatar-pool`, `/admin/avatar-pool/backfill`, `/admin/system-conversations`, `/admin/system-conversations/send`, `/admin/conversation-notices`, `/admin/reports`, `/admin/login-streak/rank`, `/admin/private-msg/rank`, `/admin/user-password`, `/admin/keyword-alerts`, `/admin/alert-keywords`, `/admin/alert-bot-config`, `/admin/domain/encrypt`, `/admin/domain/decrypt`, `/admin/domain/obfuscate`, `/admin/domain/config`, `/admin/domain/push-update`, `/admin/domain/push-bucket-update` | + +通用响应结构建议: + +```json +{ + "code": 0, + "message": "ok", + "data": { + "items": [], + "total": 0, + "page": 1, + "size": 20 + } +} +``` + +列表接口统一分页参数: + +- `page` +- `page_size` +- 各页面查询条件 + +## 5. 核心数据模型建议 + +建议至少设计以下数据表或领域模型: + +- 管理员账号:账号、密码、TOTP、角色、权限、登录历史 +- 用户:用户编号、用户名、昵称、手机号、头像、状态、注册方式、用户分组、最近登录、创建时间 +- 用户分组:权限开关、好友上限、登录白名单、排序权重、默认分组标记 +- 群组:群号、名称、群主、成员数、状态、创建时间 +- 群成员:用户、群、角色、群昵称、禁言状态、加入时间 +- 消息:会话类型、发送人、接收人/群、消息类型、内容、文件信息、状态、发送时间 +- 文件记录:发送人、接收方、文件类型、文件名、大小、URL、消息关联、状态 +- 邀请码:邀请码、指定用户、自动加好友、使用人数、创建人、状态、有效期 +- 敏感词:词条、等级、替换词、状态、创建时间 +- 用户反馈:用户、联系方式、内容、优先级、状态、处理人、处理备注 +- 操作日志:管理员、动作、目标类型、目标、查询条件、IP、时间 +- 用户账号审计:目标用户、操作者、操作类型、变更前后、IP、归属地、设备 +- 群事件审计:群、事件类型、操作人、目标用户、可见范围、旧值、新值、时间 +- 登录历史:账号类型、账号、登录时间、设备信息、IP、归属地 +- 登录失败日志:类型、账号、失败原因、IP、User-Agent、锁定/解锁状态 +- 租户设置:消息、文件、验证码、撤回时间等私域配置 +- 安全策略:密码、注册登录、聊天、群组、注销、IP 黑名单、已读状态配置 + +## 6. 权限建议 + +前端 bundle 中能看到的权限点包括: + +- `tenant_manage` +- `user_manage` +- `group_manage` +- `view_private_chat` +- `view_group_chat` +- `view_audit_log` + +建议在 HotGo 中拆成更细的菜单/按钮权限: + +- 用户管理:查看、创建、批量创建、导入、详情、重置密码、调整分组、封禁、强制下线、注销、IP 白名单 +- 群组管理:查看、详情、群文件、封禁、解散、成员踢出、禁言/解除禁言 +- 内容审核:消息查看、文件预览下载删除、敏感词增删改导入导出、反馈处理导出 +- 日志审计:查看、导出、解锁、IP 关联用户查看、按 IP 封禁 +- 系统设置:私域设置保存、安全策略保存 + +## 7. 实现优先级 + +第一阶段建议先完成可见菜单: + +1. 后台登录、权限、菜单、布局。 +2. 数据概览统计。 +3. 用户管理、用户分组、群组管理、群成员管理、邀请码管理。 +4. 单聊/群聊消息、文件管理、敏感词库、用户反馈。 +5. 操作日志、用户账号审计、群事件审计、登录历史、IP 统计、失败日志。 +6. 私域设置、安全策略。 + +第二阶段再补 bundle 中存在但当前菜单未展示的能力: + +- 租户、管理员、角色、节点、头像池、系统会话、系统通知、报表、排行榜、关键词告警、域名加密等。 + +## 8. 采集产物 + +本次调研文件保存在: + +- 截图目录:`docs/admin-adfahi-research/screenshots-queried/` +- 每页结构化采集:`docs/admin-adfahi-research/*.json` +- 前端 bundle:`docs/admin-adfahi-research/index-RosMaKu2.js` +- 接口尝试结果:`docs/admin-adfahi-research/api-samples.json` diff --git a/docs/backstage/IM-Admin内部查询接口缺口清单.md b/docs/backstage/IM-Admin内部查询接口缺口清单.md new file mode 100644 index 0000000..6831bc8 --- /dev/null +++ b/docs/backstage/IM-Admin内部查询接口缺口清单.md @@ -0,0 +1,582 @@ +# IM Admin 内部查询接口缺口清单 + +> 目标:梳理 HotGo/Admin 迁移到“Admin 只访问 hotgo,IM Server 只访问租户库”后,IM Server 需要补齐的 `/internal/admin-query/*` 只读接口。 +> 当前日期:2026-08-27 +> 适用项目:`im_manager` Admin 后台、`im_server` IM 服务端。 + +## 1. 背景 + +Kafka 写链路已经在 Admin 侧补齐:Admin 写操作落 `im_admin_commands`、`im_admin_outbox`,通过 Kafka 发给 IM Server,IM Server 执行业务后回传 `im.admin.command.result`。 + +查询链路还没有完全隔离。Admin 当前仍有不少页面通过 `imtenant.DB()` 或 `resolveImTenantDB()` 直连租户库。若要严格满足目标架构,Admin 查询租户业务数据时应调用 IM Server 的内部只读接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/... +``` + +IM Server 当前已经具备部分内部查询接口,但还不能覆盖 Admin 后台页面。 + +## 2. 已有接口 + +以下接口在 IM Server 当前实现中已经存在,可供 Admin 先迁移: + +| 接口 | 用途 | 备注 | +|---|---|---| +| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 用户详情 | 需要增强字段,见 P0。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 群详情和治理配置 | 需要增强 topic 基础字段,见 P0。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 群成员列表 | 当前偏 cursor 分页,Admin 表格还需要 total。 | +| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 消息详情 | 当前 Admin 暂未完整迁移消息页面。 | +| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 文件详情 | 还缺文件列表和引用列表。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | Kafka 死信列表 | Admin 已接入。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | Kafka 死信详情 | Admin 已接入。 | + +## 3. 统一约定 + +### 3.1 鉴权 + +沿用 IM Server 内部 API HMAC 鉴权: + +```text +X-Service-Name +X-Timestamp +X-Nonce +X-Signature +``` + +服务端需要校验: + +1. service 是否存在。 +2. service 是否包含 `admin_query` capability。 +3. timestamp 是否在允许窗口内。 +4. nonce 是否防重放。 +5. 签名是否匹配。 + +### 3.2 分页返回 + +Admin 的 `BasicTable` 更适合 page/per_page/total 格式。建议所有列表接口统一返回: + +```json +{ + "code": 0, + "data": { + "items": [], + "total": 0, + "page": 1, + "per_page": 20 + } +} +``` + +如果 IM Server 内部更适合 cursor,也建议同时返回: + +```json +{ + "code": 0, + "data": { + "items": [], + "total": 0, + "page": 1, + "per_page": 20, + "next_cursor": "", + "has_more": false + } +} +``` + +### 3.3 详情返回 + +详情接口统一返回: + +```json +{ + "code": 0, + "data": {} +} +``` + +### 3.4 时间与 ID + +1. 时间统一返回 RFC3339/RFC3339Nano 字符串。 +2. 雪花 ID、Tinode 用户 ID、19 位整数 ID 建议以字符串返回,避免前端丢精度。 +3. 入参同时兼容字符串 ID 和纯数字 ID 时,IM Server 内部自行解析。 + +## 4. P0:优先补齐 + +P0 是 Admin 当前核心页面迁移所需接口,补完后可以先迁移用户、群组、文件等高频页面。 + +### 4.1 用户列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/users +``` + +用途:IM 用户管理列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `user_id` | string | 用户 ID,支持精确查询。 | +| `nickname` | string | 昵称模糊查询。 | +| `auth_name` | string | 登录标识,匹配 username/phone/email/凭证。 | +| `state` | int | 用户状态。 | +| `created_at_start` | string | 创建时间开始。 | +| `created_at_end` | string | 创建时间结束。 | +| `last_seen_start` | string | 最后在线开始。 | +| `last_seen_end` | string | 最后在线结束。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量,最大建议 200。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `user_id` | 字符串用户 ID。 | +| `username` | 用户名。 | +| `phone` | 手机号。 | +| `nickname` | 昵称。 | +| `public_account` | 公开账号。 | +| `state` | 用户状态。 | +| `gender` | 性别。 | +| `region` | 地区。 | +| `auth_names` | 登录标识摘要。 | +| `credentials` | 凭证摘要。 | +| `auth_version` | 鉴权版本。 | +| `password_updated_at` | 密码更新时间。 | +| `last_seen` | 最后在线时间。 | +| `user_agent` | 最后设备 UA。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | + +当前 Admin 直连涉及表:`im_users`、`im_user_profiles`、`im_user_security`、`im_credentials`。 + +### 4.2 用户详情增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/users/{user_id} +``` + +建议增强返回字段: + +| 字段 | 说明 | +|---|---| +| `profile` | 结构化用户资料。 | +| `public` | Tinode public JSON。 | +| `trusted` | Tinode trusted JSON。 | +| `access` | 访问权限 JSON。 | +| `tags` | 标签。 | +| `security` | `auth_version/password_updated_at` 等安全信息。 | +| `credentials` | 凭证列表,包含 method/value/done。 | + +### 4.3 用户设备列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}/devices +``` + +用途:用户详情中的登录设备列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `device_id` | string | 设备 ID。 | +| `platform` | string | 平台。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `device_id` | 设备 ID。 | +| `platform` | 平台。 | +| `user_agent` | UA。 | +| `last_seen` | 最后在线。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | + +当前 Admin 直连涉及表:`im_devices`。 + +### 4.4 群列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups +``` + +用途:IM 群组管理列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `topic` | string | 群 Topic。 | +| `owner_id` | string | 群主 ID。 | +| `state` | int | Tinode topic 状态。 | +| `group_status` | string | 业务群状态。 | +| `created_at_start` | string | 创建时间开始。 | +| `created_at_end` | string | 创建时间结束。 | +| `touched_at_start` | string | 活跃时间开始。 | +| `touched_at_end` | string | 活跃时间结束。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `topic` | 群 Topic。 | +| `state` | Tinode 状态。 | +| `owner_id` | 群主 ID。 | +| `seq_id` | 最新消息序号。 | +| `del_id` | 删除序号。 | +| `sub_count` | Tinode 订阅成员数。 | +| `touched_at` | 活跃时间。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | +| `public` | Tinode public JSON。 | +| `trusted` | Tinode trusted JSON。 | +| `tags` | 标签。 | +| `aux` | 辅助 JSON。 | +| `group_name` | 群名称。 | +| `avatar_file_id` | 群头像文件 ID。 | +| `description` | 群简介。 | +| `group_status` | 业务群状态。 | +| `join_approval` | 入群审批开关。 | +| `mute_all` | 全员禁言。 | +| `member_upload_files` | 成员上传文件。 | +| `member_private_chat` | 成员私聊。 | +| `member_invite` | 成员邀请。 | +| `max_member_count` | 最大成员数。 | +| `max_admin_count` | 最大管理员数。 | +| `business_member_count` | 业务成员数。 | +| `settings_version` | 治理配置版本。 | +| `settings_updated_at` | 治理配置更新时间。 | + +当前 Admin 直连涉及表:`im_topics`、`im_group_settings`。 + +### 4.5 群详情增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic} +``` + +当前接口主要返回 `im_group_settings`。建议增强 Tinode topic 基础字段: + +| 字段 | 说明 | +|---|---| +| `state` | Topic 状态。 | +| `seq_id` | 最新消息序号。 | +| `del_id` | 删除序号。 | +| `sub_count` | 订阅数量。 | +| `public` | public JSON。 | +| `trusted` | trusted JSON。 | +| `access` | access JSON。 | +| `tags` | tags JSON。 | +| `aux` | aux JSON。 | +| `touched_at` | 活跃时间。 | + +### 4.6 群成员列表增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members +``` + +建议增强查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `user_id` | string | 用户 ID 精确过滤。 | +| `role` | string | `owner/admin/member`。 | +| `member_status` | string | 成员状态。 | +| `q` | string | 昵称、公开账号等关键词。 | +| `eligible_role` | string | 候选角色筛选。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | +| `cursor` | string | 兼容 cursor 分页。 | +| `limit` | int | 兼容 cursor 分页。 | + +建议增强返回: + +| 字段 | 说明 | +|---|---| +| `total` | 总数,Admin 表格需要。 | +| `items[].user_id` | 用户 ID。 | +| `items[].nickname` | 昵称。 | +| `items[].state` | 用户状态。 | +| `items[].mode_want` | 期望权限。 | +| `items[].mode_given` | 授予权限。 | +| `items[].recv_seq_id` | 接收序号。 | +| `items[].read_seq_id` | 已读序号。 | +| `items[].del_id` | 删除序号。 | +| `items[].role` | 业务角色。 | +| `items[].member_status` | 成员状态。 | +| `items[].group_nickname` | 群昵称。 | +| `items[].pinned` | 置顶。 | +| `items[].mute_notifications` | 免打扰。 | +| `items[].join_source` | 加入来源。 | +| `items[].joined_by` | 邀请或处理人。 | +| `items[].joined_at` | 加入时间。 | +| `items[].left_at` | 离开时间。 | +| `items[].removed_by` | 移除人。 | +| `items[].version` | 版本。 | + +当前 Admin 直连涉及表:`im_group_members`、`im_subscriptions`、`im_users`、`im_user_profiles`。 + +### 4.7 入群申请列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/join-requests +``` + +建议查询参数:`status`、`applicant_user_id`、`page`、`per_page`。 + +建议返回字段:`request_id`、`topic`、`applicant_user_id`、`inviter_user_id`、`source`、`message`、`status`、`reject_reason`、`handled_by`、`handled_at`、`expires_at`、`version`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_join_requests`。 + +### 4.8 群邀请列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/invitations +``` + +建议查询参数:`status`、`target_user_id`、`inviter_user_id`、`page`、`per_page`。 + +建议返回字段:`invitation_id`、`topic`、`inviter_user_id`、`target_user_id`、`message`、`status`、`result`、`join_request_id`、`expires_at`、`handled_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_invitations`。 + +### 4.9 群公告列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/announcements +``` + +建议查询参数:`status`、`page`、`per_page`。 + +建议返回字段:`announcement_id`、`topic`、`author_user_id`、`content`、`status`、`version`、`published_at`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_announcements`。 + +### 4.10 群禁言列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/mutes +``` + +建议查询参数:`status`、`user_id`、`page`、`per_page`。 + +建议返回字段:`topic`、`user_id`、`muted_by`、`muted_at`、`expires_at`、`reason`、`status`、`revoked_by`、`revoked_at`、`version`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_mutes`。 + +### 4.11 群管理日志 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/admin-logs +``` + +建议查询参数:`action`、`operator_id`、`page`、`per_page`。 + +建议返回字段:`log_id`、`topic`、`operator_id`、`target_user_id`、`action`、`object_type`、`object_id`、`before_data`、`after_data`、`request_id`、`idempotency_key`、`created_at`。 + +当前 Admin 直连涉及表:`im_group_admin_logs`。 + +### 4.12 群文件列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/files +``` + +用途:群治理页面中的群文件列表。不要和已有单文件详情接口混淆。 + +建议查询参数:`status`、`file_type`、`file_name`、`uploader_id`、`page`、`per_page`。 + +建议返回字段:`group_file_id`、`topic`、`file_id`、`source_topic`、`source_seq`、`uploader_id`、`file_name`、`mime_type`、`file_type`、`file_size`、`status`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_files`。 + +### 4.13 文件列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/files +``` + +用途:IM 文件管理列表。 + +建议查询参数:`file_id`、`user_id`、`status`、`mime_type`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`file_id`、`user_id`、`mime_type`、`file_size`、`file_name`、`location`、`etag`、`status`、`quarantine_reason`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_fileuploads`。 + +### 4.14 文件引用列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}/links +``` + +用途:文件详情中展示消息引用。 + +建议返回字段:`topic`、`seq_id`、`user_id`、`created_at`。 + +当前 Admin 直连涉及表:`im_filemsglinks`。 + +## 5. P1:后台审核和运营页面 + +### 5.1 审计日志 + +```text +GET /internal/admin-query/tenants/{tenant_code}/audit-logs +GET /internal/admin-query/tenants/{tenant_code}/audit-logs/{id} +``` + +建议查询参数:`actor_user_id`、`action`、`target_type`、`target_id`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`request_id`、`actor_user_id`、`action`、`target_type`、`target_id`、`ip`、`user_agent`、`detail`、`created_at`。 + +当前 Admin 直连涉及表:`im_audit_logs`。 + +### 5.2 实名审核 + +```text +GET /internal/admin-query/tenants/{tenant_code}/realname-verifications +GET /internal/admin-query/tenants/{tenant_code}/realname-verifications/{id} +``` + +建议查询参数:`user_id`、`status`、`real_name`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`user_id`、`real_name`、`id_card_no_masked`、`front_file_id`、`back_file_id`、`status`、`reject_reason`、`reviewed_by`、`reviewed_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_realname_verifications`。 + +### 5.3 举报工单 + +```text +GET /internal/admin-query/tenants/{tenant_code}/abuse-reports +GET /internal/admin-query/tenants/{tenant_code}/abuse-reports/{id} +GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target +``` + +建议查询参数:`reporter_user_id`、`target_type`、`target_id`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`report_id`、`reporter_user_id`、`target_type`、`target_id`、`reason_code`、`description`、`evidence_file_ids`、`status`、`handler_id`、`handled_at`、`handle_result`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_abuse_reports`。 + +### 5.4 意见反馈 + +```text +GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions +GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions/{id} +``` + +建议查询参数:`user_id`、`category`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`feedback_id`、`user_id`、`category`、`content`、`contact`、`file_ids`、`status`、`handler_id`、`handled_at`、`reply`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_feedback_submissions`。 + +### 5.5 业务 Outbox + +```text +GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events +GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events +GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events/{id} +GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events/{id} +``` + +建议查询参数:`event_type`、`status`、`target_type`、`target`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +当前 Admin 直连涉及表:`im_business_events_outbox`、`im_group_events_outbox`。 + +## 6. P2:如果要完全消除租户库直连 + +这些不是 Kafka 联调第一优先级,但如果最终要求 Admin 完全不碰租户库,也需要 IM Server 提供内部查询或改为 hotgo 摘要表。 + +### 6.1 组织架构 + +```text +GET /internal/admin-query/tenants/{tenant_code}/org/departments +GET /internal/admin-query/tenants/{tenant_code}/org/members +``` + +当前 Admin 直连涉及表:`im_org_departments`、`im_org_members`、`im_users`。 + +### 6.2 工作台应用 + +```text +GET /internal/admin-query/tenants/{tenant_code}/workbench/apps +``` + +当前 Admin 直连涉及工作台相关租户表。 + +### 6.3 租户任务看板摘要 + +```text +GET /internal/admin-query/tenants/{tenant_code}/dashboard/summary +``` + +当前 Admin 会统计多个租户库中的待处理实名、举报、反馈、Outbox 等数量。 + +### 6.4 租户配置类查询 + +如果这些配置仍放在租户库,也需要补接口;如果后续迁到 hotgo 控制库,则不需要通过 IM Server 查询。 + +```text +GET /internal/admin-query/tenants/{tenant_code}/feedback-config +GET /internal/admin-query/tenants/{tenant_code}/mailbox-config +GET /internal/admin-query/tenants/{tenant_code}/abuse-reasons +GET /internal/admin-query/tenants/{tenant_code}/app-releases +``` + +涉及模块:反馈配置、总经理信箱配置、举报原因、App 发布策略。 + +## 7. Admin 侧迁移建议 + +### 7.1 第一批迁移 + +在 IM Server 补完 P0 后,Admin 优先迁移: + +1. 用户列表、用户详情、用户设备。 +2. 群列表、群详情、群成员。 +3. 群入群申请、邀请、公告、禁言、管理日志、群文件。 +4. 文件列表、文件详情、文件引用。 + +### 7.2 第二批迁移 + +在 IM Server 补完 P1 后,Admin 再迁移: + +1. 审计日志。 +2. 实名审核。 +3. 举报工单。 +4. 意见反馈。 +5. 业务 Outbox。 + +### 7.3 保留 hotgo 查询的范围 + +以下数据天然属于 hotgo 控制库,Admin 可继续查 hotgo,不需要走 IM Server: + +1. 租户表和租户绑定表。 +2. Admin 命令表:`im_admin_commands`。 +3. Admin Outbox 表:`im_admin_outbox`。 +4. Admin Kafka 审计事件表:`im_audit_events`。 +5. 菜单、角色、管理员、系统配置、字典等 HotGo 系统表。 + +## 8. 验收标准 + +1. Admin 查询租户业务数据时,不再出现新的 `imtenant.DB()` 调用。 +2. Admin 查询 IM Server 内部接口时,统一使用 HMAC 签名。 +3. 列表接口支持 `page/per_page/total`,兼容后台表格分页。 +4. 详情接口返回结构化 JSON,不返回数据库内部敏感字段。 +5. 用户 ID、雪花 ID 等大整数以字符串返回。 +6. IM Server 为内部查询写审计日志,至少包含 `tenant_code`、`service_name`、`path`、`query`、`trace_id`、`status`、`duration_ms`。 +7. 关闭 IM Server 内部查询能力时,Admin 应得到明确错误,不泄露租户库信息。 + diff --git a/docs/backstage/IM-Kafka接入方案.md b/docs/backstage/IM-Kafka接入方案.md index 5f23e0c..c9dfa26 100644 --- a/docs/backstage/IM-Kafka接入方案.md +++ b/docs/backstage/IM-Kafka接入方案.md @@ -1,7 +1,7 @@ # IM Kafka 接入方案 -> 日期:2026-08-26 -> 关联文档:[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM 前后端逐图对接协议](./IM-前后端对接文档.md) +> 日期:2026-08-26 +> 关联文档:[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM 前后端逐图对接协议](./IM-前后端对接文档.md) > 目标:明确 Admin 后台、IM 服务、HotGo 控制库、租户业务库之间的 Kafka 接入边界、查询方式、鉴权方式和首批落地范围。 ## 0. 与范围识别文档的关系 @@ -786,6 +786,55 @@ Admin 处理命令: 2. IM 按死信记录中的 `source_topic` 重新投递原 envelope。 3. 投递成功后把死信标记为 `replayed`,并累计 `replay_count`。 4. 如果重放后仍失败,普通消费者会再次写入新的 dead 记录,保留完整处理痕迹。 + +### 11.12 IM Server 已接入的用户治理命令 + +IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin 侧写 hotgo 操作单并发送 Kafka Command,IM 消费后修改租户库用户状态、踢在线连接、清理缓存并回传 `im.admin.command.result`。 + +支持命令: + +| `event_type` | 行为 | Payload 关键字段 | +| --- | --- | --- | +| `user.ban` | 封禁用户,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.unban` | 解封用户,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.freeze` | 冻结账号,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.unfreeze` | 恢复账号,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.device.kick` | 踢指定设备在线连接,并删除设备推送记录。 | `actor_user_id`、`user_id` 或 `target_user_id`、`device_id`、`reason` | +| `user.logout_all` | 踢用户所有在线连接,不修改账号状态。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.account.cancel` | 注销账号,默认软删除;`hard=true` 时硬删除。 | `actor_user_id`、`user_id` 或 `target_user_id`、`hard`、`reason` | +| `user.account.delete` | 删除账号,按硬删除处理。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | + +命令示例: + +```json +{ + "event_id": "admin:user:ban:default:usrabc:001", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "user.ban", + "biz_type": "user_governance", + "biz_id": "usrabc", + "idempotency_key": "admin-operation-id", + "occurred_at": "2026-08-26T10:10:00.000Z", + "payload": { + "command_id": "admin-operation-id", + "actor_user_id": "usrAdmin", + "user_id": "usrabc", + "reason": "违规处理" + } +} +``` + +处理说明: + +1. 封禁/冻结共用 Tinode 用户状态 `StateSuspended`;解封/恢复共用 `StateOK`。 +2. 状态变更后 IM 会清理用户缓存,并向本节点 Hub 投递用户状态变更。 +3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。 +4. 全端登出只踢在线连接,不修改用户状态。 +5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。 +6. 命令执行成功后统一回传 `im.admin.command.result`,并写入 `im.audit.event`。 + 7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。 事件示例: diff --git a/docs/backstage/IM-Kafka接入联调测试文档.md b/docs/backstage/IM-Kafka接入联调测试文档.md new file mode 100644 index 0000000..5fa0a54 --- /dev/null +++ b/docs/backstage/IM-Kafka接入联调测试文档.md @@ -0,0 +1,1144 @@ +# IM Kafka 接入联调测试文档 + +> 关联文档:[IM Kafka 接入方案](./IM-Kafka接入方案.md)、[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM Kafka 基础表 SQL](./sql/IM-Kafka基础表SQL.sql) +> 适用范围:Admin/HotGo 与 IM Server 通过 Kafka 和内部只读 API 联调。 +> 当前约定:Admin 只访问 `hotgo`,IM Server 只访问租户库;跨系统写操作走 Kafka,跨系统查询走内部只读 API。 + +## 1. 联调目标 + +1. 验证 Kafka 基础生产、消费、幂等、重试、死信、Outbox 能正常工作。 +2. 验证 Admin 后台操作通过 `im.admin.command` 投递到 IM Server,并通过 `im.admin.command.result` 返回最终状态。 +3. 验证 IM Server 消息异步落库、文件生命周期、工作台事件、审计事件、缓存失效、死信处理、用户治理命令全部可用。 +4. 验证 Admin 与 IM Server 不跨库直连:Admin 查询租户业务数据走 IM 内部 API;IM 查询 hotgo 配置走 Admin 内部 API。 +5. 验证 Kafka 异常情况下不会产生重复业务结果,且失败可重试、可进入死信、可人工重放。 + +## 2. 环境准备 + +### 2.1 服务与依赖 + +| 组件 | 要求 | +| --- | --- | +| MySQL | `hotgo` 控制库、租户库如 `tinode` 已创建并可连接。 | +| Redis | IM Server 已配置 Redis,用于在线状态、短状态、热缓存和跨节点 fanout。 | +| Kafka | Topic 已创建或允许自动创建;建议测试环境显式创建。 | +| OSS | 文件上传下载仍走 OSS,Kafka 只处理文件元数据和生命周期事件。 | +| Admin/HotGo | 已接入 Kafka producer/consumer、outbox、内部配置查询接口。 | +| IM Server | 已启用 Kafka、内部 Admin Query API、Admin Internal Config Client。 | + +### 2.2 Kafka Topic + +| Topic | 方向 | 用途 | +| --- | --- | --- | +| `im.admin.command` | Admin -> IM | 后台群治理、用户治理、死信处理命令。 | +| `im.admin.command.result` | IM -> Admin | 后台命令执行结果。 | +| `im.config.event` | Admin -> IM | 租户配置、字典、第三方配置变更通知。 | +| `im.cache.invalidate` | Admin/IM -> IM | 跨节点缓存失效。 | +| `im.message.persist` | IM -> IM | 私聊、群聊消息异步落库。 | +| `im.file.lifecycle` | IM -> IM | 文件上传完成、附件绑定、群文件登记/删除。 | +| `im.workbench.event` | Admin/IM -> IM | 工作台事件、红点、系统通知。 | +| `im.audit.event` | IM -> Admin/审计消费者 | 审计事件。 | +| `im.dead.letter` | IM -> Admin/告警 | 消费失败死信。 | + +说明:`im.message.push` 已规划但本轮不联调,离线 Push Kafka 化仍在增强项中。 + +### 2.3 数据库准备 + +租户库必须执行: + +```sql +SOURCE docs/sql/IM-Kafka基础表SQL.sql; +``` + +重点确认租户库存在: + +| 表 | 用途 | +| --- | --- | +| `im_kafka_consume_log` | 消费幂等、重试状态。 | +| `im_kafka_outbox` | IM 侧可靠生产 Kafka 事件。 | +| `im_kafka_dead_letters` | 死信记录与人工处理状态。 | +| `im_messages` | 私聊消息存储。 | +| `im_group_messages` | 群聊消息存储。 | +| `im_filemsglinks` | 私聊附件引用。 | +| `im_group_files` | 群文件和群附件引用。 | +| `im_system_notifications` | 工作台等系统通知。 | +| `im_business_events_outbox` | WS 业务事件投递队列。 | + +Admin/HotGo 侧需具备: + +| 表/能力 | 用途 | +| --- | --- | +| `im_admin_commands` | Admin 操作单,记录后台操作 `pending/running/succeeded/failed/dead`。 | +| `im_admin_outbox` | Admin outbox,与操作单同事务写入,可靠发送 Kafka。 | +| `im_kafka_dead_letter_events` | Admin 消费 `im.dead.letter` 后落 HotGo,用于告警和排障。 | +| `im_audit_events` | Admin 消费 `im.audit.event` 后落 HotGo,用于 Kafka 审计事件列表。 | +| Admin 配置表/字典表 | 为 `/internal/im-config/*` 提供数据源。 | + +Admin/HotGo 侧需执行: + +```sql +SOURCE server/storage/data/generate/im_kafka_admin_tables.sql; +SOURCE server/storage/data/generate/im_kafka_event_tables.sql; +SOURCE server/storage/data/generate/im_kafka_command_menu.sql; +SOURCE server/storage/data/generate/im_dead_letter_menu.sql; +SOURCE server/storage/data/generate/im_kafka_audit_menu.sql; +``` + +### 2.4 IM Server 配置 + +`tinode.conf` 或 Docker 环境变量: + +```json +{ + "kafka": { + "enabled": true, + "brokers": ["127.0.0.1:9092"], + "client_id": "im-server", + "consumer_group": "im-server", + "allow_auto_topic_creation": false, + "max_retry_count": 5 + }, + "internal_api": { + "enabled": true, + "time_window_seconds": 300, + "services": { + "hotgo-admin": { + "secret": "same-secret", + "allow": ["admin_query"] + } + } + }, + "admin_internal_api": { + "enabled": true, + "base_url": "http://hotgo-admin:8080", + "service_name": "im-server", + "secret": "same-secret", + "timeout_ms": 3000 + } +} +``` + +### 2.5 Admin/HotGo 配置 + +Admin 需要: + +1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.workbench.event`。 +2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`、`im.audit.event`。 +3. 内部接口客户端调用 IM `/internal/admin-query/*`。如果 IM API 前缀为 `/v1`,Admin 配置 `imInternalApi.baseUrl` 应包含 `/v1`,例如 `http://im-server:6060/v1`。 +4. 内部接口服务端提供 IM `/internal/im-config/*`。 +5. 内部接口签名与 IM 一致。 + +HMAC 签名请求头: + +| Header | 说明 | +| --- | --- | +| `X-Service-Name` | 调用方服务名,如 `hotgo-admin` 或 `im-server`。 | +| `X-Timestamp` | Unix 秒级时间戳。 | +| `X-Nonce` | 一次性随机串。 | +| `X-Signature` | HMAC-SHA256 hex 签名。 | + +签名文本: + +```text +METHOD + "\n" + RequestURI + "\n" + BodySHA256Hex + "\n" + Timestamp + "\n" + Nonce +``` + +### 2.6 Admin 与 IM Server 分工 + +| 模块 | Admin/HotGo 负责 | IM Server 负责 | +| --- | --- | --- | +| 库访问边界 | 只访问 `hotgo` 控制库,不直连租户库。 | 只访问租户库,不直连 `hotgo`。 | +| 后台写操作 | 写 Admin 操作单和 Admin outbox,生产 `im.admin.command`。 | 消费命令、执行租户业务、回传 `im.admin.command.result`。 | +| 后台查询 | 通过 IM `/internal/admin-query/*` 查询租户业务数据。 | 提供白名单只读查询接口,统一 HMAC 鉴权。 | +| IM 配置读取 | 提供 `/internal/im-config/*`。 | 通过 Admin 内部配置接口读取租户、注册、第三方配置。 | +| Kafka 死信 | 消费 `im.dead.letter`,展示告警和处理入口。 | 记录死信,提供查询接口,消费处理命令。 | +| 审计 | 消费 `im.audit.event` 并落 HotGo 审计表。 | 关键业务动作生产审计事件。 | + +### 2.7 IM 内部查询接口矩阵 + +Admin 调用以下接口时统一使用 `GET /v1/internal/admin-query/tenants/{tenant_code}/...`,如果部署未配置 `/v1` 前缀,则以实际 IM HTTP 前缀为准。 + +P0 核心页面: + +| 接口 | 用途 | +| --- | --- | +| `/users` | 用户列表。 | +| `/users/{user_id}` | 用户详情。 | +| `/users/{user_id}/devices` | 用户设备列表。 | +| `/groups` | 群列表。 | +| `/groups/{topic}` | 群详情和治理配置。 | +| `/groups/{topic}/members` | 群成员分页列表。 | +| `/groups/{topic}/join-requests` | 入群申请列表。 | +| `/groups/{topic}/invitations` | 群邀请列表。 | +| `/groups/{topic}/announcements` | 群公告列表。 | +| `/groups/{topic}/mutes` | 群禁言列表。 | +| `/groups/{topic}/admin-logs` | 群管理日志。 | +| `/groups/{topic}/files` | 群文件列表。 | +| `/files` | 文件列表。 | +| `/files/{file_id}` | 文件详情。 | +| `/files/{file_id}/links` | 文件引用列表。 | +| `/kafka/dead-letters` | Kafka 死信列表。 | +| `/kafka/dead-letters/{dead_id}` | Kafka 死信详情。 | + +P1/P2 运营和完整隔离页面: + +| 接口 | 用途 | +| --- | --- | +| `/audit-logs`、`/audit-logs/{id}` | 审计日志列表和详情。 | +| `/realname-verifications`、`/realname-verifications/{id}` | 实名审核列表和详情。 | +| `/abuse-reports`、`/abuse-reports/{id}` | 举报工单列表和详情。 | +| `/reports/abuse/{report_id}/target` | 举报目标快照。 | +| `/feedback-submissions`、`/feedback-submissions/{id}` | 意见反馈列表和详情。 | +| `/outbox/business-events`、`/outbox/group-events` | 业务 Outbox 和群 Outbox 列表。 | +| `/outbox/business-events/{id}`、`/outbox/group-events/{id}` | Outbox 详情。 | +| `/org/departments`、`/org/members` | 组织部门和组织成员。 | +| `/workbench/apps` | 工作台应用配置。 | +| `/dashboard/summary` | 租户任务看板摘要。 | +| `/feedback-config`、`/mailbox-config` | 反馈配置、总经理信箱配置。 | +| `/abuse-reasons`、`/app-releases` | 举报原因、App 发布策略。 | + +列表接口统一验收 `items/total/page/per_page`,详情接口统一验收 `code=0,data={...}`。用户 ID、雪花 ID、`seq_id`、`dead_id` 等大整数或长 ID 建议按字符串处理,避免前端丢精度。 + +### 2.8 Admin 内部配置接口矩阵 + +IM Server 调 Admin 时统一使用 HMAC 签名,Admin 需提供: + +| 接口 | 用途 | +| --- | --- | +| `GET /internal/im-config/tenants/by-code/{tenant_code}` | 按租户编码查询租户。 | +| `GET /internal/im-config/tenants/by-id/{tenant_id}` | 按租户 ID 查询租户。 | +| `GET /internal/im-config/tenants?state=active&limit=1000` | 拉取启用租户列表。 | +| `GET /internal/im-config/registration/sms` | 读取注册短信配置。 | +| `GET /internal/im-config/third-party/{group}?keys=a,b` | 读取 OSS、文件安全、声网、极光等第三方配置。 | + +联调时需要特别确认 OSS 和文件安全配置已经从 Admin 字典项返回,IM 不再依赖本地硬编码。 + +### 2.9 Admin Command 事件矩阵 + +| 事件类型 | 业务 | +| --- | --- | +| `group.profile.update` | 后台修改群资料。 | +| `group.governance.update` | 后台修改群治理配置。 | +| `group.admin.set`、`group.admin.remove` | 设置或取消群管理员。 | +| `group.member.mute`、`group.member.unmute` | 成员禁言或解禁。 | +| `group.member.remove` | 移除群成员。 | +| `group.owner.transfer` | 转让群主。 | +| `user.ban`、`user.unban` | 用户封禁和解封。 | +| `user.freeze`、`user.unfreeze` | 用户冻结和恢复。 | +| `user.device.kick` | 踢指定设备。 | +| `user.logout_all` | 全端登出。 | +| `user.account.cancel`、`user.account.delete` | 注销或删除账号。 | +| `kafka.dead_letter.ignore` | 忽略死信。 | +| `kafka.dead_letter.resolve` | 标记死信已解决。 | +| `kafka.dead_letter.replay` | 重放死信。 | + +所有 Admin Command 都必须带 `event_id`、`tenant_code`、`event_type`、`biz_type`、`biz_id`、`payload.command_id`、`payload.actor_user_id`。Admin 不应把 Kafka 投递成功当成业务成功,最终状态以 `im.admin.command.result` 为准。 + +## 3. 通用验收规则 + +每个 Kafka 用例都必须检查: + +1. Kafka 原始事件 `event_id` 全局唯一,同一事件重复投递不会重复产生业务副作用。 +2. `tenant_code` 能正确路由到目标租户库。 +3. 消费成功后 `im_kafka_consume_log.status=succeeded`。 +4. 消费失败后 `retry_count` 增加,并按 `retry_backoff_ms` 重试。 +5. 超过上限后写入 `im_kafka_dead_letters`,并投递 `im.dead.letter`。 +6. Admin Command 成功、失败、死信都必须回传 `im.admin.command.result`。 +7. 业务结果、审计事件、日志里的 `trace_id/event_id/biz_id` 可串联排查。 + +## 4. 联调用例 + +### TC-KAFKA-001 基础启动与 Topic 连通 + +目标:验证 Admin 和 IM 都能连接 Kafka,IM 消费者全部启动。 + +步骤: + +1. 启动 Kafka、Redis、MySQL。 +2. 启动 Admin/HotGo。 +3. 启动 IM Server。 +4. 查看 IM 日志应出现 Kafka enabled,且无 consumer 启动错误。 +5. Admin 在「IM管理 -> Kafka命令」点击「发送缓存失效测试」,或调用 `POST /admin/imKafkaCommand/cacheInvalidate`,发送一条无业务副作用的测试 `im.cache.invalidate` 事件。 + +预期: + +1. IM 成功消费测试事件。 +2. `im_kafka_consume_log` 写入一条 succeeded 记录。 +3. 无死信。 + +### TC-KAFKA-002 内部接口鉴权成功 + +目标:验证 Admin 调 IM 内部查询接口签名通过。 + +请求: + +```http +GET /v1/internal/admin-query/tenants/default/users/usrxxx +X-Service-Name: hotgo-admin +X-Timestamp: +X-Nonce: +X-Signature: +``` + +预期: + +1. HTTP 200。 +2. 响应 `code=0`。 +3. 返回用户基础资料。 + +### TC-KAFKA-003 内部接口鉴权失败 + +目标:验证内部接口拒绝错误签名、过期时间戳、重复 nonce。 + +步骤: + +1. 使用错误 secret 调用 `/internal/admin-query/*`。 +2. 使用超过时间窗的 timestamp 调用。 +3. 使用同一 nonce 重复调用两次。 + +预期: + +1. 错误 secret 返回 401。 +2. 过期 timestamp 返回 401。 +3. 重复 nonce 第二次返回 401。 +4. 业务数据不泄露。 + +### TC-KAFKA-004 IM 调 Admin 配置查询 + +目标:验证 IM 通过 Admin `/internal/im-config/*` 获取 hotgo 配置。 + +步骤: + +1. 开启 IM `admin_internal_api.enabled=true`。 +2. Admin 提供以下接口: + - `GET /internal/im-config/tenants/by-code/default` + - `GET /internal/im-config/tenants/by-id/1` + - `GET /internal/im-config/tenants?state=active&limit=1000` + - `GET /internal/im-config/registration/sms` + - `GET /internal/im-config/third-party/im_third_party?keys=...` +3. 重启 IM。 +4. 执行登录、上传、文件安全配置读取等动作。 + +预期: + +1. IM 优先请求 Admin 内部配置接口。 +2. Admin 验签通过并返回必要字段。 +3. IM 本地功能正常。 +4. Admin 接口异常时,IM 可回落旧 DB 适配器,日志有 warn。 + +说明:Admin `/internal/im-config/*` 是内部服务接口,不带 `/admin` 后台前缀。 + +### TC-KAFKA-005 Admin 群资料修改命令 + +目标:验证后台群资料修改通过 Kafka 到 IM。 + +Admin 发送: + +```json +{ + "event_id": "admin:group:profile:update:001", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "group.profile.update", + "biz_type": "group_admin", + "biz_id": "grpxxx", + "payload": { + "command_id": "cmd-group-profile-001", + "actor_user_id": "usrAdmin", + "topic": "grpxxx", + "version": 1, + "profile": { + "name": "新群名称", + "description": "新群描述", + "avatar_file_id": "file_xxx" + } + } +} +``` + +预期: + +1. IM 更新群业务表。 +2. `im.admin.command.result` 返回 `status=succeeded`。 +3. C 端在线群成员收到业务刷新事件或可重新拉取看到新资料。 +4. 消费日志 succeeded。 +5. 审计事件已投递。 + +### TC-KAFKA-006 Admin 群治理配置 + +目标:验证后台修改入群审批、全员禁言、成员上传、私聊、邀请开关。 + +事件类型:`group.governance.update` + +Payload: + +```json +{ + "command_id": "cmd-group-govern-001", + "actor_user_id": "usrAdmin", + "topic": "grpxxx", + "version": 2, + "governance": { + "join_approval": true, + "mute_all": true, + "member_upload_files": false, + "member_private_chat": false, + "member_invite": false + } +} +``` + +预期: + +1. 群设置版本递增或与业务规则一致。 +2. 新配置对成员发送、上传、邀请等入口生效。 +3. Admin 收到成功结果。 + +### TC-KAFKA-007 Admin 群成员治理 + +目标:验证管理员设置、取消、禁言、解禁、移除成员、转让群主。 + +事件类型: + +| 命令 | 预期 | +| --- | --- | +| `group.admin.set` | 成员角色变为 admin。 | +| `group.admin.remove` | 成员角色恢复 member。 | +| `group.member.mute` | 成员禁言生效。 | +| `group.member.unmute` | 成员禁言解除。 | +| `group.member.remove` | 成员离群,不能继续收发该群消息。 | +| `group.owner.transfer` | 群主变更,旧群主权限调整。 | + +每条命令都检查: + +1. 租户库业务表状态正确。 +2. 在线 C 端收到状态变化。 +3. Admin 操作单从 running 变为 succeeded。 +4. 重复发送同一 `event_id` 不重复执行。 + +### TC-KAFKA-008 用户封禁与解封 + +目标:验证 Admin 可通过 Kafka 封禁/解封用户。 + +封禁事件: + +```json +{ + "event_id": "admin:user:ban:001", + "tenant_code": "default", + "event_type": "user.ban", + "biz_type": "user_governance", + "biz_id": "usrxxx", + "payload": { + "command_id": "cmd-user-ban-001", + "actor_user_id": "usrAdmin", + "user_id": "usrxxx", + "reason": "违规" + } +} +``` + +步骤: + +1. 目标用户保持在线。 +2. Admin 发送 `user.ban`。 +3. 目标用户尝试继续发消息。 +4. 目标用户重新登录。 +5. Admin 发送 `user.unban`。 +6. 目标用户重新登录。 + +预期: + +1. 封禁后用户状态为 `susp`。 +2. 在线会话被踢下线。 +3. 封禁期间登录被拒绝。 +4. 解封后状态为 `ok`,可正常登录。 +5. 两条命令均回传 result。 + +### TC-KAFKA-009 用户冻结与恢复 + +目标:验证冻结/恢复账号命令。 + +事件类型:`user.freeze`、`user.unfreeze` + +预期: + +1. `user.freeze` 设置用户状态 `susp` 并踢下线。 +2. `user.unfreeze` 恢复用户状态 `ok`。 +3. 重复投递同一 `event_id` 不重复产生额外副作用。 + +### TC-KAFKA-010 踢指定设备 + +目标:验证只踢指定设备,不影响同账号其他设备。 + +前置: + +1. 同一用户使用两个设备登录,设备 ID 分别为 `device-a`、`device-b`。 + +事件: + +```json +{ + "event_id": "admin:user:device:kick:001", + "tenant_code": "default", + "event_type": "user.device.kick", + "biz_type": "user_governance", + "biz_id": "usrxxx:device-a", + "payload": { + "command_id": "cmd-device-kick-001", + "actor_user_id": "usrAdmin", + "user_id": "usrxxx", + "device_id": "device-a", + "reason": "后台踢设备" + } +} +``` + +预期: + +1. `device-a` 对应会话被踢。 +2. `device-b` 保持在线。 +3. `device-a` 推送设备记录被删除。 +4. Admin 收到 `evicted_sessions` 数量。 + +### TC-KAFKA-011 全端登出 + +目标:验证后台让用户所有设备退出登录。 + +事件类型:`user.logout_all` + +预期: + +1. 用户所有在线会话被踢。 +2. 用户状态不改变。 +3. 用户可重新登录。 + +### TC-KAFKA-012 注销与删除账号 + +目标:验证 Admin 可注销或删除租户内账号。 + +事件类型: + +| 命令 | 预期 | +| --- | --- | +| `user.account.cancel` | 默认软删除,`hard=true` 时硬删除。 | +| `user.account.delete` | 按硬删除处理。 | + +步骤: + +1. 目标用户在线。 +2. Admin 发送注销命令。 +3. 查询用户状态。 +4. 尝试重新登录。 + +预期: + +1. 用户会话被踢。 +2. 用户相关 Topic 被停止。 +3. 用户被删除或标记 `del`。 +4. 登录失败。 +5. Admin 收到 result。 + +### TC-KAFKA-013 私聊消息异步落库 + +目标:验证 Kafka 开启时私聊消息通过 `im.message.persist` 落库。 + +步骤: + +1. 用户 A 与用户 B 建立私聊。 +2. A 发送带 `x-im-client-mid` 的私聊消息。 +3. 查询 Kafka `im.message.persist`。 +4. 查询租户库 `im_messages`。 +5. A 使用同一 `x-im-client-mid` 重复发送。 + +预期: + +1. A 立即收到服务端 ack 和 `seq_id`。 +2. `seq_id` 为 BIGINT/int64。 +3. 消费成功后 `im_messages` 有一条记录。 +4. 重复发送不重复落库。 +5. 消费日志 succeeded。 + +### TC-KAFKA-014 群聊消息异步落库 + +目标:验证群消息写入 `im_group_messages`,不混入私聊表。 + +步骤: + +1. 创建群并加入至少两个成员。 +2. 成员发送群消息。 +3. 查询 `im_group_messages`。 +4. 查询 `im_messages`。 +5. 重连客户端按 Topic seq 拉取消息。 + +预期: + +1. 群消息只写 `im_group_messages`。 +2. `seq_id` 单调增长,类型为 BIGINT/int64。 +3. 客户端补偿拉取成功。 +4. 24 小时热消息可进入 Redis 热缓存。 + +### TC-KAFKA-015 消息消费失败重试 + +目标:验证 `im.message.persist` 消费失败可重试并最终成功。 + +步骤: + +1. 临时制造租户库写入失败,如锁表或停止库连接。 +2. 发送消息。 +3. 恢复数据库。 +4. 等待重试。 + +预期: + +1. `im_kafka_consume_log.retry_count` 增加。 +2. 未超过重试上限时不进入死信。 +3. 数据库恢复后消息最终落库。 +4. 状态变为 succeeded。 + +### TC-KAFKA-016 文件上传完成事件 + +目标:验证文件上传完成后产生 `im.file.lifecycle` 事实事件。 + +步骤: + +1. 客户端上传文件到 OSS。 +2. 上传完成后查询租户库文件记录。 +3. 检查 Kafka outbox 和 `im.file.lifecycle`。 + +预期: + +1. 文件字节流只进入 OSS。 +2. Kafka 只包含文件元数据,不包含文件内容。 +3. 上传完成事件可被消费并审计。 + +### TC-KAFKA-017 消息附件绑定 + +目标:验证消息落库后附件引用由 `im.file.lifecycle` 绑定。 + +步骤: + +1. 上传图片或文件。 +2. 发送带附件的私聊消息。 +3. 发送带附件的群聊消息。 +4. 查询附件引用表。 + +预期: + +1. 私聊附件写入 `im_filemsglinks`。 +2. 群聊附件写入 `im_group_files`。 +3. 重复消费不重复绑定。 +4. 文件 ACL 与业务引用可追踪。 + +### TC-KAFKA-018 群文件登记与删除 + +目标:验证群文件业务事件接入 `im.file.lifecycle`。 + +步骤: + +1. 群内登记文件。 +2. 查询 `im_group_files`。 +3. 删除群文件。 +4. 检查生命周期事件和审计事件。 + +预期: + +1. 群文件登记成功。 +2. 删除状态正确。 +3. 文件事件可用于后续回收/合规删除。 + +### TC-KAFKA-019 工作台会议事件 + +目标:验证会议创建、更新、取消、提醒、参会响应产生工作台事件。 + +步骤: + +1. 创建会议。 +2. 修改会议。 +3. 发送提醒。 +4. 参会人响应。 +5. 取消会议。 + +预期: + +1. 每个动作产生 `im.workbench.event`。 +2. 消费后写 `im_system_notifications`。 +3. 写 `im_business_events_outbox`。 +4. 在线 C 端收到系统通知或红点刷新。 +5. 审计事件已投递。 + +Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作台测试事件」,或调用 `POST /admin/imKafkaCommand/workbenchEvent`,验证 Admin 具备生产 `im.workbench.event` 的能力。真实会议、日报/月报、信箱业务结果仍以 IM Server 对应业务用例为准。 + +### TC-KAFKA-020 日报/月报事件 + +目标:验证日报、月报提交和催办事件。 + +步骤: + +1. 创建日报草稿。 +2. 保存并提交日报。 +3. 管理员催办日报。 +4. 创建、提交、重开月报。 + +预期: + +1. 对应事件进入 `im.workbench.event`。 +2. 通知目标用户正确。 +3. 工作台红点或系统通知可被客户端看到。 + +### TC-KAFKA-021 总经理信箱事件 + +目标:验证总经理信箱提交事件和审计。 + +步骤: + +1. 用户提交信箱内容。 +2. 检查 `im.workbench.event`。 +3. 检查 `im.audit.event`。 +4. Admin 后台消费后生成处理单。 + +预期: + +1. 提交成功后有工作台事件。 +2. 审计事件包含用户、租户、业务 ID。 +3. Admin 可基于事件处理。 + +### TC-KAFKA-022 配置事件失效 + +目标:验证 Admin 配置变更后 IM 清理进程内缓存。 + +事件类型: + +| 事件 | 预期 | +| --- | --- | +| `tenant.updated` | 清租户缓存。 | +| `tenant.status.changed` | 清租户缓存。 | +| `tenant.limits.updated` | 清租户配置缓存。 | +| `tenant.features.updated` | 清租户功能缓存。 | +| `dictionary.updated` | 清字典/第三方配置缓存。 | +| `third_party.updated` | 清第三方配置缓存。 | +| `registration.config.updated` | 清注册短信配置缓存。 | + +步骤: + +1. Admin 修改配置。 +2. 发送 `im.config.event`。 +3. IM 再次读取对应配置。 + +预期: + +1. IM 消费成功。 +2. 下次读取不使用旧缓存。 +3. 可从 Admin 内部配置接口重新获取新值。 + +### TC-KAFKA-023 通用缓存失效 + +目标:验证 `im.cache.invalidate` 可按 namespace 删除缓存。 + +示例事件: + +```json +{ + "event_id": "cache:user:001", + "tenant_code": "default", + "event_type": "cache.redis_object.invalidate", + "biz_type": "cache", + "biz_id": "user:usrxxx", + "payload": { + "tenant_code": "default", + "tenant_id": 1, + "namespace": "user", + "keys": ["usrxxx"] + } +} +``` + +预期: + +1. 用户 Redis 对象缓存被删除。 +2. 后续查询重新读 MySQL 或 Admin 内部配置接口。 +3. 消费幂等。 + +### TC-KAFKA-024 群热消息缓存失效 + +目标:验证群热消息可按 Topic 或 seq range 失效。 + +事件: + +```json +{ + "event_id": "cache:group-hot:001", + "tenant_code": "default", + "event_type": "cache.group_hot_messages.invalidate", + "biz_type": "cache", + "biz_id": "grpxxx", + "payload": { + "tenant_id": 1, + "namespace": "group_hot_messages", + "topic": "grpxxx", + "ranges": [{"low": 100, "hi": 120}] + } +} +``` + +预期: + +1. 指定群热消息范围失效。 +2. 客户端再次拉取时能从最终存储补偿。 + +### TC-KAFKA-025 死信生成 + +目标:验证不可恢复错误进入死信。 + +步骤: + +1. Admin 发送不存在用户或非法 payload 的命令。 +2. 或临时制造持续失败直到超过最大重试。 +3. 查询 `im_kafka_dead_letters`。 +4. 消费 `im.dead.letter`。 + +预期: + +1. 死信表出现记录。 +2. 死信 Topic 有 `kafka.dead_letter.created`。 +3. Admin 操作单收到 `status=dead` 的 result。 +4. 死信记录包含原始 envelope、reason、error、retry_count。 + +### TC-KAFKA-026 死信查询 + +目标:验证 Admin 可通过 IM 内部 API 查询死信。 + +请求: + +```http +GET /v1/internal/admin-query/tenants/default/kafka/dead-letters?status=dead&limit=50 +GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} +``` + +预期: + +1. 列表返回摘要,不返回超大 envelope。 +2. 详情返回原始 envelope。 +3. 支持 `status/biz_type/biz_id/event_type/cursor/limit` 过滤。 + +### TC-KAFKA-027 死信忽略与解决 + +目标:验证 Admin 可通过 Kafka 命令处理死信状态。 + +事件类型: + +```json +{ + "event_type": "kafka.dead_letter.ignore", + "payload": { + "command_id": "cmd-dead-ignore-001", + "actor_user_id": "usrAdmin", + "dead_id": "evt_dead_xxx", + "note": "测试死信忽略" + } +} +``` + +预期: + +1. `ignore` 后死信状态为 `ignored`。 +2. `resolve` 后死信状态为 `resolved`。 +3. Admin 收到 result。 +4. 审计事件已投递。 + +### TC-KAFKA-028 死信重放 + +目标:验证死信可人工重放。 + +步骤: + +1. 修复导致死信的业务数据或配置。 +2. Admin 发送 `kafka.dead_letter.replay`。 +3. 观察原 Topic 是否重新收到 envelope。 +4. 查询死信记录和消费日志。 + +预期: + +1. IM 将死信改为 `pending_replay`。 +2. 删除原 `im_kafka_consume_log`。 +3. 按原 `source_topic` 重投 envelope。 +4. 重投成功后死信状态为 `replayed`,`replay_count` 增加。 +5. 原业务消费者重新处理并成功。 + +### TC-KAFKA-029 审计事件 + +目标:验证关键后台和敏感操作都有审计事件。 + +覆盖: + +1. 群治理命令。 +2. 用户治理命令。 +3. 文件生命周期。 +4. 工作台状态变化。 +5. 死信生成和处理。 + +预期: + +1. `im.audit.event` 收到事件。 +2. 事件包含 `tenant_code`、`actor`、`biz_type`、`biz_id`、`event_type`、`occurred_at`。 +3. Admin 审计消费者可落 HotGo `im_audit_events`。 +4. Admin 可在「IM管理 -> Kafka审计事件」查看审计事件列表和详情。 + +### TC-KAFKA-030 Outbox 可靠投递 + +目标:验证 IM 侧 Kafka Outbox 防止业务成功但 Kafka 丢事件。 + +步骤: + +1. 临时停止 Kafka。 +2. 执行会产生 outbox 的操作,如发送消息、提交工作台事件。 +3. 查询 `im_kafka_outbox`。 +4. 恢复 Kafka。 +5. 等待 outbox worker 投递。 + +预期: + +1. Kafka 停止时业务 outbox 记录为 `pending/failed`。 +2. 恢复后记录变为 `sent`。 +3. 下游消费者最终收到事件。 +4. 不重复产生业务副作用。 + +## 5. Admin 侧联调检查清单 + +| 检查项 | 验收标准 | +| --- | --- | +| 操作单 | 每个后台写操作先写 hotgo 操作单。 | +| Admin outbox | 操作单与 outbox 同事务写入。 | +| Command 生产 | `event_id/idempotency_key/tenant_code/biz_id` 稳定可追踪。 | +| Result 消费 | `im.admin.command.result` 能更新操作单状态。 | +| 死信消费 | `im.dead.letter` 能生成告警或待处理记录。 | +| 审计消费 | `im.audit.event` 能落 HotGo `im_audit_events`,并可在 Kafka审计事件页面查看。 | +| 配置接口 | `/internal/im-config/*` 支持 IM HMAC 调用。 | +| 查询客户端 | 调 IM `/internal/admin-query/*` 带签名并处理 401/403/404/5xx。 | +| 页面状态 | 后台页面显示 pending/running/succeeded/failed/dead,不把 Kafka 发送成功当业务成功。 | + +## 6. IM Server 侧联调检查清单 + +| 检查项 | 验收标准 | +| --- | --- | +| Kafka 启动 | producer、consumer、outbox worker 正常启动。 | +| 租户路由 | `tenant_code` 能路由到正确租户库。 | +| 消费幂等 | 重复 `event_id` 不重复执行业务。 | +| 重试 | 可重试错误按退避时间重试。 | +| 死信 | 超过上限或不可重试错误进入死信并回 result。 | +| Admin Query API | 内部接口验签、鉴权、nonce 防重放生效。 | +| Admin Config Client | 可从 Admin 拉租户、注册、第三方配置。 | +| 用户治理 | 封禁/冻结踢下线,解封/恢复可登录,设备踢出只影响指定设备。 | +| 群治理 | 群资料、角色、禁言、移除、转让群主全部生效。 | +| 消息落库 | 私聊写 `im_messages`,群聊写 `im_group_messages`。 | +| 文件生命周期 | 附件引用绑定到正确业务表。 | +| 工作台事件 | 生成系统通知和 WS 业务事件。 | +| 审计 | 关键动作进入 `im.audit.event`。 | + +## 7. 回归用例 + +1. Kafka 关闭时,IM 基础登录、私聊、群聊、文件上传下载仍可用。 +2. Redis 异常时,Kafka 消费不应 panic;允许热缓存和 nonce 校验降级按配置表现。 +3. Admin 内部配置接口不可用时,IM 本地旧 DB 读取兜底可用。 +4. IM 内部查询接口关闭时,Admin 查询应收到 404,不泄露数据。 +5. 同一用户在多端登录,`user.device.kick` 只踢指定设备,`user.logout_all` 踢全部设备。 +6. 同一 Admin Command 重复发送 3 次,业务只成功一次,结果可重复查询。 +7. 群消息高并发发送时 `seq_id` 单调递增且无重复。 +8. 文件上传后未发送消息,不应产生错误附件引用。 +9. 工作台事件目标用户为空时,不应产生错误通知。 +10. 死信重放失败后应再次进入死信,不覆盖原处理痕迹。 + +## 8. 联调问题定位 + +| 现象 | 优先排查 | +| --- | --- | +| Admin 操作一直 pending | Admin outbox 是否成功投递;`queue.driver` 是否为 `kafka`;Kafka broker 是否可用。 | +| Admin 操作一直 running | Admin 是否消费 `im.admin.command.result`;IM 是否收到 command;`event_id` 是否重复卡在 processing。 | +| IM 消费失败 | 查 `im_kafka_consume_log.last_reason/last_error` 和 IM 日志 `event_id`。 | +| 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 | +| 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 | +| 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 | +| 消息未落库 | 查 `im_kafka_outbox` 是否 sent,`im.message.persist` 是否消费成功。 | +| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 | +| 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 | +| 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 | + +## 9. 联调完成标准 + +1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期、工作台、缓存失效、死信。 +2. Admin 操作单状态闭环可视化:pending -> running -> succeeded/failed/dead。 +3. IM 租户库无重复业务记录,Kafka 消费日志无异常堆积。 +4. 死信可查询、可忽略、可解决、可重放。 +5. Admin 与 IM 均不跨库直连,内部查询接口鉴权通过。 +6. 离线 Push Kafka 化未纳入本次验收,继续按增强项跟进。 + +## 10. 本地 Docker 联调记录 + +### 10.1 2026-08-27 冒烟联调 + +本轮不是全量联调,只完成本地 Docker 环境启动和 Kafka/内部接口主链路冒烟。后续仍需按第 4 章逐条补齐。 + +联调环境: + +| 组件 | 地址/容器 | 结果 | +| --- | --- | --- | +| MySQL | `relaxed_cori:3306`,宿主机 `127.0.0.1:3306` | 已连接,本地库包含 `hotgo`、`tinode`。 | +| Redis | `brave_blackburn:6379`,宿主机 `127.0.0.1:6379` | 已连接。 | +| Kafka | `im-local-kafka:9092`,宿主机 `127.0.0.1:19092` | 已创建联调 Topic。 | +| IM Server | `im-local-im:6060`,宿主机 `127.0.0.1:6060` | healthy,Kafka consumer 已启动。 | +| Admin/HotGo | `im-local-admin:8000`,宿主机 `127.0.0.1:8000` | healthy,Kafka consumer 已启动。 | + +说明:本地已有 Kafka 容器 `kafka:9092` 的 `advertised.listeners=localhost:9092`,Docker 内服务会回连自身 localhost,故本轮单独启动 `im-local-kafka` 用于容器联调。 + +本轮已测: + +| 用例/链路 | 测试内容 | 结果 | 证据 | +| --- | --- | --- | --- | +| TC-KAFKA-001 | Kafka Topic 创建,IM consumer 启动。 | 通过 | IM 日志显示 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.message.persist`、`im.file.lifecycle`、`im.workbench.event` consumer started。 | +| TC-KAFKA-002 | Admin -> IM 内部查询 HMAC。 | 通过 | `GET /internal/admin-query/tenants/default/users?page=1&per_page=1` 返回 `code=0`,`total=29`。 | +| TC-KAFKA-004 | IM -> Admin 内部配置 HMAC。 | 通过 | `GET /internal/im-config/tenants?limit=1000&state=active` 返回 `code=0` 和 default 租户。 | +| TC-KAFKA-008/011 | `user.logout_all` 后台用户治理命令。 | 通过 | `tinode.im_kafka_consume_log` 中 `local-logout-fixed-*` 状态为 `succeeded`。 | +| TC-KAFKA-023 | `im.cache.invalidate` 通用缓存失效。 | 通过 | `local-cache-*` 写入 `im_kafka_consume_log`,状态 `succeeded`,IM 日志显示 `kafka cache invalidate applied`。 | +| TC-KAFKA-025 | 非法 `user.logout_all` 进入死信。 | 通过 | `local-logout-*` 状态为 `dead`,`im_kafka_dead_letters` 写入 `MALFORMED_COMMAND`。 | +| TC-KAFKA-029 | 用户治理审计事件。 | 通过 | Admin 消费 `im.audit.event` 链路已验证;正式落表应为 `hotgo.im_audit_events`。 | + +本轮发现并处理: + +| 问题 | 现象 | 处理 | +| --- | --- | --- | +| Admin Docker 前端构建失败 | `pnpm run build` 非 TTY 环境报 `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`。 | Admin `Dockerfile` 增加 `ENV CI=true`。 | +| Admin Kafka 版本配置不兼容 | Admin 启动时报 `invalid version 2.0.0.0`。 | 本地联调配置临时改为 `version: "2.0.0"`。 | +| IM 启动缺少 UID key | IM 报 `crypto/xtea: invalid key size 0`。 | Docker 启动显式传入 `UID_ENCRYPTION_KEY`、`API_KEY_SALT`、`AUTH_TOKEN_KEY`。 | +| HotGo 表前缀不一致 | Admin 内部配置接口错误查询 `hg_im_tenant`,但 IM 业务/配置表正式表名应保持 `im_tenant`。 | 联调时暴露的是 Admin 查询/ORM 表名前缀问题;正式方案必须修 Admin 侧表名映射或 DAO 前缀逻辑,不能新增 `hg_im_*` 兼容表。 | +| Admin result 消费格式不兼容 | Admin 消费 `im.admin.command.result` 报 `IM命令结果缺少command_id`。 | Admin consumer 已兼容 Kafka Envelope + payload 格式。 | +| Admin 审计表前缀不一致 | Admin 消费 `im.audit.event` 错误查询 `hg_im_audit_events`,正式表名应保持 `im_audit_events`。 | 正式方案必须修 Admin 侧审计模型/DAO 表名映射,所有 `im_*` 业务表不加 `hg_` 前缀。 | + +本轮未覆盖,后续继续: + +| 范围 | 待测内容 | +| --- | --- | +| 群治理 | 群资料、治理配置、管理员设置/取消、成员禁言/解禁、移除成员、转让群主。 | +| 用户治理完整集 | `user.ban`、`user.unban`、`user.freeze`、`user.unfreeze`、`user.device.kick`、注销/删除账号。 | +| 消息异步落库 | 私聊 `im_messages`、群聊 `im_group_messages`、`seq_id` BIGINT、重复 `client_mid` 幂等。 | +| 文件生命周期 | 上传完成、附件绑定、群文件登记/删除、OSS 元数据和引用表。 | +| 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 | +| 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 | +| Outbox | Kafka 停止/恢复时 `im_kafka_outbox`、Admin outbox 的可靠投递。 | +| Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 | + +### 10.2 2026-08-27 完整后端链路联调 + +本轮在本地 Docker 环境继续补齐 Kafka 后端链路联调。覆盖方式以 Kafka 直接投递、内部 HTTP HMAC 调用、MySQL 业务表/消费日志核验为主;Admin 前端页面点击未做浏览器人工验收,页面层需要后续按菜单再走一遍。 + +联调环境延续 10.1: + +| 组件 | 地址/容器 | 结果 | +| --- | --- | --- | +| MySQL | `relaxed_cori:3306` | 正常。 | +| Redis | `brave_blackburn:6379` | 正常。 | +| Kafka | `im-local-kafka:9092`,宿主机 `127.0.0.1:19092` | 正常;Outbox 用例中曾停止并恢复。 | +| IM Server | `im-local-im:6060` | 已重建为包含本轮修复的 `im-local-server:dev`,当前 healthy。 | +| Admin/HotGo | `im-local-admin:8000` | 当前 healthy;Kafka 断开时曾 fatal 退出,见问题记录。 | + +本轮通过项: + +| 用例/范围 | 覆盖内容 | 结果 | 证据 | +| --- | --- | --- | --- | +| TC-KAFKA-003 | IM 内部查询接口未签名访问。 | 通过 | `GET /internal/admin-query/...` 未带签名返回 401 `AUTH_REQUIRED`。 | +| TC-KAFKA-004 | Admin 内部配置接口签名成功/失败。 | 通过 | 未签名返回 401;签名访问 `/internal/im-config/tenants?limit=1&state=active` 返回 default 租户。 | +| TC-KAFKA-005 | 后台群资料修改。 | 通过 | `group.profile.update` 消费 `succeeded`,`im_group_settings.name/description/version` 更新。 | +| TC-KAFKA-006 | 后台群治理配置。 | 通过 | `group.governance.update` 消费 `succeeded`,`join_approval/mute_all/member_*` 更新。 | +| TC-KAFKA-007 | 群管理员、禁言、移除、转让群主。 | 通过 | `group.admin.set/remove`、`group.member.mute/unmute/remove`、`group.owner.transfer` 均有成功消费记录;`im_group_members/im_group_mutes` 状态变更正确。 | +| TC-KAFKA-008 | 用户封禁与解封。 | 通过 | `user.ban/user.unban` 消费 `succeeded`,用户最终恢复 `state=0`。 | +| TC-KAFKA-009 | 用户冻结与恢复。 | 通过 | `user.freeze/user.unfreeze` 消费 `succeeded`,用户最终恢复 `state=0`。 | +| TC-KAFKA-010 | 踢指定设备。 | 通过 | `user.device.kick` 消费 `succeeded`,不存在设备按幂等成功处理。 | +| TC-KAFKA-011 | 全端登出。 | 通过 | `user.logout_all` 消费 `succeeded`。 | +| TC-KAFKA-012 | 注销与删除账号。 | 通过 | `user.account.cancel/user.account.delete` 使用本地测试账号验证,消费 `succeeded`。 | +| TC-KAFKA-013 | 私聊消息异步落库。 | 通过 | `message.persist.p2p` 消费 `succeeded`,`im_messages` 写入测试消息。 | +| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `message.persist.group` 消费 `succeeded`,`im_group_messages` 写入测试群消息。 | +| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | `file.upload.completed` 消费 `succeeded`。 | +| TC-KAFKA-017 | 消息附件绑定。 | 通过 | 群聊附件绑定写入 `im_group_files`;私聊附件绑定修复后写入 `im_filemsglinks`。 | +| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | `group.file.created/group.file.deleted` 消费 `succeeded`。 | +| TC-KAFKA-019 | 工作台会议事件。 | 通过 | `workbench.meeting.created` 消费 `succeeded`,写入 `im_system_notifications` 和 `im_business_events_outbox`。 | +| TC-KAFKA-022 | 配置事件失效。 | 通过 | `dictionary.updated` 消费 `succeeded`。 | +| TC-KAFKA-023 | 通用缓存失效。 | 通过 | `cache.redis_object.invalidate` 消费 `succeeded`。 | +| TC-KAFKA-024 | 群热消息缓存失效。 | 通过 | `cache.group_hot_messages.invalidate` 消费 `succeeded`。 | +| TC-KAFKA-025 | 死信生成。 | 通过 | 故意发送非法 `user.logout_all`,消费状态 `dead`,写入 `im_kafka_dead_letters`。 | +| TC-KAFKA-026 | 死信查询。 | 通过 | `/internal/admin-query/tenants/default/kafka/dead-letters` 和详情接口返回 200。 | +| TC-KAFKA-027 | 死信忽略与解决。 | 通过 | `kafka.dead_letter.ignore/resolve` 消费 `succeeded`,死信状态变为 `ignored/resolved`。 | +| TC-KAFKA-028 | 死信重放。 | 通过 | `kafka.dead_letter.replay` 消费 `succeeded`,死信状态变为 `replayed`,`replay_count=1`。 | +| TC-KAFKA-029 | 审计事件。 | 通过 | 用户治理、群治理、死信、文件等事件投递 `im.audit.event`;正式落表应为 `hotgo.im_audit_events`。 | +| TC-KAFKA-030 | IM Kafka Outbox 可靠投递。 | 通过 | Kafka 停止时 outbox `pending -> failed`;Kafka 恢复并重启 IM 后 `failed -> sent`,下游消费成功。 | + +本轮消费统计: + +| 口径 | 数量 | +| --- | ---: | +| `codex_it_*` 和本轮修复验证事件总数 | 38 | +| 成功消费 | 35 | +| 故意制造死信 | 3 | +| 修复后仍卡 processing | 0 | + +代表性事件: + +| 类型 | Event ID | +| --- | --- | +| 用户治理批次 | `codex_it_110224_*` | +| 群成员移除复测 | `codex_it_remove_110302_271b2ff6` | +| 私聊/群聊消息落库 | `codex_it_msg_110432_*`、`codex_it_msg_110434_*` | +| 私聊附件修复验证 | `codex_it_fix_p2p_attach_b93a9a4b` | +| 工作台事件 | `codex_it_evt_110730_workbench_meeting_created_b764faa3` | +| Outbox 断 Kafka 恢复 | `codex_it_outbox_7f52e221` | +| 死信处理 | `codex_it_dead_111524_*`、`codex_it_dead_111528_*`、`codex_it_dead_111531_*` | + +本轮发现并处理: + +| 问题 | 影响 | 处理结果 | +| --- | --- | --- | +| 私聊附件绑定触发 IM panic | `file.attachments.bind` 对私聊消息调用 `store.Files.LinkAttachments(tenantID, "", msgId, attachments)` 时,空 topic 进入 `types.GetTopicCat(topic)` 导致 `slice bounds out of range`,IM 退出。 | 已修复 `server/store/store.go`:仅当 `topic != ""` 时判断 `TopicCatMe`;重建 IM 后复测私聊附件绑定成功,`im_filemsglinks` 写入。 | +| Kafka 停止时 Admin 进程 fatal 退出 | Outbox 断 Kafka 用例中停止 `im-local-kafka` 后,Admin consumer 报 `client has run out of available brokers` 并 `FATA` 退出。 | 已记录为 Admin 侧待修复问题:Kafka consumer 断 broker 不应 fatal,应退避重连。 | +| Kafka 停止/恢复后 IM consumer 自动恢复不稳定 | Kafka 恢复后,IM 仍 healthy,但消费端未立即消费新探针;重启 IM 后积压事件被补偿消费。 | 已记录为 IM 侧增强/修复项:Kafka reader 断 broker 后需要自动重建 reader 并继续消费。 | +| 备用群业务快照不一致 | 对 `grpEC7ddYMoI5s` 执行 `group.member.remove` 返回 `RESOURCE_NOT_FOUND` 并进入死信。 | 换用业务表已确认成员的 `grpgvTd1mFvM2M` 后移除成功;前一次作为死信生成用例保留。 | + +未做或需要人工补充: + +| 范围 | 说明 | +| --- | --- | +| Admin 前端页面点击 | 本轮验证了后端接口、Kafka 生产消费和数据库状态;未使用浏览器逐个菜单点选操作单页面、死信页面、审计页面。 | +| 真实客户端 WS 在线通知 | 后台命令会生成业务事件/系统通知;本轮未启动真实 C 端 WebSocket 客户端观察 UI 实时刷新。 | +| Admin 查询迁移状态 | 本轮验证 IM Server 内部查询接口可用,但 Admin 查询侧尚未全部切换到内部查询 API,页面查询隔离需要后续迁移。 | +| 离线 Push | 按既定范围仍放在未实现增强项,本轮不验收。 | + +### 10.3 2026-08-27 内部查询 API 双向联调 + +本轮在本地 Docker 环境重建 `im-local-server:dev` 和 `im-local-admin:dev` 后,对 Admin 查询 IM Server、IM Server 查询 Admin 两个方向做 HMAC 内部接口联调。IM Server 使用本地 MySQL `tinode`,Admin 使用本地 MySQL `hotgo`;Admin 本地联调容器为了避开队列消费者 Kafka 版本解析退出问题,临时使用 `disk` 队列驱动,不影响本节 HTTP 内部接口结论。 + +双向接口结果: + +| 方向 | 接口/范围 | 结果 | 证据 | +| --- | --- | --- | --- | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users?page=1&per_page=3` | 通过 | 返回 200,首条 `user_id=1565659909440147456`,JSON 类型为 number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users/{user_id}` | 通过 | 使用数据库 BIGINT 用户 ID 查询详情返回 200。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users?user_id={user_id}` | 通过 | 使用数据库 BIGINT 用户 ID 过滤列表返回 200。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/groups/grpgvTd1mFvM2M` | 通过 | 返回 200,`owner_id=1565659909045882880`,JSON 类型为 number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/groups/grpgvTd1mFvM2M/members` | 通过 | 返回 200,成员 `user_id/joined_by` 均为数据库 BIGINT number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/kafka/dead-letters?limit=1` | 通过 | 返回 200。 | +| Admin -> IM Server | 未签名请求 | 通过 | 返回 401,提示 `internal signature required`。 | +| IM Server -> Admin | `/internal/im-config/tenants?limit=1&state=active` | 通过 | 返回 200,租户 `default`,业务库 `tinode`。 | +| IM Server -> Admin | `/internal/im-config/tenants/by-code/default` | 通过 | 返回 200,租户 `default`,业务库 `tinode`。 | +| IM Server -> Admin | `/internal/im-config/registration/sms` | 通过 | 返回 200,`enabled=true`。 | +| IM Server -> Admin | `/internal/im-config/third-party/oss?keys=endpoint,bucket,access_key_id` | 通过 | 返回 200,配置 map 正常返回。 | +| IM Server -> Admin | 未签名请求 | 通过 | 返回 401。 | + +运行时链路也已确认:`im-local-im` 启动后持续通过 Docker 网络访问 `im-local-admin:8000/internal/im-config/tenants?limit=1000&state=active`,Admin 日志显示每 2 秒一次返回 200,说明不是只通过脚本单独打通。 + +本轮发现并处理: + +| 问题 | 影响 | 处理结果 | +| --- | --- | --- | +| Admin `/internal/im-config/registration/sms` HTTP 200 但业务 `code=53` | IM Server 查询注册短信开关时会拿到业务失败响应。 | 已修复 Admin 控制器扫描目标,将 `g.Map` 改为 `*entity.SysConfig`,重建 Admin 后复测返回 `code=0`。 | +| Admin 本地容器入口模板缺少新增内部接口配置 | 使用默认入口脚本会覆盖完整 `config.yaml`,导致内部接口密钥配置丢失。 | 本地联调改为挂载完整临时配置并直接启动 `./hotgo`;正式镜像建议同步补齐 `config.template`。 | +| Admin 本地 Kafka 队列版本解析退出 | Admin 启动队列消费者时 `invalid version 2.0.0.0`,进程 fatal。 | 本地 HTTP 联调临时切到 `disk` 队列驱动;Kafka consumer 退避重连和版本配置仍按增强项跟进。 | +| Admin 租户表查询仍出现 `hg_im_tenant` | 默认数据库前缀 `hg_` 会污染 `im_tenant/im_tenant_config` 这类 IM 公共表查询。 | 已修复 Admin `imtenant.RegistryDB()`:自动建立/使用 `Prefix` 为空的 `imRegistry` 连接;重建 Admin 后日志确认查询 `hotgo.im_tenant`,不再访问 `hg_im_tenant`。 | + +ID 口径结论:IM Server 外部聊天协议仍使用 `usr...`;Admin 内部管理/查询接口统一使用数据库 BIGINT/int64,查询返回和写命令入参不再混用 Tinode `usr...` 格式。 diff --git a/docs/backstage/IM-Kafka接入范围识别.md b/docs/backstage/IM-Kafka接入范围识别.md index 7e388a7..9316573 100644 --- a/docs/backstage/IM-Kafka接入范围识别.md +++ b/docs/backstage/IM-Kafka接入范围识别.md @@ -1,7 +1,7 @@ # IM Kafka 接入范围识别 -> 日期:2026-08-26 -> 依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md) +> 日期:2026-08-26 +> 依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md) > 目标:从前后端对接文档逐章识别适合接入 Kafka 的链路,明确 Kafka 与 WebSocket、Redis、MySQL、OSS 的边界。 ## 1. 总体边界 @@ -73,14 +73,7 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、 3. 总经理信箱提交生产 `im.workbench.event` 和 `im.audit.event`,后台处理结果再回推 C 端。 4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。 -## 7. 已补充:缓存失效与 Admin 配置查询 - -1. `im.cache.invalidate` 已接入 IM 消费端,支持租户、注册短信、第三方配置、字典配置、Redis 对象缓存、群热消息缓存失效。 -2. IM 已增加 Admin 内部配置查询客户端,开启 `admin_internal_api.enabled=true` 后优先通过 Admin 查询 hotgo 侧配置,旧数据库读取作为灰度兜底。 -3. Admin 侧需要补齐 `/internal/im-config/*` 只读接口,并与 IM 使用同一套 HMAC 签名密钥。 -4. Kafka 死信已补齐查询、忽略、解决、重放闭环:查询走 IM 内部 API,处理走 `im.admin.command`。 - -## 8. 首批建议实现 +## 7. 首批建议实现 1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。 2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。 diff --git a/docs/backstage/IM-后台全量需求文档.md b/docs/backstage/IM-后台全量需求文档.md new file mode 100644 index 0000000..a528341 --- /dev/null +++ b/docs/backstage/IM-后台全量需求文档.md @@ -0,0 +1,570 @@ +# IM 后台全量需求文档 + +> 版本:V1.0 草案 +> 编写日期:2026-08-21 +> 适用范围:HotGo 管理后台、IM 服务端、IM 客户端动态配置、控制库、租户业务库 +> 输入来源:`docs/im/IM-前后端对接文档.md`、`docs/im/IM-注册、邀请码与认证数据模型改造需求文档.md`、`docs/im/IM-HotGo注册短信开关与邀请码管理改造交接文档.md`、`docs/backstage/IM-后台需求文档.md` + +## 1. 文档目标 + +本文从前后端对接协议、注册/邀请码/认证改造文档中,完整识别 IM 项目需要由后台管理系统承载的能力,包括: + +1. 后台页面和业务管理模块。 +2. 平台级、租户级开关和配置。 +3. 后端统一维护、前端动态加载的字典枚举。 +4. 定时任务、Outbox、异步投递、重试和补偿任务。 +5. 客户端通过 `bootstrap`、配置接口、字典接口动态渲染的 UI 能力。 +6. 权限、审计、日志脱敏、导出和安全边界。 + +本文是后台全量需求盘点,不替代具体接口设计和数据库迁移 SQL。后续开发时,应按 HotGo 项目规范拆成独立 CRUD 模块、配置模块、任务模块和权限菜单。 + +## 2. 基础原则 + +### 2.1 多租户边界 + +当前多租户采用“一个 HotGo 控制库 + 每租户一个 IM 独立业务库”方案。 + +| 数据类型 | 存储位置 | 是否带 `tenant_id` | 说明 | +| --- | --- | --- | --- | +| 租户元数据 | HotGo 控制库 | 是 | `im_tenant`、租户配置、平台审计、邀请码主数据 | +| 平台全局配置 | HotGo 控制库 | 否或按配置域区分 | 例如 `hg_sys_config.group=im` | +| 邀请码批次和主表 | HotGo 控制库 | 是 | 后台生成、查询、撤销、导出 | +| 租户用户、群、消息、文件、组织、工单 | 租户 IM 业务库 | 否 | 通过登录态租户上下文选择库,不在业务表里保存 `tenant_id` | +| 租户内使用事实和业务 Outbox | 租户 IM 业务库 | 否 | 例如 `im_registration_invite_uses`、`im_business_events_outbox` | + +浏览器不能传入可信 `tenant_id`、`db_name`、`db_dsn`。平台管理员跨租户操作时,由后端基于登录身份和权限选择租户;租户管理员只能使用自身绑定的租户上下文。 + +### 2.2 HotGo 与 IM 服务职责 + +| 系统 | 职责 | +| --- | --- | +| HotGo 后台 | 菜单权限、后台页面、平台控制库、租户元数据、平台配置、邀请码管理、运维诊断、后台审计 | +| IM 服务端 | 客户端注册登录、实时消息、业务状态机、租户业务库事务、WebSocket、Outbox 消费、客户端动态配置下发 | +| 客户端 | 根据服务端返回的配置、权限、字典和策略动态渲染,不硬编码业务开关和枚举 | + +原则上,租户内用户、群、文件、实名、举报、反馈等业务状态变更优先通过 IM 服务端管理 API 或受控业务通道完成,避免绕过 IM 的状态机和审计。当前注册邀请码和密码重置存在明确的 HotGo 直连数据库边界,必须严格按交接文档执行。 + +### 2.3 配置分层 + +| 层级 | 示例 | 管理入口 | +| --- | --- | --- | +| 平台全局配置 | 注册短信发送开关、短信服务商、对象存储默认配置、通用风控模板 | 平台后台 | +| 租户基础配置 | 租户状态、企业码、名称、数据库、配额、品牌、终端地址 | 平台后台,部分租户只读 | +| 租户业务策略 | 消息策略、文件策略、群治理、隐私默认值、工作台模块 | 企业后台 | +| 用户个人配置 | 隐私开关、青少年模式状态、设备推送 Token | 客户端为主,后台只读或安全处置 | +| 前端动态 UI 配置 | `bootstrap.features`、`settings_menu`、`workbench.modules`、原因字典 | IM 服务端返回,来源于后台配置和权限 | + +## 3. 后台全量模块总览 + +| 模块 | 平台后台 | 企业后台 | 类型 | 优先级 | +| --- | --- | --- | --- | --- | +| 租户生命周期 | 是 | 否 | 管理页面、任务、审计 | P0 | +| 租户配置与配额 | 是 | 部分只读或申请变更 | 配置、开关、动态 UI | P0 | +| 注册短信开关 | 是 | 否 | 全局开关、审计 | P0 | +| 邀请码管理 | 平台可代管 | 是 | CRUD、导出、审计 | P0 | +| 管理员、角色、权限 | 是 | 是 | 权限、字典、审计 | P0 | +| 用户与账号安全 | 平台可运维 | 是 | 管理页面、Outbox | P0 | +| 组织架构 | 否 | 是 | CRUD、导入导出、动态 UI | P1 | +| 群治理 | 平台可审计 | 是 | 配置、管理页面、字典 | P1 | +| 消息策略与系统消息 | 平台模板 | 租户配置 | 配置、模板、任务 | P1 | +| 文件、收藏、搜索治理 | 平台运维 | 是 | 配置、任务、审计 | P1 | +| Push、短信、TURN、对象存储 | 是 | 租户专属配置 | 敏感配置、状态检查 | P1 | +| 工作台应用 | 平台模板 | 是 | 动态 UI 配置 | P2 | +| 会议 | 平台审计 | 是 | 管理页面、提醒任务 | P2 | +| 日报、月报 | 平台审计 | 是 | 模板、统计、提醒任务 | P2 | +| 总经理信箱 | 平台审计 | 是 | 配置、工单、动态文案 | P2 | +| 实名认证 | 平台审计 | 是 | 审核、策略、字典 | P2 | +| 青少年模式 | 平台模板 | 是 | 策略、动态文案、限制 | P2 | +| 反诈与内容审核 | 平台策略 | 租户策略与处置 | 风控、字典、审计 | P2 | +| 举报与反馈 | 平台可审计 | 是 | 原因字典、工单、导出 | P2 | +| App 版本更新 | 是 | 可按租户覆盖 | 配置、动态 UI | P2 | +| Outbox 与后台任务 | 是 | 只读本租户 | 运维、重试、死信 | P0 | +| 审计与日志脱敏 | 是 | 只读本租户 | 安全基础能力 | P0 | + +## 4. 开关与配置清单 + +### 4.1 平台全局开关 + +| 配置键 | 类型 | 默认 | 管理权限 | 使用方 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `im.register_sms_enabled` | bool | true | 平台超级管理员 | IM 注册发码接口 | 控制注册验证码是否真实发送;关闭时仍返回倒计时,不暴露给客户端 | +| `im.invite_batch_max_quantity` | int | 1000 | 平台管理员 | HotGo 邀请码管理 | 单批邀请码生成数量上限 | +| `im.invite_export_max_rows` | int | 待定 | 平台管理员 | HotGo 导出 | 防止明文邀请码大批量泄露 | +| `im.sms_provider_enabled` | bool | true | 平台管理员 | IM 发码服务 | 是否启用短信服务商,不等同于注册短信业务开关 | +| `im.audit_retention_days` | int | 待定 | 平台管理员 | 审计任务 | 平台审计保留期 | +| `im.outbox_retry_enabled` | bool | true | 平台运维 | Outbox 任务 | 是否自动重试平台/租户 Outbox | + +`register_sms_enabled` 存在 HotGo `hg_sys_config`,真实值必须保存为 `1/0`。前端复用 `sys_switch` 时要显式映射:UI `1=开启`、`2=关闭`,不能把 `2` 直接写成 bool。 + +### 4.2 租户基础配置 + +| 配置 | 类型 | 是否动态下发 | 管理入口 | 说明 | +| --- | --- | --- | --- | --- | +| 租户状态 | enum | 是 | 平台后台 | `provisioning/active/suspended/deleting/deleted` | +| 企业码 | string | 登录前解析 | 平台后台 | 客户端 `tenants/resolve` 使用 | +| 租户名称、Logo、品牌色 | string/file | 是 | 平台后台 | 登录页、工作台、关于页展示 | +| `db_name/db_dsn` | string | 否 | 平台后台 | 只允许后端使用,不给浏览器 | +| 用户配额 | int | 是 | 平台后台 | 注册、导入用户、邀请加入时强校验 | +| 群数量配额 | int | 是 | 平台后台 | 建群时校验 | +| 群成员上限 | int | 是 | 平台/企业后台 | 建群、邀请、审批通过时校验 | +| 文件存储配额 | int64 | 是 | 平台/企业后台 | 上传、转发、收藏、群文件共用 | +| 终端 API 地址 | url | 登录前解析 | 平台后台 | HTTP、WebSocket、文件服务地址 | +| 可用登录方式 | array enum | 登录前解析 | 平台后台 | 密码、手机号、企业码等 | +| 密码策略 | json | 登录/注册/改密 | 平台/企业后台 | 长度、复杂度、历史密码、过期策略 | +| App 更新策略 | json | 是 | 平台后台 | platform/channel 维度版本策略 | + +### 4.3 客户端 Bootstrap 动态配置 + +`GET /v1/im/bootstrap` 是客户端登录后的 UI 和能力裁剪总入口,后台必须为以下字段提供配置来源。 + +| 字段 | 后台配置项 | 客户端影响 | +| --- | --- | --- | +| `features.create_group` | 是否允许用户建群 | 隐藏或禁用建群入口 | +| `features.user_search` | 是否允许搜索用户 | 隐藏添加好友搜索入口 | +| `features.scan_qr` | 是否启用扫码 | 隐藏二维码/扫码入口 | +| `features.system_notifications` | 系统通知开关 | 展示系统通知 Topic | +| `features.message_recall` | 消息撤回开关 | 长按菜单是否显示撤回 | +| `features.message_edit` | 消息编辑开关 | 长按菜单是否显示编辑 | +| `features.organization` | 组织架构开关 | 工作台/通讯录组织入口 | +| `features.meetings` | 会议开关 | 工作台会议入口 | +| `features.daily_report/monthly_report` | 日报/月报开关 | 工作台报表入口 | +| `features.executive_mailbox` | 总经理信箱开关 | 工作台信箱入口 | +| `message_policy.recall_window_seconds` | 撤回时间窗口 | 超时隐藏操作 | +| `message_policy.edit_window_seconds` | 编辑时间窗口 | 超时隐藏操作 | +| `message_policy.supported_actions` | 消息操作集合 | 转发、收藏、复制、删除等 | +| `limits.max_file_size` | 单文件大小 | 上传前校验 | +| `limits.max_text_length` | 文本最大长度 | 输入框限制 | +| `limits.max_forward_targets` | 转发目标上限 | 多选转发限制 | +| `settings_menu` | 设置菜单配置 | iOS/Android 设置页动态入口 | +| `workbench.modules` | 工作台模块配置 | 工作台排序、图标、路由、角标 | + +### 4.4 注册、认证与邀请码配置 + +| 配置 | 作用 | 后台入口 | 备注 | +| --- | --- | --- | --- | +| 注册短信发送 | 是否真实调用短信服务商 | 平台后台“IM 注册” | 全局开关 | +| 用户名正则 | 普通用户名规则 | 平台只读配置或代码常量 | 当前固定 `^[a-z0-9]{1,10}$` | +| 固定国家号 | 手机号补 `+86` | 平台只读配置 | 本期固定中国 | +| 邀请码长度和字符集 | 生成规则 | 平台只读配置 | 当前固定 5 位 `[A-Za-z]` | +| 邀请码批量上限 | 批量生成限制 | 平台后台 | 默认 1000 | +| 邀请码有效期 | 单批/单码字段 | 企业后台 | 创建时必填,必须晚于当前时间 | +| 邀请码明文查看权限 | 控制列表/详情是否显示明文 | 菜单权限 | 查看、查询、导出需独立审计 | +| 密码重置策略 | 管理员重置密码规则 | 企业后台 | 写 `security.password_reset` Outbox | +| 登录 Token 失效策略 | `auth_version` 校验 | 后台只读/运维 | 重置密码后清退旧会话 | + +### 4.5 消息与会话策略 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 文本最大长度 | int | 输入框、服务端发送校验 | +| 消息撤回开关和时间窗 | bool/int | P2P、群聊均需服务端校验 | +| 消息编辑开关和时间窗 | bool/int | 编辑后产生版本或审计 | +| 多选转发目标上限 | int | 单选、多选、合并转发共用 | +| 支持消息操作 | array enum | copy、forward、favorite、recall、edit、delete | +| 已读回执策略 | enum/bool | 可受隐私和企业强制策略影响 | +| 链接识别与风险检测 | bool/json | URL 发送前后检查,触发反诈挑战 | +| 系统通知 Topic | string/json | 系统消息列表和角标 | +| 消息频控 | json | 防刷、风控、举报联动 | + +### 4.6 通讯录、好友与隐私策略 + +| 配置 | 类型 | 客户端影响 | +| --- | --- | --- | +| 是否允许添加好友 | bool | 添加好友入口 | +| 是否允许精准账号搜索 | bool | 用户 ID/用户名/手机号搜索 | +| 添加我的方式 | enum array | 账号、手机号、二维码、群聊 | +| 好友申请验证策略 | enum | 直接添加、需要验证、禁止 | +| 黑名单能力 | bool | 黑名单入口和服务端拦截 | +| 资料可见性默认值 | enum | 主页字段裁剪 | +| 最近在线可见性 | enum | 会话状态展示 | +| 已读回执默认值 | bool | 会话回执 | +| 企业强制隐私字段 | json | `editable=false` 时前端禁用 | + +### 4.7 群治理配置 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 是否允许建群 | bool | bootstrap 和建群接口 | +| 群成员上限 | int | 加群、邀请、审批事务内校验 | +| 入群策略 | enum | free、approval、invite_only | +| 成员邀请权限 | enum/bool | 群成员是否可邀请别人 | +| 成员上传文件 | bool | 群文件上传入口 | +| 成员私聊 | bool | 群成员主页是否允许发起会话 | +| 成员互加好友 | bool | 群资料页添加好友入口 | +| 全员禁言 | bool | 输入框禁用 | +| 成员禁言时长 | enum/int | 后台和群管理员使用 | +| 管理员数量上限 | int | 添加管理员时校验 | +| 群二维码有效期 | int | 二维码页和失效任务 | +| 群公告策略 | json | 发布、置顶、已读统计 | + +### 4.8 文件、收藏与搜索策略 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 单文件大小上限 | int64 | 上传前后校验 | +| 允许 MIME/扩展名 | array | 图片、视频、语音、普通文件按 purpose 区分 | +| 文件 purpose 策略 | json | avatar、chat、group_file、report、abuse_evidence、feedback | +| 文件保留期 | int | 过期清理任务 | +| 孤儿文件清理时间窗 | int | 上传未绑定业务时清理 | +| 文件病毒扫描 | bool/json | 同步拦截或异步隔离 | +| 收藏总量/容量上限 | int/int64 | 收藏入口和服务端校验 | +| 搜索开关 | bool | 聊天记录、文件、链接搜索入口 | +| 搜索索引策略 | json | 重建、增量、失败重试 | + +### 4.9 通话、Push 和外部服务配置 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| 音视频通话开关 | bool | 呼叫入口 | +| 通话超时 | int | invite 超时时间 | +| TURN/STUN 服务器 | secret/json | WebRTC ICE 配置 | +| 通话记录保留期 | int | 后台查询和清理 | +| APNS/FCM 配置 | secret/json | 推送服务 | +| Push 锁屏隐私 | enum/bool | 通知内容是否显示明文 | +| Push 模板 | json | 系统消息、好友申请、会议提醒等 | +| 短信服务商配置 | secret/json | 验证码、通知短信 | +| 对象存储配置 | secret/json | 文件服务 | +| 外部 REST 插件配置 | secret/json | 扩展业务调用 | + +### 4.10 办公协同配置 + +| 模块 | 配置 | 说明 | +| --- | --- | --- | +| 工作台 | 模块 code、名称、图标、路由、排序、启用、权限 | `GET /v1/im/workbench` 动态返回 | +| 组织架构 | 组织可见范围、是否允许跨部门搜索 | 通讯录和工作台组织入口 | +| 会议 | 是否启用、提醒提前量、可见范围、取消策略 | 会议通知和提醒任务 | +| 日报 | 模板、必填规则、最大长度、补交天数、提醒时间 | 普通用户和管理员统计 | +| 月报 | 模板、必填规则、最大长度、重开流程 | 月报统计和详情 | +| 总经理信箱 | 是否启用、匿名、必填字段、最大长度、附件策略、提示文案 | 文案必须服务端下发,不由客户端硬编码 | + +### 4.11 安全、审核、举报与反馈配置 + +| 模块 | 配置 | 说明 | +| --- | --- | --- | +| 实名认证 | 是否强制、允许跳过、提醒周期、重复身份策略、审核字段 | 影响 `bootstrap.security.realname` | +| 青少年模式 | 策略版本、限制集合、提示文案、是否可启用 | 客户端确认时携带 `policy_version` | +| 反诈风控 | 高风险动作、提示文案、二次确认、阻断策略、Token TTL | 返回 `RISK_CONFIRMATION_REQUIRED` | +| 内容审核 | 审核模式、词库、插件、异步处置 | sync_block、async_hold、async_post_audit | +| 举报 | target_type 原因字典、证据规则、合并策略、限频 | `GET /v1/im/reports/abuse/reasons` | +| 反馈 | 类别、最大长度、联系方式、附件策略、诊断授权文案 | `GET /v1/im/feedback/config` | + +### 4.12 App 更新配置 + +| 配置 | 类型 | 说明 | +| --- | --- | --- | +| platform | enum | ios、android、desktop 等 | +| channel | string/enum | stable、beta、enterprise | +| latest_version | string | 最新版本 | +| min_supported | string | 最低支持版本 | +| update_type | enum | none、optional、recommended、force | +| package_url/store_url | url | 安装来源 | +| release_notes | text | 更新说明 | +| remind_after_seconds | int | 非强制更新再次提醒间隔 | + +## 5. 字典与枚举清单 + +后台新增枚举必须由后端统一注册字典,前端通过字典接口加载,不能在页面硬编码文本。 + +### 5.1 平台与租户 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_tenant_status` | provisioning、active、suspended、deleting、deleted | +| `im_tenant_plan` | free、standard、enterprise、custom | +| `im_tenant_config_scope` | platform、tenant、user | +| `im_operation_result` | success、failed、partial | + +### 5.2 注册、认证、邀请码 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_register_sms_status` | enabled、disabled | +| `im_invite_status` | active、expired、revoked | +| `im_invite_action` | create、batch_create、view、query_exact、revoke、batch_revoke、export | +| `im_auth_scene` | register、login、forgot_password、bind_phone、close_youth_mode | +| `im_auth_channel` | sms、password、independent_password | +| `im_challenge_status` | pending、verified、expired、consumed、failed | +| `im_user_status` | normal、disabled、locked、deleted | +| `im_auth_level` | anonymous、authenticated、admin | + +### 5.3 好友、群与消息 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_friend_request_status` | pending、accepted、rejected、expired、cancelled | +| `im_friend_add_method` | account、phone、qr、group | +| `im_profile_visibility` | public、friends、none | +| `im_group_role` | owner、admin、member | +| `im_group_join_policy` | free、approval、invite_only | +| `im_group_request_status` | pending、approved、rejected、expired | +| `im_group_mute_type` | all、member | +| `im_message_action` | copy、forward、favorite、recall、edit、delete、multi_select | +| `im_message_content_type` | text、image、audio、video、file、card、link、system | +| `im_read_state` | unread、read | + +### 5.4 文件、搜索、通话 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_file_purpose` | chat、user_avatar、group_avatar、group_file、daily_report、monthly_report、abuse_evidence、feedback | +| `im_file_status` | uploaded、bound、quarantined、deleted、expired | +| `im_file_scan_status` | pending、clean、risky、failed | +| `im_search_index_status` | pending、indexed、failed、deleted | +| `im_call_type` | audio、video | +| `im_call_status` | ringing、accepted、declined、missed、cancelled、ended、timeout | + +### 5.5 办公协同 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_workbench_module_status` | enabled、disabled | +| `im_meeting_status` | scheduled、cancelled、finished | +| `im_meeting_response` | pending、accepted、declined、tentative | +| `im_report_type` | daily、monthly | +| `im_report_status` | draft、submitted、reopened、archived | +| `im_mailbox_submission_status` | received、assigned、replied、closed | + +### 5.6 安全、审核、举报、反馈 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_realname_status` | unverified、pending、approved、rejected | +| `im_youth_restriction` | night_quiet、content_filter、payment_guard、contact_limit | +| `im_risk_level` | low、medium、high | +| `im_risk_decision` | continue、cancel | +| `im_moderation_mode` | off、sync_block、async_hold、async_post_audit | +| `im_moderation_status` | pending、passed、blocked、removed | +| `im_abuse_target_type` | user、group、message | +| `im_abuse_report_status` | received、processing、resolved、rejected、merged | +| `im_abuse_reason` | fraud、harassment、pornography、violence、spam、other | +| `im_feedback_category` | bug、suggestion、complaint、other | +| `im_feedback_status` | received、processing、resolved、closed | + +### 5.7 终端、外部服务与错误 + +| 字典键建议 | 枚举 | +| --- | --- | +| `im_app_platform` | ios、android、desktop、web | +| `im_app_channel` | stable、beta、enterprise | +| `im_update_type` | none、optional、recommended、force | +| `im_push_provider` | apns、fcm、vendor | +| `im_outbox_status` | pending、processing、done、failed、dead | +| `im_error_reason` | TENANT_UNAVAILABLE、TENANT_MISMATCH、AUTH_FAILED、TOKEN_EXPIRED、ACCOUNT_DISABLED、RATE_LIMITED、VALIDATION_FAILED、IDEMPOTENCY_CONFLICT、RESOURCE_NOT_FOUND、FORBIDDEN、FRIEND_RELATION_REQUIRED、MESSAGE_NOT_ALLOWED、GROUP_MUTED、GROUP_MEMBER_LIMIT_EXCEEDED、TENANT_USER_QUOTA_EXCEEDED、TENANT_GROUP_QUOTA_EXCEEDED、FILE_TOO_LARGE、FILE_FORBIDDEN、FILE_UNAVAILABLE、STORAGE_QUOTA_EXCEEDED、REALNAME_REQUIRED、YOUTH_MODE_RESTRICTED、RISK_CONFIRMATION_REQUIRED、RISK_ACTION_BLOCKED、REPORT_RATE_LIMITED | + +## 6. 定时任务与异步任务 + +### 6.1 P0 基础任务 + +| 任务 | 触发方式 | 作用 | 后台能力 | +| --- | --- | --- | --- | +| 租户业务库初始化 | 创建租户后异步执行 | 创建库、执行 schema、初始化系统数据 | 进度、失败原因、重试 | +| 租户健康检查 | 定时 | 检查 DB 连接、schema version、基础 Topic | 状态看板、告警 | +| Outbox 消费状态巡检 | 定时 | 检查积压、失败、死信 | 列表、重试、死信 | +| `security.password_reset` 消费 | IM 轮询或 Kafka | 清退会话、拒绝旧 Token | 事件查询、重试 | +| 注册邀请码使用投影 | IM Outbox 或投影任务 | 将租户库 `invite_uses` 汇总到控制库主表 | 使用次数、最后使用时间、幂等补偿 | +| 审计日志脱敏与落库 | 同步/异步 | 保存操作审计,避免明文泄露 | 查询、导出、保留期 | + +### 6.2 业务任务 + +| 任务 | 说明 | +| --- | --- | +| 邀请码过期状态刷新 | 邀请码可查询时实时判断过期;可增加定时任务做状态冗余和统计,不作为注册校验唯一依据 | +| 邀请码批量生成 | 大批量生成应可异步化,支持进度、失败重试、幂等键 | +| 文件孤儿清理 | 上传后未绑定业务的文件定期清理 | +| 文件过期清理 | 按 purpose 和保留期清理 | +| 病毒扫描和隔离处理 | 异步扫描结果更新文件状态并通知用户 | +| 搜索索引重建 | 消息、文件、链接索引的全量/增量重建 | +| 系统通知投递 | 系统消息、公告、风控提醒、会议提醒的 Push 和站内信投递 | +| Push 重试和死信 | 第三方推送失败后的重试、死信、凭证异常告警 | +| 会议提醒 | 按会议配置提醒参会人,支持提醒未响应者 | +| 日报/月报提醒 | 按租户配置提醒未提交者,生成统计 | +| 总经理信箱通知 | 新提交、分派、回复、关闭通知 | +| 实名审核超时提醒 | 提醒审核员处理 | +| 风控 challenge 过期 | 清理过期 challenge,记录取消/过期审计 | +| 举报和反馈 SLA 提醒 | 超时未处理提醒,支持工单状态流转 | +| App 更新配置缓存刷新 | 版本策略变更后刷新缓存 | + +### 6.3 任务后台需求 + +1. 任务列表:任务名、租户、状态、进度、最近执行时间、耗时、错误原因。 +2. 任务详情:输入参数、幂等键、关联审计、关联 Outbox、重试次数。 +3. 操作:手动重试、暂停、恢复、标记死信、导出错误明细。 +4. 安全:租户管理员只能看本租户任务;平台运维可跨租户查看但必须审计。 + +## 7. 前端 UI 动态配置需求 + +### 7.1 客户端动态 UI + +客户端不能硬编码以下 UI 能力,必须由服务端返回: + +1. 登录前:租户品牌、企业码解析结果、终端地址、认证方式、密码策略、App 更新策略。 +2. 登录后:首页入口、`features`、`permissions`、`settings_menu`、工作台模块、系统通知 Topic。 +3. 聊天页:可执行消息操作、撤回/编辑时间窗、上传限制、转发上限、已读回执策略。 +4. 通讯录:组织架构入口、用户搜索入口、添加我的方式、黑名单入口。 +5. 群聊:入群策略、群文件入口、群成员私聊、成员邀请、禁言状态、管理员入口。 +6. 工作台:模块名称、图标、路由、排序、角标、启用状态和权限。 +7. 日报/月报:模板、校验、补交天数、是否展示管理员统计。 +8. 总经理信箱:提示文案、匿名说明、必填字段、附件规则。 +9. 实名、青少年模式、反诈:策略版本、提示文案、限制集合、确认 Token。 +10. 举报与反馈:原因、类别、最大长度、附件规则、诊断授权文案。 + +### 7.2 HotGo 管理后台动态 UI + +HotGo 后台自身也需要动态化: + +1. 平台管理员显示租户选择器;租户管理员隐藏租户选择器并固定当前租户。 +2. 按权限显示按钮:邀请码明文查看、导出、撤销、密码重置、跨租户查看必须独立权限。 +3. 表格状态、筛选下拉、表单选项必须从后端字典加载。 +4. 敏感配置字段只允许写入,不默认回显明文;回显使用脱敏值和“已配置”状态。 +5. 导出前必须展示范围、数量、敏感字段提示,二次确认并写审计。 +6. 表单提交不接受浏览器传入 `tenant_id/db_name/db_dsn` 作为可信路由依据。 + +## 8. 后台页面需求 + +### 8.1 平台后台 + +| 页面 | 核心能力 | +| --- | --- | +| 租户管理 | 创建、初始化、启停、删除流程、配置 DB、查看用量 | +| 租户配置 | 配额、品牌、终端地址、登录方式、密码策略、功能开关 | +| IM 注册设置 | 注册短信发送开关、批量邀请码上限、导出限制 | +| 平台管理员 | 平台账号、角色、权限、二次认证 | +| 外部服务配置 | SMS、Push、TURN、对象存储、审核插件 | +| App 版本 | 按 platform/channel 配置版本更新策略 | +| 运维看板 | 租户健康、Outbox、任务、缓存、连接状态 | +| 平台审计 | 跨租户操作、敏感配置变更、导出记录 | + +### 8.2 企业后台 + +| 页面 | 核心能力 | +| --- | --- | +| 邀请码管理 | 单个生成、批量生成、列表、精确查询、批次详情、撤销、导出、使用记录 | +| 企业管理员 | 添加管理员、设置角色、移除管理员、权限范围 | +| 用户管理 | 用户列表、状态、资料、重置密码、撤销会话、设备、安全事件 | +| 组织架构 | 部门树、成员、移动、导入导出、组织权限 | +| 群管理 | 群列表、详情、成员、管理员、禁言、转让群主、解散、群文件 | +| 消息与系统通知 | 模板、公告、系统通知、投递记录 | +| 文件治理 | 文件列表、用途、大小、状态、隔离、删除、下载审计 | +| 搜索治理 | 索引状态、重建、失败明细 | +| 工作台 | 模块配置、排序、图标、角标来源、权限绑定 | +| 会议 | 列表、详情、取消、提醒未响应者、统计 | +| 日报/月报 | 模板、统计、明细、提醒、重开流程 | +| 总经理信箱 | 配置、提交列表、分派、回复、关闭 | +| 实名审核 | 提交列表、详情、通过、拒绝、策略 | +| 青少年模式 | 策略、文案、用户状态、独立密码重置 | +| 反诈与审核 | 风控策略、challenge、内容审核队列、处置 | +| 举报工单 | 原因配置、举报列表、证据、处置、合并 | +| 意见反馈 | 类别配置、反馈列表、诊断附件、处理状态 | +| 租户审计 | 本租户后台操作、敏感查看、导出、业务处置 | + +## 9. 数据与接口边界 + +### 9.1 控制库建议承载 + +| 表或配置 | 说明 | +| --- | --- | +| `im_tenant` | 租户元数据、状态、企业码、DB 路由 | +| `im_tenant_config` | 租户配置、配额、功能开关、品牌 | +| `hg_sys_config` | 平台全局配置,例如注册短信发送 | +| `im_registration_invite_batches` | 邀请码批次 | +| `im_registration_invite_codes` | 邀请码主表、明文码、状态、使用统计投影 | +| `im_admin_operation_audit` | IM 后台业务审计 | +| `hg_admin_member.tenant_id` | 后台管理员绑定 IM 租户 | +| 平台 Outbox/任务表 | 平台任务、投递、重试、死信 | + +### 9.2 租户业务库建议承载 + +| 表 | 说明 | +| --- | --- | +| `im_users` | 用户主数据,目标包含 `username` 非空唯一、`phone` 可空唯一 | +| `im_auth` | 只按 `userid` 保存 Basic 密码凭据,删除 `scheme/uname` | +| `im_user_security` | `auth_version`、安全状态 | +| `im_registration_invite_uses` | 邀请码使用事实,只由 IM 注册事务写 | +| `im_business_events_outbox` | 租户内业务事件,例如密码重置、邀请码使用 | +| 用户、组织、群、文件、会议、报表、信箱、实名、举报、反馈业务表 | 不带 `tenant_id`,由选库隔离 | + +### 9.3 注册认证迁移后台需求 + +1. 增加迁移状态看板:每个租户当前 schema version、迁移批次、成功/失败数。 +2. 存量用户 username 回填报告:冲突、非法字符、保留账号、自动生成结果。 +3. phone 规范化报告:可转为 `+86`、重复冲突、格式异常。 +4. `im_auth` 双读/切换/清理阶段状态展示。 +5. 迁移前后登录回归数据:用户名登录、手机号登录、忘记密码、管理员重置密码。 +6. 失败租户支持重试和导出错误明细。 + +## 10. 权限、审计与日志脱敏 + +### 10.1 权限点 + +P0 必须独立拆分以下权限: + +1. 注册短信开关读取、修改。 +2. 邀请码列表、创建、批量创建、明文查看、精确查询、撤销、批量撤销、导出。 +3. 租户创建、启停、DB 配置查看、DB 配置修改。 +4. 用户查看、状态修改、重置密码、撤销会话、设备查看。 +5. 审计查看、审计导出。 +6. Outbox 查看、重试、标记死信。 +7. 外部服务敏感配置查看状态、修改。 + +### 10.2 审计范围 + +必须审计: + +1. 注册短信开关变更。 +2. 邀请码生成、批量生成、明文查询/查看、导出、撤销。 +3. 管理员重置密码、撤销会话、禁用用户。 +4. 租户创建、启停、删除流程、DB 路由配置变更。 +5. 群管理员变更、禁言、转让群主、解散群。 +6. 文件下载敏感证据、隔离、删除。 +7. 实名审核、举报处置、反馈处理。 +8. 风控挑战确认、取消、阻断。 +9. 任何跨租户查看、导出和代操作。 + +审计字段至少包含:`request_id`、`tenant_id`、`operator_id`、`operator_role`、`action`、`target_type`、`target_id`、`result`、`ip`、`user_agent`、`detail_json`、`created_at`。 + +### 10.3 日志脱敏 + +访问日志、错误日志、审计摘要、导出任务日志不得记录以下明文: + +1. 邀请码、验证码、密码、独立密码。 +2. `Authorization`、`serviceSecret`、短信/Push/对象存储密钥。 +3. `db_dsn`、数据库密码。 +4. 身份证件、实名原始图片、敏感证据文件临时 URL。 + +字段名至少覆盖:`inviteCode`、`invite_code`、`code`、`verification_code`、`password`、`secret`、`serviceSecret`、`service_secret`、`authorization`、`db_dsn`。 + +## 11. 验收清单 + +### 11.1 P0 + +1. 平台管理员可管理租户生命周期、配置和配额,租户管理员无法越权。 +2. 每个租户业务库独立,租户业务表不依赖 `tenant_id` 过滤。 +3. 注册短信开关只有平台超级管理员可改,值保存为 `1/0`,IM 服务缓存期内生效。 +4. 邀请码支持单个、批量、列表、精确查询、撤销、导出和使用统计。 +5. 邀请码明文查看、查询、导出都有独立权限和审计。 +6. 管理员重置密码写入 `im_auth`、递增 `auth_version`、写 `security.password_reset` Outbox。 +7. `bootstrap` 能返回功能开关、消息策略、限制、设置菜单、工作台基础配置。 +8. `hg_sys_log` 和业务日志不会泄露邀请码、密码、验证码、Token、密钥和 DSN。 + +### 11.2 P1 + +1. 用户、组织、群治理、文件治理、系统通知、Push/SMS/TURN/对象存储配置可后台管理。 +2. 群成员上限、用户配额、文件配额、消息策略在并发事务中强校验。 +3. 文件清理、Outbox 重试、Push 重试、搜索索引任务可观察、可重试。 +4. 前端所有状态、下拉、按钮权限从字典和权限接口动态加载。 + +### 11.3 P2 + +1. 工作台、会议、日报、月报、总经理信箱配置能动态影响客户端。 +2. 实名、青少年模式、反诈、举报、反馈具备策略配置、工单处理、审计。 +3. App 更新策略按 platform/channel 下发,异常时不误触发强制更新。 +4. 敏感证据、实名资料、诊断包具备权限、保留期和下载审计。 + +## 12. 已发现冲突与待确认项 + +1. 注册规则存在文档冲突:`IM-注册、邀请码与认证数据模型改造需求文档.md` 定义用户名和手机号是独立字段,二者同时填写时都保存;`IM-HotGo注册短信开关与邀请码管理改造交接文档.md` 的 3.1 仍写“手机号非空时忽略用户名”。建议以新的注册模型文档为准,并同步修正交接文档。 +2. 邀请码批次和主表目前按交接文档放在 HotGo 控制库,带 `tenant_id`;邀请码使用事实放在租户业务库。该设计不违反“租户业务表不带 tenant_id”,但需要在实现中明确控制库和业务库边界。 +3. 邀请码过期是否需要物理状态刷新任务待定。注册校验必须实时判断 `expires_at`,定时任务只能做冗余状态和统计。 +4. 注册短信开关当前是平台全局配置;未来若需要企业级差异,需迁移到 `im_tenant_config` 并定义优先级。 +5. 当前交接文档允许 HotGo 对部分租户库直连操作;长期是否统一收敛到 IM 管理 API 或 Kafka,需要在运维网络条件稳定后再评审。 +6. 开发/测试环境固定验证码 `123456` 不能进入生产;生产必须使用强随机验证码和真实短信服务商响应。 diff --git a/docs/backstage/IM-后台菜单覆盖测试文档.md b/docs/backstage/IM-后台菜单覆盖测试文档.md new file mode 100644 index 0000000..6b5f475 --- /dev/null +++ b/docs/backstage/IM-后台菜单覆盖测试文档.md @@ -0,0 +1,418 @@ +# IM 后台菜单覆盖测试文档 + +## 1. 测试范围 + +本文档用于覆盖 IM 后台菜单从「组织架构」开始,沿左侧菜单向下逐个页面点击验证。 + +当前覆盖菜单顺序: + +1. 组织架构 +2. 文件治理 +3. 注册邀请码 +4. 文件策略 +5. 通知模板 +6. 工作台配置 +7. 外部服务配置 +8. 版本更新 +9. 举报原因 +10. 总经理信箱配置 +11. 意见反馈配置 +12. 任务看板 +13. Kafka 命令 +14. Kafka 死信 +15. Kafka 审计事件 + +## 2. 测试环境 + +| 项目 | 地址 | +| --- | --- | +| 前端 | `http://127.0.0.1:8001/` | +| 后端 | `http://127.0.0.1:8000/admin` | +| 登录账号 | 使用具备 IM 管理权限的后台账号 | +| 浏览器 | Chrome,打开 DevTools Network 和 Console | + +## 3. 通用检查项 + +每进入一个菜单页面,都先执行以下检查: + +| 编号 | 检查项 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| COMMON-001 | 点击左侧菜单 | URL、页签、面包屑、页面标题正确变化 | 待测 | +| COMMON-002 | 页面首次加载 | 无白屏,无永久 loading,无明显样式错乱 | 待测 | +| COMMON-003 | Console | 无新的 JS Error,例如组件方法不存在、字典方法不存在 | 待测 | +| COMMON-004 | Network | 页面主列表接口 HTTP 200,业务响应 `code=0` | 待测 | +| COMMON-005 | 查询按钮 | 点击后列表刷新,请求参数包含搜索表单值 | 待测 | +| COMMON-006 | 重置按钮 | 搜索条件清空,列表回到默认查询 | 待测 | +| COMMON-007 | 展开/收起 | 搜索区域展开、收起正常,不遮挡表格 | 待测 | +| COMMON-008 | 表格刷新图标 | 点击后重新请求列表接口 | 待测 | +| COMMON-009 | 表格密度/列设置 | 可打开设置,切换后表格仍正常显示 | 待测 | +| COMMON-010 | 分页 | 切换页码和每页数量,请求参数正确,页面不报错 | 待测 | + +说明:涉及删除、撤销、重放、清理等副作用操作时,优先使用测试数据。没有测试数据时,至少验证弹窗、参数校验、取消流程;正式执行前确认不会影响线上有效数据。 + +## 4. 组织架构 + +菜单路径:`IM管理 / 组织架构` + +主要区域:部门列表、成员列表。 + +### 4.1 搜索与列表 + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ORG-001 | 打开「组织架构」 | 页面标题显示「组织架构」,部门列表和成员列表正常渲染 | 待测 | +| ORG-002 | 选择租户后查询部门 | 部门列表请求成功,显示部门 ID、部门名称、状态、成员数等字段 | 待测 | +| ORG-003 | 输入部门名称、状态后点击查询 | 部门列表按条件刷新 | 待测 | +| ORG-004 | 输入成员关键词、在职状态后点击查询 | 成员列表按条件刷新 | 待测 | +| ORG-005 | 点击部门行「成员」 | 成员列表切换到当前部门范围,添加成员按钮可用 | 待测 | + +### 4.2 部门按钮 + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ORG-010 | 点击「新增部门」 | 打开部门编辑弹窗 | 待测 | +| ORG-011 | 新增部门弹窗点击「取消」 | 弹窗关闭,列表不刷新或数据不变化 | 待测 | +| ORG-012 | 新增部门弹窗直接点击「保存」 | 必填项校验正常展示 | 待测 | +| ORG-013 | 填写部门名称、上级部门、排序、状态后点击「保存」 | 保存成功,部门列表刷新,新部门出现 | 待测 | +| ORG-014 | 点击部门行「编辑」 | 打开编辑弹窗,已有数据正确回填 | 待测 | +| ORG-015 | 修改部门信息后点击「保存」 | 保存成功,列表展示新值 | 待测 | +| ORG-016 | 点击部门行「删除」再点取消 | 弹窗关闭,部门不删除 | 待测 | +| ORG-017 | 点击部门行「删除」并确认 | 删除成功,列表刷新;有子部门或成员时应给出清晰错误 | 待测 | +| ORG-018 | 点击「导出部门」 | 触发文件下载,导出条件与当前查询一致 | 待测 | + +### 4.3 成员按钮 + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ORG-020 | 未选择部门时观察「添加成员」 | 按钮禁用或提示先选择部门 | 待测 | +| ORG-021 | 选择部门后点击「添加成员」 | 打开成员编辑弹窗,部门信息默认带入 | 待测 | +| ORG-022 | 成员弹窗直接点击「保存」 | 必填项校验正常展示 | 待测 | +| ORG-023 | 填写用户 ID、岗位、排序、可联系等信息后保存 | 保存成功,成员列表刷新 | 待测 | +| ORG-024 | 点击成员行「编辑」 | 打开编辑弹窗,成员信息正确回填 | 待测 | +| ORG-025 | 修改成员信息后保存 | 保存成功,列表展示新值 | 待测 | +| ORG-026 | 点击成员行「移除」再取消 | 弹窗关闭,成员仍保留 | 待测 | +| ORG-027 | 点击成员行「移除」并确认 | 移除成功,成员列表刷新 | 待测 | +| ORG-028 | 点击「导出成员」 | 触发文件下载,导出条件与当前查询一致 | 待测 | + +## 5. 文件治理 + +菜单路径:`IM管理 / 文件治理` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| FILE-001 | 打开页面 | 文件列表正常展示,包含文件 ID、状态、用途、上传用户、MIME、大小、引用数等字段 | 待测 | +| FILE-002 | 按租户、文件 ID、上传用户、状态、用途、MIME、Topic、更新时间查询 | 列表按条件刷新 | 待测 | +| FILE-003 | 点击行操作「详情」 | 打开详情弹窗,展示文件基础信息和引用列表 | 待测 | +| FILE-004 | 详情弹窗关闭 | 弹窗正常关闭,列表状态不丢失 | 待测 | +| FILE-005 | 点击行操作「隔离」再取消 | 确认框正常,取消后文件状态不变 | 待测 | +| FILE-006 | 点击行操作「隔离」并确认 | 文件状态变为隔离或列表刷新后状态正确 | 待测 | +| FILE-007 | 点击行操作「删除」再取消 | 确认框正常,取消后文件不删除 | 待测 | +| FILE-008 | 点击行操作「删除」并确认 | 删除成功,列表刷新 | 待测 | +| FILE-009 | 点击「清理孤儿文件」再取消 | 确认框正常,取消后不触发清理 | 待测 | +| FILE-010 | 点击「清理孤儿文件」并确认 | 清理请求成功,页面给出成功或结果提示 | 待测 | + +## 6. 注册邀请码 + +菜单路径:`IM管理 / 注册邀请码` + +实际菜单路由:`/#/im/registrationInvite` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| INVITE-001 | 打开页面 | 邀请码列表正常展示,包含租户、邀请码、状态、批次 ID、到期时间、使用次数等字段 | 待测 | +| INVITE-002 | 平台管理员不选择租户点击「添加」 | 提示先选择租户 | 待测 | +| INVITE-003 | 选择租户后点击「添加」 | 打开生成邀请码弹窗 | 待测 | +| INVITE-004 | 生成弹窗点击「取消」 | 弹窗关闭,列表不变化 | 待测 | +| INVITE-005 | 生成弹窗清空到期时间后点击「确认」 | 表单校验提示请选择到期时间 | 待测 | +| INVITE-006 | 填写到期时间、备注后点击「确认」 | 生成成功,列表刷新并出现新邀请码 | 待测 | +| INVITE-007 | 点击「批量生成」 | 打开批量生成弹窗,包含生成数量、到期时间、备注 | 待测 | +| INVITE-008 | 批量生成数量小于 1 或大于 1000 | 表单校验拦截 | 待测 | +| INVITE-009 | 输入合法数量后确认 | 批量生成成功,列表出现同一批次的邀请码 | 待测 | +| INVITE-010 | 按邀请码、状态、批次 ID、创建人、创建时间、到期时间查询 | 列表按条件刷新 | 待测 | +| INVITE-011 | 点击行操作「批次」 | 打开批次详情弹窗,展示总数、有效、撤销、过期、使用次数和明细 | 待测 | +| INVITE-012 | 点击有效邀请码行操作「撤销」再取消 | 确认框正常,取消后状态不变 | 待测 | +| INVITE-013 | 点击有效邀请码行操作「撤销」并确认 | 撤销成功,状态变为已撤销 | 待测 | +| INVITE-014 | 点击有效批次行操作「撤销批次」再取消 | 确认框正常,取消后批次状态不变 | 待测 | +| INVITE-015 | 点击有效批次行操作「撤销批次」并确认 | 批次内有效邀请码变为已撤销 | 待测 | +| INVITE-016 | 点击「导出」 | 下载 CSV 文件,内容与当前筛选条件一致 | 待测 | + +## 7. 文件策略 + +菜单路径:`IM管理 / 文件策略` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| FILE-POLICY-001 | 打开页面 | 策略列表正常展示,包含用途、大小上限、保留天数、自动清理、病毒扫描、MIME 白名单等字段 | 待测 | +| FILE-POLICY-002 | 按租户、用途、自动清理、病毒扫描查询 | 列表按条件刷新 | 待测 | +| FILE-POLICY-003 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| FILE-POLICY-004 | 编辑弹窗点击「取消」 | 弹窗关闭,数据不变化 | 待测 | +| FILE-POLICY-005 | 修改大小上限、保留天数、开关、MIME 白名单后保存 | 保存成功,列表刷新并展示新值 | 待测 | +| FILE-POLICY-006 | 输入非法大小或保留天数后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 8. 通知模板 + +菜单路径:`IM管理 / 通知模板` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| NOTIFY-001 | 打开页面 | 模板列表正常展示,包含场景、状态、站内、Push、短信、标题模板、内容模板等字段 | 待测 | +| NOTIFY-002 | 按租户、场景、状态查询 | 列表按条件刷新 | 待测 | +| NOTIFY-003 | 点击行操作「编辑」 | 打开编辑弹窗,模板字段正确回填 | 待测 | +| NOTIFY-004 | 编辑弹窗点击「取消」 | 弹窗关闭,数据不变化 | 待测 | +| NOTIFY-005 | 修改标题模板、内容模板、站内/Push/短信开关后保存 | 保存成功,列表刷新并展示新值 | 待测 | +| NOTIFY-006 | 清空必填字段后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 9. 工作台配置 + +菜单路径:`IM管理 / 工作台配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| WORKBENCH-001 | 打开页面 | 工作台模块列表正常展示,包含编码、名称、路由、排序、图标 ID、角标 Key、状态、权限等字段 | 待测 | +| WORKBENCH-002 | 按租户、编码、名称、状态查询 | 列表按条件刷新 | 待测 | +| WORKBENCH-003 | 点击「新增模块」 | 打开模块编辑弹窗 | 待测 | +| WORKBENCH-004 | 新增弹窗点击「取消」 | 弹窗关闭,列表不变化 | 待测 | +| WORKBENCH-005 | 新增弹窗直接保存 | 必填项校验正常展示 | 待测 | +| WORKBENCH-006 | 填写编码、名称、路由、排序、状态等信息后保存 | 保存成功,列表刷新 | 待测 | +| WORKBENCH-007 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| WORKBENCH-008 | 修改模块信息后保存 | 保存成功,列表展示新值 | 待测 | +| WORKBENCH-009 | 点击行操作「禁用」再取消 | 确认框正常,取消后状态不变 | 待测 | +| WORKBENCH-010 | 点击行操作「禁用」并确认 | 禁用成功,列表刷新后状态正确 | 待测 | +| WORKBENCH-011 | 点击「保存排序」 | 排序保存成功;列表刷新后顺序保持一致 | 待测 | + +## 10. 外部服务配置 + +菜单路径:`IM管理 / 外部服务配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| EXT-CONFIG-001 | 打开页面 | 配置列表正常展示,包含服务、服务商、端点、启用、配置状态、敏感配置、检查结果等字段 | 待测 | +| EXT-CONFIG-002 | 按租户、服务、服务商、启用状态查询 | 列表按条件刷新 | 待测 | +| EXT-CONFIG-003 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| EXT-CONFIG-004 | 编辑弹窗切换「启用」开关 | 开关状态可变更,保存前不应提前提交 | 待测 | +| EXT-CONFIG-005 | 修改服务商、端点、配置内容后保存 | 保存成功,列表刷新并展示新值 | 待测 | +| EXT-CONFIG-006 | 点击行操作「检查」再取消 | 确认框正常,取消后不触发检查 | 待测 | +| EXT-CONFIG-007 | 点击行操作「检查」并确认 | 检查请求成功,检查结果和检查时间刷新 | 待测 | + +## 11. 版本更新 + +菜单路径:`IM管理 / 版本更新` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| RELEASE-001 | 打开页面 | 版本策略列表正常展示,包含平台、渠道、最新版本、最低支持、更新类型、下载地址等字段 | 待测 | +| RELEASE-002 | 按租户、平台、渠道查询 | 列表按条件刷新 | 待测 | +| RELEASE-003 | 点击「新增策略」 | 打开策略编辑弹窗 | 待测 | +| RELEASE-004 | 新增弹窗直接保存 | 必填项校验正常展示 | 待测 | +| RELEASE-005 | 填写平台、渠道、版本号、更新类型、地址、发布时间后保存 | 保存成功,列表刷新 | 待测 | +| RELEASE-006 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| RELEASE-007 | 修改版本策略后保存 | 保存成功,列表展示新值 | 待测 | +| RELEASE-008 | 输入非法版本号或 URL 后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 12. 举报原因 + +菜单路径:`IM管理 / 举报原因` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| ABUSE-001 | 打开页面 | 原因列表正常展示,包含对象、编码、文案、要求描述、允许证据、排序、状态等字段 | 待测 | +| ABUSE-002 | 按租户、对象、编码、状态查询 | 列表按条件刷新 | 待测 | +| ABUSE-003 | 点击「新增原因」 | 打开原因编辑弹窗 | 待测 | +| ABUSE-004 | 新增弹窗直接保存 | 必填项校验正常展示 | 待测 | +| ABUSE-005 | 填写对象、编码、文案、排序、状态等信息后保存 | 保存成功,列表刷新 | 待测 | +| ABUSE-006 | 点击行操作「编辑」 | 打开编辑弹窗,字段正确回填 | 待测 | +| ABUSE-007 | 修改原因配置后保存 | 保存成功,列表展示新值 | 待测 | +| ABUSE-008 | 点击行操作「禁用」再取消 | 确认框正常,取消后状态不变 | 待测 | +| ABUSE-009 | 点击行操作「禁用」并确认 | 禁用成功,列表刷新后状态正确 | 待测 | + +## 13. 总经理信箱配置 + +菜单路径:`IM管理 / 总经理信箱配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| MAILBOX-001 | 打开页面 | 配置表单正常展示,租户选择框可用 | 待测 | +| MAILBOX-002 | 选择租户 | 自动加载该租户信箱配置 | 待测 | +| MAILBOX-003 | 点击「刷新」 | 当前租户配置重新加载,表单无异常 | 待测 | +| MAILBOX-004 | 未选择租户时观察「保存」 | 按钮禁用或提示先选择租户 | 待测 | +| MAILBOX-005 | 修改启用状态、接收人、处理策略等配置后点击「保存」 | 保存成功,再次刷新后配置保持一致 | 待测 | +| MAILBOX-006 | 输入非法配置后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 14. 意见反馈配置 + +菜单路径:`IM管理 / 意见反馈配置` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| FEEDBACK-001 | 打开页面 | 配置表单正常展示,租户选择框可用 | 待测 | +| FEEDBACK-002 | 选择租户 | 自动加载该租户反馈配置 | 待测 | +| FEEDBACK-003 | 点击「刷新」 | 当前租户配置重新加载,表单无异常 | 待测 | +| FEEDBACK-004 | 未选择租户时观察「保存」 | 按钮禁用或提示先选择租户 | 待测 | +| FEEDBACK-005 | 修改启用状态、分类、处理人、限制策略等配置后点击「保存」 | 保存成功,再次刷新后配置保持一致 | 待测 | +| FEEDBACK-006 | 输入非法配置后保存 | 表单校验或后端错误提示清晰 | 待测 | + +## 15. 任务看板 + +菜单路径:`IM管理 / 任务看板` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| TASK-001 | 打开页面 | 看板列表正常展示,包含租户、企业码、状态、DB、业务待投递、业务失败、群待投递、群失败、孤儿文件、待物理清理等字段 | 待测 | +| TASK-002 | 按租户、状态查询 | 列表按条件刷新 | 待测 | +| TASK-003 | 查看建议动作字段 | 异常租户展示可理解的建议动作;正常租户展示正常或为空 | 待测 | +| TASK-004 | 刷新表格 | 统计数据重新加载,无接口报错 | 待测 | + +## 16. Kafka 命令 + +菜单路径:`IM管理 / Kafka命令` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| KAFKA-CMD-001 | 打开页面 | 命令列表正常展示,包含租户、命令 ID、事件类型、业务类型、业务 ID、状态、错误码、消息等字段 | 待测 | +| KAFKA-CMD-002 | 按租户、命令 ID、事件 ID、事件类型、业务 ID、状态、创建时间查询 | 列表按条件刷新 | 待测 | +| KAFKA-CMD-003 | 点击「发送缓存失效测试」 | 请求成功,新增一条缓存失效命令或页面提示成功 | 待测 | +| KAFKA-CMD-004 | 点击「发送工作台测试事件」 | 请求成功,新增一条工作台测试事件命令或页面提示成功 | 待测 | +| KAFKA-CMD-005 | 点击行操作「详情」 | 打开详情弹窗,展示命令 payload、结果、错误原因等信息 | 待测 | +| KAFKA-CMD-006 | 详情弹窗点击「关闭」 | 弹窗正常关闭 | 待测 | +| KAFKA-CMD-007 | 发送测试事件后等待消费结果 | 状态从待发送/已发送变为成功或失败,失败时错误信息可读 | 待测 | + +## 17. Kafka 死信 + +菜单路径:`IM管理 / Kafka死信` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| DEAD-001 | 打开页面 | 死信列表正常展示,包含死信 ID、事件 ID、来源 Topic、消费组、事件类型、业务类型、业务 ID、状态等字段 | 待测 | +| DEAD-002 | 按租户、状态、事件类型、业务类型、业务 ID 查询 | 列表按条件刷新 | 待测 | +| DEAD-003 | 点击行操作「详情」 | 打开详情弹窗,展示死信 payload、错误原因、处理信息 | 待测 | +| DEAD-004 | 详情弹窗点击「关闭」 | 弹窗正常关闭 | 待测 | +| DEAD-005 | 详情弹窗点击「忽略」 | 操作成功,状态变为已忽略或列表刷新后状态正确 | 待测 | +| DEAD-006 | 详情弹窗点击「标记已处理」 | 操作成功,状态变为已处理 | 待测 | +| DEAD-007 | 详情弹窗点击「重放」 | 操作成功,重放次数增加,状态或处理结果正确更新 | 待测 | +| DEAD-008 | 对已处理、已忽略死信重复操作 | 系统应拦截或给出清晰提示,不出现脏状态 | 待测 | + +## 18. Kafka 审计事件 + +菜单路径:`IM管理 / Kafka审计事件` + +| 编号 | 操作 | 预期结果 | 结果 | +| --- | --- | --- | --- | +| KAFKA-AUDIT-001 | 打开页面 | 审计事件列表正常展示,包含租户、事件 ID、事件类型、动作、资源、资源 ID、操作者、结果等字段 | 待测 | +| KAFKA-AUDIT-002 | 按租户编码、事件 ID、事件类型、动作、资源、资源 ID、结果、接收时间查询 | 列表按条件刷新 | 待测 | +| KAFKA-AUDIT-003 | 点击行操作「详情」 | 打开详情弹窗,展示事件详情、请求上下文、结果信息 | 待测 | +| KAFKA-AUDIT-004 | 详情弹窗点击「关闭」 | 弹窗正常关闭 | 待测 | + +## 19. 测试数据建议 + +| 数据类型 | 建议准备 | +| --- | --- | +| 租户 | 至少 1 个正常租户,具备独立业务库 | +| 组织架构 | 至少 1 个根部门、1 个子部门、2 个用户成员 | +| 文件治理 | 至少 1 个正常文件、1 个可隔离文件、1 个孤儿文件 | +| 注册邀请码 | 至少 1 个有效邀请码、1 个过期邀请码、1 个批次邀请码 | +| Kafka | Kafka、消费者、IM Server 正常运行;准备 1 条可产生成功结果的命令和 1 条可产生死信的命令 | +| 配置类 | 文件策略、通知模板、工作台、外部服务、版本更新、举报原因均有初始数据 | + +## 20. 缺陷记录模板 + +| 缺陷编号 | 菜单 | 操作步骤 | 实际结果 | 预期结果 | Console/Network 信息 | 严重级别 | 状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| BUG-001 | | | | | | | 待处理 | + +## 21. 第一轮执行记录 + +执行时间:2026-09-03 15:16 - 15:22 + +执行环境: + +| 项目 | 值 | +| --- | --- | +| 前端 | `http://127.0.0.1:8001/` | +| 后端 | Docker 容器 `im-local-admin`,端口 `8000` | +| MySQL | Docker 容器 `relaxed_cori`,库 `hotgo`、`tinode`、`tinode_test` | +| Redis | Docker 容器 `brave_blackburn` | +| Kafka | Docker 容器 `kafka` | + +本轮说明: + +1. 从左侧菜单「组织架构」开始,按菜单顺序逐页点击。 +2. 对查询、重置、展开、刷新、新增、编辑、详情等非破坏性按钮进行了点击验证。 +3. 对删除、隔离、撤销、禁用、重放等有副作用的按钮,本轮只验证入口和取消流程,不确认执行。 +4. 「收起」按钮在自动化脚本里先于「展开」检测,产生过误报,本轮不作为缺陷记录。 +5. 「注册邀请码」菜单点击正常,真实路由为 `/#/im/registrationInvite`;直接访问 `/#/im/registrationInvite/index` 会进入 ErrorPage,测试文档已修正。 + +### 21.1 页面通过项 + +| 菜单 | 本轮结果 | +| --- | --- | +| 组织架构 | 页面可打开,查询/重置/展开/新增部门可点击;添加成员因未先选择部门处于禁用,符合预期 | +| 文件治理 | 页面可打开,查询/重置/展开/详情可点击;列表有数据 | +| 注册邀请码 | 通过左侧菜单可打开,查询/重置/展开/添加/批量生成/导出可见;列表有 2 条历史数据 | +| 工作台配置 | 页面可打开,查询/重置/展开/新增模块/编辑可点击;禁用弹窗可打开并取消 | +| 版本更新 | 页面可打开,查询/重置/展开/新增策略/编辑可点击;列表有数据 | +| 举报原因 | 页面可打开,查询/重置/展开/新增原因/编辑可点击;禁用弹窗可打开并取消 | +| 总经理信箱配置 | 页面可打开,保存/刷新按钮可见,刷新可点击 | +| 意见反馈配置 | 页面可打开,保存/刷新按钮可见,刷新可点击 | +| 任务看板 | 页面可打开,查询/重置/展开可点击 | + +### 21.2 本轮发现的问题 + +| 缺陷编号 | 菜单 | 操作步骤 | 实际结果 | 预期结果 | 证据 | 严重级别 | 状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| BUG-IM-001 | 文件策略 | 点击「IM管理 / 文件策略」,或点击查询/重置 | 页面 Console 报 `检查IM文件策略失败`,列表无法加载,行操作「编辑」不可测 | 文件策略列表应正常加载,缺省策略应自动初始化 | 后端日志:`SELECT COUNT(1) FROM im_file_policy ... Error 1146: Table 'hotgo.im_file_policy' doesn't exist`;代码位置:`server/internal/logic/sys/im_file_policy.go:128` | P0 | 待处理 | +| BUG-IM-002 | 通知模板 | 点击「IM管理 / 通知模板」,或点击查询/重置 | 页面 Console 报 `检查IM通知模板失败,请稍后重试!`,列表无法加载,行操作「编辑」不可测 | 通知模板列表应正常加载,缺省模板应自动初始化 | 后端日志:`SELECT COUNT(1) FROM im_notification_template ... Error 1146: Table 'hotgo.im_notification_template' doesn't exist`;代码位置:`server/internal/logic/sys/im_notification.go:123` | P0 | 待处理 | +| BUG-IM-003 | 外部服务配置 | 点击「IM管理 / 外部服务配置」,或点击查询/重置 | 页面 Console 报 `初始化IM外部服务配置失败`,列表无法加载,行操作「编辑」「检查」不可测 | 外部服务配置列表应正常加载,缺省服务配置应自动初始化 | 后端日志:`SELECT COUNT(1) FROM im_external_config ... Error 1146: Table 'hotgo.im_external_config' doesn't exist`;代码位置:`server/internal/logic/sys/im_external_config.go:194` | P0 | 待处理 | +| BUG-IM-004 | Kafka命令 | 点击「IM管理 / Kafka命令」后点击「发送缓存失效测试」 | 页面 toast 显示 `读取IM命令Outbox失败`,Kafka 命令列表仍为 0 条 | 应写入 Outbox 并投递 Kafka,页面能看到新事件或明确状态 | 后端日志:`读取IM命令Outbox失败: element of parameter "pointer" for function Scan should type of struct/*struct/[]struct/[]*struct`;代码位置:`server/internal/library/imkafka/command.go:103` | P0 | 待处理 | +| BUG-IM-005 | Kafka命令 | 点击「发送缓存失效测试」「发送工作台测试事件」后查看数据库 | 正确表 `hotgo.im_admin_outbox` 为 0 条;带前缀表 `hotgo.hg_im_admin_outbox` 新增 3 条 pending 记录 | Admin 新增 IM 表和代码查询应统一使用无前缀 `im_*` 表,避免 `hg_im_*` 和 `im_*` 双表分裂 | MySQL:`SHOW TABLES LIKE 'hg_im_%'` 可见 `hg_im_admin_outbox`、`hg_im_admin_commands`、`hg_im_audit_events`;`hg_im_admin_outbox` 有 pending 数据,`im_admin_outbox` 为空 | P0 | 待处理 | +| BUG-IM-006 | Kafka死信 | 点击「IM管理 / Kafka死信」 | 页面可打开,但列表无数据,行操作「详情」「忽略」「标记已处理」「重放」无法覆盖 | 联调测试库应准备至少 1 条死信数据,才能覆盖处置按钮 | MySQL:`hotgo.im_kafka_dead_letter_events` 计数为 0 | P1 | 待补测试数据 | +| BUG-IM-007 | Kafka审计事件 | 点击「IM管理 / Kafka审计事件」 | 页面可打开,但列表无数据,行操作「详情」无法覆盖 | 联调测试库应准备至少 1 条审计事件数据,才能覆盖详情按钮 | MySQL:`hotgo.im_audit_events` 计数为 0 | P1 | 待补测试数据 | + +### 21.3 数据库核查结果 + +| 库 | 表 | 结果 | +| --- | --- | --- | +| `hotgo` | `im_file_policy` | 不存在 | +| `hotgo` | `im_notification_template` | 不存在 | +| `hotgo` | `im_external_config` | 不存在 | +| `hotgo` | `im_admin_commands` | 存在,0 条 | +| `hotgo` | `im_admin_outbox` | 存在,0 条 | +| `hotgo` | `hg_im_admin_outbox` | 存在,3 条 pending | +| `hotgo` | `im_kafka_dead_letter_events` | 存在,0 条 | +| `hotgo` | `im_audit_events` | 存在,0 条 | + +### 21.4 下一步建议 + +1. 先导入缺失表 SQL:`server/storage/data/generate/im_file_policy_menu.sql`、`server/storage/data/generate/im_notification_menu.sql`、`server/storage/data/generate/im_external_config_menu.sql`。 +2. 修复 Kafka Outbox 表名前缀分裂问题,确保所有 Kafka Admin 表统一访问 `im_*`,不要落到 `hg_im_*`。 +3. 修复 `DispatchOutboxByEvent` 中 `g.DB().GetOne` 触发的 `Scan` 参数类型错误。 +4. 准备 Kafka 死信和 Kafka 审计事件测试数据后,重新覆盖「详情」「忽略」「标记已处理」「重放」等按钮。 +5. 上述 P0 处理后,重新执行本测试文档第 4 到第 18 章。 + +## 22. 第二轮修复记录 + +执行时间:2026-09-03 15:37 - 15:50 + +### 22.1 已处理项 + +| 缺陷编号 | 处理结果 | 复测结果 | +| --- | --- | --- | +| BUG-IM-001 | 已补齐 `hotgo.im_file_policy` 表;访问文件策略列表时可自动初始化默认策略 | `/admin/imFilePolicy/list?tenantId=1` 返回 `code=0`,列表 6 条 | +| BUG-IM-002 | 已补齐 `hotgo.im_notification_template` 表;访问通知模板列表时可自动初始化默认模板 | `/admin/imNotification/templateList` 返回 `code=0`,列表 7 条 | +| BUG-IM-003 | 已补齐 `hotgo.im_external_config` 表;访问外部服务配置列表时可自动初始化默认配置 | `/admin/imExternalConfig/list?tenantId=1` 返回 `code=0`,列表 5 条 | +| BUG-IM-004 | 已修复 Kafka Admin 命令/Outbox 读写 DB,统一改为无前缀 `imRegistry` 连接 | 点击等价接口 `/admin/imKafkaCommand/cacheInvalidate` 返回 `code=0`,不再报 `读取IM命令Outbox失败` | +| BUG-IM-005 | 已修复 `im_admin_commands`、`im_admin_outbox`、`im_audit_events`、`im_kafka_dead_letter_events` 读写前缀分裂 | 新增 Kafka 测试事件写入 `hotgo.im_admin_outbox`,状态为 `sent`;没有继续写入 `hg_im_admin_outbox` | + +### 22.2 额外修复 + +| 问题 | 处理结果 | 复测结果 | +| --- | --- | --- | +| Docker 本地 admin 缺少运行时 MySQL/Redis 环境变量导致启动 panic | 本地容器启动时显式传入 `MYSQL_DSN`、`REDIS_ADDR`、`TOKEN_SECRET_KEY` 等变量 | `im-local-admin` 正常启动,`http://127.0.0.1:8000/admin/` 可访问 | +| admin 容器内 `im-server:6060` 解析到不可用地址,死信内部查询 EOF | `config.template`、`entrypoint.sh` 增加 IM API 环境变量;本地容器使用 `IM_INTERNAL_API_BASE_URL=http://im-local-im:6060` | `/admin/imDeadLetter/list?tenantId=1` 返回 `code=0`,列表 18 条 | +| 测试库 IM 菜单中文标题乱码 | 已在测试库用 UTF-8 连接修复 `hg_admin_menu.title`;新增 `im_menu_title_repair.sql` 可重复修复 | `imManager`、`imOrgIndex`、`imFilePolicyIndex`、`imKafkaCommandIndex` 等标题查询显示正常中文 | +| 部分新增菜单 SQL 使用旧版 `hg_admin_menu` 字段 `route/is_cache/created_by/updated_by` | 已转换相关 `*_menu.sql` 菜单插入字段为当前 HotGo 表结构 | 相关 SQL 在本地 `hotgo` 库重复执行通过,无 `Unknown column 'route'` | + +### 22.3 当前剩余注意项 + +1. 当前浏览器页签仍保存旧 token,页面上可能继续弹「登录身份已失效」;刷新并重新登录后再继续 UI 点击测试。 +2. `hg_im_admin_outbox` 里还保留第一轮测试误写入的 3 条旧数据,仅用于证明旧问题,不影响新链路;后续可按运维需要清理。 +3. Kafka 审计事件列表当前仍无数据,需要 IM Server 产生或导入审计事件后才能覆盖详情弹窗。 diff --git a/docs/backstage/IM-未实现增强项.md b/docs/backstage/IM-未实现增强项.md new file mode 100644 index 0000000..0bd3f7c --- /dev/null +++ b/docs/backstage/IM-未实现增强项.md @@ -0,0 +1,358 @@ +# IM 未实现增强项 + +> 更新时间:2026-08-22 +> 范围:HotGo IM 后台、IM 服务端配合项、客户端动态配置来源 + +本文用于记录当前后台已识别但尚未完成的增强项,作为后续开发排期和验收依据。当前多租户方案为“一个 HotGo 控制库 + 每租户一个独立 IM 业务库”,租户业务表不带 `tenant_id`,后台必须通过登录态解析租户并选择租户库。 + +## 已完成基线 + +1. 邀请码管理:单个生成、批量生成、列表、批次详情、撤销、批量撤销、导出、注册短信开关。 +2. 租户管理:列表、详情、编辑、状态、用量/健康、删除前检查、进入删除流程、恢复。 +3. 用户管理:列表、详情、设备、重置密码、撤销会话、账号状态停用/恢复。 +4. 审计日志:列表、详情、导出。 +5. Outbox 运维:列表、详情、重试、标记死信,覆盖 `im_business_events_outbox` 和 `im_group_events_outbox`。 +6. 租户边界:用户、审计、群、实名、工单、Outbox 等后台接口已按登录态解析租户范围。 +7. 群治理处置:治理配置更新、解散群、成员禁言、解除禁言、入群审批、管理员设置/取消、群主转让、群文件删除。 + +## 剩余 P0 + +### P0-1 Bootstrap 动态配置来源 + +状态:未实现。 + +目标: + +1. 后台提供 `bootstrap` 所需配置来源,包括 `features`、`message_policy`、`limits`、`settings_menu`、`workbench.modules`。 +2. IM 服务端从后台配置或控制库读取后,下发给客户端。 +3. 客户端不硬编码功能开关、消息策略、设置菜单和工作台基础入口。 + +建议实现: + +1. 扩展 `im_tenant_config` 或新增独立配置表。 +2. 使用 GoFrame CLI 重新生成 DAO/entity/do,禁止手改生成文件。 +3. 后台提供租户配置页面,敏感字段脱敏回显。 +4. IM 服务端实现配置读取和缓存,缓存失效策略单独定义。 + +依赖: + +1. 控制库迁移 SQL。 +2. IM 服务端 `bootstrap` 接口对接。 +3. 客户端按返回配置裁剪 UI。 + +### P0-2 完整租户配置与配额 + +状态:部分实现。 + +已实现: + +1. `max_users` +2. `max_groups` +3. `max_group_members` +4. 租户状态、企业码、名称、DB 路由 + +未实现: + +1. 品牌 Logo、品牌色、登录页展示配置。 +2. HTTP API、WebSocket、文件服务等终端地址。 +3. 可用登录方式。 +4. 密码策略。 +5. 文件存储配额。 +6. 功能开关配置。 +7. App 更新策略基础配置。 + +建议实现: + +1. 将基础配额、品牌、终端地址、登录方式、密码策略拆成清晰字段或 JSON 配置。 +2. 对 `db_dsn`、密钥类字段只保存不明文回显。 +3. 平台管理员可编辑,租户管理员按权限只读或申请变更。 + +### P0-3 日志脱敏统一治理 + +状态:未实现。 + +目标: + +1. 访问日志、错误日志、审计摘要、导出任务日志不得泄露敏感字段。 +2. 覆盖邀请码、验证码、密码、Token、服务密钥、数据库 DSN、实名证件信息。 + +至少覆盖字段: + +1. `inviteCode` +2. `invite_code` +3. `code` +4. `verification_code` +5. `password` +6. `secret` +7. `serviceSecret` +8. `service_secret` +9. `authorization` +10. `db_dsn` +11. `id_number` +12. `token` + +建议实现: + +1. 在统一响应/日志中间件或日志写入前增加脱敏工具。 +2. 对业务审计 `detail_json` 做字段级脱敏。 +3. 补充单元测试,覆盖嵌套 JSON、大小写字段、数组对象。 + +### P0-4 邀请码明文查看与精确查询审计拆分 + +状态:部分实现。 + +已实现: + +1. 邀请码生成、批量生成、列表、批次详情、撤销、批量撤销、导出。 +2. 基础菜单权限。 + +待确认/增强: + +1. 明文查看是否需要独立权限点。 +2. 精确查询邀请码是否需要独立权限点。 +3. 导出明文邀请码是否需要二次确认和独立审计。 +4. 查询/查看/导出操作是否全部写入业务审计。 + +建议实现: + +1. 增加 `imInvitePlainView` 或等价权限。 +2. 列表默认脱敏,只有具备权限时显示明文。 +3. 精确查询、明文查看、导出记录审计,审计中不写邀请码明文。 + +## 后续 P1 + +### P1-0 组织架构管理 + +状态:部分实现。 + +已实现: + +1. 部门列表。 +2. 部门新增、编辑、移动。 +3. 部门删除前校验子部门和成员。 +4. 部门移动防环校验。 +5. 部门成员列表。 +6. 成员添加、编辑、调岗、移除。 +7. 组织搜索基础接口,覆盖部门和成员。 +8. 组织操作写入 IM 后台审计。 +9. 部门导出和成员导出。 + +待实现/配合: + +1. 组织数据权限:当前后台按租户选库隔离,部门级 `org_admin` 数据范围尚未建模。 +2. 组织导入:需要定义 Excel 字段、冲突覆盖规则、失败明细和幂等更新策略。 +3. 离职用户处理策略:当前支持 `employment_state` 和 `contactable`,客户端/IM 服务端需按该状态裁剪通讯录和可联系入口。 +4. 组织版本增量同步:当前维护部门/成员版本号,客户端增量同步协议需由 IM 服务端继续承接。 + +### P1-1 群治理处置动作 + +状态:已完成基础后台闭环。 + +已实现: + +1. 群列表、详情。 +2. 成员列表。 +3. 治理配置查看。 +4. 入群申请、邀请、公告、禁言、管理日志列表。 +5. 治理配置更新。 +6. 全员禁言开关。 +7. 单人禁言/解禁。 +8. 解散群。 +9. 入群审批通过/拒绝。 +10. 设置/取消管理员。 +11. 转让群主。 +12. 群文件列表和删除处置。 + +待 IM 服务端配合确认: + +1. 后台写入 `im_group_events_outbox` 后,IM 服务端消费者需同步在线会话、群资料缓存和成员 ACL。 +2. 群文件删除当前为业务索引软删除,底层对象文件物理清理归入 P1-2 文件治理。 + +### P1-2 文件治理 + +状态:部分实现。 + +已实现: + +1. 文件列表。 +2. 文件用途、大小、状态查询。 +3. 隔离、删除。 +4. 文件详情查看审计。 +5. 孤儿文件手动清理。 +6. 孤儿文件定时任务 `imFileCleanupOrphan`,参数为保留天数、单租户处理上限。 +7. 租户级文件用途策略配置:大小上限、保留天数、MIME 白名单、自动清理开关、病毒扫描开关。 + +待实现/配合: + +1. 下载审计:需要 IM 服务端文件下载链路或后台下载代理在实际下载时写入审计。 +2. 底层对象物理删除:当前后台将 `im_fileuploads.status` 标记为已删除,物理对象清理需由文件服务或 IM 服务端清理器消费 `location`。 +3. 过期文件执行:后台已提供策略配置,IM 服务端/文件清理器需读取 `im_file_policy` 后执行过期扫描、状态更新和物理清理。 +4. 文件用途识别目前基于 `im_filemsglinks`/群文件索引做基础归类,头像、举报证据、反馈附件等用途需要 IM 服务端补齐绑定关系或独立索引。 +5. 病毒扫描执行:后台已提供开关,实际扫描任务、扫描结果回写和用户通知需要 IM 服务端或文件服务承接。 + +### P1-3 外部服务配置 + +状态:部分实现。 + +已实现: + +1. SMS 服务商配置。 +2. Push/APNS/FCM 配置。 +3. TURN/STUN 配置。 +4. 对象存储配置。 +5. 审核插件配置。 +6. 敏感字段 `secret_json` 只写入,不明文回显,仅返回 `hasSecret`。 +7. 基础配置完整性检查,写入 `status`、`checked_at`、`check_result`。 +8. 配置变更和检查写入 IM 后台审计。 + +待实现/配合: + +1. 真实连通性检查:需要各服务商 SDK 或 IM 服务端提供探测接口。 +2. IM 服务端 `bootstrap`、短信、Push、TURN、对象存储、审核链路需要读取 `im_external_config`。 +3. `im_external_config` 为新增控制库表,需要执行 `server/storage/data/generate/im_external_config_menu.sql`。 + +### P1-4 任务看板 + +状态:部分实现。 + +已实现: + +1. 只读任务看板,按租户汇总 IM 业务库健康状态。 +2. Outbox 巡检:统计业务事件和群事件的待投递、失败数量。 +3. 文件清理巡检:统计孤儿文件和待物理清理文件数量。 +4. 展示最新业务事件、最新群事件、最新文件更新时间。 +5. 根据失败/待处理/待清理数量给出基础建议动作。 + +待实现/配合: + +1. 主动执行任务:当前看板只读,不直接触发修复或调度。 +2. Push 重试:需要 IM 服务端暴露 Push 失败队列、重试入口或统一任务表。 +3. 搜索索引重建:需要 IM 服务端定义索引任务入口、进度查询和失败重试模型。 +4. 邀请码投影补偿:需要 IM 服务端或后台定义投影任务表、幂等键和重放策略。 +5. 文件物理清理任务详情:需要文件服务或 IM 服务端回写物理删除结果。 +6. 跨租户任务执行审计:执行型任务落地后需补充独立权限点和审计明细。 + +### P1-5 系统通知模板 + +状态:部分实现。 + +已实现: + +1. 租户级通知模板列表。 +2. 账号安全、好友申请、群事件、文件事件、会议提醒、日报月报提醒、工单处理等默认场景初始化。 +3. 站内通知、Push、短信渠道开关。 +4. 标题模板、内容模板、扩展 JSON 配置。 +5. 模板启用/禁用。 +6. 模板变更写入 IM 后台审计。 + +待实现/配合: + +1. 实际投递:需要 IM 服务端 Outbox 消费器或系统 Topic 桥接读取模板并发送。 +2. 投递记录:需要 IM 服务端回写站内、Push、短信投递状态和失败原因。 +3. 模板变量校验:当前仅校验非空和 JSON 格式,变量白名单、缺失变量检查需要按具体场景定义。 +4. 多语言模板:当前为单语言模板,后续如客户端支持多语言需扩展语言维度。 + +## 后续 P2 + +### P2-1 工作台模块配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户工作台模块列表、搜索。 +2. 模块新增、编辑、启用/禁用。 +3. 模块排序接口和后台入口。 +4. 默认初始化组织架构、会议通知、日报、月报、总经理信箱五个入口。 +5. 路由选项对齐 IM 服务端当前白名单,避免配置后客户端接口丢弃。 +6. 工作台配置变更写入 IM 后台审计。 + +待 IM 服务端/客户端配合确认: + +1. `GET /v1/im/workbench` 当前读取 `im_workbench_apps` 已具备,需在实际环境确认租户库连接与后台修改后的缓存刷新策略。 +2. 用户或角色级工作台权限表 `im_workbench_user_apps` 尚未接入后台,当前只覆盖租户级模块配置。 +3. 未读角标实时事件 `workbench.badge.updated` 的聚合和增量推送仍由 IM 服务端承接。 +4. 新增自定义入口若超出当前五个路由,需要先扩展 IM 服务端路由白名单和客户端页面。 + +### P2-2 App 版本更新策略 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户级客户端版本策略列表、搜索。 +2. 按 `platform + channel` 配置最新版本、最低支持版本、更新类型、商店地址、安装包地址、校验值和再次提醒时间。 +3. 默认初始化 iOS/Android stable 策略。 +4. 配置变更写入 IM 后台审计。 + +待 IM 服务端/客户端配合确认: + +1. `GET /v1/im/config/app-version` 已有 IM 服务端读取链路,需确认实际环境租户库数据和客户端版本比较规则。 +2. 灰度比例、分渠道灰度名单、强更弹窗样式尚未建模。 + +### P2-3 举报原因配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 按用户、群组、消息三类对象配置举报原因。 +2. 支持原因新增、编辑、禁用、排序、是否要求描述、是否允许上传证据。 +3. 默认初始化诈骗、骚扰、垃圾广告、违法违规等基础原因。 +4. 配置变更写入 IM 后台审计。 + +待实现/配合: + +1. 举报工单 SLA、合并策略、批量导出仍未实现。 +2. 举报工单处置结果与客户端通知联动仍由后续工单模块增强。 + +### P2-4 总经理信箱配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户级总经理信箱启用/禁用。 +2. 匿名提交开关。 +3. 必填字段、最大字数、附件策略配置。 +4. 告知标题和告知内容配置,更新时同步刷新 `notice_updated_at`。 +5. 配置变更写入 IM 后台审计。 + +待实现/配合: + +1. 信箱提交列表、处理状态、回复闭环尚未实现。 +2. 受理人/部门、SLA、匿名身份展示范围尚未建模。 + +### P2-5 意见反馈配置 + +状态:已完成基础后台闭环。 + +已实现: + +1. 租户级意见反馈分类配置。 +2. 最大字数、附件策略、联系方式开关、诊断信息提示配置。 +3. 配置变更写入 IM 后台审计。 + +待实现/配合: + +1. 反馈提交列表、合并、回复、导出尚未实现。 +2. 客户端分类文案如需多语言,需要扩展分类结构。 + +### P2 待实现 + +1. 会议。 +2. 日报/月报。 +3. 总经理信箱提交处理闭环。 +4. 青少年模式。 +5. 反诈与内容审核策略。 +6. 举报/反馈 SLA、合并、导出。 +7. 实名审核策略配置。 + +## 验收提示 + +1. 所有后台租户业务操作必须通过登录态解析租户,不能信任浏览器传入的 `tenantId`。 +2. 平台管理员跨租户操作必须选择租户并写审计。 +3. 租户管理员隐藏租户选择器,后端使用绑定租户。 +4. 所有新增按钮必须有独立菜单权限。 +5. 前端字典、状态、下拉选项必须从后端字典加载。 +6. 敏感字段不得在日志、审计、导出文件中明文出现。 diff --git a/docs/im/IM-前后端对接文档.md b/docs/im/IM-前后端对接文档.md index b416f68..9a28d68 100644 --- a/docs/im/IM-前后端对接文档.md +++ b/docs/im/IM-前后端对接文档.md @@ -2,8 +2,8 @@ > 版本:v2.0 > 日期:2026-08-11 -> 需求依据:[IM 产品原型需求文档](IM-产品原型需求文档.md) -> 设计依据:[IM 系统设计文档](IM-系统设计文档.md) +> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md) +> 设计依据:[IM 系统设计文档](./IM-系统设计文档.md) > 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。 ## 0. 公共对接约定 @@ -29,7 +29,7 @@ API Key 是应用级接入标识,由部署人员在发布客户端前生成并 1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 -3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 +3. 输出中的 `HMAC salt` 配置到服务端 `server/meetingim.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 生成示意: @@ -44,7 +44,7 @@ API Key、企业码和用户 Token 是三个不同概念: | 值 | 取得时间 | 作用 | 是否代表用户身份 | | --- | --- | --- | --- | -| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | +| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 MeetingIM 入口 | 否 | | `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | | `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | @@ -57,11 +57,11 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或 | 通道 | 地址 | 认证 | 用途 | | --- | --- | --- | --- | | WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | -| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 | -| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | -| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token ` | 文件上传下载;禁止 Query 传 Token | +| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 | +| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | +| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token ` | 文件上传下载;禁止 Query 传 Token | -HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 +HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: @@ -135,6 +135,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 ```text 读取客户端预置的 host 和普通 API Key -> 用户输入/客户端读取 tenant_code +-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 -> 带 API Key 完成 WebSocket Upgrade -> hi(tenant_code) -> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 @@ -146,7 +147,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 -> 进入可发送状态 ``` -当前服务端没有 `/v1/im/tenants/resolve`,因此现阶段企业码是否有效是在第一帧 `{hi}` 中校验的。只有 WebSocket Upgrade 返回 `101 Switching Protocols` 后才能发送下面的 JSON 文本帧。 +当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 `hi` 请求: @@ -164,7 +165,9 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 {"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}} ``` -`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 +`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer `,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 + +当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。 `hi` 成功示例: @@ -218,7 +221,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 ```http POST /v0/channels/lp HTTP/1.1 Host: im.example.com -X-Tinode-APIKey: <预置的普通 API Key> +X-MeetingIM-APIKey: <预置的普通 API Key> Content-Length: 0 ``` @@ -232,7 +235,7 @@ Content-Length: 0 ```http POST /v0/channels/lp?sid=session-id HTTP/1.1 -X-Tinode-APIKey: <预置的普通 API Key> +X-MeetingIM-APIKey: <预置的普通 API Key> Content-Type: application/json {"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} @@ -242,16 +245,16 @@ POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 ```http GET /v0/channels/lp?sid=session-id HTTP/1.1 -X-Tinode-APIKey: <预置的普通 API Key> +X-MeetingIM-APIKey: <预置的普通 API Key> ``` 收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 `sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。 -#### 0.5.3 目标产品流程 +#### 0.5.3 企业解析与产品流程 -第 1.1 节规划的 `POST /v1/im/tenants/resolve` 属于新增接口。实现后目标流程调整为: +第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为: ```text 读取预置 API Key @@ -261,7 +264,169 @@ X-Tinode-APIKey: <预置的普通 API Key> -> login -> sub me -> 恢复会话 ``` -企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。 +企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。 + +#### 0.5.4 WSS 契约输入输出示例 + +本节用于 WebSocket 联调。除特别说明外: + +- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。 +- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。 +- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。 +- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。 +- 成功控制包一般为 `{ctrl.code=2xx}`;失败控制包一般为 `{ctrl.code=4xx/5xx}`,产品稳定错误优先读 `ctrl.params.reason`,没有时按 `code/text` 兜底。 + +##### 0.5.4.1 连接、握手、登录和注册 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| WebSocket Upgrade | `GET /v0/channels?apikey=AQ...` | HTTP `101 Switching Protocols`;API Key 无效时 HTTP `403` | +| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` | +| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | +| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` | + +##### 0.5.4.2 启动订阅、会话列表和增量恢复 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| 订阅本人 `me` | `{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}` | `{"ctrl":{"id":"sub-me-1","code":200,"text":"ok"}}`,随后可能收到 `{"meta":{"id":"sub-me-1","topic":"me","desc":{"public":{"fn":"alice"}},"sub":[{"topic":"grp...","public":{"name":"项目群"},"seq":12,"read":10,"recv":12,"updated":"2026-08-20T09:00:00Z"}]}}` | +| 订阅查找 `fnd` | `{"sub":{"id":"sub-fnd-1","topic":"fnd"}}` | `{"ctrl":{"id":"sub-fnd-1","code":200,"text":"ok"}}` | +| 订阅会话并拉历史 | `{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":1,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"ctrl":{"id":"sub-topic-1","code":200,"text":"ok"}}`,随后 `{"meta":{"id":"sub-topic-1","topic":"grp...","desc":{"public":{"name":"项目群"},"seq":12},"sub":[{"user":"usr...","mode":"JRWP"}]}}` 和多条 `{data}` | +| 断线恢复增量 | `{"sub":{"id":"resume-grp-1","topic":"grp...","get":{"what":"data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"data":{"topic":"grp...","seq":125,"from":"usr...","ts":"2026-08-20T09:00:00Z","content":{"text":"恢复后的第一条"}}}` | +| 只拉元数据 | `{"get":{"id":"get-topic-1","topic":"grp...","what":"desc sub"}}` | `{"meta":{"id":"get-topic-1","topic":"grp...","desc":{"seq":12,"public":{"name":"项目群"}},"sub":[{"user":"usr...","read":10,"recv":12}]}}` | +| 离开/取消订阅 | `{"leave":{"id":"leave-1","topic":"grp...","unsub":false}}` | `{"ctrl":{"id":"leave-1","code":200,"text":"ok"}}` | + +##### 0.5.4.3 发送消息、媒体卡片、名片和转发卡片 + +| 类型 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | +| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` | +| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` | +| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` | +| 名片消息 | `{"pub":{"id":"pub-card-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"contact_card","x-im-client-mid":"mid_card_1"},"content":{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan"}}}` | `{"ctrl":{"id":"pub-card-1","code":202,"params":{"seq":16}}}` | +| 合并转发卡片 | `{"pub":{"id":"pub-fwd-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"forward_bundle","x-im-client-mid":"mid_fwd_1"},"content":{"bundle_id":"fbundle_1","title":"聊天记录","item_count":3,"preview":[{"from_display":"张三","summary":"你好"}]}}}` | `{"ctrl":{"id":"pub-fwd-1","code":202,"params":{"seq":17}}}` | +| @成员消息 | `{"pub":{"id":"pub-mention-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_at_1","mentions":[{"user_id":"usr...","offset":0,"length":3}]},"content":{"text":"@张三 看一下"}}}` | `{"ctrl":{"id":"pub-mention-1","code":202,"params":{"seq":18}}}` | +| @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` | +| 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 | + +##### 0.5.4.4 回执、正在输入、删除和清空记录 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 送达回执 | `{"note":{"topic":"p2p...","what":"recv","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"recv","seq":12}}` | +| 已读回执 | `{"note":{"topic":"p2p...","what":"read","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"read","seq":12}}`;客户端本地把该 topic `read` 更新为 12 | +| 正在输入 | `{"note":{"topic":"p2p...","what":"kp"}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"kp"}}`,本地超时消失 | +| 正在录音可选 | `{"note":{"topic":"grp...","what":"kpa"}}` | 对端收到 `{"info":{"topic":"grp...","from":"usr...","what":"kpa"}}`,不落库 | +| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` | +| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` | + +##### 0.5.4.5 Topic 资料、个人群设置和标签 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 更新群公开资料 | `{"set":{"id":"set-grp-1","topic":"grp...","desc":{"public":{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2}}}}` | `{"ctrl":{"id":"set-grp-1","code":200,"text":"ok"}}`;成员收到 `{"pres":{"topic":"grp...","what":"upd"}}` | +| 更新个人资料 | `{"set":{"id":"set-me-1","topic":"me","desc":{"public":{"fn":"新昵称","avatar_file_id":"file_avatar"}}}}` | `{"ctrl":{"id":"set-me-1","code":200,"text":"ok"}}` | +| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` | +| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` | + +##### 0.5.4.6 系统通知、好友和群业务下行 + +服务端主动下行分两类:MeetingIM 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。 + +用户维度业务事件统一格式: + +```json +{ + "info": { + "topic": "usr_target", + "from": "usr_actor", + "what": "im", + "event": "friend.request.accepted", + "payload": { + "user_id": "usr_actor" + }, + "ts": "2026-08-25T10:00:00Z" + } +} +``` + +群维度业务事件统一格式: + +```json +{ + "info": { + "topic": "grp_xxx", + "from": "usr_actor", + "what": "group", + "event": "group.member.joined", + "payload": { + "topic": "grp_xxx", + "user_id": "usr_xxx" + }, + "ts": "2026-08-25T10:00:00Z" + } +} +``` + +| 事件 | 接收方 | 服务端下行示例 | 客户端动作 | +| --- | --- | --- | --- | +| 好友申请创建 | 被申请人 | `{"info":{"topic":"usr_target","from":"usr_applicant","what":"im","event":"friend.request.created","payload":{"user_id":"usr_applicant"}}}` | 刷新 `GET /v1/im/friend-requests` 和 `GET /v1/im/bootstrap` 的新朋友未处理数;系统通知列表也会新增记录 | +| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 | +| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 | +| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 | +| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context | +| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 | +| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 | +| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 | +| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 | +| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 | +| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 | + +业务事件写入 outbox 后会立即尝试投递;Hub 不可用或队列满时保持 pending,后台 poller 按在线租户重试。`registration.invite_used` 和 `security.password_reset` 是服务端内部消费事件,不作为普通业务事件下发给 App。 + +##### 0.5.4.7 WebRTC 通话信令 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 发起语音通话 | `{"pub":{"id":"call-pub-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call","x-im-client-mid":"mid_call_1"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-pub-1","code":202,"text":"accepted","params":{"seq":30}}}`;被叫收到同一 `{data seq=30}` | +| 被叫响铃 | `{"note":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | 主叫收到 `{"info":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | +| 被叫接听 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":30,"payload":{"callId":"call_1"}}}` | 双方收到通话状态 `accept`;原消息可被服务端替换为 `head.webrtc:"accepted"` | +| SDP offer | `{"note":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | +| SDP answer | `{"note":{"topic":"p2p...","what":"call","event":"answer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `event:"answer"` | +| ICE candidate | `{"note":{"topic":"p2p...","what":"call","event":"ice-candidate","seq":30,"payload":{"callId":"call_1","candidate":{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}}}}` | 对端收到 `event:"ice-candidate"` | +| 挂断/取消 | `{"note":{"topic":"p2p...","what":"call","event":"hang-up","seq":30,"payload":{"callId":"call_1","reason":"cancelled"}}}` | 服务端替换原通话消息:`{"data":{"topic":"p2p...","seq":30,"head":{"replace":":30","webrtc":"missed","webrtc-duration":0,"x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","status":"missed"}}}` | +| 异常事件 | `{"note":{"topic":"p2p...","what":"call","event":"media-state","seq":30,"payload":{"callId":"call_1"}}}` | 当前未开放时返回 `{"ctrl":{"code":400,"text":"malformed","params":{"reason":"CALL_EVENT_UNSUPPORTED"}}}` 或直接忽略,客户端不要提前依赖 | + +##### 0.5.4.8 WSS 错误、重试和客户端处理 + +| 场景 | 服务端返回示例 | 客户端处理 | +| --- | --- | --- | +| API Key 无效 | Upgrade HTTP `403 {"ctrl":{"code":403,"text":"valid API key required"}}` | 停止重连,上报环境配置错误 | +| 未先 `hi` 就登录 | `{"ctrl":{"id":"login-1","code":400,"text":"tenant required","params":{"reason":"TENANT_REQUIRED"}}}` | 关闭连接,从 Upgrade + `hi` 重来 | +| 登录失败 | `{"ctrl":{"id":"login-1","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | 清密码,保留账号,显示统一账号或密码错误 | +| Token 过期 | `{"ctrl":{"id":"login-token-1","code":401,"text":"authentication failed","params":{"reason":"TOKEN_EXPIRED"}}}` | 清当前租户 token,回登录页 | +| 无 Topic 权限 | `{"ctrl":{"id":"pub-1","code":403,"text":"permission denied","params":{"reason":"MESSAGE_NOT_ALLOWED"}}}` | 发送气泡置为失败,不伪装已发送 | +| 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 | +| 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 | + + +#### 0.5.5 Long Polling 与文件基础示例 + +##### 0.5.5.1 WebSocket 与文件基础接口 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` | +| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | +| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | +| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token `;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | +| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token `,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | ### 0.6 Topic 与产品消息 @@ -294,7 +459,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 -3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 +3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。 4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 @@ -304,19 +469,19 @@ X-Tinode-APIKey: <预置的普通 API Key> 企业码登录 -**状态:新增企业解析接口;WebSocket 租户握手已实现。** +**状态:企业解析接口与 WebSocket 租户握手均已实现。** **当前系统的执行方式:** - 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 -- 客户端从构建/环境配置读取 `host` 和普通 API Key,使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 +- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 - Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 - 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 - 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 -**目标产品新增方式:** +**企业解析接口:** -- 新增 `POST /v1/im/tenants/resolve`,未登录但必须带 `X-Tinode-APIKey`,Header 传 `X-Tenant-Code: acme`;Body 为 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 +- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 @@ -342,16 +507,121 @@ X-Tinode-APIKey: <预置的普通 API Key> } ``` +### 1.1.1 企业注册-登录入口.png + +企业注册-登录入口 + +**状态:已实现。** + +- 页面入口:企业码登录页右上角展示“企业注册”,底部展示“遇到问题?”。两个入口均为未登录入口,但必须使用客户端预置的普通 API Key,不需要用户 Token。 +- 点击“企业注册”:进入 1.1.2 企业资料提交流程。客户端保留当前已输入的 `tenant_code` 作为可选线索,但不得把它当成已解析企业身份。 +- 点击“遇到问题?”:进入 1.1.4 问题反馈页。若用户已输入企业码,提交反馈时带 `tenant_code_hint`;未输入时可为空。 +- 埋点和日志:可记录入口点击事件,但不得记录 API Key、完整联系方式、证照图片 URL 或上传文件真实地址。 +- 入口展示:入口文案必须避开系统状态栏、安全区域和键盘遮挡;网络不可用时仍允许进入填写页,提交时再提示网络错误。 + +### 1.1.2 企业注册-填写注册信息.png + +企业注册-填写注册信息 + +**状态:已实现。** + +- 页面步骤:展示“填写企业信息、确定信息、提交审核”三步,当前处于第 1 步。提交前客户端在本页完成格式校验,服务端仍做最终校验。 +- 必填字段:`credit_code` 18 位统一社会信用代码,客户端仅去掉首尾空格并转大写展示;提交值必须匹配 `^[0-9A-Z]{18}$`。不得提交中文、空格或短码。 +- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。 +- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。 +- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。 +- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 +- 请求体: + +```json +{ + "credit_code": "91310000MA1K000000", + "enterprise_email": "admin@example.com", + "license_file_id": "file_01K...", + "tenant_code_hint": "acme", + "client_context": { + "platform": "android", + "app_version": "1.0.0", + "device_model": "Pixel 8" + } +} +``` + +- 成功响应:服务端创建企业注册申请单,状态为 `received` 或 `pending_review`。客户端进入 1.1.3,不得重复提交同一个幂等键。 + +```json +{ + "application_id": "entapp_01K...", + "status": "received", + "estimated_review_hours": 24, + "submitted_at": "2026-08-24T08:30:15.123Z" +} +``` + +- 失败处理:`INVALID_CREDIT_CODE` 显示统一社会信用代码格式错误;`INVALID_EMAIL` 定位邮箱输入;`FILE_UNAVAILABLE` 允许重新上传;`APPLICATION_DUPLICATED` 提示已有申请正在审核;`RATE_LIMITED` 按服务端退避时间禁用提交按钮。 + +### 1.1.3 企业注册-提交成功提示.png + +企业注册-提交成功提示 + +**状态:已实现。** + +- 触发条件:1.1.2 提交接口返回成功后,在当前页展示 Toast:“提交成功,我们会尽快联系你”。 +- 状态保存:客户端保存 `application_id,status,submitted_at,estimated_review_hours` 到本地短期缓存,用于返回页面时展示“已提交”状态。该缓存不代表审核结果,最终状态以服务端和邮件/通知为准。 +- 防重复:提交成功后禁用提交按钮;用户返回后再次进入企业注册页,可显示已提交提示并允许重新填写新申请,但必须使用新的 `Idempotency-Key`。 +- 审核通知:审核结果默认通过 `enterprise_email` 触达;如果后续后台接入站内通知或短信,客户端只按服务端下发的公开状态展示,不自行推断审核通过。 +- 文案规则:页面底部审核时长应使用服务端返回的 `estimated_review_hours` 或后台配置,不写死。 + +### 1.1.4 企业注册-问题反馈.png + +企业注册-问题反馈 + +**状态:已实现。** + +- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。 +- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。 +- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。 +- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 +- 请求体: + +```json +{ + "description": "企业码 acme 提示不可用,无法进入企业。", + "contact": "user@example.com", + "tenant_code_hint": "acme", + "screenshot_file_ids": ["file_01K..."], + "client_context": { + "platform": "ios", + "app_version": "1.0.0", + "network": "wifi" + } +} +``` + +- 成功响应:客户端展示“已收到反馈,我们会尽快处理”,并清空草稿和已提交截图引用。 + +```json +{ + "feedback_id": "entryfb_01K...", + "status": "received", + "submitted_at": "2026-08-24T08:35:15.123Z" +} +``` + +- 失败处理:`DESCRIPTION_REQUIRED` 或 `DESCRIPTION_TOO_LONG` 定位描述框;`TOO_MANY_FILES` 提示最多 9 张;`FILE_UNAVAILABLE` 提示重新上传失效图片;`RATE_LIMITED` 禁用提交并展示稍后重试。 + ### 1.2 登录页面.png 登录页面 -**状态:基本账号密码登录已实现;协议勾选和安全 Token 生命周期需改造。** +**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** - 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 -- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 -- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token。 -- 待改造:登录成功后签发短期 access token 与可轮换 refresh token;Token 绑定 `tenant_id/user_id/auth_version/device_id`。修改密码、封禁账号后旧 Token 失效。 +- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 +- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 +- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer `;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 +- Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 +- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 登录成功示例: @@ -389,17 +659,42 @@ X-Tinode-APIKey: <预置的普通 API Key> 注册页面 -**状态:Tinode `{acc}` 基础注册已实现;短信预校验与租户配额需改造。** +**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** -- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138****0000"}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 -- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 -- WebSocket:把一次性验证结果放在 `{acc}.cred`,不得写入 `desc.private`。服务端必须在当前 Session 租户内消费该 Token。 +- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 +- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 +- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 +- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 +- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 +- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 +- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 +- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。 +- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。 +- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 +- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 +- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 +- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。 +- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 + +用户名方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(username:password)","login":true,"tags":["tel:+86138..."],"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} ``` -- 成功:返回新 `user` 和登录 Token,自动 `sub me`。创建用户前原子校验 `im_tenant_config.max_user_count`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 -- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 + +手机号验证方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` + +仅手机号注册方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` +- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 +- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 ### 1.5 注册页面-倒计时.png @@ -417,11 +712,14 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** +- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。 - 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。 - 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。 - 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。 -- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 +- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。 +- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。 - 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 +- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。 ### 1.7 身份验证(安卓).png @@ -442,7 +740,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。 - 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。 -- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,通过 `GET /v1/im/security/realname` 或 `security.realname.updated` 系统事件更新状态。 +- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,当前以 `GET /v1/im/security/realname` 查询状态为准。后续如启用 `security.realname.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。 - 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 ### 1.9 实名认证-已实名.png @@ -476,6 +774,33 @@ X-Tinode-APIKey: <预置的普通 API Key> - 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 - 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 +### 1.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 1.12.1 未登录、企业与账号安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` | +| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` | +| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` | +| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` | +| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | +| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | +| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` | +| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` | + ## 2. 消息首页、全局导航与系统通知 ### 2.1 消息.png @@ -486,7 +811,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。 - 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。 -- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限和系统 Topic;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 +- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限、系统 Topic、`entry_badges` 和 `entries`;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 - 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 ### 2.2 消息-无网络状态.png @@ -517,7 +842,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:基础消息操作部分已实现;产品权限矩阵需改造。** - 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 -- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 +- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 MeetingIM `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 - 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 - 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 @@ -527,11 +852,30 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:需改造。** -- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 +- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。 - 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 - 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 +### 2.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 2.6.1 登录后启动、消息首页与系统通知 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | +| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` | + ## 3. 单聊会话、会话建立与消息状态 ### 3.1 个人对话框(对方的状态).png @@ -552,7 +896,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 - `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 -- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 +- 好友关系变化由服务端生成 0.5.4.6 定义的 WS 业务事件,客户端不得根据本地动作自行永久插入系统通知记录。 ### 3.3 个人对话框(我的状态).png @@ -580,7 +924,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增好友审批与 Topic 联动。** -- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 +- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。 - 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 - 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 @@ -651,7 +995,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。 - `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。 -- 点击卡片调用 `GET /v1/im/users/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 +- 点击卡片调用 `GET /v1/im/users/{identifier}/profile`;`identifier` 可传 `user_id`、`username` 或 `public_account`,用户不可见或已离职时显示失效卡片,不跨租户查询。 ## 4. 消息输入、媒体内容与消息操作 @@ -846,6 +1190,36 @@ X-Tinode-APIKey: <预置的普通 API Key> - 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 - 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 +### 4.21 会话与消息接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 4.21.1 会话上下文、消息操作与检索 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` | +| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | +| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | +| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | +| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | +| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","file":{"file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf"},"availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` | +| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | + +消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。 + ## 5. 通讯录、好友关系与黑名单 ### 5.1 通讯录.png @@ -855,8 +1229,9 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增产品通讯录接口。** - 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 - 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 -- 新朋友未处理数、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 返回。列表缓存以租户和用户隔离。 +- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 ### 5.2 新朋友.png @@ -867,6 +1242,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 +- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 ### 5.3 添加好友.png @@ -885,7 +1261,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 -- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 +- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。 - 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 ### 5.5 添加好友-输入精准用户ID.png @@ -914,7 +1290,8 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** -- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 +- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。 +- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 @@ -984,21 +1361,54 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** -- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 +- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 - 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 +### 5.15 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 5.15.1 通讯录、好友、黑名单与二维码 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/friends?query=&initial=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","initial":"Z","topic":"p2p...","relationship":"friend","online_visibility":"unknown","capabilities":{"can_send":true,"can_call":true}}],"next_cursor":""}` | +| `GET /v1/im/friends?query=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","topic":"p2p...","relationship":"friend"}],"next_cursor":""}` | +| `GET /v1/im/friend-requests?direction=inbound&status=all&cursor=` | Query only | `{"items":[{"request_id":"frq_1","direction":"inbound","applicant_id":"usr_a","target_user_id":"usr_b","peer":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","source":"search","status":"pending","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/friend-requests` | `{"target_user_id":"usr...","message":"验证留言","source":"search","source_topic":""}` | `{"request_id":"frq_1","applicant_id":"usr_me","target_user_id":"usr...","message":"验证留言","source":"search","status":"pending","topic":"p2p...","created_at":"2026-08-20T09:00:00Z","expires_at":"2026-08-27T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/accept` | `{"version":1}` | `{"request_id":"frq_1","status":"accepted","topic":"p2p...","handled_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | +| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` | +| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `PUT /v1/im/blocks/{user_id}` | `{}` | `{"user_id":"usr...","blocked":true,"blocked_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/blocks/{user_id}` | Path user ID | `{"user_id":"usr...","blocked":false}` | +| `POST /v1/im/blocks/batch-delete` | `{"user_ids":["usr_a","usr_b"]}` | `{"items":[{"user_id":"usr_a","blocked":false}],"failed":[]}` | +| `POST /v1/im/me/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三"}}` | +| `POST /v1/im/users/{identifier}/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三","public_account":"zhangsan"}}` | +| `POST /v1/im/qrcodes/resolve` | `{"token":"token...","purpose":"add_friend"}` | `{"purpose":"add_friend","user":{"user_id":"usr...","display_name":"张三"},"relationship":"none","can_add":true}` | + ## 6. 群聊创建、资料维护与成员治理 ### 6.1 群聊.png 群聊 -**状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。** +**状态:MeetingIM 群 Topic 已实现基础能力;产品群列表与配额需改造。** - 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 -- 创建:`POST /v1/im/groups`,Body `{"name":"...","member_user_ids":[],"avatar_file_id":"..."}`;原子校验租户群数上限和群成员上限,返回 `topic`。 -- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 +- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。 ### 6.2 空白页.png @@ -1015,7 +1425,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增邀请卡片和加入接口。** -- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 +- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。 - 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 - 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 @@ -1026,8 +1436,8 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增群成员事件。** - 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 -- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 -- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 +- 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。 +- 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 ### 6.5 邀请进群申请.png @@ -1056,7 +1466,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 - 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 -- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 +- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 ### 6.8 群公告状态/邀请进群申请同时存在.png @@ -1240,7 +1650,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 -- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 +- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成 `group.owner.transferred` 群业务事件。 - 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 ### 6.27 群文件.png @@ -1263,6 +1673,61 @@ X-Tinode-APIKey: <预置的普通 API Key> - 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 +### 6.29 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 6.29.1 群聊、群成员与群治理 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/groups?category=owned&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"owner","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `GET /v1/im/groups?category=owned\|managed\|joined&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"admin","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` | +| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` | +| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` | +| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` | +| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` | +| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | +| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | +| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | +| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | +| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | +| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | +| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` | +| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` | +| `GET /v1/im/groups/{topic}/announcements/latest` | Path topic | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":1,"published_at":"2026-08-20T09:00:00Z","permissions":{"can_edit":true}}` | +| `PUT /v1/im/groups/{topic}/announcements/current` | `{"content":"新的公告","version":1}` | `{"announcement_id":"ann_1","topic":"grp...","content":"新的公告","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/announcements/{announcement_id}` | Path announcement | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/announcements/{id}/read` | `{}` | `{"announcement_id":"ann_1","read_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/governance` | Path topic | `{"join_approval":false,"mute_all":false,"member_upload_files":true,"member_private_chat":true,"member_invite":true,"version":1}` | +| `PATCH /v1/im/groups/{topic}/governance` | `{"join_approval":true,"member_invite":false,"version":1}` | `{"join_approval":true,"member_invite":false,"version":2}` | +| `GET /v1/im/groups/{topic}/role-limits` | Path topic | `{"max_admin_count":10,"current_admin_count":1}` | +| `GET /v1/im/groups/{topic}/members?role=admin&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr_admin","display_name":"管理员","role":"admin"}],"next_cursor":""}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","role":"member","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/admins/batch` | `{"user_ids":["usr..."],"version":1}` | `{"succeeded":[{"user_id":"usr...","role":"admin"}],"admins":[{"user_id":"usr...","role":"admin"}],"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/admins/{user_id}` | Header `If-Match: 2` 或 Body version | `{"user_id":"usr...","role":"member","updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/mutes/batch` | `{"members":[{"user_id":"usr...","expires_at":null}],"reason":"刷屏"}` | `{"topic":"grp...","items":[{"user_id":"usr...","muted":true,"muted_at":"2026-08-20T09:00:00Z","expires_at":null}]}` | +| `GET /v1/im/groups/{topic}/mutes?cursor=` | Query only | `{"topic":"grp...","items":[{"user":{"user_id":"usr...","display_name":"张三"},"muted_by":"usr_admin","muted_at":"2026-08-20T09:00:00Z","expires_at":null}],"next_cursor":""}` | +| `DELETE /v1/im/groups/{topic}/mutes/{user_id}` | Path user | `{"user_id":"usr...","muted":false}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/owner-transfer` | Header `X-Action-Token: action_token`;Body `{"target_user_id":"usr...","version":2}` | `{"owner":"usr...","previous_owner":"usr_old","version":3}` | +| `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 待修复 | Query only | `{"topic":"grp...","items":[{"group_file_id":"gfile_1","file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf","uploader":{"user_id":"usr...","display_name":"张三"},"created_at":"2026-08-20T09:00:00Z","download_state":"available","permissions":{"can_delete":true}}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/files` 待修复 | `{"file_id":"file_1"}` | `{"group_file_id":"gfile_1","file_id":"file_1","topic":"grp...","created_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 待修复 | Path group file ID | `{"group_file_id":"gfile_1","deleted":true}` | + ## 7. 文件下载、收藏管理与聊天记录检索 ### 7.1 我的收藏.png @@ -1347,7 +1812,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:底层下载已实现租户校验;资源 ACL 需改造。** -- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token `;禁止 `?auth=&secret=`。 +- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token `;禁止 `?auth=&secret=`。 - 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 - 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 @@ -1359,7 +1824,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: `。 - ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 -- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 +- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。 ### 7.11 文件-下载完成.png @@ -1416,6 +1881,32 @@ X-Tinode-APIKey: <预置的普通 API Key> - 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 - 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 +### 7.17 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 7.17.1 收藏、转发与文件安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/favorites?type=all&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"text","snapshot":{"text":"收藏内容"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/favorites?type=all\|text\|image\|video\|voice\|file\|link&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"image","snapshot":{"thumbnail_file_id":"file_thumb","file_id":"file_img"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `POST /v1/im/favorites/batch` | `{"messages":[{"topic":"grp...","seq":"12"}]}` | `{"created_count":1,"existing_count":0,"failed":[]}` | +| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` | +| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` | +| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` | +| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` | +| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/messages/forward` 待实现 | `{"mode":"separate","sources":[{"topic":"grp...","seq":"12"}],"target_topics":["p2p..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":"12"},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}` | +| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` | +| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` | + ## 8. 实时语音与视频通话 ### 8.1 各种通话提示状态.png @@ -1444,6 +1935,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** +- 音视频入口先看 `GET /v1/im/bootstrap` 的 `features.audio_call/video_call` 和 `permissions.audio_call/video_call`;这两个值来源于 Admin 后台现有配置读取链路 `im_client_feature` 分组中的 `audio_call_enabled`、`video_call_enabled`,与声网参数使用同一个配置读取方法,但不放在声网分组。具体到某个会话或用户时,再看 `capabilities.can_call`。任一为 `false` 时前端不展示对应语音/视频按钮。 - 发起前查询 `capabilities.can_call`,再发布持久邀请消息: ```json @@ -1527,6 +2019,25 @@ X-Tinode-APIKey: <预置的普通 API Key> - 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 - 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 +### 8.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 8.12.1 实时通话与通话记录 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` | +| WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | +| `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` | +| `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | + ## 9. 企业工作台与办公协同 ### 9.1 工作台.png @@ -1536,8 +2047,9 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 -- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 +- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 ### 9.2 部门组织框架.png @@ -1548,6 +2060,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 - 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 - 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 +- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。 ### 9.3 会议通知-发起人状态.png @@ -1558,6 +2071,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 - 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。 - 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。 +- 主动下行:参会人收到 `info.what="im"`,事件为 `meeting.created/updated/canceled/reminded/response.updated`,payload 包含 `meeting_id,version,state`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。 ### 9.4 会议通知-接收人状态.png @@ -1566,7 +2080,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 -- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 +- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。 - 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 ### 9.5 日报.png @@ -1657,6 +2171,51 @@ X-Tinode-APIKey: <预置的普通 API Key> - 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 - 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 +### 9.14 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 9.14.1 工作台、组织、会议、日报、月报、信箱 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"meetings":true}}}` | +| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` | +| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` | +| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` | +| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` | +| `PATCH /v1/im/meetings/{id}` | `{"title":"新标题","version":1}` | `{"meeting_id":"mtg_1","title":"新标题","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` | +| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | +| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | +| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | +| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | +| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` | +| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"本月完成客户拜访和合同整理...","next_plan_short":"下月推进回款和方案评审","status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"完成项目阶段验收...","next_plan_short":"继续推进上线准备","status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` | +| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` | +| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/monthly/statistics?month=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `GET /v1/im/reports/monthly/{report_id}` | Path report ID | `{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"work_summary":"总结","next_plan":"计划","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `POST /v1/im/reports/monthly/{id}/reopen` 待实现 | `{"version":3,"reason":"补充内容"}` | `{"report_id":"mr_1","status":"draft","version":4}` | +| `GET /v1/im/executive-mailbox/config` | Query only | `{"enabled":true,"allow_anonymous":true,"required_fields":["subject","content"],"max_length":1000,"attachment_policy":{"max_files":5,"purpose":"executive_mailbox"},"notice":{"title":"总经理信箱","content":"请如实反馈","updated_at":"2026-08-20T09:00:00Z"}}` | +| `POST /v1/im/executive-mailbox/submissions` | Header `Idempotency-Key`;Body `{"subject":"建议","content":"建议内容","anonymous":false,"attachment_file_ids":[]}` | `{"submission_id":"mail_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | + ## 10. 个人资料、通用设置、隐私与青少年模式 ### 10.1 我的.png @@ -1673,10 +2232,10 @@ X-Tinode-APIKey: <预置的普通 API Key> 个人信息 -**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** +**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 -- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 +- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 +- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 ### 10.3 设置昵称.png @@ -1723,7 +2282,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:菜单纯客户端;数据接口部分新增。** - 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 -- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 ### 10.8 设置(安卓).png @@ -1859,6 +2418,38 @@ X-Tinode-APIKey: <预置的普通 API Key> - 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 - 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 +### 10.22 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 10.22.1 个人资料、设置、隐私、青少年模式与设备 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | +| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | +| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` | +| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode"}` | +| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/security/challenges` | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/security/challenges/{id}/verify` | `{"code":"123456"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | +| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | +| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"fcm","token":"push_token","platform":"android","app_version":"1.0.0"}` | `{"device_id":"device_1","provider":"fcm","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | + ## 11. 反诈安全、举报与意见反馈 ### 11.1 反诈弹窗(安卓).png @@ -1869,7 +2460,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 - 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 -- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 +- 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。 ### 11.2 反诈弹窗(二次提示安卓).png @@ -1911,13 +2502,34 @@ X-Tinode-APIKey: <预置的普通 API Key> - 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。 - 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。 + +### 11.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 11.6.1 反诈、举报与意见反馈 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm` | `{"decision":"continue"}` | `{"risk_action_token":"risk_token","action":"send_message","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/reports/abuse/reasons?target_type=user\|group\|message` 需修复 | Query only | `{"items":[{"reason_code":"fraud","label":"诈骗","requires_description":true,"allows_evidence":true},{"reason_code":"harassment","label":"骚扰","requires_description":false,"allows_evidence":true}]}` | +| `POST /v1/im/reports/abuse` | `{"target":{"type":"message","topic":"grp...","seq":"12"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file_1"]}` | `{"report_id":"abr_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/feedback/config` | Query only | `{"categories":["bug","suggestion","complaint","other"],"max_length":1000,"allow_contact":true,"attachment_policy":{"max_files":5,"purpose":"feedback"},"diagnostics_notice":"仅在你同意时上传脱敏诊断信息。","updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/feedback` | `{"category":"bug","content":"页面异常","contact":"15500000000","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"14"},"include_diagnostics":false}` | `{"feedback_id":"fb_1","status":"received","created_at":"2026-08-20T09:00:00Z"}` | + ## 12. 联调实施清单 ### 12.1 当前可直接联调的基础能力 1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 -3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。 +3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 MeetingIM 协议。 4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 @@ -1926,8 +2538,8 @@ X-Tinode-APIKey: <预置的普通 API Key> | 优先级 | 改造项 | 阻塞图片章节 | | --- | --- | --- | | P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | -| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 | -| P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | +| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 | +| P0 | 普通用户禁止发布系统 Topic;系统通知和业务事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | | P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | | P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | | P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | @@ -1950,7 +2562,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | | T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | | T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | -| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | | T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | | T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 |