From 8b30b8241c62a12d5cb90145c0eda173fd3cb55f Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 13 Sep 2026 23:25:48 +0800 Subject: [PATCH] feat: refine IM admin management workflows --- Dockerfile | 2 +- .../sql/IM-租户业务库初始化.sql | 1618 +++++++++++++++++ docs/im/IM-前后端对接文档.md | 274 ++- server/api/admin/imgroup/im_group.go | 24 +- server/internal/cmd/http.go | 2 + server/internal/consts/im_kafka.go | 1 + .../internal/controller/admin/sys/im_group.go | 42 +- server/internal/controller/health/health.go | 69 + server/internal/library/queue/kafkamq.go | 41 + server/internal/logic/sys/im_group.go | 383 +--- server/internal/logic/sys/im_user.go | 133 +- server/internal/model/input/sysin/im_group.go | 42 +- server/internal/service/sys.go | 12 +- web/src/views/imUser/governance.vue | 4 +- web/src/views/imUser/index.vue | 6 +- 15 files changed, 2128 insertions(+), 525 deletions(-) create mode 100644 docs/admin-adfahi-research/sql/IM-租户业务库初始化.sql create mode 100644 server/internal/controller/health/health.go diff --git a/Dockerfile b/Dockerfile index d8c29c2..59d4088 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,7 +60,7 @@ RUN mkdir -p storage/logs storage/data storage/cache storage/diskqueue \ && chmod +x entrypoint.sh hotgo HEALTHCHECK --interval=1m --timeout=3s --start-period=20s \ - CMD nc -z localhost 8000 || exit 1 + CMD wget -q -O /dev/null http://localhost:8000/healthz || exit 1 EXPOSE 8000 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/im/IM-前后端对接文档.md b/docs/im/IM-前后端对接文档.md index ba6ed67..9a28d68 100644 --- a/docs/im/IM-前后端对接文档.md +++ b/docs/im/IM-前后端对接文档.md @@ -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` | 同上 | +| 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-Tinode-Auth: token ` | 文件上传下载;禁止 Query 传 Token | +| 文件 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: @@ -221,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 ``` @@ -235,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"}} @@ -245,7 +245,7 @@ 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 等待下行消息。 @@ -270,7 +270,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 本节用于 WebSocket 联调。除特别说明外: -- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-Tinode-APIKey`。 +- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。 - 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。 - 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。 - 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。 @@ -334,16 +334,60 @@ X-Tinode-APIKey: <预置的普通 API Key> | 更新会话私有设置 | `{"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 系统事件、好友和群业务下行 +##### 0.5.4.6 系统通知、好友和群业务下行 -| 事件 | 服务端下行示例 | 客户端动作 | -| --- | --- | --- | -| 好友申请创建 | `{"data":{"topic":"sys","seq":101,"from":"usr_system","head":{"mime":"application/json","x-im-type":"system_event"},"content":{"event_type":"friend.request.created","request_id":"frq_1","applicant":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","action":{"type":"friend_request","request_id":"frq_1"}}}}` | 系统消息入库/展示,刷新 `friend-requests` 和 `bootstrap.entry_badges` | -| 好友关系变化 | `{"data":{"topic":"sys","seq":102,"head":{"x-im-type":"system_event"},"content":{"event_type":"friend_relationship_changed","peer_user_id":"usr_a","relationship":"friend","topic":"p2p..."}}}` | 刷新会话 context,解锁或锁定输入区 | -| 群邀请 | `{"data":{"topic":"sys","seq":103,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_invitation","invitation_id":"ginv_1","group":{"topic":"grp...","name":"项目群"},"inviter":{"user_id":"usr_a","display_name":"张三"},"status":"pending","expires_at":"2026-08-27T09:00:00Z"}}}` | 展示邀请卡,点击后走邀请接受或入群申请接口 | -| 入群申请 | `{"data":{"topic":"grp...","seq":20,"head":{"x-im-type":"system_event"},"content":{"event_type":"group.join_request.created","request_id":"gjr_1","applicant":{"user_id":"usr_a","display_name":"张三"},"source":"qr"}}}` | 管理员刷新 `join-requests` 和 `summary.pending_join_request_count` | -| 群成员变化 | `{"data":{"topic":"grp...","seq":21,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_membership_changed","operator":"usr_owner","target_users":["usr_a"],"action":"join","result":"joined"}}}` | 刷新群成员列表、群详情成员数和会话系统提示 | -| 工作台未读变化 | `{"data":{"topic":"sys","seq":104,"head":{"x-im-type":"system_event"},"content":{"event_type":"workbench.badge.updated","modules":[{"code":"meetings","unread_count":2}],"workbench_total_unread":2}}}` | 更新工作台 badge;必要时重新拉 `GET /v1/im/workbench` | +服务端主动下行分两类: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 通话信令 @@ -381,8 +425,8 @@ X-Tinode-APIKey: <预置的普通 API Key> | 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-Tinode-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-Tinode-Auth: token `,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | +| `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 与产品消息 @@ -437,7 +481,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **企业解析接口:** -- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;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`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 @@ -463,6 +507,109 @@ 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 登录页面 @@ -472,8 +619,8 @@ X-Tinode-APIKey: <预置的普通 API Key> - 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 - 协议:已完成 `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-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 -- Token:当前复用 Tinode 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 +- 产品 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` 按专用页面处理。 @@ -593,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 @@ -631,7 +778,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -642,6 +789,9 @@ X-Tinode-APIKey: <预置的普通 API Key> | 接口 | 输入示例 | 成功 `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}` | @@ -692,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=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 @@ -702,7 +852,7 @@ 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 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 @@ -711,7 +861,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -721,7 +871,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | 接口 | 输入示例 | 成功 `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},"permissions":{"create_group":true,"user_search":true,"scan_qr":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/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"}` | @@ -746,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 @@ -774,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` 拉取申请上下文和正常消息。 @@ -1044,7 +1194,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -1068,6 +1218,8 @@ X-Tinode-APIKey: <预置的普通 API Key> | `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 @@ -1090,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 @@ -1208,7 +1361,7 @@ 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 消息缓存并保留会话关系。 @@ -1216,7 +1369,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -1251,11 +1404,11 @@ X-Tinode-APIKey: <预置的普通 API Key> 群聊 -**状态: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":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。 -- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。 ### 6.2 空白页.png @@ -1272,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`。 @@ -1283,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 @@ -1313,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 @@ -1497,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 @@ -1524,7 +1677,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -1659,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` 显示失效。 @@ -1732,7 +1885,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -1782,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 @@ -1869,7 +2023,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -1895,7 +2049,7 @@ 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 @@ -1906,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 @@ -1916,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 @@ -1924,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 @@ -2019,7 +2175,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -2041,16 +2197,16 @@ X-Tinode-APIKey: <预置的普通 API Key> | `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","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","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | +| `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":{"user_id":"usr...","display_name":"张三"},"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":{"user_id":"usr...","display_name":"张三"},"status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `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}` | @@ -2076,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,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 -- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 +- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 ### 10.3 设置昵称.png @@ -2266,7 +2422,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -2304,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 @@ -2351,7 +2507,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 以下示例用于本章 HTTP 联调快速查阅。除特别说明外: -- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 产品 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":"..."}`。 @@ -2373,7 +2529,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 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}` 的基础上传下载及租户存储隔离。 @@ -2383,7 +2539,7 @@ 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 | 普通用户禁止发布系统 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 章 | diff --git a/server/api/admin/imgroup/im_group.go b/server/api/admin/imgroup/im_group.go index fc55a89..0f1b788 100644 --- a/server/api/admin/imgroup/im_group.go +++ b/server/api/admin/imgroup/im_group.go @@ -60,7 +60,9 @@ type GovernanceUpdateReq struct { sysin.ImGroupGovernanceUpdateInp } -type GovernanceUpdateRes struct{} +type GovernanceUpdateRes struct { + *sysin.ImGroupCommandModel +} // ProfileUpdateReq 修改IM群资料 type ProfileUpdateReq struct { @@ -78,7 +80,9 @@ type DissolveReq struct { sysin.ImGroupDissolveInp } -type DissolveRes struct{} +type DissolveRes struct { + *sysin.ImGroupCommandModel +} // MuteReq 禁言IM群成员 type MuteReq struct { @@ -86,7 +90,9 @@ type MuteReq struct { sysin.ImGroupMuteInp } -type MuteRes struct{} +type MuteRes struct { + *sysin.ImGroupCommandModel +} // MemberMuteReq Kafka禁言IM群成员 type MemberMuteReq struct { @@ -104,7 +110,9 @@ type UnmuteReq struct { sysin.ImGroupUnmuteInp } -type UnmuteRes struct{} +type UnmuteRes struct { + *sysin.ImGroupCommandModel +} // MemberUnmuteReq Kafka解除IM群成员禁言 type MemberUnmuteReq struct { @@ -148,7 +156,9 @@ type RoleUpdateReq struct { sysin.ImGroupRoleUpdateInp } -type RoleUpdateRes struct{} +type RoleUpdateRes struct { + *sysin.ImGroupCommandModel +} // AdminSetReq 设置IM群管理员 type AdminSetReq struct { @@ -176,7 +186,9 @@ type OwnerTransferReq struct { sysin.ImGroupOwnerTransferInp } -type OwnerTransferRes struct{} +type OwnerTransferRes struct { + *sysin.ImGroupCommandModel +} // JoinRequestListReq 查询IM入群申请列表 type JoinRequestListReq struct { diff --git a/server/internal/cmd/http.go b/server/internal/cmd/http.go index c00f2c6..88bf396 100644 --- a/server/internal/cmd/http.go +++ b/server/internal/cmd/http.go @@ -11,6 +11,7 @@ import ( "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/util/gmode" + "hotgo/internal/controller/health" "hotgo/internal/library/addons" "hotgo/internal/library/casbin" "hotgo/internal/library/hggen" @@ -33,6 +34,7 @@ var ( // 请求响应结束后回调 s.BindHookHandler("/*any", ghttp.HookAfterOutput, service.Hook().AfterOutput) + s.BindHandler("/healthz", health.Serve) // 注册全局中间件 s.BindMiddleware("/*any", []ghttp.HandlerFunc{ diff --git a/server/internal/consts/im_kafka.go b/server/internal/consts/im_kafka.go index 7622bcf..3709b9c 100644 --- a/server/internal/consts/im_kafka.go +++ b/server/internal/consts/im_kafka.go @@ -38,6 +38,7 @@ const ( ImAdminCommandGroupMemberUnmute = "group.member.unmute" ImAdminCommandGroupMemberRemove = "group.member.remove" ImAdminCommandGroupOwnerTransfer = "group.owner.transfer" + ImAdminCommandGroupDissolve = "group.dissolve" ImAdminCommandUserBan = "user.ban" ImAdminCommandUserUnban = "user.unban" ImAdminCommandUserFreeze = "user.freeze" diff --git a/server/internal/controller/admin/sys/im_group.go b/server/internal/controller/admin/sys/im_group.go index fddde99..09d4e1c 100644 --- a/server/internal/controller/admin/sys/im_group.go +++ b/server/internal/controller/admin/sys/im_group.go @@ -77,7 +77,12 @@ func (c *cImGroup) Governance(ctx context.Context, req *imgroup.GovernanceReq) ( // GovernanceUpdate 更新IM群治理配置 func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.GovernanceUpdateReq) (res *imgroup.GovernanceUpdateRes, err error) { - err = service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp) + data, err := service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp) + if err != nil { + return + } + res = new(imgroup.GovernanceUpdateRes) + res.ImGroupCommandModel = data return } @@ -94,13 +99,23 @@ func (c *cImGroup) ProfileUpdate(ctx context.Context, req *imgroup.ProfileUpdate // Dissolve 解散IM群 func (c *cImGroup) Dissolve(ctx context.Context, req *imgroup.DissolveReq) (res *imgroup.DissolveRes, err error) { - err = service.SysImGroup().Dissolve(ctx, &req.ImGroupDissolveInp) + data, err := service.SysImGroup().Dissolve(ctx, &req.ImGroupDissolveInp) + if err != nil { + return + } + res = new(imgroup.DissolveRes) + res.ImGroupCommandModel = data return } // Mute 禁言IM群成员 func (c *cImGroup) Mute(ctx context.Context, req *imgroup.MuteReq) (res *imgroup.MuteRes, err error) { - err = service.SysImGroup().Mute(ctx, &req.ImGroupMuteInp) + data, err := service.SysImGroup().Mute(ctx, &req.ImGroupMuteInp) + if err != nil { + return + } + res = new(imgroup.MuteRes) + res.ImGroupCommandModel = data return } @@ -117,7 +132,12 @@ func (c *cImGroup) MemberMute(ctx context.Context, req *imgroup.MemberMuteReq) ( // Unmute 解除IM群成员禁言 func (c *cImGroup) Unmute(ctx context.Context, req *imgroup.UnmuteReq) (res *imgroup.UnmuteRes, err error) { - err = service.SysImGroup().Unmute(ctx, &req.ImGroupUnmuteInp) + data, err := service.SysImGroup().Unmute(ctx, &req.ImGroupUnmuteInp) + if err != nil { + return + } + res = new(imgroup.UnmuteRes) + res.ImGroupCommandModel = data return } @@ -157,7 +177,12 @@ func (c *cImGroup) JoinRequestReject(ctx context.Context, req *imgroup.JoinReque // RoleUpdate 更新IM群成员角色 func (c *cImGroup) RoleUpdate(ctx context.Context, req *imgroup.RoleUpdateReq) (res *imgroup.RoleUpdateRes, err error) { - err = service.SysImGroup().RoleUpdate(ctx, &req.ImGroupRoleUpdateInp) + data, err := service.SysImGroup().RoleUpdate(ctx, &req.ImGroupRoleUpdateInp) + if err != nil { + return + } + res = new(imgroup.RoleUpdateRes) + res.ImGroupCommandModel = data return } @@ -185,7 +210,12 @@ func (c *cImGroup) AdminRemove(ctx context.Context, req *imgroup.AdminRemoveReq) // OwnerTransfer 转让IM群主 func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) { - err = service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp) + data, err := service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp) + if err != nil { + return + } + res = new(imgroup.OwnerTransferRes) + res.ImGroupCommandModel = data return } diff --git a/server/internal/controller/health/health.go b/server/internal/controller/health/health.go new file mode 100644 index 0000000..9da288d --- /dev/null +++ b/server/internal/controller/health/health.go @@ -0,0 +1,69 @@ +package health + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + + "hotgo/internal/library/queue" +) + +type response struct { + Status string `json:"status"` + Checks map[string]string `json:"checks"` +} + +// Serve exposes an unauthenticated dependency health endpoint for container probes. +func Serve(r *ghttp.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + r.Response.Header().Set("Allow", http.MethodGet+", "+http.MethodHead) + r.Response.WriteStatusExit(http.StatusMethodNotAllowed, "method not allowed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + checks := map[string]string{} + statusCode := http.StatusOK + + if err := g.DB().Ctx(ctx).Raw("SELECT 1").Scan(new(int)); err != nil { + checks["mysql"] = err.Error() + statusCode = http.StatusServiceUnavailable + } else { + checks["mysql"] = "ok" + } + + if _, err := g.Redis().Do(ctx, "PING"); err != nil { + checks["redis"] = err.Error() + statusCode = http.StatusServiceUnavailable + } else { + checks["redis"] = "ok" + } + + if strings.EqualFold(strings.TrimSpace(g.Cfg().MustGet(ctx, "queue.driver").String()), "kafka") { + if err := queue.KafkaHealthCheck(ctx); err != nil { + checks["kafka"] = err.Error() + statusCode = http.StatusServiceUnavailable + } else { + checks["kafka"] = "ok" + } + } else { + checks["kafka"] = "disabled" + } + + body := response{Status: "ok", Checks: checks} + if statusCode != http.StatusOK { + body.Status = "error" + } + r.Response.Header().Set("Content-Type", "application/json; charset=utf-8") + r.Response.WriteHeader(statusCode) + if r.Method != http.MethodHead { + r.Response.WriteJsonExit(body) + return + } + r.Exit() +} diff --git a/server/internal/library/queue/kafkamq.go b/server/internal/library/queue/kafkamq.go index 66e3738..8757039 100644 --- a/server/internal/library/queue/kafkamq.go +++ b/server/internal/library/queue/kafkamq.go @@ -9,6 +9,7 @@ import ( "context" "crypto/sha256" "crypto/sha512" + "errors" "hash" "strings" "time" @@ -270,6 +271,46 @@ func configureKafkaSASL(conf *sarama.Config, connOpt KafkaConfig) { } } +// KafkaHealthCheck verifies broker connectivity by creating a short-lived Sarama client. +func KafkaHealthCheck(ctx context.Context) error { + if len(config.Kafka.Address) == 0 { + return errors.New("queue kafka address is empty") + } + kfkVersion, err := parseKafkaVersion(config.Kafka.Version) + if err != nil { + return err + } + if !validateVersion(kfkVersion) { + kfkVersion = sarama.V2_4_0_0 + } + conf := sarama.NewConfig() + conf.Version = kfkVersion + conf.ClientID = "hotgo-healthz" + conf.Net.DialTimeout = 2 * time.Second + conf.Net.ReadTimeout = 2 * time.Second + conf.Net.WriteTimeout = 2 * time.Second + configureKafkaSASL(conf, KafkaConfig{ + UserName: config.Kafka.Username, + Password: config.Kafka.Password, + Mechanism: config.Kafka.Mechanism, + }) + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + client, err := sarama.NewClient(config.Kafka.Address, conf) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + if len(client.Brokers()) == 0 { + return errors.New("no kafka brokers available") + } + return nil +} + func scramClientGenerator(hashFunc func() hash.Hash) func() sarama.SCRAMClient { return func() sarama.SCRAMClient { return &xdgSCRAMClient{HashGeneratorFcn: scram.HashGeneratorFcn(hashFunc)} diff --git a/server/internal/logic/sys/im_group.go b/server/internal/logic/sys/im_group.go index 88c3355..8925fc8 100644 --- a/server/internal/logic/sys/im_group.go +++ b/server/internal/logic/sys/im_group.go @@ -88,7 +88,7 @@ func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model { "t.trusted trusted_json", "t.tags tags_json", "t.aux aux_json", - "COALESCE(gs.name, '') group_name", + "COALESCE(NULLIF(gs.name, ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.fn')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.name')), ''), t.name) group_name", "COALESCE(gs.avatar_file_id, '') avatar_file_id", "COALESCE(gs.description, '') description", "COALESCE(gs.group_status, '') group_status", @@ -161,7 +161,7 @@ func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res * "t.aux aux_json", "t.access access_json", "t.usebt use_bt", - "COALESCE(gs.name, '') group_name", + "COALESCE(NULLIF(gs.name, ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.fn')), ''), NULLIF(JSON_UNQUOTE(JSON_EXTRACT(t.public, '$.name')), ''), t.name) group_name", "COALESCE(gs.avatar_file_id, '') avatar_file_id", "COALESCE(gs.description, '') description", "COALESCE(gs.group_status, '') group_status", @@ -349,50 +349,24 @@ func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp) } // GovernanceUpdate 更新IM群治理配置 -func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (err error) { +func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) { if err = in.Filter(ctx); err != nil { - return + return nil, err } - db, _, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return err + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "governance": map[string]any{ + "join_approval": in.JoinApproval == 1, + "mute_all": in.MuteAll == 1, + "member_upload_files": in.MemberUploadFiles == 1, + "member_private_chat": in.MemberPrivateChat == 1, + "member_invite": in.MemberInvite == 1, + "max_member_count": in.MaxMemberCount, + "max_admin_count": in.MaxAdminCount, + }, } - actor := contexts.GetUserId(ctx) - requestId := gctx.CtxId(ctx) - if requestId == "" { - requestId = guid.S() - } - now := gtime.Now() - return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - before, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) - if txErr != nil { - return txErr - } - if before == nil { - return gerror.New("群治理配置不存在") - } - if before["group_status"].String() == consts.ImGroupStatusDissolved { - return gerror.New("已解散群不能修改治理配置") - } - if _, txErr = tx.Exec(`UPDATE im_group_settings SET - join_approval=?,mute_all=?,member_upload_files=?,member_private_chat=?,member_invite=?, - max_member_count=?,max_admin_count=?,version=version+1,updated_at=? - WHERE topic=?`, - in.JoinApproval, in.MuteAll, in.MemberUploadFiles, in.MemberPrivateChat, in.MemberInvite, - in.MaxMemberCount, in.MaxAdminCount, now, in.Topic); txErr != nil { - return txErr - } - after, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) - if txErr != nil { - return txErr - } - if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, 0, "governance_update", "group_settings", in.Topic, before.Map(), after.Map(), requestId); txErr != nil { - return txErr - } - return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.governance_updated", g.Map{ - "topic": in.Topic, "operator_id": actor, "request_id": requestId, - }) - }) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupGovernUpdate, payload, 0) } func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) { @@ -412,101 +386,21 @@ func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfil } // Dissolve 解散IM群 -func (s *sSysImGroup) Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error) { +func (s *sSysImGroup) Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (res *sysin.ImGroupCommandModel, err error) { if err = in.Filter(ctx); err != nil { - return + return nil, err } - db, _, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return err + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "reason": in.Reason, } - actor := contexts.GetUserId(ctx) - requestId := gctx.CtxId(ctx) - if requestId == "" { - requestId = guid.S() - } - now := gtime.Now() - return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - before, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) - if txErr != nil { - return txErr - } - if before == nil { - return gerror.New("群治理配置不存在") - } - if before["group_status"].String() == consts.ImGroupStatusDissolved { - return gerror.New("群已解散") - } - result, txErr := tx.Exec(`UPDATE im_group_settings - SET group_status=?,dissolved_by=?,dissolved_at=?,version=version+1,updated_at=? - WHERE topic=? AND group_status<>?`, - consts.ImGroupStatusDissolved, actor, now, now, in.Topic, consts.ImGroupStatusDissolved) - if txErr != nil { - return txErr - } - if affected, _ := result.RowsAffected(); affected == 0 { - return gerror.New("群已解散") - } - after, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) - if txErr != nil { - return txErr - } - if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, 0, "group_dissolve", "group", in.Topic, before.Map(), after.Map(), requestId); txErr != nil { - return txErr - } - return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.dissolved", g.Map{ - "topic": in.Topic, "operator_id": actor, "request_id": requestId, "reason": in.Reason, - }) - }) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupDissolve, payload, 0) } // Mute 禁言IM群成员 -func (s *sSysImGroup) Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error) { - if err = in.Filter(ctx); err != nil { - return - } - db, _, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return err - } - actor := contexts.GetUserId(ctx) - requestId := gctx.CtxId(ctx) - if requestId == "" { - requestId = guid.S() - } - now := gtime.Now() - return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - if txErr := s.ensureActiveGroupMember(ctx, tx, in.Topic, in.UserId); txErr != nil { - return txErr - } - before, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - muteId, txErr := imtenant.NextSnowflakeID(ctx) - if txErr != nil { - return gerror.Wrap(txErr, "生成禁言ID失败") - } - if _, txErr = tx.Exec(`INSERT INTO im_group_mutes - (id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version) - VALUES(?,?,?,?,?,?,?, ?,1) - ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at), - expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status), - revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`, - muteId, in.Topic, in.UserId, actor, now, in.ExpiresAt, in.Reason, consts.ImGroupMuteStatusActive); txErr != nil { - return txErr - } - after, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "member_mute", "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil { - return txErr - } - return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.mute.updated", g.Map{ - "topic": in.Topic, "user_id": in.UserId, "muted": true, "expires_at": in.ExpiresAt, "request_id": requestId, - }) - }) +func (s *sSysImGroup) Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) { + return s.MemberMute(ctx, in) } func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) { @@ -526,49 +420,8 @@ func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) } // Unmute 解除IM群成员禁言 -func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error) { - if err = in.Filter(ctx); err != nil { - return - } - db, _, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return err - } - actor := contexts.GetUserId(ctx) - requestId := gctx.CtxId(ctx) - if requestId == "" { - requestId = guid.S() - } - now := gtime.Now() - return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - before, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if before == nil || before["status"].String() != consts.ImGroupMuteStatusActive { - return gerror.New("成员未处于禁言状态") - } - result, txErr := tx.Exec(`UPDATE im_group_mutes - SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? - WHERE topic=? AND user_id=? AND status=?`, - consts.ImGroupMuteStatusRevoked, actor, now, now, in.Topic, in.UserId, consts.ImGroupMuteStatusActive) - if txErr != nil { - return txErr - } - if affected, _ := result.RowsAffected(); affected == 0 { - return gerror.New("成员未处于禁言状态") - } - after, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "member_unmute", "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil { - return txErr - } - return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.mute.updated", g.Map{ - "topic": in.Topic, "user_id": in.UserId, "muted": false, "request_id": requestId, - }) - }) +func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) { + return s.MemberUnmute(ctx, in) } func (s *sSysImGroup) MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) { @@ -677,77 +530,18 @@ func (s *sSysImGroup) resolveJoinRequest(ctx context.Context, in *sysin.ImGroupJ } // RoleUpdate 更新IM群成员角色 -func (s *sSysImGroup) RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (err error) { +func (s *sSysImGroup) RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) { if err = in.Filter(ctx); err != nil { - return + return nil, err } - if in.Role != consts.ImGroupRoleAdmin && in.Role != consts.ImGroupRoleMember { - return gerror.New("只能设置管理员或普通成员") + switch in.Role { + case consts.ImGroupRoleAdmin: + return s.AdminSet(ctx, in) + case consts.ImGroupRoleMember: + return s.AdminRemove(ctx, in) + default: + return nil, gerror.New("只能设置管理员或普通成员") } - db, _, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return err - } - actor := contexts.GetUserId(ctx) - requestId := gctx.CtxId(ctx) - if requestId == "" { - requestId = guid.S() - } - modeGiven := imGroupModeMember - action := "member_role_member" - if in.Role == consts.ImGroupRoleAdmin { - modeGiven = imGroupModeAdmin - action = "member_role_admin" - } - now := gtime.Now() - return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - if err := s.ensureActiveGroup(ctx, tx, in.Topic); err != nil { - return err - } - before, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if before == nil || before["member_status"].String() != consts.ImGroupMemberStatusActive { - return gerror.New("成员不在群内或不是正常状态") - } - if before["role"].String() == consts.ImGroupRoleOwner { - return gerror.New("群主角色请通过群主转让处理") - } - if in.Role == consts.ImGroupRoleAdmin { - if txErr = s.ensureAdminLimit(ctx, tx, in.Topic, in.UserId); txErr != nil { - return txErr - } - } - subResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=? - WHERE topic=? AND userid=? AND deletedat IS NULL`, modeGiven, now, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if affected, _ := subResult.RowsAffected(); affected == 0 { - return gerror.New("成员通信订阅不存在") - } - result, txErr := tx.Exec(`UPDATE im_group_members - SET role=?,version=version+1,updated_at=? - WHERE topic=? AND user_id=? AND member_status=?`, - in.Role, now, in.Topic, in.UserId, consts.ImGroupMemberStatusActive) - if txErr != nil { - return txErr - } - if affected, _ := result.RowsAffected(); affected == 0 { - return gerror.New("成员不在群内或不是正常状态") - } - after, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, action, "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil { - return txErr - } - return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.role.updated", g.Map{ - "topic": in.Topic, "user_id": in.UserId, "role": in.Role, "request_id": requestId, - }) - }) } func (s *sSysImGroup) AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) { @@ -775,105 +569,16 @@ func (s *sSysImGroup) AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpda } // OwnerTransfer 转让IM群主 -func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error) { +func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) { if err = in.Filter(ctx); err != nil { - return + return nil, err } - db, _, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return err + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "target_user_id": strconv.FormatInt(in.UserId, 10), } - actor := contexts.GetUserId(ctx) - requestId := gctx.CtxId(ctx) - if requestId == "" { - requestId = guid.S() - } - now := gtime.Now() - return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - beforeGroup, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) - if txErr != nil { - return txErr - } - if beforeGroup == nil { - return gerror.New("群治理配置不存在") - } - if beforeGroup["group_status"].String() == consts.ImGroupStatusDissolved { - return gerror.New("已解散群不能转让群主") - } - previousOwner := beforeGroup["owner_user_id"].Int64() - if previousOwner <= 0 { - return gerror.New("原群主不存在") - } - if previousOwner == in.UserId { - return gerror.New("新群主不能与原群主相同") - } - if txErr = s.ensureActiveGroupMember(ctx, tx, in.Topic, previousOwner); txErr != nil { - return txErr - } - if txErr = s.ensureActiveGroupMember(ctx, tx, in.Topic, in.UserId); txErr != nil { - return txErr - } - beforeOld, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, previousOwner) - if txErr != nil { - return txErr - } - beforeNew, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - oldSubResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=? - WHERE topic=? AND userid=? AND deletedat IS NULL`, imGroupModeAdmin, now, in.Topic, previousOwner) - if txErr != nil { - return txErr - } - if affected, _ := oldSubResult.RowsAffected(); affected == 0 { - return gerror.New("原群主通信订阅不存在") - } - if _, txErr = tx.Exec(`UPDATE im_group_members SET role=?,version=version+1,updated_at=? - WHERE topic=? AND user_id=? AND member_status=?`, consts.ImGroupRoleAdmin, now, in.Topic, previousOwner, consts.ImGroupMemberStatusActive); txErr != nil { - return txErr - } - newSubResult, txErr := tx.Exec(`UPDATE im_subscriptions SET modegiven=?,updatedat=? - WHERE topic=? AND userid=? AND deletedat IS NULL`, imGroupModeOwner, now, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if affected, _ := newSubResult.RowsAffected(); affected == 0 { - return gerror.New("新群主通信订阅不存在") - } - if _, txErr = tx.Exec(`UPDATE im_group_members SET role=?,version=version+1,updated_at=? - WHERE topic=? AND user_id=? AND member_status=?`, consts.ImGroupRoleOwner, now, in.Topic, in.UserId, consts.ImGroupMemberStatusActive); txErr != nil { - return txErr - } - if _, txErr = tx.Exec(`UPDATE im_topics SET owner=?,updatedat=? WHERE name=?`, in.UserId, now, in.Topic); txErr != nil { - return txErr - } - if _, txErr = tx.Exec(`UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE topic=?`, in.UserId, now, in.Topic); txErr != nil { - return txErr - } - afterGroup, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic) - if txErr != nil { - return txErr - } - afterOld, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, previousOwner) - if txErr != nil { - return txErr - } - afterNew, txErr := s.groupMemberSnapshot(ctx, tx, in.Topic, in.UserId) - if txErr != nil { - return txErr - } - if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "owner_transfer", "group", in.Topic, g.Map{ - "group": recordMap(beforeGroup), "previous_owner": recordMap(beforeOld), "new_owner": recordMap(beforeNew), - }, g.Map{ - "group": recordMap(afterGroup), "previous_owner": recordMap(afterOld), "new_owner": recordMap(afterNew), - }, requestId); txErr != nil { - return txErr - } - return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.owner.transferred", g.Map{ - "topic": in.Topic, "previous_owner": previousOwner, "owner": in.UserId, "request_id": requestId, - }) - }) + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupOwnerTransfer, payload, 0) } func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, topic string, eventType string, payload map[string]any, version int64) (*sysin.ImGroupCommandModel, error) { diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go index 08f4aec..084c038 100644 --- a/server/internal/logic/sys/im_user.go +++ b/server/internal/logic/sys/im_user.go @@ -16,7 +16,6 @@ import ( "hotgo/internal/dao" "hotgo/internal/library/contexts" "hotgo/internal/library/imadmin" - "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" @@ -483,21 +482,21 @@ func (s *sSysImUser) ResetPassword(ctx context.Context, in *sysin.ImUserPassword if err = in.Filter(ctx); err != nil { return nil, err } - tenant, err := resolveImAdminTenant(ctx, in.TenantId) - if err != nil { - return nil, err - } userId, err := sysin.ParseOptionalInt64(in.UserId) if err != nil { return nil, err } - command, err := s.publishPasswordResetCommand(ctx, tenant, userId, in.NewPassword) + res, err = s.syncPasswordReset(ctx, in, userId) result := "success" if err != nil { result = "failed" } - _ = writeImAdminAudit(ctx, tenant.Id, "user_password_reset", "user", userId, result, nil) - return command, err + tenantId := in.TenantId + if res != nil && res.TenantID > 0 { + tenantId = res.TenantID + } + _ = writeImAdminAudit(ctx, tenantId, "user_password_reset", "user", userId, result, nil) + return res, err } func (s *sSysImUser) RevokeSessions(ctx context.Context, in *sysin.ImUserRevokeSessionsInp) (err error) { @@ -686,59 +685,53 @@ func (s *sSysImUser) Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) } func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan) } func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban) } func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze) } func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze) } func (s *sSysImUser) DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { if err = in.FilterDevice(ctx); err != nil { return nil, err } - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick) } func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll) } func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel) } func (s *sSysImUser) AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { in.Hard = true - return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete) + return s.syncGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete) } -func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) { +func (s *sSysImUser) syncGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) { if err := in.Filter(ctx); err != nil { return nil, err } - _, tenant, err := imtenant.DB(ctx, in.TenantId) + tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { return nil, err } - user := contexts.GetUser(ctx) - actorName := "" - if user != nil { - actorName = user.RealName - } payload := map[string]any{ - "actor_user_id": strconv.FormatInt(in.ActorUserId, 10), - "user_id": strconv.FormatInt(in.UserId, 10), - "target_user_id": strconv.FormatInt(in.UserId, 10), - "reason": in.Reason, + "event_type": eventType, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "reason": in.Reason, } if in.DeviceId != "" { payload["device_id"] = in.DeviceId @@ -746,75 +739,51 @@ func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImU if eventType == consts.ImAdminCommandUserCancel || eventType == consts.ImAdminCommandUserDelete { payload["hard"] = in.Hard } - command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ - TenantID: tenant.Id, - TenantCode: tenant.Code, - EventType: eventType, - BizType: "user", - BizID: strconv.FormatInt(in.UserId, 10), - Actor: &imkafka.Actor{ - Type: "admin", - ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), - DisplayName: actorName, - }, - Payload: payload, - CreatedBy: contexts.GetUserId(ctx), - }) + res, err := s.syncUserAction(ctx, tenant, in.UserId, eventType, payload) if err != nil { return nil, err } - return &sysin.ImUserCommandModel{ - CommandID: command.CommandID, - EventID: command.EventID, - Status: command.Status, - TenantID: command.TenantID, - TenantCode: command.TenantCode, - EventType: command.EventType, - IdempotencyKey: command.IdempotencyKey, - }, nil + return res, nil } -func (s *sSysImUser) publishPasswordResetCommand(ctx context.Context, tenant *entity.ImTenant, userId int64, newPassword string) (*sysin.ImUserCommandModel, error) { - passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) +func (s *sSysImUser) syncPasswordReset(ctx context.Context, in *sysin.ImUserPasswordResetInp, userId int64) (*sysin.ImUserCommandModel, error) { + tenant, err := resolveImAdminTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) if err != nil { return nil, gerror.Wrap(err, "生成密码摘要失败") } - user := contexts.GetUser(ctx) - actorName := "" - if user != nil { - actorName = user.RealName - } payload := map[string]any{ - "operator_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), - "user_id": strconv.FormatInt(userId, 10), - "target_user_id": strconv.FormatInt(userId, 10), - "password_hash": base64.StdEncoding.EncodeToString(passwordHash), + "event_type": consts.ImAdminCommandUserPasswordReset, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "password_hash": base64.StdEncoding.EncodeToString(passwordHash), } - command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ - TenantID: tenant.Id, - TenantCode: tenant.Code, - EventType: consts.ImAdminCommandUserPasswordReset, - BizType: "user", - BizID: strconv.FormatInt(userId, 10), - Actor: &imkafka.Actor{ - Type: "admin", - ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), - DisplayName: actorName, - }, - Payload: payload, - CreatedBy: contexts.GetUserId(ctx), - }) + return s.syncUserAction(ctx, tenant, userId, consts.ImAdminCommandUserPasswordReset, payload) +} + +func (s *sSysImUser) syncUserAction(ctx context.Context, tenant *entity.ImTenant, userId int64, eventType string, payload map[string]any) (*sysin.ImUserCommandModel, error) { + var out struct { + UserId int64 `json:"user_id"` + EventType string `json:"event_type"` + Status string `json:"status"` + Result map[string]any `json:"result"` + } + path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users/" + strconv.FormatInt(userId, 10) + "/action" + err := imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, &out) if err != nil { return nil, err } + status := out.Status + if status == "" { + status = consts.ImAdminCommandStatusSucceeded + } return &sysin.ImUserCommandModel{ - CommandID: command.CommandID, - EventID: command.EventID, - Status: command.Status, - TenantID: command.TenantID, - TenantCode: command.TenantCode, - EventType: command.EventType, - IdempotencyKey: command.IdempotencyKey, + Status: status, + TenantID: tenant.Id, + TenantCode: tenant.Code, + EventType: eventType, }, nil } diff --git a/server/internal/model/input/sysin/im_group.go b/server/internal/model/input/sysin/im_group.go index 69df7d1..1d370fe 100644 --- a/server/internal/model/input/sysin/im_group.go +++ b/server/internal/model/input/sysin/im_group.go @@ -34,7 +34,7 @@ type ImGroupListModel struct { TenantName string `json:"tenantName" dc:"租户名称"` Topic string `json:"topic" dc:"Topic"` State int `json:"state" dc:"状态"` - OwnerId int64 `json:"ownerId" dc:"群主ID"` + OwnerId int64 `json:"ownerId,string" dc:"群主ID"` OwnerNickname string `json:"ownerNickname" dc:"群主昵称"` OwnerUsername string `json:"ownerUsername" dc:"群主用户名"` SeqId int `json:"seqId" dc:"最新消息序号"` @@ -101,7 +101,7 @@ func (in *ImGroupMemberListInp) Filter(ctx context.Context) (err error) { } type ImGroupMemberListModel struct { - UserId int64 `json:"userId" dc:"用户ID"` + UserId int64 `json:"userId,string" dc:"用户ID"` Nickname string `json:"nickname" dc:"昵称"` State int `json:"state" dc:"用户状态"` ModeWant string `json:"modeWant" dc:"期望权限"` @@ -119,10 +119,10 @@ type ImGroupMemberListModel struct { Pinned int `json:"pinned" dc:"置顶"` MuteNotifications int `json:"muteNotifications" dc:"免打扰"` JoinSource string `json:"joinSource" dc:"加入来源"` - JoinedBy int64 `json:"joinedBy" dc:"邀请/处理人"` + JoinedBy int64 `json:"joinedBy,string" dc:"邀请/处理人"` JoinedAt *gtime.Time `json:"joinedAt" dc:"业务加入时间"` LeftAt *gtime.Time `json:"leftAt" dc:"离开时间"` - RemovedBy int64 `json:"removedBy" dc:"移除人"` + RemovedBy int64 `json:"removedBy,string" dc:"移除人"` Version int `json:"version" dc:"版本"` } @@ -143,7 +143,7 @@ type ImGroupGovernanceModel struct { TenantId int64 `json:"tenantId" dc:"租户ID"` TenantName string `json:"tenantName" dc:"租户名称"` Topic string `json:"topic" dc:"Topic"` - OwnerUserId int64 `json:"ownerUserId" dc:"群主ID"` + OwnerUserId int64 `json:"ownerUserId,string" dc:"群主ID"` Name string `json:"name" dc:"群名称"` AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` Description string `json:"description" dc:"简介"` @@ -157,8 +157,8 @@ type ImGroupGovernanceModel struct { MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"` MemberCount int `json:"memberCount" dc:"成员数"` Version int `json:"version" dc:"版本"` - CreatedBy int64 `json:"createdBy" dc:"创建人"` - DissolvedBy int64 `json:"dissolvedBy" dc:"解散人"` + CreatedBy int64 `json:"createdBy,string" dc:"创建人"` + DissolvedBy int64 `json:"dissolvedBy,string" dc:"解散人"` DissolvedAt *gtime.Time `json:"dissolvedAt" dc:"解散时间"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` @@ -338,13 +338,13 @@ type ImGroupJoinRequestListModel struct { Id int64 `json:"id" dc:"ID"` RequestId string `json:"requestId" dc:"申请ID"` Topic string `json:"topic" dc:"Topic"` - ApplicantUserId int64 `json:"applicantUserId" dc:"申请人ID"` - InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"` + ApplicantUserId int64 `json:"applicantUserId,string" dc:"申请人ID"` + InviterUserId int64 `json:"inviterUserId,string" dc:"邀请人ID"` Source string `json:"source" dc:"来源"` Message string `json:"message" dc:"申请消息"` Status string `json:"status" dc:"状态"` RejectReason string `json:"rejectReason" dc:"拒绝原因"` - HandledBy int64 `json:"handledBy" dc:"处理人"` + HandledBy int64 `json:"handledBy,string" dc:"处理人"` HandledAt *gtime.Time `json:"handledAt" dc:"处理时间"` ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` Version int `json:"version" dc:"版本"` @@ -368,13 +368,13 @@ type ImGroupFileListModel struct { FileId string `json:"fileId" dc:"文件ID"` SourceTopic string `json:"sourceTopic" dc:"来源Topic"` SourceSeq int `json:"sourceSeq" dc:"来源消息序号"` - UploaderId int64 `json:"uploaderId" dc:"上传人ID"` + UploaderId int64 `json:"uploaderId,string" dc:"上传人ID"` FileName string `json:"fileName" dc:"文件名"` MimeType string `json:"mimeType" dc:"MIME类型"` FileType string `json:"fileType" dc:"文件类型"` FileSize int64 `json:"fileSize" dc:"文件大小"` Status string `json:"status" dc:"状态"` - DeletedBy int64 `json:"deletedBy" dc:"删除人"` + DeletedBy int64 `json:"deletedBy,string" dc:"删除人"` DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` @@ -407,8 +407,8 @@ type ImGroupInvitationListModel struct { Id int64 `json:"id" dc:"ID"` InvitationId string `json:"invitationId" dc:"邀请ID"` Topic string `json:"topic" dc:"Topic"` - InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"` - TargetUserId int64 `json:"targetUserId" dc:"目标用户ID"` + InviterUserId int64 `json:"inviterUserId,string" dc:"邀请人ID"` + TargetUserId int64 `json:"targetUserId,string" dc:"目标用户ID"` Message string `json:"message" dc:"邀请消息"` Status string `json:"status" dc:"状态"` Result string `json:"result" dc:"结果"` @@ -429,12 +429,12 @@ type ImGroupAnnouncementListModel struct { Id int64 `json:"id" dc:"ID"` AnnouncementId string `json:"announcementId" dc:"公告ID"` Topic string `json:"topic" dc:"Topic"` - AuthorUserId int64 `json:"authorUserId" dc:"发布人ID"` + AuthorUserId int64 `json:"authorUserId,string" dc:"发布人ID"` Content string `json:"content" dc:"内容"` Status string `json:"status" dc:"状态"` Version int `json:"version" dc:"版本"` PublishedAt *gtime.Time `json:"publishedAt" dc:"发布时间"` - DeletedBy int64 `json:"deletedBy" dc:"删除人"` + DeletedBy int64 `json:"deletedBy,string" dc:"删除人"` DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` @@ -449,13 +449,13 @@ type ImGroupMuteListInp struct { type ImGroupMuteListModel struct { Topic string `json:"topic" dc:"Topic"` - UserId int64 `json:"userId" dc:"用户ID"` - MutedBy int64 `json:"mutedBy" dc:"禁言人"` + UserId int64 `json:"userId,string" dc:"用户ID"` + MutedBy int64 `json:"mutedBy,string" dc:"禁言人"` MutedAt *gtime.Time `json:"mutedAt" dc:"禁言时间"` ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` Reason string `json:"reason" dc:"原因"` Status string `json:"status" dc:"状态"` - RevokedBy int64 `json:"revokedBy" dc:"解除人"` + RevokedBy int64 `json:"revokedBy,string" dc:"解除人"` RevokedAt *gtime.Time `json:"revokedAt" dc:"解除时间"` Version int `json:"version" dc:"版本"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` @@ -473,8 +473,8 @@ type ImGroupAdminLogListModel struct { Id int64 `json:"id" dc:"ID"` LogId string `json:"logId" dc:"日志ID"` Topic string `json:"topic" dc:"Topic"` - OperatorId int64 `json:"operatorId" dc:"操作人"` - TargetUserId int64 `json:"targetUserId" dc:"目标用户"` + OperatorId int64 `json:"operatorId,string" dc:"操作人"` + TargetUserId int64 `json:"targetUserId,string" dc:"目标用户"` Action string `json:"action" dc:"动作"` ObjectType string `json:"objectType" dc:"对象类型"` ObjectId string `json:"objectId" dc:"对象ID"` diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index b358819..fb91818 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -264,17 +264,17 @@ type ( // Governance 获取IM群治理配置 Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error) // GovernanceUpdate 更新IM群治理配置 - GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (err error) + GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) // ProfileUpdate 修改IM群资料 ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) // Dissolve 解散IM群 - Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error) + Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (res *sysin.ImGroupCommandModel, err error) // Mute 禁言IM群成员 - Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error) + Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) // MemberMute Kafka禁言IM群成员 MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) // Unmute 解除IM群成员禁言 - Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error) + Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) // MemberUnmute Kafka解除IM群成员禁言 MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) // MemberRemove 移出IM群成员 @@ -284,13 +284,13 @@ type ( // JoinRequestReject 拒绝IM入群申请 JoinRequestReject(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) // RoleUpdate 更新IM群成员角色 - RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (err error) + RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) // AdminSet 设置IM群管理员 AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) // AdminRemove 删除IM群管理员 AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) // OwnerTransfer 转让IM群主 - OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error) + OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) // JoinRequestList 获取IM入群申请列表 JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) // FileList 获取IM群文件列表 diff --git a/web/src/views/imUser/governance.vue b/web/src/views/imUser/governance.vue index f5f64a6..0c13690 100644 --- a/web/src/views/imUser/governance.vue +++ b/web/src/views/imUser/governance.vue @@ -132,8 +132,8 @@ }; loading.value = true; try { - const res = await apiMap[actionForm.action](params); - message.success(`命令已提交:${res?.commandId || '-'}`); + await apiMap[actionForm.action](params); + message.success('操作已完成'); emit('reload'); closeForm(); } finally { diff --git a/web/src/views/imUser/index.vue b/web/src/views/imUser/index.vue index 6026f02..5e94ba1 100644 --- a/web/src/views/imUser/index.vue +++ b/web/src/views/imUser/index.vue @@ -392,7 +392,7 @@ userId: Number(record.userId), reason: '后台强制下线', }); - message.success('强制下线命令已提交'); + message.success('强制下线已完成'); reloadTable(); }, }); @@ -470,7 +470,7 @@ userId: Number(record.userId), reason: banning ? '后台封禁' : '后台解封', }); - message.success(`${banning ? '封禁' : '解封'}命令已提交`); + message.success(`${banning ? '封禁' : '解封'}已完成`); reloadTable(); }, }); @@ -488,7 +488,7 @@ userId: Number(record.userId), reason: '后台强制注销', }); - message.success('强制注销命令已提交'); + message.success('强制注销已完成'); reloadTable(); }, });