From aa48225d85cc0c37be538dc064194b88eddddd5e Mon Sep 17 00:00:00 2001 From: wells <123456@qq.com> Date: Sat, 8 Aug 2026 14:56:37 +0800 Subject: [PATCH] OpenIMApiClient --- .../common/constant/OpenImConstants.java | 284 +++ .../tailbet/common/enums/ErrorCodeEnum.java | 1120 ++++++++++ .../common/exception/BusinessException.java | 113 + .../config/GlobalExceptionHandler.java | 198 +- .../java/com/tailbet/config/OpenIMConfig.java | 34 - .../com/tailbet/config/OpenIMProperties.java | 144 ++ src/main/java/com/tailbet/model/vo/R.java | 4 + .../openim/OpenIMAdminTokenManager.java | 170 ++ .../com/tailbet/openim/OpenIMApiClient.java | 1876 +++++++++++++++++ .../java/com/tailbet/openim/OpenIMClient.java | 184 -- src/main/resources/application.yml | 36 +- 11 files changed, 3931 insertions(+), 232 deletions(-) create mode 100644 src/main/java/com/tailbet/common/constant/OpenImConstants.java create mode 100644 src/main/java/com/tailbet/common/enums/ErrorCodeEnum.java create mode 100644 src/main/java/com/tailbet/common/exception/BusinessException.java delete mode 100644 src/main/java/com/tailbet/config/OpenIMConfig.java create mode 100644 src/main/java/com/tailbet/config/OpenIMProperties.java create mode 100644 src/main/java/com/tailbet/openim/OpenIMAdminTokenManager.java create mode 100644 src/main/java/com/tailbet/openim/OpenIMApiClient.java delete mode 100644 src/main/java/com/tailbet/openim/OpenIMClient.java diff --git a/src/main/java/com/tailbet/common/constant/OpenImConstants.java b/src/main/java/com/tailbet/common/constant/OpenImConstants.java new file mode 100644 index 0000000..1478e0d --- /dev/null +++ b/src/main/java/com/tailbet/common/constant/OpenImConstants.java @@ -0,0 +1,284 @@ +package com.tailbet.common.constant; + +/** + * OpenIM 协议相关常量 + *

+ * 统一管理 Webhook 回调命令、请求字段、响应字段、消息类型等协议魔法值, + * 避免散落在 Controller / Service 中。 + *

+ * + * @author socialapp团队 + * @since 1.0.7 + */ +public final class OpenImConstants { + + private OpenImConstants() { + } + + // ==================== Webhook 回调命令 ==================== + + /** 单聊消息发送后回调 */ + public static final String CALLBACK_AFTER_SEND_SINGLE_MSG = "callbackAfterSendSingleMsgCommand"; + + /** 群聊消息发送后回调 */ + public static final String CALLBACK_AFTER_SEND_GROUP_MSG = "callbackAfterSendGroupMsgCommand"; + + /** 成员进群后回调 */ + public static final String CALLBACK_AFTER_MEMBER_ENTER_GROUP = "callbackAfterMemberEnterGroupCommand"; + + /** 成员退群后回调 */ + public static final String CALLBACK_AFTER_MEMBER_QUIT_GROUP = "callbackAfterMemberQuitGroupCommand"; + + /** 用户上线回调 */ + public static final String CALLBACK_AFTER_USER_ONLINE = "callbackAfterUserOnlineCommand"; + + /** 用户下线回调 */ + public static final String CALLBACK_AFTER_USER_OFFLINE = "callbackAfterUserOfflineCommand"; + + // ==================== 请求头 / JSON 字段名 ==================== + + /** OpenIM Webhook 鉴权请求头名 */ + public static final String HEADER_TOKEN = "token"; + + /** 发送方用户 ID */ + public static final String FIELD_SEND_ID = "sendID"; + + /** 接收方用户 ID */ + public static final String FIELD_RECV_ID = "recvID"; + + /** 群组 ID(OpenIM 群 ID) */ + public static final String FIELD_GROUP_ID = "groupID"; + + /** 用户 ID */ + public static final String FIELD_USER_ID = "userID"; + + /** 平台 ID */ + public static final String FIELD_PLATFORM_ID = "platformID"; + + /** 消息内容 */ + public static final String FIELD_CONTENT = "content"; + + /** 消息内容类型 */ + public static final String FIELD_CONTENT_TYPE = "contentType"; + + /** 客户端消息 ID */ + public static final String FIELD_CLIENT_MSG_ID = "clientMsgID"; + + /** 服务端消息 ID */ + public static final String FIELD_SERVER_MSG_ID = "serverMsgID"; + + // ==================== Webhook 成功响应 ==================== + + /** 响应字段:动作码 */ + public static final String RESP_ACTION_CODE = "actionCode"; + + /** 响应字段:错误码 */ + public static final String RESP_ERR_CODE = "errCode"; + + /** 响应字段:错误信息 */ + public static final String RESP_ERR_MSG = "errMsg"; + + /** 响应字段:错误详情 */ + public static final String RESP_ERR_DLT = "errDlt"; + + /** 响应字段:下一动作码 */ + public static final String RESP_NEXT_CODE = "nextCode"; + + /** OpenIM 成功码(actionCode / errCode / nextCode) */ + public static final int RESP_CODE_SUCCESS = 0; + + /** OpenIM 成功响应空字符串字段值 */ + public static final String RESP_EMPTY_MSG = ""; + + // ==================== Token / 鉴权错误码(OpenIM 官方) ==================== + + /** Token 过期 */ + public static final int ERR_TOKEN_EXPIRED = 1501; + + /** Token 无效 */ + public static final int ERR_TOKEN_INVALID = 1502; + + /** Token 格式错误 */ + public static final int ERR_TOKEN_MALFORMED = 1503; + + /** Token 不存在 */ + public static final int ERR_TOKEN_NOT_EXIST = 1504; + + /** Token 平台不一致 */ + public static final int ERR_TOKEN_DIFFERENT_PLATFORM = 1505; + + /** Token 被踢(多端/多实例刷新 adminToken、重复 get_user_token 等) */ + public static final int ERR_TOKEN_KICKED = 1506; + + /** Token 尚未生效 */ + public static final int ERR_TOKEN_NOT_VALID_YET = 1507; + + /** + * 服务端代操作使用的平台 ID(Web)。 + * 与 App 端 iOS/Android 平台隔离,避免顶掉用户已登录 session。 + */ + public static final int PLATFORM_ID_SERVER_WEB = 5; + + /** App 端 iOS 平台 ID */ + public static final int PLATFORM_ID_IOS = 1; + + /** App 端 Android 平台 ID */ + public static final int PLATFORM_ID_ANDROID = 2; + + // ==================== 会话摘要 / 未读 ==================== + + /** 会话 lastMessage 摘要最大长度 */ + public static final int CONVERSATION_CONTENT_MAX_LEN = 100; + + /** 未读数自增 SQL 片段 */ + public static final String SQL_UNREAD_COUNT_INCREMENT = "unread_count = unread_count + 1"; + + /** 新建会话初始未读数(接收方) */ + public static final int UNREAD_COUNT_INITIAL_RECEIVER = 1; + + /** 新建会话初始未读数(发送方) */ + public static final int UNREAD_COUNT_INITIAL_SENDER = 0; + + // ==================== OpenIM contentType ==================== + + /** 文本 */ + public static final int CONTENT_TYPE_TEXT = 101; + + /** 图片 */ + public static final int CONTENT_TYPE_IMAGE = 102; + + /** 语音 */ + public static final int CONTENT_TYPE_VOICE = 103; + + /** 视频 */ + public static final int CONTENT_TYPE_VIDEO = 104; + + /** 文件 */ + public static final int CONTENT_TYPE_FILE = 105; + + /** 位置 */ + public static final int CONTENT_TYPE_LOCATION = 106; + + /** 名片 */ + public static final int CONTENT_TYPE_CARD = 114; + + /** 自定义消息 */ + public static final int CONTENT_TYPE_CUSTOM = 110; + + // ==================== OpenIM sessionType ==================== + + /** 单聊 */ + public static final int SESSION_TYPE_SINGLE = 1; + + /** 群聊 */ + public static final int SESSION_TYPE_GROUP = 3; + + // ==================== 离线推送 extras / 文案 ==================== + + /** 推送业务类型:IM 离线消息 */ + public static final String PUSH_BIZ_TYPE_IM_MESSAGE = "IM_MESSAGE"; + + /** 推送标题 */ + public static final String PUSH_TITLE_NEW_MESSAGE = "您有新消息"; + + /** 发送方昵称兜底 */ + public static final String PUSH_DEFAULT_SENDER_NAME = "用户"; + + /** 系统/管理员代发时的发送方昵称兜底(如 OpenIM imAdmin) */ + public static final String PUSH_DEFAULT_SYSTEM_SENDER_NAME = "系统"; + + /** 群名称兜底 */ + public static final String PUSH_DEFAULT_GROUP_NAME = "群聊"; + + /** extras:会话 ID */ + public static final String EXTRAS_CONVERSATION_ID = "conversationId"; + + /** extras:发送方 ID */ + public static final String EXTRAS_SENDER_ID = "senderId"; + + /** extras:消息类型 */ + public static final String EXTRAS_MESSAGE_TYPE = "messageType"; + + /** extras:会话类型 */ + public static final String EXTRAS_SESSION_TYPE = "sessionType"; + + /** extras:本地群组 ID */ + public static final String EXTRAS_GROUP_ID = "groupId"; + + /** 单聊 conversationId 前缀 */ + public static final String CONVERSATION_ID_SINGLE_PREFIX = "si_"; + + /** 群聊 conversationId 前缀(业务推送 extras 约定,非 OpenIM 原生 ID) */ + public static final String CONVERSATION_ID_GROUP_PREFIX = "group:"; + + /** + * OpenIM 原生群聊 conversationID 前缀(sessionType=3 ReadGroupChat) + * 形如 {@code sg_{openimGroupId}} + */ + public static final String OPENIM_NATIVE_GROUP_CONVERSATION_PREFIX = "sg_"; + + /** 单聊 conversationId 分隔符 */ + public static final String CONVERSATION_ID_SEPARATOR = "_"; + + /** 推送正文最大长度(对齐 PushSendReq) */ + public static final int PUSH_CONTENT_MAX_LEN = 200; + + /** 推送 bizId 最大长度(对齐 PushSendReq) */ + public static final int PUSH_BIZ_ID_MAX_LEN = 64; + + /** 推送摘要最大长度 */ + public static final int PUSH_SUMMARY_MAX_LEN = 50; + + /** 单次 Feign 推送批量用户数 */ + public static final int PUSH_BATCH_SIZE = 500; + + /** 离线推送幂等 TTL(分钟) */ + public static final long PUSH_DEDUP_TTL_MINUTES = 10L; + + /** Redis 幂等占位值 */ + public static final String PUSH_DEDUP_PLACEHOLDER = "1"; + + /** 推送正文:昵称与摘要分隔符 */ + public static final String PUSH_BODY_NAME_CONTENT_SEPARATOR = ":"; + + /** 推送正文:群名与发送者分隔符 */ + public static final String PUSH_BODY_GROUP_SEPARATOR = " | "; + + /** 摘要:图片 */ + public static final String SUMMARY_IMAGE = "[图片]"; + + /** 摘要:语音 */ + public static final String SUMMARY_VOICE = "[语音]"; + + /** 摘要:视频 */ + public static final String SUMMARY_VIDEO = "[视频]"; + + /** 摘要:文件 */ + public static final String SUMMARY_FILE = "[文件]"; + + /** 摘要:位置 */ + public static final String SUMMARY_LOCATION = "[位置]"; + + /** 摘要:名片 */ + public static final String SUMMARY_CARD = "[名片]"; + + /** 摘要:其他消息 */ + public static final String SUMMARY_DEFAULT = "[消息]"; + + /** JSON 对象起始字符(用于判断 content 是否为 JSON 字符串) */ + public static final char JSON_OBJECT_START = '{'; + + /** 自定义消息 content.data / extension 字段 */ + public static final String FIELD_DATA = "data"; + + /** 自定义消息 extension 字段 */ + public static final String FIELD_EXTENSION = "extension"; + + /** 业务自定义类型字段 */ + public static final String FIELD_CUSTOM_TYPE = "customType"; + + /** + * 红包领取提醒:仅聊天内系统记录,禁止离线推送 / 系统通知 + */ + public static final String CUSTOM_TYPE_RED_ENVELOPE_CLAIM_NOTICE = "red_envelope_claim_notice"; +} diff --git a/src/main/java/com/tailbet/common/enums/ErrorCodeEnum.java b/src/main/java/com/tailbet/common/enums/ErrorCodeEnum.java new file mode 100644 index 0000000..0ee3801 --- /dev/null +++ b/src/main/java/com/tailbet/common/enums/ErrorCodeEnum.java @@ -0,0 +1,1120 @@ +package com.tailbet.common.enums; + +import lombok.Getter; + +/** + * 错误码枚举 + *

+ * 统一定义系统错误码,便于前后端统一处理和国际化。 + * 错误码分段规则: + * - 200:成功 + * - 400xx:客户端参数错误 + * - 401xx:认证相关错误 + * - 403xx:权限相关错误 + * - 404xx:资源不存在 + * - 500xx:服务端内部错误 + * - 600xx:业务逻辑错误 + *

+ *

+ * message 字段存储国际化消息 key,格式为 {@code error.{code}}, + * 由 GlobalExceptionHandler 通过 MessageSource 解析为对应语言的文本。 + *

+ * + * @author socialapp团队 + * @since 1.0.0 + */ +@Getter +public enum ErrorCodeEnum { + + // ==================== 成功 ==================== + /** 操作成功 */ + SUCCESS(200, "error.200"), + + // ==================== 客户端错误 (400xx) ==================== + /** 请求参数错误 */ + PARAM_ERROR(40001, "error.40001"), + /** 参数校验失败 */ + PARAM_VALIDATE_ERROR(40002, "error.40002"), + /** 请求格式错误 */ + PARAM_FORMAT_ERROR(40003, "error.40003"), + /** 请求过于频繁 */ + REQUEST_TOO_FREQUENT(40004, "error.40004"), + /** 业务层限流触发 */ + RATE_LIMIT_EXCEEDED(40005, "error.40005"), + /** 用户名已被使用 */ + USERNAME_ALREADY_EXISTS(40009, "error.40009"), + /** targetIds 不能为空 */ + PARAM_IDS_EMPTY(40010, "error.40010"), + /** 单次最多校验 50 条 */ + PARAM_MAX_CHECK_LIMIT(40011, "error.40011"), + /** 视频时长必须在1秒到1小时之间 */ + PARAM_VIDEO_DURATION_INVALID(40012, "error.40012"), + /** 密码和验证码不能同时为空 */ + PARAM_PASSWORD_AND_CAPTCHA_EMPTY(40013, "error.40013"), + /** 两次输入的新密码不一致 */ + PARAM_PASSWORDS_NOT_MATCH(40014, "error.40014"), + /** 新密码不能与原密码相同 */ + PARAM_NEW_PASSWORD_SAME(40015, "error.40015"), + /** 原密码错误 */ + PARAM_OLD_PASSWORD_WRONG(40016, "error.40016"), + /** 状态值无效,只能是0或1 */ + PARAM_STATUS_INVALID(40017, "error.40017"), + /** 分享平台不能为空 */ + PARAM_SHARE_PLATFORM_EMPTY(40018, "error.40018"), + /** autoDeleteEnabled 参数不能为空 */ + PARAM_AUTO_DELETE_ENABLED_EMPTY(40019, "error.40019"), + /** 章节不属于指定的主题 */ + PARAM_CHAPTER_NOT_BELONG_TO_TOPIC(40020, "error.40020"), + /** 不能为负数 */ + PARAM_FIELD_NOT_NEGATIVE(40021, "error.40021"), + /** 无效的红包类型 */ + REDPACKET_TYPE_INVALID(40022, "error.40022"), + /** userId 不能为空 */ + PARAM_USER_ID_EMPTY(40023, "error.40023"), + /** asset 不能为空 */ + PARAM_ASSET_EMPTY(40024, "error.40024"), + /** business 不能为空 */ + PARAM_BUSINESS_EMPTY(40025, "error.40025"), + /** mainBusiness 不能为空 */ + PARAM_MAIN_BUSINESS_EMPTY(40026, "error.40026"), + /** businessId 不能为空 */ + PARAM_BUSINESS_ID_EMPTY(40027, "error.40027"), + /** detail 不能为空 */ + PARAM_DETAIL_EMPTY(40028, "error.40028"), + + // ==================== 认证错误 (401xx) ==================== + /** 未登录或登录已过期 */ + UNAUTHORIZED(40101, "error.40101"), + /** Token无效 */ + TOKEN_INVALID(40102, "error.40102"), + /** Token已过期 */ + TOKEN_EXPIRED(40103, "error.40103"), + /** 登录失败 */ + LOGIN_FAILED(40104, "error.40104"), + /** 账号已被禁用 */ + ACCOUNT_DISABLED(40105, "error.40105"), + /** 验证码错误 */ + CAPTCHA_ERROR(40106, "error.40106"), + /** Token类型不匹配 */ + TOKEN_TYPE_MISMATCH(40107, "error.40107"), + + // ==================== 权限错误 (403xx) ==================== + /** 无权限访问 */ + FORBIDDEN(40301, "error.40301"), + /** 权限不足 */ + PERMISSION_DENIED(40302, "error.40302"), + /** 资源访问被拒绝 */ + RESOURCE_ACCESS_DENIED(40303, "error.40303"), + /** 不能删除自己的账号 */ + OPERATION_CANNOT_DELETE_SELF(40304, "error.40304"), + /** 不能删除超级管理员 */ + OPERATION_CANNOT_DELETE_SUPER_ADMIN(40305, "error.40305"), + /** 不能禁用自己的账号 */ + OPERATION_CANNOT_DISABLE_SELF(40306, "error.40306"), + /** 不能禁用超级管理员 */ + OPERATION_CANNOT_DISABLE_SUPER_ADMIN(40307, "error.40307"), + /** 不能将自己设为父菜单 */ + OPERATION_CANNOT_SET_SELF_AS_PARENT(40308, "error.40308"), + /** 该菜单下存在子菜单,请先删除子菜单 */ + OPERATION_HAS_CHILD_MENUS(40309, "error.40309"), + /** 不能修改超级管理员角色 */ + OPERATION_CANNOT_MODIFY_SUPER_ADMIN_ROLE(40310, "error.40310"), + /** 不能删除超级管理员角色 */ + OPERATION_CANNOT_DELETE_SUPER_ADMIN_ROLE(40311, "error.40311"), + /** 该角色下存在管理员,无法删除 */ + OPERATION_ROLE_HAS_ADMINS(40312, "error.40312"), + /** 当前KYC状态不允许审核通过操作 */ + OPERATION_KYC_STATUS_NOT_ALLOW_APPROVE(40313, "error.40313"), + /** 当前KYC状态不允许审核拒绝操作 */ + OPERATION_KYC_STATUS_NOT_ALLOW_REJECT(40314, "error.40314"), + /** 不能重置自己的密码(请使用修改密码) */ + OPERATION_CANNOT_RESET_OWN_PASSWORD(40315, "error.40315"), + + // ==================== 资源不存在 (404xx) ==================== + /** 资源不存在 */ + NOT_FOUND(40401, "error.40401"), + /** 资源不存在(通用别名) */ + RESOURCE_NOT_FOUND(40401, "error.40401"), + /** 用户不存在 */ + USER_NOT_FOUND(40402, "error.40402"), + /** 数据不存在 */ + DATA_NOT_FOUND(40403, "error.40403"), + /** 管理员不存在 */ + ADMIN_NOT_FOUND(40404, "error.40404"), + /** 角色不存在 */ + ROLE_NOT_FOUND(40405, "error.40405"), + /** 菜单不存在 */ + MENU_NOT_FOUND(40406, "error.40406"), + /** 父菜单不存在 */ + PARENT_MENU_NOT_FOUND(40407, "error.40407"), + /** 视频不存在 */ + ACADEMY_VIDEO_NOT_FOUND(40408, "error.40408"), + /** 章节不存在 */ + ACADEMY_CHAPTER_NOT_FOUND(40409, "error.40409"), + /** 主题不存在 */ + ACADEMY_TOPIC_NOT_FOUND(40410, "error.40410"), + /** 商学院视频状态异常,无法分享 */ + ACADEMY_VIDEO_STATUS_INVALID(40411, "error.40411"), + /** 短视频不存在 */ + SHORT_VIDEO_NOT_FOUND(40412, "error.40412"), + /** 短视频状态异常,无法分享 */ + SHORT_VIDEO_STATUS_INVALID(40413, "error.40413"), + /** 评论不存在 */ + COMMENT_NOT_FOUND(40414, "error.40414"), + /** KYC认证记录不存在 */ + KYC_RECORD_NOT_FOUND(40415, "error.40415"), + /** 分类不存在 */ + CATEGORY_NOT_FOUND(40416, "error.40416"), + /** 资产配置不存在 */ + ASSET_CONFIG_NOT_FOUND(40417, "error.40417"), + /** 发现页分类不存在 */ + DISCOVER_CATEGORY_NOT_FOUND(40418, "error.40418"), + /** 发现页入口不存在 */ + DISCOVER_ENTRY_NOT_FOUND(40419, "error.40419"), + /** 手续费配置不存在 */ + FEE_CONFIG_NOT_FOUND(40420, "error.40420"), + /** 未找到匹配的冻结记录 */ + FREEZE_RECORD_NOT_FOUND(40421, "error.40421"), + /** 白名单记录不存在 */ + WITHDRAW_WHITELIST_NOT_FOUND(40422, "error.40422"), + + // ==================== 服务端错误 (500xx) ==================== + /** 系统内部错误 */ + INTERNAL_ERROR(50001, "error.50001"), + /** 系统错误(通用别名) */ + SYSTEM_ERROR(50001, "error.50001"), + /** 系统繁忙 */ + SYSTEM_BUSY(50003, "error.50003"), + /** 数据库操作失败 */ + DATABASE_ERROR(50002, "error.50002"), + /** 远程服务调用失败 */ + RPC_ERROR(50003, "error.50003"), + /** 缓存操作失败 */ + CACHE_ERROR(50004, "error.50004"), + /** 消息队列操作失败 */ + MQ_ERROR(50005, "error.50005"), + /** 用户ID生成失败 */ + ID_GENERATOR_FAILED(50006, "error.50006"), + /** 用户ID已耗尽 */ + ID_EXHAUSTED(50007, "error.50007"), + + // ==================== 业务错误 (600xx) ==================== + /** 业务操作失败 */ + BUSINESS_ERROR(60001, "error.60001"), + /** 数据已存在 */ + DATA_ALREADY_EXISTS(60002, "error.60002"), + /** 数据状态异常 */ + DATA_STATUS_ERROR(60003, "error.60003"), + /** 操作不允许 */ + OPERATION_NOT_ALLOWED(60004, "error.60004"), + /** 文件上传失败 */ + FILE_UPLOAD_ERROR(60005, "error.60005"), + /** 文件过大 */ + FILE_TOO_LARGE(60006, "error.60006"), + /** 文件类型不支持 */ + FILE_TYPE_NOT_SUPPORTED(60007, "error.60007"), + + // ==================== 冲突错误 (409xx) ==================== + /** 角色编码已存在 */ + ROLE_CODE_ALREADY_EXISTS(40901, "error.40901"), + /** 用户名已存在 */ + USERNAME_ALREADY_EXISTS_ADMIN(40902, "error.40902"), + /** 视频名称已存在 */ + ACADEMY_VIDEO_NAME_EXISTS(40903, "error.40903"), + /** 主题名称已存在 */ + ACADEMY_TOPIC_NAME_EXISTS(40904, "error.40904"), + /** 该分类名称已被使用,请更换名称 */ + CATEGORY_NAME_EXISTS(40905, "error.40905"), + /** 该分类名称已被使用,请更换名称(发现页) */ + DISCOVER_CATEGORY_NAME_EXISTS(40906, "error.40906"), + /** 该入口标题已被使用,请更换标题 */ + DISCOVER_ENTRY_TITLE_EXISTS(40907, "error.40907"), + /** 用户名已被注册 */ + USERNAME_ALREADY_REGISTERED(40908, "error.40908"), + /** 邮箱已被注册 */ + EMAIL_ALREADY_REGISTERED(40909, "error.40909"), + /** 该邮箱已被其他账号绑定 */ + EMAIL_ALREADY_BOUND(40910, "error.40910"), + /** 您已提交过博主申请,请勿重复申请 */ + BLOGGER_APPLICATION_EXISTS(40911, "error.40911"), + + // ==================== 业务错误(60041-60068) ==================== + /** 登录失败次数过多 */ + ACCOUNT_LOCKED_LOGIN(60041, "error.60041"), + /** 登录会话已过期,请重新登录 */ + LOGIN_SESSION_EXPIRED(60042, "error.60042"), + /** Google验证码错误,请重新输入 */ + GOOGLE_CAPTCHA_ERROR(60043, "error.60043"), + /** 绑定会话已过期,请重新登录 */ + BIND_SESSION_EXPIRED(60044, "error.60044"), + /** Google验证码错误,请确认已正确扫码 */ + GOOGLE_CAPTCHA_SCAN_ERROR(60045, "error.60045"), + /** 注册过于频繁,请1小时后再试 */ + REGISTER_TOO_FREQUENT(60046, "error.60046"), + /** 今日注册次数已达上限,请明日再试 */ + REGISTER_DAILY_LIMIT(60047, "error.60047"), + /** 图片中未检测到人脸,请重新拍摄 */ + FACE_NO_FACE_DETECTED(60048, "error.60048"), + /** 人脸识别失败,请重试 */ + FACE_RECOGNITION_FAILED(60049, "error.60049"), + /** 人脸注册失败,请重试 */ + FACE_REGISTER_FAILED(60050, "error.60050"), + /** 活体检测未通过,请按照提示完成活体检测 */ + FACE_LIVENESS_FAILED(60051, "error.60051"), + /** 人脸图片质量不符合要求,请重新拍摄 */ + FACE_IMAGE_QUALITY_LOW(60052, "error.60052"), + /** 图片格式错误 */ + FACE_IMAGE_FORMAT_ERROR(60053, "error.60053"), + /** 该人脸已注册过账号,请直接登录 */ + FACE_ALREADY_REGISTERED_RPC(60054, "error.60054"), + /** 发送次数过多,请1小时后再试 */ + EMAIL_SEND_TOO_FREQUENT(60055, "error.60055"), + /** 该邮箱今日发送次数已达上限,请明日再试 */ + EMAIL_DAILY_LIMIT(60056, "error.60056"), + /** 验证码发送次数过多,请1小时后再尝试 */ + CAPTCHA_SEND_TOO_FREQUENT(60057, "error.60057"), + /** 验证码不存在或已过期 */ + CAPTCHA_NOT_EXISTS(60058, "error.60058"), + /** 验证码已失效,请重新获取 */ + CAPTCHA_EXPIRED(60059, "error.60059"), + /** 验证码错误,请重新输入 */ + CAPTCHA_WRONG(60060, "error.60060"), + /** 邮件发送失败,请稍后重试 */ + EMAIL_SEND_FAILED(60061, "error.60061"), + /** KYC AES密钥未配置 */ + KYC_AES_KEY_NOT_CONFIGURED(60062, "error.60062"), + /** 请先完成实名认证后再申请博主 */ + BLOGGER_NOT_VERIFIED(60063, "error.60063"), + /** 冻结操作失败,已回滚 */ + FUND_FREEZE_FAILED(60064, "error.60064"), + /** 封面图文件不能为空 */ + COVER_IMAGE_EMPTY(60065, "error.60065"), + /** 封面图仅支持 JPG/PNG/SVG 格式 */ + COVER_IMAGE_FORMAT_INVALID(60066, "error.60066"), + /** 封面图大小不能超过 1MB */ + COVER_IMAGE_SIZE_EXCEEDED(60067, "error.60067"), + /** 文件大小不能超过1GB */ + FILE_SIZE_EXCEED_1GB(60068, "error.60068"), + /** 不支持的币种操作 */ + ASSET_NOT_SUPPORTED_OPERATION(60069, "error.60069"), + /** 获取手续费配置失败 */ + FEE_CONFIG_FETCH_FAILED(60070, "error.60070"), + /** 冻结资金失败 */ + FREEZE_FUND_FAILED(60071, "error.60071"), + /** 提币上链失败 */ + WITHDRAW_SUBMIT_CHAIN_FAILED(60072, "error.60072"), + /** 清除冻结失败 */ + CLEAR_LIFT_FAILED(60073, "error.60073"), + /** 查询余额失败 */ + BALANCE_QUERY_FAILED(60074, "error.60074"), + /** 红包扣款失败 */ + REDPACKET_DEDUCT_FAILED(60075, "error.60075"), + /** 红包入账失败 */ + REDPACKET_CREDIT_FAILED(60076, "error.60076"), + /** 红包退款失败 */ + REDPACKET_REFUND_FAILED(60077, "error.60077"), + /** 当前红包状态不允许强制过期 */ + REDPACKET_STATUS_INVALID_FORCE_EXPIRE(60078, "error.60078"), + /** 余额查询失败(RPC) */ + RPC_BALANCE_QUERY_FAILED(60079, "error.60079"), + /** 充值入账失败(RPC) */ + RPC_DEPOSIT_CONFIRM_FAILED(60080, "error.60080"), + /** 账变历史查询失败(RPC) */ + RPC_HISTORY_QUERY_FAILED(60081, "error.60081"), + /** increase和decrease不能同时大于0 */ + PARAM_INCREASE_DECREASE_BOTH_POSITIVE(60082, "error.60082"), + + // ==================== 会议相关错误 ==================== + /** 当前已有进行中的会议 */ + MEETING_ALREADY_ACTIVE(4004, "error.4004"), + /** 当前无进行中的会议 */ + MEETING_NOT_ACTIVE(4005, "error.4005"), + /** 会议码无效或会议已结束 */ + MEETING_CODE_INVALID(4006, "error.4006"), + + // ==================== 人脸识别错误 ==================== + /** 人脸未注册 */ + FACE_NOT_REGISTERED(60010, "error.60010"), + /** 人脸已注册 */ + FACE_ALREADY_REGISTERED(60011, "error.60011"), + /** 人脸不匹配 */ + FACE_NOT_MATCH(60012, "error.60012"), + /** 活体检测失败 */ + LIVENESS_FAILED(60013, "error.60013"), + /** 人脸识别功能未开启 */ + FACE_FEATURE_DISABLED(60016, "error.60016"), + /** 账号已锁定 */ + ACCOUNT_LOCKED(40108, "error.40108"), + /** 邮箱已注册 */ + EMAIL_ALREADY_EXISTS(60014, "error.60014"), + /** 邮箱未注册 */ + EMAIL_NOT_FOUND(60015, "error.60015"), + /** 不支持使用临时邮箱注册 */ + EMAIL_DOMAIN_BLOCKED(60017, "error.60017"), + + // ==================== 钱包服务错误 ==================== + /** KYC认证已提交,请勿重复提交 */ + KYC_ALREADY_SUBMITTED(60020, "error.60020"), + /** 请先完成KYC认证 */ + KYC_NOT_APPROVED(60021, "error.60021"), + /** 请先设置支付密码 */ + PAY_PASSWORD_NOT_SET(60022, "error.60022"), + /** 支付密码错误 */ + PAY_PASSWORD_WRONG(60023, "error.60023"), + /** 支付密码已设置,请使用修改接口 */ + PAY_PASSWORD_ALREADY_SET(60024, "error.60024"), + /** 新支付密码不能与旧密码相同 */ + PAY_PASSWORD_SAME(60025, "error.60025"), + /** 该币种不支持 */ + ASSET_NOT_SUPPORTED(60026, "error.60026"), + /** 该币种不支持充币 */ + ASSET_NOT_SUPPORT_DEPOSIT(60027, "error.60027"), + /** 仅支持提币到AIA链地址 */ + ASSET_NOT_SUPPORT_WITHDRAW(60028, "error.60028"), + /** 钱包余额不足 */ + INSUFFICIENT_BALANCE(60029, "error.60029"), + /** 余额不足(通用别名) */ + BALANCE_NOT_ENOUGH(60029, "error.60029"), + /** 提币金额小于最小限制 */ + MIN_WITHDRAW_AMOUNT(60030, "error.60030"), + /** 划转金额小于最小限制 */ + MIN_TRANSFER_AMOUNT(60031, "error.60031"), + /** 订单状态冲突,请稍后重试 */ + ORDER_STATUS_CONFLICT(60032, "error.60032"), + /** 操作进行中,请勿重复提交 */ + OPERATION_IN_PROGRESS(60033, "error.60033"), + /** 系统维护中,请稍后重试 */ + SERVICE_UNAVAILABLE(60034, "error.60034"), + /** 风控审核未通过 */ + RISK_REJECTED(60035, "error.60035"), + /** 操作需要延迟处理 */ + RISK_DELAY_RESPONSE(60036, "error.60036"), + /** 风控服务异常,操作已拒绝 */ + RISK_SERVICE_UNAVAILABLE(60037, "error.60037"), + /** 资产引擎响应超时 */ + ENGINE_TIMEOUT(60038, "error.60038"), + /** 资产引擎业务异常 */ + ENGINE_BUSINESS_ERROR(60039, "error.60039"), + /** 补偿操作失败,请联系客服 */ + COMPENSATION_FAILED(60040, "error.60040"), + /** 支付密码已锁定 */ + PAY_PASSWORD_LOCKED(60041, "error.60041"), + /** 超过今日提币上限 */ + WITHDRAW_DAILY_LIMIT_EXCEEDED(60042, "error.60042"), + + // ==================== 资产服务错误 (60100-60119) ==================== + /** 请先设置支付密码 */ + ASSET_PAY_PASSWORD_NOT_SET(60100, "error.60100"), + /** 支付密码错误 */ + ASSET_PAY_PASSWORD_WRONG(60101, "error.60101"), + /** 新支付密码不能与旧密码相同 */ + ASSET_PAY_PASSWORD_SAME(60102, "error.60102"), + /** 支付密码已锁定,请稍后再试 */ + ASSET_PAY_PASSWORD_LOCKED(60103, "error.60103"), + /** 请先完成KYC认证 */ + ASSET_KYC_NOT_APPROVED(60104, "error.60104"), + /** KYC认证已提交,请勿重复提交 */ + ASSET_KYC_ALREADY_SUBMITTED(60105, "error.60105"), + /** 该币种不支持 */ + FUND_ASSET_NOT_SUPPORTED(60106, "error.60106"), + /** 余额不足 */ + FUND_INSUFFICIENT_BALANCE(60107, "error.60107"), + /** 低于最小提币金额 */ + FUND_MIN_WITHDRAW_AMOUNT(60108, "error.60108"), + /** 超过今日提币上限 */ + FUND_WITHDRAW_DAILY_LIMIT_EXCEEDED(60109, "error.60109"), + /** 低于最小转账金额 */ + FUND_MIN_TRANSFER_AMOUNT(60110, "error.60110"), + + // ==================== 订单服务错误 (60120-60139) ==================== + /** 订单不存在 */ + ORDER_NOT_FOUND(60120, "error.60120"), + /** 订单状态冲突,请稍后重试 */ + ORDER_STATUS_CONFLICT_ERROR(60121, "error.60121"), + /** 当前状态不允许该操作 */ + ORDER_OPERATION_NOT_ALLOWED(60122, "error.60122"), + /** 重复提交,请勿重复操作 */ + ORDER_IDEMPOTENCY_KEY_DUPLICATE(60123, "error.60123"), + /** 红包不存在 */ + REDPACKET_NOT_FOUND(60124, "error.60124"), + /** 红包已过期 */ + REDPACKET_EXPIRED(60125, "error.60125"), + /** 红包已被抢完 */ + REDPACKET_FINISHED(60126, "error.60126"), + /** 您已抢过该红包 */ + REDPACKET_ALREADY_GRABBED(60127, "error.60127"), + /** 不能抢自己发的红包 */ + REDPACKET_CANNOT_GRAB_OWN(60128, "error.60128"), + /** 该地址不在白名单中 */ + WITHDRAW_ADDRESS_NOT_WHITELISTED(60129, "error.60129"), + /** 红包结算中,请稍后 */ + REDPACKET_EXPIRING(60130, "error.60130"), + /** 当前红包状态不允许设置过期时间(需为进行中) */ + REDPACKET_STATUS_INVALID_EXTEND_EXPIRE(60131, "error.60131"), + /** 红包新过期时间必须晚于当前时间 */ + REDPACKET_NEW_EXPIRE_TIME_INVALID(60132, "error.60132"), + + + // ==================== 红包雨错误 (63201-63249) · V1.1.3 ==================== + /** 红包雨功能关闭 */ + RAIN_DISABLED(63201, "error.63201"), + /** 红包雨房间排队已满 */ + RAIN_QUEUE_FULL(63202, "error.63202"), + /** 红包雨不存在 */ + RAIN_NOT_FOUND(63203, "error.63203"), + /** 红包雨状态不可点击 */ + RAIN_HIT_NOT_ALLOWED(63204, "error.63204"), + /** 红包雨重复点击幂等(可作为成功提示,一般不抛) */ + RAIN_HIT_DUPLICATE(63205, "error.63205"), + /** 红包雨点击过于频繁 */ + RAIN_HIT_TOO_FAST(63206, "error.63206"), + /** 非场控无权暂停/恢复红包雨 */ + RAIN_NOT_CONTROLLER(63207, "error.63207"), + /** 房间已暂停无法开播红包雨 */ + RAIN_ROOM_PAUSED(63208, "error.63208"), + /** 红包雨发送失败(扣款/发起失败,不重试) */ + RAIN_DEDUCT_FAILED(63209, "error.63209"), + /** 红包雨结算未完成 */ + RAIN_SETTLE_NOT_READY(63210, "error.63210"), + /** 红包雨系统繁忙(单雨限流) */ + RAIN_BUSY(63211, "error.63211"), + /** 红包雨金额非法 */ + RAIN_AMOUNT_INVALID(63212, "error.63212"), + /** 红包雨入账失败 */ + RAIN_CREDIT_FAILED(63213, "error.63213"), + /** 红包雨退款失败 */ + RAIN_REFUND_FAILED(63214, "error.63214"), + + // ==================== 补偿任务错误 (60140-60149) ==================== + /** 补偿任务执行中 */ + COMPENSATION_EXECUTING(60140, "error.60140"), + /** 补偿操作失败,请联系客服 */ + COMPENSATION_TASK_FAILED(60141, "error.60141"), + + // ==================== 钱包服务错误 (60150-60159) ==================== + /** 充值回调验签失败 */ + DEPOSIT_CALLBACK_VERIFY_FAILED(60150, "error.60150"), + /** 充值地址未找到 */ + DEPOSIT_ADDRESS_NOT_FOUND(60151, "error.60151"), + /** 提币上链失败 */ + WITHDRAW_SUBMIT_FAILED(60152, "error.60152"), + /** 创建充值地址失败 */ + WALLET_ADDRESS_CREATE_FAILED(60153, "error.60153"), + /** 充值入账哈希重复 */ + DEPOSIT_TX_HASH_DUPLICATE(60154, "error.60154"), + + // ==================== IM服务错误 (14001-14999) ==================== + // ---- IM 会话 (14001-14099) ---- + /** 会话不存在 */ + IM_CONVERSATION_NOT_FOUND(14001, "error.14001"), + /** 不能给自己创建会话 */ + IM_CANNOT_CREATE_CONVERSATION_WITH_SELF(14002, "error.14002"), + /** 会话已改由 OpenIM 管理,业务会话接口已废弃 */ + IM_CONVERSATION_DEPRECATED(14003, "error.14003"), + /** 每页最多查询200条消息 */ + IM_SYNC_LIMIT_EXCEEDED(14051, "error.14051"), + /** 批量检查会话数不能超过50 */ + IM_SYNC_CHECK_LIMIT_EXCEEDED(14052, "error.14052"), + + // ---- IM 好友 (14101-14199) ---- + /** 不能添加自己为好友 */ + IM_CANNOT_ADD_SELF_AS_FRIEND(14101, "error.14101"), + /** 已经是好友关系 */ + IM_ALREADY_FRIENDS(14102, "error.14102"), + /** 已经发送过好友申请,请等待对方处理 */ + IM_FRIEND_REQUEST_PENDING(14103, "error.14103"), + /** 好友申请不存在 */ + IM_FRIEND_REQUEST_NOT_FOUND(14104, "error.14104"), + /** 无权处理该好友申请 */ + IM_FRIEND_REQUEST_NOT_RECIPIENT(14105, "error.14105"), + /** 好友关系不存在 */ + IM_FRIENDSHIP_NOT_FOUND(14106, "error.14106"), + /** 搜索关键词不能为空 */ + IM_SEARCH_KEYWORD_EMPTY(14107, "error.14107"), + /** 该好友申请已处理 */ + IM_FRIEND_REQUEST_ALREADY_PROCESSED(14108, "error.14108"), + /** 好友备注不合法 */ + IM_FRIEND_REMARK_INVALID(14120, "error.14120"), + + // ---- IM 群组 (14201-14299) ---- + /** 您不在该群组中 */ + IM_NOT_GROUP_MEMBER(14201, "error.14201"), + /** 群组不存在或已解散 */ + IM_GROUP_NOT_FOUND(14202, "error.14202"), + /** 只有群主或管理员可以更新群组信息 */ + IM_GROUP_NOT_MANAGER_UPDATE(14203, "error.14203"), + /** 只有群主可以解散群组 */ + IM_GROUP_NOT_OWNER_DISSOLVE(14204, "error.14204"), + /** 只有群主或管理员可以添加成员 */ + IM_GROUP_NOT_MANAGER_ADD(14205, "error.14205"), + /** 只有群主或管理员可以移除成员 */ + IM_GROUP_NOT_MANAGER_REMOVE(14206, "error.14206"), + /** 只有群主可以移除管理员 */ + IM_GROUP_NOT_OWNER_REMOVE_ADMIN(14207, "error.14207"), + /** 群组成员数量已达上限 */ + IM_GROUP_MEMBER_LIMIT_REACHED(14208, "error.14208"), + /** 该用户已在群组中 */ + IM_USER_ALREADY_IN_GROUP(14209, "error.14209"), + /** 不能移除群主 */ + IM_CANNOT_REMOVE_OWNER(14210, "error.14210"), + /** 该成员不在群组中 */ + IM_GROUP_MEMBER_NOT_FOUND(14211, "error.14211"), + /** 只有群主或管理员可以开启会议 */ + IM_GROUP_NOT_MANAGER_START_MEETING(14212, "error.14212"), + /** 只有群主可以结束会议 */ + IM_GROUP_NOT_OWNER_END_MEETING(14213, "error.14213"), + /** 您已被限制加入该会议 */ + IM_MEETING_BLACKLISTED(14214, "error.14214"), + /** 只有群主可以修改成员角色 */ + IM_GROUP_NOT_OWNER_UPDATE_ROLE(14215, "error.14215"), + /** 不能修改自己的角色 */ + IM_CANNOT_UPDATE_OWN_ROLE(14216, "error.14216"), + /** 角色值只能为 admin 或 member */ + IM_INVALID_ROLE_VALUE(14217, "error.14217"), + /** 只有群主可以转让群主 */ + IM_GROUP_NOT_OWNER_TRANSFER(14218, "error.14218"), + /** 不能将群主转让给自己 */ + IM_CANNOT_TRANSFER_OWNER_TO_SELF(14219, "error.14219"), + /** 您已在该群组中 */ + IM_USER_ALREADY_IN_GROUP_JOIN(14220, "error.14220"), + /** 群主不可退出群组 */ + IM_OWNER_CANNOT_LEAVE_GROUP(14221, "error.14221"), + /** 您已被拉黑,无法加入该群 */ + IM_GROUP_USER_BLACKLISTED(14230, "error.14230"), + /** 仅群主可操作群黑名单 */ + IM_GROUP_NOT_OWNER_BLACKLIST(14231, "error.14231"), + /** 用户不在群黑名单中 */ + IM_GROUP_BLACKLIST_NOT_FOUND(14232, "error.14232"), + /** 群昵称不合法 */ + IM_GROUP_NICKNAME_INVALID(14233, "error.14233"), + /** 不能拉黑自己 */ + IM_CANNOT_BLACKLIST_SELF(14234, "error.14234"), + /** 不能拉黑群主 */ + IM_CANNOT_BLACKLIST_OWNER(14235, "error.14235"), + /** 不能移除管理员 */ + IM_CANNOT_REMOVE_ADMIN(14236, "error.14236"), + /** 不能拉黑管理员 */ + IM_CANNOT_BLACKLIST_ADMIN(14237, "error.14237"), + + // ---- IM 会议/连麦/媒体 (14301-14399) ---- + /** 只有会议发起人可以审批媒体权限 */ + IM_MEETING_NOT_OWNER_MEDIA_APPROVE(14301, "error.14301"), + /** 只有会议发起人可以撤销媒体权限 */ + IM_MEETING_NOT_OWNER_MEDIA_REVOKE(14302, "error.14302"), + /** 只有会议发起人可以进行静音控制 */ + IM_MEETING_NOT_OWNER_MUTE(14303, "error.14303"), + /** 未找到目标用户的匹配媒体轨道 */ + IM_MEDIA_TRACK_NOT_FOUND(14304, "error.14304"), + /** 不能将自己从会议中踢出 */ + IM_CANNOT_KICK_SELF(14305, "error.14305"), + /** 只有会议发起人可以将成员踢出会议 */ + IM_MEETING_NOT_OWNER_KICK(14306, "error.14306"), + /** 发起人默认拥有连麦权限 */ + IM_INITIATOR_NO_COHOST_NEEDED(14307, "error.14307"), + /** 您已提交过连麦申请,请等待审批 */ + IM_COHOST_REQUEST_PENDING(14308, "error.14308"), + /** 您已在连麦中,无需重复申请 */ + IM_COHOST_ALREADY_ACTIVE(14309, "error.14309"), + /** 连麦申请数量已达上限 */ + IM_COHOST_REQUEST_LIMIT_REACHED(14310, "error.14310"), + /** 发送连麦申请通知失败 */ + IM_COHOST_REQUEST_NOTIFY_FAILED(14311, "error.14311"), + /** 只有会议发起人可以查看连麦申请 */ + IM_MEETING_NOT_OWNER_COHOST_VIEW(14312, "error.14312"), + /** 只有会议发起人可以审批连麦 */ + IM_MEETING_NOT_OWNER_COHOST_APPROVE(14313, "error.14313"), + /** 未找到待审批的连麦申请 */ + IM_COHOST_PENDING_REQUEST_NOT_FOUND(14314, "error.14314"), + /** 连麦人数已达上限 */ + IM_COHOST_COUNT_LIMIT_REACHED(14315, "error.14315"), + /** 发送连麦审批通知失败 */ + IM_COHOST_APPROVE_NOTIFY_FAILED(14316, "error.14316"), + /** 只有会议发起人可以取消连麦 */ + IM_MEETING_NOT_OWNER_COHOST_CANCEL(14317, "error.14317"), + /** 未找到该用户的连麦记录 */ + IM_COHOST_RECORD_NOT_FOUND(14318, "error.14318"), + /** 发送下麦通知失败 */ + IM_COHOST_OFFLINE_NOTIFY_FAILED(14319, "error.14319"), + /** 未找到待撤回的连麦申请 */ + IM_COHOST_CANCEL_REQUEST_NOT_FOUND(14320, "error.14320"), + /** 发送撤回申请通知失败 */ + IM_COHOST_CANCEL_NOTIFY_FAILED(14321, "error.14321"), + /** 发送媒体权限请求失败 */ + IM_MEDIA_REQUEST_NOTIFY_FAILED(14322, "error.14322"), + /** 发送媒体审批通知失败 */ + IM_MEDIA_APPROVE_NOTIFY_FAILED(14323, "error.14323"), + /** 发送媒体撤销通知失败 */ + IM_MEDIA_REVOKE_NOTIFY_FAILED(14324, "error.14324"), + /** 只有会议发起人可以控制弹幕 */ + IM_MEETING_DANMAKU_NOT_OWNER(14325, "error.14325"), + /** 不能禁发自己的弹幕 */ + IM_MEETING_DANMAKU_CANNOT_BAN_SELF(14326, "error.14326"), + /** 敏感词过滤服务异常 */ + IM_SENSITIVE_FILTER_FAILED(14327, "error.14327"), + + // ---- IM LiveKit 通话 (14401-14499) ---- + /** 目标用户ID不能为空 */ + IM_LIVEKIT_TARGET_USER_EMPTY(14401, "error.14401"), + /** 双方不是好友关系,无法发起通话 */ + IM_LIVEKIT_NOT_FRIENDS(14402, "error.14402"), + /** 群组ID不能为空 */ + IM_LIVEKIT_GROUP_ID_EMPTY(14403, "error.14403"), + /** 群组不存在 */ + IM_LIVEKIT_GROUP_NOT_FOUND(14404, "error.14404"), + /** 多人通话房间名无效(须以 mpcall_ 开头) */ + IM_LIVEKIT_MULTI_CALL_ROOM_INVALID(14405, "error.14405"), + /** 多人通话须提供邀请人或目标用户以校验好友关系 */ + IM_LIVEKIT_MULTI_CALL_PEER_REQUIRED(14406, "error.14406"), + + // ---- IM OpenIM 第三方集成 (14601-14699) ---- + /** 调用 OpenIM 接口发生网络/HTTP异常 */ + IM_OPENIM_HTTP_ERROR(14601, "error.14601"), + /** OpenIM 用户注册失败 */ + IM_OPENIM_USER_REGISTER_FAILED(14602, "error.14602"), + /** 获取 OpenIM 用户 Token 失败 */ + IM_OPENIM_GET_TOKEN_FAILED(14603, "error.14603"), + /** 获取 OpenIM 管理员 Token 失败 */ + IM_OPENIM_GET_ADMIN_TOKEN_FAILED(14604, "error.14604"), + /** OpenIM 创建群组失败 */ + IM_OPENIM_CREATE_GROUP_FAILED(14605, "error.14605"), + /** OpenIM 解散群组失败 */ + IM_OPENIM_DISMISS_GROUP_FAILED(14606, "error.14606"), + /** OpenIM 邀请入群失败 */ + IM_OPENIM_INVITE_GROUP_FAILED(14607, "error.14607"), + /** OpenIM 踢出群成员失败 */ + IM_OPENIM_KICK_GROUP_FAILED(14608, "error.14608"), + /** OpenIM 添加好友失败 */ + IM_OPENIM_ADD_FRIEND_FAILED(14609, "error.14609"), + /** OpenIM 删除好友失败 */ + IM_OPENIM_DELETE_FRIEND_FAILED(14610, "error.14610"), + /** OpenIM 发送消息失败 */ + IM_OPENIM_SEND_MSG_FAILED(14611, "error.14611"), + /** OpenIM 设置群成员角色失败 */ + IM_OPENIM_SET_MEMBER_ROLE_FAILED(14612, "error.14612"), + /** OpenIM 拉黑失败 */ + IM_OPENIM_ADD_BLACK_FAILED(14613, "error.14613"), + /** OpenIM 取消拉黑失败 */ + IM_OPENIM_REMOVE_BLACK_FAILED(14614, "error.14614"), + /** OpenIM 处理好友申请(同意/拒绝)失败 */ + IM_OPENIM_RESPOND_FRIEND_FAILED(14615, "error.14615"), + /** OpenIM 更新群资料失败 */ + IM_OPENIM_SET_GROUP_INFO_FAILED(14616, "error.14616"), + /** OpenIM 转让群主失败 */ + IM_OPENIM_TRANSFER_OWNER_FAILED(14617, "error.14617"), + /** OpenIM 退出群组失败 */ + IM_OPENIM_QUIT_GROUP_FAILED(14618, "error.14618"), + /** OpenIM 设置好友备注失败 */ + IM_OPENIM_SET_FRIEND_REMARK_FAILED(14620, "error.14620"), + /** OpenIM 设置群成员昵称失败 */ + IM_OPENIM_SET_MEMBER_NICKNAME_FAILED(14621, "error.14621"), + /** OpenIM 同步官方标识失败 */ + IM_OPENIM_SET_OFFICIAL_EX_FAILED(14622, "error.14622"), + /** OpenIM 强制用户下线失败 */ + IM_OPENIM_FORCE_LOGOUT_FAILED(14623, "error.14623"), + + // ---- IM 收藏 / 表情 (14701-14799) · V1.1.1 ---- + /** 收藏不存在 */ + IM_FAVORITE_NOT_FOUND(14701, "error.14701"), + /** 收藏内容类型不合法 */ + IM_FAVORITE_TYPE_INVALID(14702, "error.14702"), + /** 收藏内容为空或非法 */ + IM_FAVORITE_CONTENT_INVALID(14703, "error.14703"), + /** 收藏已达上限 */ + IM_FAVORITE_LIMIT_REACHED(14704, "error.14704"), + /** 表情包不存在或已下架 */ + IM_STICKER_PACK_NOT_FOUND(14711, "error.14711"), + /** 用户表情不存在 */ + IM_USER_STICKER_NOT_FOUND(14712, "error.14712"), + /** 表情媒体类型不合法 */ + IM_STICKER_MEDIA_TYPE_INVALID(14713, "error.14713"), + /** 视频表情时长非法(须 1~5000ms) */ + IM_STICKER_DURATION_INVALID(14714, "error.14714"), + /** 用户表情数量已达上限 */ + IM_USER_STICKER_LIMIT_REACHED(14715, "error.14715"), + /** 表情 URL 非法 */ + IM_STICKER_URL_INVALID(14716, "error.14716"), + /** 批量撤回消息参数非法(空列表或超上限) */ + IM_MSG_REVOKE_SEQS_INVALID(14720, "error.14720"), + /** 批量撤回消息全部失败 */ + IM_MSG_REVOKE_ALL_FAILED(14721, "error.14721"), + /** 系统表情导入失败(EmojiHub 拉取/解析/入库) */ + IM_EMOJI_IMPORT_FAILED(14730, "error.14730"), + /** 系统表情缓存刷新失败 */ + IM_EMOJI_CACHE_FAILED(14731, "error.14731"), + + // ==================== IM 批量转发错误 (63001-63013) · V1.1.3 ==================== + /** 批量转发功能未开放 */ + FORWARD_FEATURE_DISABLED(63001, "error.63001"), + /** 批量转发 scope 参数无效 */ + FORWARD_SCOPE_INVALID(63002, "error.63002"), + /** 今日批量转发任务额度已用完 */ + FORWARD_DAILY_QUOTA_EXCEEDED(63003, "error.63003"), + /** 批量转发目标超过上限 */ + FORWARD_TARGET_LIMIT_EXCEEDED(63004, "error.63004"), + /** 批量转发消息数量超过上限 */ + FORWARD_MESSAGE_LIMIT_EXCEEDED(63005, "error.63005"), + /** 批量转发消息类型不支持 */ + FORWARD_MESSAGE_TYPE_UNSUPPORTED(63006, "error.63006"), + /** 批量转发媒体资源无效 */ + FORWARD_MEDIA_INVALID(63007, "error.63007"), + /** 幂等键与原请求不一致 */ + FORWARD_IDEMPOTENCY_CONFLICT(63008, "error.63008"), + /** 批量转发任务不存在或无权访问 */ + FORWARD_JOB_NOT_FOUND(63009, "error.63009"), + /** 批量转发任务状态不允许当前操作 */ + FORWARD_JOB_STATUS_INVALID(63010, "error.63010"), + /** estimateToken 无效或过期 */ + FORWARD_ESTIMATE_TOKEN_INVALID(63011, "error.63011"), + /** 已有运行中的批量转发任务 */ + FORWARD_JOB_ALREADY_RUNNING(63012, "error.63012"), + /** 源消息不存在或无权访问 */ + FORWARD_SOURCE_INVALID(63013, "error.63013"), + + + // ==================== 游戏资金划转错误 (60160-60169) ==================== + + /** 游戏资金划转处理中,请勿重复提交(bizOrderNo 维度锁竞争) */ + GAMEFUND_TRANSFER_BUSY(42901, "error.42901"), + + /** 游戏资金划转重试中,请稍后再试(orderNo 维度锁竞争) */ + GAMEFUND_RETRY_BUSY(42902, "error.42902"), + + /** 业务订单号冲突(bizOrderNo 已存在但参数不一致) */ + GAMEFUND_BIZ_ORDER_CONFLICT(40901, "error.40901"), + + /** 游戏资金划转订单不存在 */ + GAMEFUND_ORDER_NOT_FOUND(60160, "error.60160"), + + /** 订单状态不允许重试(非 FAILED 状态) */ + GAMEFUND_INVALID_RETRY_STATUS(60161, "error.60161"), + + /** 资产引擎调用失败 */ + GAMEFUND_LEDGER_CALL_FAILED(60162, "error.60162"), + + /** 资产引擎返回非成功状态 */ + GAMEFUND_LEDGER_RETURN_ERROR(60163, "error.60163"), + + /** 用户余额不足(OUT 场景) */ + GAMEFUND_INSUFFICIENT_BALANCE(60164, "error.60164"), + + /** 平台→游戏划转已关闭(全局开关) */ + GAMEFUND_OUT_DISABLED(60310, "error.60310"), + + /** 游戏→平台划转已关闭(全局开关) */ + GAMEFUND_IN_DISABLED(60311, "error.60311"), + + // ==================== 直播服务错误 (14501-14599) ==================== + + /** 已有进行中的直播 */ + LIVE_ALREADY_ACTIVE(14501, "error.14501"), + /** 直播场次不存在 */ + LIVE_SESSION_NOT_FOUND(14502, "error.14502"), + /** 直播已结束 */ + LIVE_SESSION_ENDED(14503, "error.14503"), + /** 仅主播可执行此操作 */ + LIVE_NOT_ANCHOR(14504, "error.14504"), + /** 预约时间无效 */ + LIVE_INVALID_SCHEDULE_TIME(14505, "error.14505"), + /** 当前状态不允许该操作 */ + LIVE_INVALID_STATUS(14506, "error.14506"), + /** 已关注该主播 */ + LIVE_ALREADY_FOLLOWED(14507, "error.14507"), + /** 未关注该主播 */ + LIVE_NOT_FOLLOWED(14508, "error.14508"), + /** 已预约该场次 */ + LIVE_ALREADY_RESERVED(14509, "error.14509"), + /** 未预约该场次 */ + LIVE_NOT_RESERVED(14510, "error.14510"), + /** 弹幕发送过于频繁 */ + LIVE_DANMAKU_TOO_FREQUENT(14511, "error.14511"), + /** 弹幕内容包含敏感词 */ + LIVE_DANMAKU_SENSITIVE(14512, "error.14512"), + /** 主播用户不存在 */ + LIVE_ANCHOR_NOT_FOUND(14513, "error.14513"), + /** 该场次已点赞 */ + LIVE_DUPLICATE_LIKE(14514, "error.14514"), + /** 直播未开始或已结束,无法进入 */ + LIVE_ROOM_NOT_LIVING(14515, "error.14515"), + + // ==================== 直播主播申请错误 (14516-14520) ==================== + + /** 请先完成实名认证 */ + LIVE_ANCHOR_NOT_VERIFIED(14517, "error.14517"), + + /** 已提交过主播申请 */ + LIVE_ANCHOR_ALREADY_APPLIED(14518, "error.14518"), + + /** 申请记录不存在 */ + LIVE_ANCHOR_APPLICATION_NOT_FOUND(14519, "error.14519"), + + /** 未通过主播审核(开播时校验) */ + LIVE_ANCHOR_NOT_APPROVED(14520, "error.14520"), + + // ==================== 订单服务扩展错误 (60165-60209) ==================== + /** 无效的转账类型 */ + TRANSFER_TYPE_INVALID(60165, "error.60165"), + /** 用户间转账必须指定目标用户 */ + TRANSFER_TARGET_USER_EMPTY(60166, "error.60166"), + /** 不能给自己转账 */ + TRANSFER_CANNOT_SELF(60167, "error.60167"), + /** 扣款失败 */ + TRANSFER_DEDUCT_FAILED(60168, "error.60168"), + /** 协议类型不能为空 */ + AGREEMENT_TYPE_EMPTY(60169, "error.60169"), + /** 无效的协议类型 */ + AGREEMENT_TYPE_INVALID(60170, "error.60170"), + /** 协议未发布 */ + AGREEMENT_NOT_PUBLISHED(60171, "error.60171"), + /** 该协议类型下版本已存在 */ + AGREEMENT_VERSION_EXISTS(60172, "error.60172"), + /** 协议不存在 */ + AGREEMENT_NOT_FOUND(60173, "error.60173"), + /** 协议正在发布中,请稍后再试 */ + AGREEMENT_PUBLISHING(60174, "error.60174"), + /** 产品不存在 */ + PRODUCT_NOT_FOUND(60175, "error.60175"), + /** 产品已下架 */ + PRODUCT_OFFLINE(60176, "error.60176"), + /** 产品已售罄 */ + PRODUCT_SOLD_OUT(60177, "error.60177"), + /** 产品不存在或已下架 */ + PRODUCT_NOT_FOUND_OR_OFFLINE(60178, "error.60178"), + /** 请先同意银行卡购买协议 */ + AGREEMENT_NOT_ACCEPTED(60179, "error.60179"), + /** 邮箱验证码错误或已失效 */ + EMAIL_CAPTCHA_ERROR(60180, "error.60180"), + /** 用户注册失败,请稍后重试 */ + USER_REGISTER_FAILED(60181, "error.60181"), + /** 充值金额不足 */ + RECHARGE_AMOUNT_INSUFFICIENT(60182, "error.60182"), + /** 订单正在处理中,请勿重复操作 */ + ORDER_PROCESSING(60183, "error.60183"), + /** 已支付订单不能直接取消,请走退款流程 */ + ORDER_PAID_CANNOT_CANCEL(60184, "error.60184"), + /** 订单状态已变更 */ + ORDER_STATUS_CHANGED(60185, "error.60185"), + /** 支付密码不能为空 */ + PAY_PASSWORD_EMPTY(60186, "error.60186"), + /** 卡记录不存在 */ + CARD_RECORD_NOT_FOUND(60187, "error.60187"), + /** 无效的状态值 */ + CARD_STATUS_INVALID(60188, "error.60188"), + /** 卡号生成失败,请重试 */ + CARD_GENERATE_FAILED(60189, "error.60189"), + /** 银行卡价格必须大于0 */ + BANKCARD_PRICE_INVALID(60190, "error.60190"), + /** 支付明细异常 */ + PAYMENT_DETAIL_ERROR(60191, "error.60191"), + /** 分佣入账失败 */ + COMMISSION_SETTLE_FAILED(60192, "error.60192"), + /** 规则列表不能为空 */ + COMMISSION_RULE_LIST_EMPTY(60193, "error.60193"), + /** 层级必须在 1-20 之间 */ + COMMISSION_LEVEL_INVALID(60194, "error.60194"), + /** 无效的佣金类型 */ + COMMISSION_TYPE_INVALID(60195, "error.60195"), + /** 佣金值不能为负 */ + COMMISSION_VALUE_NEGATIVE(60196, "error.60196"), + /** 产品编码已存在 */ + BANKCARD_PRODUCT_CODE_EXISTS(60197, "error.60197"), + /** 售罄状态由系统自动维护 */ + BANKCARD_STATUS_IMMUTABLE(60198, "error.60198"), + /** 产品已有销售记录,不能删除,请下架 */ + BANKCARD_HAS_SALES_CANNOT_DELETE(60199, "error.60199"), + /** 无效的卡种 */ + BANKCARD_CARD_TYPE_INVALID(60200, "error.60200"), + /** 支付配置不能为空 */ + BANKCARD_PAYMENT_CONFIG_EMPTY(60201, "error.60201"), + /** 支付配置各币种比例之和必须等于100 */ + BANKCARD_PAYMENT_RATIO_INVALID(60202, "error.60202"), + /** 订单状态异常 */ + ORDER_STATUS_ERROR(60203, "error.60203"), + /** 无效的定价模式 */ + BANKCARD_PRICING_MODE_INVALID(60204, "error.60204"), + /** 币本位支付数量无效 */ + BANKCARD_PAYMENT_AMOUNT_INVALID(60205, "error.60205"), + /** 币本位含非USDT资产,站外余额不足时不支持USDT充值补差 */ + BANKCARD_COIN_GUEST_RECHARGE_UNSUPPORTED(60206, "error.60206"), + /** 金本位总价不能为空 */ + BANKCARD_TOTAL_PRICE_REQUIRED(60207, "error.60207"), + /** 币本位不可配置USDT总价 */ + BANKCARD_COIN_TOTAL_PRICE_FORBIDDEN(60208, "error.60208"), + + // ==================== 用户服务扩展错误 (2202, 2203, 60210-60219) ==================== + /** 密码为空,需要设置密码 */ + PASSWORD_EMPTY_NEED_SET(2202, "error.2202"), + /** App 版本过低,需要强制升级 */ + APP_VERSION_TOO_LOW(2203, "error.2203"), + /** 跳转类型只能是 URL 或 APP */ + DISCOVER_JUMP_TYPE_INVALID(60210, "error.60210"), + /** 审核状态参数非法,仅支持1(通过)或2(拒绝) */ + KYC_AUDIT_STATUS_INVALID(60211, "error.60211"), + /** 请选择要上传的头像文件 */ + FILE_AVATAR_EMPTY(60212, "error.60212"), + /** 文件不能为空 */ + FILE_EMPTY(60213, "error.60213"), + /** 请选择要上传的图标文件 */ + FILE_ICON_EMPTY(60214, "error.60214"), + /** 请选择要上传的图片文件 */ + FILE_IMAGE_EMPTY(60215, "error.60215"), + /** 不支持的 fileType */ + FILE_TYPE_INVALID(60216, "error.60216"), + /** 管理员未绑定 Google 验证器 */ + ADMIN_TOTP_NOT_BOUND(60217, "error.60217"), + + // ==================== 商学院服务扩展错误 (60220-60229) ==================== + /** 该主题下章节名称已存在 */ + CHAPTER_NAME_EXISTS_IN_TOPIC(60220, "error.60220"), + + // ==================== 短视频服务扩展错误 (60230-60239) ==================== + /** 视频标题已存在 */ + SHORT_VIDEO_NAME_EXISTS(60230, "error.60230"), + /** 已关注该用户/博主 */ + SHORT_VIDEO_ALREADY_FOLLOWED(60231, "error.60231"), + /** 未关注该用户/博主 */ + SHORT_VIDEO_NOT_FOLLOWED(60232, "error.60232"), + /** 关注目标无效 */ + SHORT_VIDEO_FOLLOW_TARGET_INVALID(60233, "error.60233"), + /** 视频当前状态不允许审核操作 */ + SHORT_VIDEO_AUDIT_STATUS_INVALID(60234, "error.60234"), + /** 短视频文件下载失败 */ + SHORT_VIDEO_DOWNLOAD_FAILED(60235, "error.60235"), + /** 重转码缺少原片地址 */ + SHORT_VIDEO_RETRANSCODE_SOURCE_MISSING(60236, "error.60236"), + /** 当前状态不允许重转码 */ + SHORT_VIDEO_RETRANSCODE_STATUS_INVALID(60237, "error.60237"), + + // ==================== IM服务扩展错误 (60240-60259) ==================== + /** 系统消息不存在 */ + SYSTEM_MESSAGE_NOT_FOUND(60240, "error.60240"), + /** 仅已发布状态的消息可推送 */ + SYSTEM_MESSAGE_NOT_PUBLISHED(60241, "error.60241"), + /** 公告不存在 */ + ANNOUNCEMENT_NOT_FOUND(60242, "error.60242"), + /** 仅已发布状态的公告可推送 */ + ANNOUNCEMENT_NOT_PUBLISHED(60243, "error.60243"), + /** 不能关注自己 */ + CANNOT_FOLLOW_SELF(60244, "error.60244"), + + // ==================== 通用服务扩展错误 (60260-60279) ==================== + /** 获取行情价格失败 */ + MARKET_PRICE_FETCH_FAILED(60260, "error.60260"), + /** 获取行情价格异常,价格为0 */ + MARKET_PRICE_ZERO(60261, "error.60261"), + /** 分享短码不能为空 */ + SHORT_CODE_EMPTY(60262, "error.60262"), + /** 分享事件类型不能为空 */ + EVENT_TYPE_EMPTY(60263, "error.60263"), + /** 单次批量删除数量超限 */ + BATCH_DELETE_MAX_EXCEEDED(60264, "error.60264"), + /** 群组已解散,无法分享 */ + GROUP_DISSOLVED_CANNOT_SHARE(60265, "error.60265"), + /** LiveKit操作失败 */ + LIVEKIT_OPERATION_FAILED(60266, "error.60266"), + /** 推送操作失败 */ + PUSH_OPERATION_FAILED(60267, "error.60267"), + /** 扣款操作失败 */ + DEDUCT_OPERATION_FAILED(60268, "error.60268"), + /** 文件大小超过限制 */ + FILE_SIZE_EXCEED_LIMIT(60269, "error.60269"), + + // ==================== S3文件服务错误 (60270-60279) ==================== + /** 文件读取失败 */ + FILE_READ_FAILED(60270, "error.60270"), + /** 文件上传至存储服务失败 */ + FILE_UPLOAD_TO_STORAGE_FAILED(60271, "error.60271"), + /** 文件删除失败 */ + FILE_DELETE_FAILED(60272, "error.60272"), + /** 初始化分片上传失败 */ + MULTIPART_UPLOAD_INIT_FAILED(60273, "error.60273"), + /** 生成分片上传URL失败 */ + MULTIPART_UPLOAD_URL_FAILED(60274, "error.60274"), + /** 查询已上传分片失败 */ + MULTIPART_UPLOAD_PARTS_QUERY_FAILED(60275, "error.60275"), + /** 完成分片上传失败 */ + MULTIPART_UPLOAD_COMPLETE_FAILED(60276, "error.60276"), + /** 取消分片上传失败 */ + MULTIPART_UPLOAD_ABORT_FAILED(60277, "error.60277"), + + // ==================== 加密/解密错误 (60278-60279) ==================== + /** AES加密失败 */ + AES_ENCRYPT_FAILED(60278, "error.60278"), + /** AES解密失败 */ + AES_DECRYPT_FAILED(60279, "error.60279"), + + // ==================== 视频处理错误 (60280-60289) ==================== + /** 无法探测视频时长 */ + VIDEO_DURATION_DETECT_FAILED(60280, "error.60280"), + /** 提取视频缩略图失败 */ + VIDEO_THUMBNAIL_EXTRACT_FAILED(60281, "error.60281"), + /** 视频转码失败 */ + VIDEO_TRANSCODE_FAILED(60282, "error.60282"), + + // ==================== 订单服务扩展错误 (60283-60289) ==================== + /** FIXED 类型佣金暂不支持,请使用 RATE 类型 */ + COMMISSION_FIXED_TYPE_NOT_SUPPORTED(60283, "error.60283"), + /** 邮件发送冷却中,请稍后再试 */ + EMAIL_SEND_COOLDOWN(60284, "error.60284"), + + // ==================== App 版本管理错误 (60290-60299) ==================== + /** 版本记录不存在 */ + APP_VERSION_NOT_FOUND(60290, "error.60290"), + /** 版本状态不允许该操作 */ + APP_VERSION_STATUS_INVALID(60291, "error.60291"), + /** 平台类型非法,仅支持 android 或 ios */ + APP_VERSION_PLATFORM_INVALID(60292, "error.60292"), + /** 同平台同版本号已存在 */ + APP_VERSION_CODE_DUPLICATE(60293, "error.60293"), + + // ==================== 直播V2扩展错误 (14521-14529) ==================== + /** 直播V2-主播未找到 */ + LIVE_V2_ANCHOR_NOT_FOUND(14521, "error.14521"), + + // ==================== 推送服务错误 (60300-60309) ==================== + /** registrationId 不能为空 */ + PUSH_DEVICE_REGISTRATION_ID_EMPTY(60300, "error.60300"), + /** 平台类型非法,仅支持 ANDROID 或 IOS */ + PUSH_DEVICE_PLATFORM_INVALID(60301, "error.60301"), + /** 设备记录不存在 */ + PUSH_DEVICE_NOT_FOUND(60302, "error.60302"), + /** 该设备已绑定当前用户 */ + PUSH_DEVICE_ALREADY_BOUND(60303, "error.60303"), + /** 推送发送失败 */ + PUSH_SEND_FAILED(60304, "error.60304"), + /** 推送配置未初始化 */ + PUSH_CONFIG_NOT_INITIALIZED(60305, "error.60305"), + /** 通话类型非法,仅支持 CALL_VOICE 或 CALL_VIDEO */ + PUSH_CALL_TYPE_INVALID(60306, "error.60306"), + /** 不能向自己发送来电推送 */ + PUSH_CALL_CANNOT_SELF(60307, "error.60307"); + + /** + * 错误码 + */ + private final int code; + + /** + * 国际化消息 key,格式为 {@code error.{code}},由 GlobalExceptionHandler 通过 MessageSource 解析 + */ + private final String message; + + ErrorCodeEnum(int code, String message) { + this.code = code; + this.message = message; + } + + /** + * 根据错误码查找枚举 + * + * @param code 错误码 + * @return 对应的ErrorCodeEnum枚举,未找到时返回INTERNAL_ERROR + */ + public static ErrorCodeEnum of(int code) { + for (ErrorCodeEnum errorCode : values()) { + if (errorCode.code == code) { + return errorCode; + } + } + return INTERNAL_ERROR; + } +} diff --git a/src/main/java/com/tailbet/common/exception/BusinessException.java b/src/main/java/com/tailbet/common/exception/BusinessException.java new file mode 100644 index 0000000..04c0a0d --- /dev/null +++ b/src/main/java/com/tailbet/common/exception/BusinessException.java @@ -0,0 +1,113 @@ +package com.tailbet.common.exception; + +import com.tailbet.common.enums.ErrorCodeEnum; +import lombok.Getter; + +/** + * 业务异常 + *

+ * 封装业务逻辑层面的异常,区别于系统运行时异常。 + * 业务异常通常有明确的错误码和提示信息,可直接返回给前端展示。 + * 使用时优先使用预定义的错误码枚举,也支持自定义消息。 + *

+ * + * @author socialapp团队 + * @since 1.0.0 + */ +@Getter +public class BusinessException extends RuntimeException { + + /** + * 错误码 + */ + private final int code; + + /** + * 附加数据(如当前场次信息等) + */ + private final Object data; + + /** + * 基于错误码枚举构造业务异常 + * + * @param errorCode 错误码枚举 + */ + public BusinessException(ErrorCodeEnum errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + this.data = null; + } + + /** + * 基于错误码枚举和自定义消息构造业务异常 + * + * @param errorCode 错误码枚举 + * @param message 自定义错误消息 + */ + public BusinessException(ErrorCodeEnum errorCode, String message) { + super(message); + this.code = errorCode.getCode(); + this.data = null; + } + + /** + * 基于错误码枚举、自定义消息和原因构造业务异常 + * + * @param errorCode 错误码枚举 + * @param message 自定义错误消息 + * @param cause 原始异常 + */ + public BusinessException(ErrorCodeEnum errorCode, String message, Throwable cause) { + super(message, cause); + this.code = errorCode.getCode(); + this.data = null; + } + + /** + * 基于状态码和自定义消息构造业务异常 + * + * @param code 状态码 + * @param message 错误消息 + */ + public BusinessException(int code, String message) { + super(message); + this.code = code; + this.data = null; + } + + /** + * 基于自定义消息构造业务异常(默认错误码 INTERNAL_ERROR) + * + * @param message 错误消息 + */ + public BusinessException(String message) { + super(message); + this.code = ErrorCodeEnum.INTERNAL_ERROR.getCode(); + this.data = null; + } + + /** + * 基于错误码枚举和附加数据构造业务异常 + *

+ * 用于需要向前端返回额外数据的异常场景, + * 如 LIVE_ALREADY_ACTIVE 时携带已有场次信息。 + *

+ * + * @param errorCode 错误码枚举 + * @param data 附加数据 + */ + public BusinessException(ErrorCodeEnum errorCode, Object data) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + this.data = data; + } + + /** + * 获取对应的错误码枚举 + * + * @return 错误码枚举 + */ + public ErrorCodeEnum getErrorCodeEnum() { + return ErrorCodeEnum.of(this.code); + } +} diff --git a/src/main/java/com/tailbet/config/GlobalExceptionHandler.java b/src/main/java/com/tailbet/config/GlobalExceptionHandler.java index a94f6f6..faae675 100644 --- a/src/main/java/com/tailbet/config/GlobalExceptionHandler.java +++ b/src/main/java/com/tailbet/config/GlobalExceptionHandler.java @@ -1,26 +1,208 @@ package com.tailbet.config; + +import com.tailbet.common.enums.ErrorCodeEnum; +import com.tailbet.common.exception.BusinessException; import com.tailbet.model.vo.R; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.util.StringUtils; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.Locale; +import java.util.stream.Collectors; /** - * 全局异常处理 + * 全局异常处理器 + *

+ * 统一捕获并处理Controller层抛出的各类异常,将异常转换为统一的Result响应格式。 + * 处理优先级:具体异常 > 通用异常 > 兜底异常。 + * 支持国际化:根据请求头 Accept-Language 自动返回对应语言的错误信息。 + *

+ * + * @author socialapp团队 + * @since 1.0.0 */ @Slf4j @RestControllerAdvice +@RequiredArgsConstructor public class GlobalExceptionHandler { - @ExceptionHandler(RuntimeException.class) - public R handleRuntimeException(RuntimeException e) { - log.error("业务异常: {}", e.getMessage()); - return R.fail(e.getMessage()); + /** + * 国际化消息源,用于将消息 key 解析为对应语言的文本 + */ + private final MessageSource messageSource; + + + /** + * 处理业务异常(最高优先级) + * + * @param e 业务异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(BusinessException.class) + @SuppressWarnings({"unchecked", "rawtypes"}) + public R handleBusinessException(BusinessException e, HttpServletRequest request) { + // 业务异常的 message 可能是 key,也可能是业务自定义文本,统一走 resolveMessage 处理 + String message = e.getMessage(); + log.warn("【业务异常】URI: {}, 错误码: {}, 消息: {}", + request.getRequestURI(), e.getCode(), message); + if (e.getData() != null) { + R r = R.fail(e.getCode(), message); + r.setData(e.getData()); + return r; + } + return R.fail(e.getCode(), message); } + /** + * 处理参数校验异常(@Valid / @RequestBody) + * + * @param e 方法参数校验异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public R handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpServletRequest request) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + log.warn("【参数校验失败】URI: {}, 错误: {}", request.getRequestURI(), message); + // 参数校验失败的 message 来自注解,直接使用,不走国际化 key 解析 + return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message); + } + + /** + * 处理参数绑定异常(@ModelAttribute) + * + * @param e 参数绑定异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(BindException.class) + public R handleBindException(BindException e, HttpServletRequest request) { + String message = e.getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + log.warn("【参数绑定失败】URI: {}, 错误: {}", request.getRequestURI(), message); + return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message); + } + + /** + * 处理约束校验异常(@Validated 方法级别) + * + * @param e 约束校验异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(ConstraintViolationException.class) + public R handleConstraintViolation(ConstraintViolationException e, HttpServletRequest request) { + String message = e.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + log.warn("【约束校验失败】URI: {}, 错误: {}", request.getRequestURI(), message); + return R.fail(ErrorCodeEnum.PARAM_VALIDATE_ERROR, message); + } + + /** + * 处理缺少请求参数异常 + * + * @param e 缺少参数异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public R handleMissingParam(MissingServletRequestParameterException e, HttpServletRequest request) { + // 缺少参数的提示信息拼接参数名,不走国际化 key + String message = e.getMessage() + + ": " + e.getParameterName(); + log.warn("【缺少参数】URI: {}, 错误: {}", request.getRequestURI(), message); + return R.fail(ErrorCodeEnum.PARAM_ERROR, message); + } + + /** + * 处理请求体不可读异常(请求体缺失、格式错误、缺少必填字段等) + * + * @param e 请求体不可读异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public R handleHttpMessageNotReadable(HttpMessageNotReadableException e, HttpServletRequest request) { + // 请求体格式错误,使用国际化后的错误码消息 + String message = e.getMessage(); + log.warn("【请求体不可读】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage()); + return R.fail(ErrorCodeEnum.PARAM_FORMAT_ERROR, message); + } + + /** + * 处理参数类型不匹配异常 + * + * @param e 参数类型不匹配异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public R handleTypeMismatch(MethodArgumentTypeMismatchException e, HttpServletRequest request) { + String message = String.format("参数[%s]类型错误,期望类型: %s", + e.getName(), e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "未知"); + log.warn("【参数类型错误】URI: {}, 错误: {}", request.getRequestURI(), message); + return R.fail(ErrorCodeEnum.PARAM_FORMAT_ERROR, message); + } + + /** + * 处理非法参数异常 + * + * @param e 非法参数异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(IllegalArgumentException.class) + public R handleIllegalArgument(IllegalArgumentException e, HttpServletRequest request) { + log.warn("【非法参数】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage()); + return R.fail(ErrorCodeEnum.PARAM_ERROR, e.getMessage()); + } + + /** + * 处理非法状态异常 + * + * @param e 非法状态异常 + * @param request HTTP请求 + * @return 统一错误响应 + */ + @ExceptionHandler(IllegalStateException.class) + public R handleIllegalState(IllegalStateException e, HttpServletRequest request) { + log.warn("【非法状态】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage()); + return R.fail(ErrorCodeEnum.OPERATION_NOT_ALLOWED, e.getMessage()); + } + + /** + * 兜底异常处理器(最低优先级) + *

+ * 捕获所有未被前面处理器捕获的异常,避免异常信息直接暴露给客户端。 + *

+ * + * @param e 异常对象 + * @param request HTTP请求 + * @return 统一错误响应 + */ @ExceptionHandler(Exception.class) - public R handleException(Exception e) { - log.error("系统异常: {}", e.getMessage(), e); - return R.fail("系统错误: " + e.getMessage()); + public R handleException(Exception e, HttpServletRequest request) { + log.error("【系统异常】URI: {}, 错误类型: {}, 消息: {}", + request.getRequestURI(), e.getClass().getName(), e.getMessage(), e); + // 兜底异常使用国际化后的系统繁忙提示 + return R.fail(ErrorCodeEnum.INTERNAL_ERROR.getCode(), e.getMessage()); } } diff --git a/src/main/java/com/tailbet/config/OpenIMConfig.java b/src/main/java/com/tailbet/config/OpenIMConfig.java deleted file mode 100644 index 589c301..0000000 --- a/src/main/java/com/tailbet/config/OpenIMConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.tailbet.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -/** - * OpenIM配置 - */ -@Data -@Component -@ConfigurationProperties(prefix = "openim") -public class OpenIMConfig { - - /** - * OpenIM API地址 - */ - private String apiUrl; - - /** - * OpenIM WebSocket地址 - */ - private String wsUrl; - - /** - * 管理员账号 - */ - private String adminUser; - - /** - * 管理员Token - */ - private String adminToken; -} diff --git a/src/main/java/com/tailbet/config/OpenIMProperties.java b/src/main/java/com/tailbet/config/OpenIMProperties.java new file mode 100644 index 0000000..267576a --- /dev/null +++ b/src/main/java/com/tailbet/config/OpenIMProperties.java @@ -0,0 +1,144 @@ +package com.tailbet.config; + +import cn.hutool.core.util.StrUtil; +import jakarta.annotation.PostConstruct; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * OpenIM 集成配置属性类 + *

+ * 从 Nacos/application.yml 中读取 OpenIM 第三方即时通讯服务相关配置属性, + * 对应配置前缀为 {@code openim}。OpenIM 是独立部署的外部第三方服务(非 Spring 微服务), + * 不参与 Nacos 服务发现,因此配置项中直接使用完整的 HTTP/WS 地址。 + *

+ * + * @author socialapp团队 + * @since 1.0.0 + */ +@Slf4j +@Data +@Component +@ConfigurationProperties(prefix = "openim") +public class OpenIMProperties { + + /** + * OpenIM REST API 外网地址,下发给客户端 {@code httpUrl}(如 https://im-api-pre.aimchat.im) + *

+ * 历史配置中亦可能同时作为服务端调用地址;配置了 {@link #internalApiUrl} 后仅用于下发。 + *

+ */ + private String apiUrl; + + /** + * OpenIM REST API 内网地址,服务端 {@code OpenIMApiClient} 调用(如 http://10.x.x.x:10002) + *

+ * 未配置时回落 {@link #apiUrl},兼容旧 Nacos。 + *

+ */ + private String internalApiUrl; + + /** + * OpenIM WebSocket 外网地址,下发给客户端用于 IM SDK 初始化(如 wss://im-ws-pre.aimchat.im) + */ + private String wsUrl; + + /** + * OpenIM 管理员密钥,用于调用管理接口换取 adminToken + * 生产环境通过 Nacos 敏感配置注入,禁止硬编码 + */ + private String secret; + + /** + * OpenIM 管理员用户 ID,默认 imAdmin + */ + private String adminUserId = "imAdmin"; + + /** + * 应用标识,用于 operationID 生成前缀 + */ + private String appId = "socialapp"; + + /** + * adminToken 有效期(秒),用于定时刷新任务计算刷新周期,默认 82800 秒(23小时) + */ + private int tokenExpireSeconds = 82800; + + /** + * 调用 OpenIM 接口的连接超时时间(毫秒),默认 5000 + */ + private int connectTimeoutMs = 5000; + + /** + * 调用 OpenIM 接口的读取超时时间(毫秒),默认 10000 + */ + private int readTimeoutMs = 10000; + + /** + * Webhook 签名密钥,用于校验 OpenIM 服务端回调请求的合法性(对应请求头 X-Webhook-Secret) + * 为空时跳过校验并输出警告日志 + */ + private String webhookSecret; + + /** + * 配置加载后的校验方法 + * 检查必填配置是否已从 Nacos 注入,未配置则输出警告日志 + */ + @PostConstruct + public void validate() { + if (StrUtil.isBlank(apiUrl)) { + log.warn("【OpenIM配置】apiUrl(外网下发 httpUrl)未配置,OpenIM 相关功能将不可用"); + } + if (StrUtil.isBlank(internalApiUrl)) { + log.warn("【OpenIM配置】internalApiUrl(内网调用)未配置,服务端将回落 apiUrl"); + } + if (StrUtil.isBlank(secret)) { + log.warn("【OpenIM配置】secret 未配置,管理员Token获取将失败"); + } + if (StrUtil.isBlank(wsUrl)) { + log.warn("【OpenIM配置】wsUrl(外网下发)未配置,客户端 SDK 可能无法初始化"); + } + if (StrUtil.isNotBlank(apiUrl)) { + log.info("【OpenIM配置】加载完成,外网apiUrl: {}, 内网internalApiUrl: {}, wsUrl: {}, adminUserId: {}, secret: {}", + apiUrl, + StrUtil.blankToDefault(internalApiUrl, "(回落 api-url)"), + wsUrl, + adminUserId, + maskString(secret)); + } + } + + /** + * 解析服务端调用 OpenIM REST 基址(优先内网) + * + * @return HTTP(S) 基址,末尾无斜杠约定由调用方拼接 path + */ + public String resolveServerApiUrl() { + return StrUtil.blankToDefault(internalApiUrl, apiUrl); + } + + /** + * 解析下发给客户端的 OpenIM HTTP API 地址(外网) + * + * @return 外网 api-url + */ + public String resolvePublicApiUrl() { + return apiUrl; + } + + /** + * 对敏感字符串进行脱敏处理 + * 只显示前3位和后3位,中间用星号代替 + * + * @param str 原始字符串 + * @return 脱敏后的字符串 + */ + private String maskString(String str) { + if (str == null || str.length() <= 6) { + return "***"; + } + return str.substring(0, 3) + "****" + str.substring(str.length() - 3); + } +} diff --git a/src/main/java/com/tailbet/model/vo/R.java b/src/main/java/com/tailbet/model/vo/R.java index 1978560..2ba0243 100644 --- a/src/main/java/com/tailbet/model/vo/R.java +++ b/src/main/java/com/tailbet/model/vo/R.java @@ -1,5 +1,6 @@ package com.tailbet.model.vo; +import com.tailbet.common.enums.ErrorCodeEnum; import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; @@ -49,6 +50,9 @@ public class R implements Serializable { public static R fail(int code, String msg) { return new R<>(code, msg, null); } + public static R fail(ErrorCodeEnum errorCode, String msg) { + return new R<>(errorCode.getCode(), msg, null); + } public boolean isSuccess() { return code == SUCCESS; diff --git a/src/main/java/com/tailbet/openim/OpenIMAdminTokenManager.java b/src/main/java/com/tailbet/openim/OpenIMAdminTokenManager.java new file mode 100644 index 0000000..128548e --- /dev/null +++ b/src/main/java/com/tailbet/openim/OpenIMAdminTokenManager.java @@ -0,0 +1,170 @@ +package com.tailbet.openim; + + +import com.tailbet.common.enums.ErrorCodeEnum; +import com.tailbet.common.exception.BusinessException; +import com.tailbet.config.OpenIMProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +/** + * OpenIM adminToken 管理器 + *

+ * 负责 OpenIM 管理员 Token 的获取与定期刷新:服务启动时通过 OpenIM + * {@code /auth/get_admin_token} 接口获取一次,之后每 23 小时自动刷新一次 + * (OpenIM 管理员 Token 默认 24 小时过期,提前 1 小时刷新以规避临界失效)。 + *

+ *

+ * 本类不依赖 {@link OpenIMApiClient},避免出现 + * OpenIMApiClient → OpenIMAdminTokenManager → OpenIMApiClient 的循环依赖, + * 内部直接使用专用 RestTemplate 调用 OpenIM 接口。 + *

+ * + * @author socialapp团队 + * @since 1.0.0 + */ +@RequiredArgsConstructor +@Slf4j +@Component +public class OpenIMAdminTokenManager { + + /** + * 定时刷新周期:23 小时(毫秒) + */ + private static final long REFRESH_INTERVAL_MS = 23 * 60 * 60 * 1000L; + + private final RestTemplate restTemplate; + + /** + * OpenIM 集成配置属性 + */ + private final OpenIMProperties openIMProperties; + + /** + * 当前有效的 adminToken,使用原子引用保证多线程读取的可见性与线程安全 + */ + private final AtomicReference tokenRef = new AtomicReference<>(""); + + /** + * 服务启动时初始化 adminToken + *

+ * 若启动时获取失败,仅记录错误日志,不阻塞服务启动; + * 后续定时任务会持续尝试刷新,业务调用方在 adminToken 为空时会因鉴权失败而收到明确异常。 + *

+ */ + @PostConstruct + public void init() { + try { + refreshToken(); + } catch (Exception e) { + log.error("【OpenIM】服务启动时获取 adminToken 失败: {}", e.getMessage(), e); + } + } + + /** + * 每 23 小时自动刷新 adminToken(避免 24 小时过期) + */ + @Scheduled(fixedDelay = REFRESH_INTERVAL_MS) + public void scheduledRefresh() { + try { + refreshToken(); + } catch (Exception e) { + log.error("【OpenIM】定时刷新 adminToken 失败: {}", e.getMessage(), e); + } + } + + /** + * 获取当前有效的 adminToken + *

+ * 若 token 尚未初始化(null 或空),则尝试立即刷新一次,防止 NPE 及空 token 导致的鉴权失败。 + *

+ * + * @return adminToken 字符串,刷新失败时返回空字符串 + */ + public String getAdminToken() { + String token = tokenRef.get(); + if (token == null || token.isEmpty()) { + log.warn("【OpenIM】adminToken 尚未初始化,尝试立即刷新"); + refreshToken(); + token = tokenRef.get(); + } + return token != null ? token : ""; + } + + /** + * 调用 OpenIM {@code /auth/get_admin_token} 接口刷新 adminToken + *

+ * 获取 adminToken 属于鉴权前置操作,请求本身不能携带 adminToken 请求头。 + * 方法加锁,避免同进程内并发刷新互相顶掉(OpenIM 同 user 新 token 会使旧 token 变为 TokenKicked)。 + *

+ */ + public synchronized void refreshToken() { + String url = openIMProperties.resolveServerApiUrl() + "/auth/get_admin_token"; + + Map body = new HashMap<>(); + body.put("secret", openIMProperties.getSecret()); + body.put("userID", openIMProperties.getAdminUserId()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("operationID", UUID.randomUUID().toString()); + HttpEntity> entity = new HttpEntity<>(body, headers); + + OpenIMApiClient.OpenIMBaseResp resp; + try { + ResponseEntity response = + restTemplate.postForEntity(url, entity, OpenIMApiClient.OpenIMBaseResp.class); + resp = response.getBody(); + } catch (org.springframework.web.client.HttpStatusCodeException e) { + // OpenIM 偶发以 HTTP 4xx + 业务 JSON 返回,尽量解析 errMsg + String raw = e.getResponseBodyAsString(); + log.error("【OpenIM】获取 adminToken HTTP 失败: status={}, body={}", e.getStatusCode().value(), raw); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED, + raw != null && !raw.isBlank() ? raw : e.getMessage()); + } + + if (resp == null || resp.getErrCode() != 0) { + String errMsg = resp != null ? resp.getErrMsg() : "响应为空"; + log.error("【OpenIM】获取 adminToken 失败: errMsg={}", errMsg); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED, errMsg); + } + + @SuppressWarnings("unchecked") + Map data = (Map) resp.getData(); + String newToken = data != null ? (String) data.get("token") : null; + if (newToken == null) { + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_ADMIN_TOKEN_FAILED, "响应中缺少 token 字段"); + } + tokenRef.set(newToken); + log.info("【OpenIM】adminToken 刷新成功: token={}", maskString(newToken)); + } + + /** + * 对敏感字符串进行脱敏处理 + * 只显示前3位和后3位,中间用星号代替 + * + * @param str 原始字符串 + * @return 脱敏后的字符串 + */ + private String maskString(String str) { + if (str == null || str.length() <= 6) { + return "***"; + } + return str.substring(0, 3) + "****" + str.substring(str.length() - 3); + } +} diff --git a/src/main/java/com/tailbet/openim/OpenIMApiClient.java b/src/main/java/com/tailbet/openim/OpenIMApiClient.java new file mode 100644 index 0000000..dd4b799 --- /dev/null +++ b/src/main/java/com/tailbet/openim/OpenIMApiClient.java @@ -0,0 +1,1876 @@ +package com.tailbet.openim; + + +import com.tailbet.common.constant.OpenImConstants; +import com.tailbet.common.enums.ErrorCodeEnum; +import com.tailbet.common.exception.BusinessException; +import com.tailbet.config.OpenIMProperties; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * OpenIM REST API 客户端 + *

+ * 封装所有对 OpenIM Server 的 HTTP 调用,包括用户注册/更新、Token 获取、 + * 群组创建/解散/成员管理、好友关系管理、服务端代发消息等。使用 {@link RestTemplate} + * 而非 Feign 调用(OpenIM 为外部第三方服务,不参与 Nacos 服务发现), + * 所有管理类请求均自动携带由 {@link OpenIMAdminTokenManager} 维护的 adminToken 请求头。 + *

+ *

+ * ⚠️ 关键约束:{@link #getUserToken(String, int)} 必须调用 OpenIM + * {@code /auth/user_token} 接口获取真实 Token,禁止业务侧本地签发 JWT 冒充 OpenIM Token。 + *

+ *

+ * 注意:所有接口路径以锁定的 OpenIM Server 版本文档为准,本类中的路径为示意, + * 实施前须对照目标版本 REST 文档核实。 + *

+ * + * @author socialapp团队 + * @since 1.0.0 + */ +@Slf4j +@Component +public class OpenIMApiClient { + + /** + * 群组类型:普通群 + */ + // OpenIM groupType: 1=私有群(Private), 2=工作群(Work/Normal), 3=公开群(Public), 4=通知群(Notification) + // 迁移普通群使用 2(工作群),与官方文档示例一致 + private static final int GROUP_TYPE_NORMAL = 2; + + /** + * 群组类型:直播超大群 + */ + private static final int GROUP_TYPE_LIVE = 2; + + /** + * 好友添加来源:搜索添加(以 OpenIM 枚举为准) + */ + private static final int FRIEND_ADD_SOURCE_SEARCH = 2; + + /** + * OpenIM 好友申请处理结果:同意 + */ + public static final int FRIEND_RESPONSE_AGREE = 1; + + /** + * OpenIM 好友申请处理结果:拒绝 + */ + public static final int FRIEND_RESPONSE_REFUSE = -1; + + /** + * 当前 OpenIM 实例是否支持 {@code /conversation/delete_conversations}。 + *

+ * 现网 {@code openim/openim-server:v3.8.3-patch.12} 的 {@code internal/api/router.go} + * conversation 路由组无此接口(官方在更晚版本才加入),调用必为 HTTP 404。 + * 默认 false,避免误调;若后续升级到带该路由的版本,可改为 true 或做启动探测。 + *

+ */ + private final AtomicBoolean deleteConversationsSupported = new AtomicBoolean(false); + + /** + * OpenIM 专用 RestTemplate + */ + @Resource(name = "openIMRestTemplate") + private RestTemplate restTemplate; + + /** + * JSON 工具(合并 OpenIM ex 字段) + */ + @Resource + private ObjectMapper objectMapper; + + /** + * OpenIM 集成配置属性 + */ + @Resource + private OpenIMProperties openIMProperties; + + /** + * OpenIM 管理员 Token 管理器 + */ + @Resource + private OpenIMAdminTokenManager adminTokenManager; + + // ==================== 用户相关 ==================== + + /** + * 注册用户到 OpenIM + * 对应接口:POST /user/user_register + * + * @param userID 业务用户 ID(转为字符串) + * @param nickname 用户昵称 + * @param faceURL 用户头像 URL + */ + public void userRegister(String userID, String nickname, String faceURL) { + String url = openIMProperties.resolveServerApiUrl() + "/user/user_register"; + + Map userInfo = new HashMap<>(); + userInfo.put("userID", userID); + userInfo.put("nickname", nickname); + userInfo.put("faceURL", faceURL != null ? faceURL : ""); + + Map body = new HashMap<>(); + body.put("users", Collections.singletonList(userInfo)); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + // errCode=1102(RegisteredAlreadyError):用户已存在,视为成功,不抛异常 + if (resp.getErrCode() == 1102 + || (resp.getErrMsg() != null && resp.getErrMsg().contains("RegisteredAlreadyError"))) { + log.debug("【OpenIM】注册用户:用户已存在跳过: userID={}", userID); + return; + } + log.error("【OpenIM】注册用户失败: userID={}, errCode={}, errMsg={}", + userID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_USER_REGISTER_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】注册用户成功: userID={}", userID); + } + + /** + * 更新 OpenIM 用户信息(昵称/头像) + * 对应接口:POST /user/update_user_info + *

+ * 用户信息同步属于非关键路径,失败仅记录日志不抛异常,避免阻塞主业务流程。 + *

+ * + * @param userID 业务用户 ID + * @param nickname 新昵称(null 则不更新) + * @param faceURL 新头像 URL(null 则不更新) + */ + public void userUpdate(String userID, String nickname, String faceURL) { + String url = openIMProperties.resolveServerApiUrl() + "/user/update_user_info"; + + Map userInfo = new HashMap<>(); + userInfo.put("userID", userID); + if (nickname != null) { + userInfo.put("nickname", nickname); + } + if (faceURL != null) { + userInfo.put("faceURL", faceURL); + } + + Map body = new HashMap<>(); + body.put("userInfo", userInfo); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.warn("【OpenIM】更新用户信息失败(非关键路径): userID={}, errCode={}, errMsg={}", + userID, resp.getErrCode(), resp.getErrMsg()); + } else { + log.debug("【OpenIM】更新用户信息成功: userID={}", userID); + } + } + + /** + * 获取用户的 OpenIM Token + * 对应接口:POST /auth/get_user_token + *

+ * ⚠️ 注意:经实际 HTTP 测试(2026-07-15),该 OpenIM 实例的正确接口路径为 /auth/get_user_token, + * 而非 OpenIM v3.8 源码(pkg.go.dev e2e 测试)中的 /auth/user_token(该路径在此实例返回 HTTP 404)。 + * 命名规律与 /group/kick_group_member → /group/kick_group 一致,说明该实例为 v3.7.x 或自研魔改版。 + *

+ *

+ * ⚠️ 重要:必须调用此接口,禁止业务侧本地签发 JWT 冒充 OpenIM Token。 + *

+ * + * @param userID 业务用户 ID + * @param platformID 平台 ID(以 OpenIM 官方枚举为准) + * @return OpenIM Token 字符串 + */ + public String getUserToken(String userID, int platformID) { + String url = openIMProperties.resolveServerApiUrl() + "/auth/get_user_token"; + + Map body = new HashMap<>(); + body.put("userID", userID); + body.put("platformID", platformID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】获取用户 Token 失败: userID={}, errCode={}, errMsg={}", + userID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_TOKEN_FAILED, resp.getErrMsg()); + } + + @SuppressWarnings("unchecked") + Map data = (Map) resp.getData(); + if (data == null || !data.containsKey("token")) { + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_GET_TOKEN_FAILED, "响应中缺少 token 字段"); + } + return (String) data.get("token"); + } + + /** + * 强制用户从指定终端下线 + * 对应接口:POST /auth/force_logout + *

+ * 客户端 SDK 会收到 onKickedOffline 回调。路径以 OpenIM 官方文档为准; + * 若当前环境返回 404,需对照实例版本核实后调整。 + *

+ * + * @param userID 业务用户 ID(字符串) + * @param platformID 终端平台 ID(1=iOS,2=Android) + */ + public void forceLogout(String userID, int platformID) { + String url = openIMProperties.resolveServerApiUrl() + "/auth/force_logout"; + + Map body = new HashMap<>(); + body.put("userID", userID); + body.put("platformID", platformID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.warn("【OpenIM】强制下线失败: userID={}, platformID={}, errCode={}, errMsg={}", + userID, platformID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_FORCE_LOGOUT_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】强制下线成功: userID={}, platformID={}", userID, platformID); + } + + /** + * 批量注册用户到 OpenIM(迁移专用) + * 对应接口:POST /user/user_register + *

+ * 单次最多支持 100 条,调用方需自行分批。 + *

+ * + * @param users 用户列表,每个 Map 包含 userID、nickname、faceURL + */ + public void userRegisterBatch(List> users) { + String url = openIMProperties.resolveServerApiUrl() + "/user/user_register"; + + Map body = new LinkedHashMap<>(); + body.put("users", users); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + // errCode=1102(RegisteredAlreadyError):批量中有用户已存在导致整批失败。 + // 此时不能直接 return(其余用户未注册),必须降级为逐条注册,已存在的跳过。 + if (resp.getErrCode() == 1102 + || (resp.getErrMsg() != null && resp.getErrMsg().contains("RegisteredAlreadyError"))) { + log.warn("【OpenIM】批量注册遇到已存在用户,降级为逐条注册: count={}", users.size()); + userRegisterOneByOne(users); + return; + } + log.error("【OpenIM】批量注册用户失败: count={}, errCode={}, errMsg={}", + users.size(), resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_USER_REGISTER_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】批量注册用户成功,数量: {}", users.size()); + } + + /** + * 逐条注册用户(userRegisterBatch 降级专用) + *

+ * 当批量注册遇到 RegisteredAlreadyError 时调用,逐条注册以确保其余用户不被漏掉。 + * 单条已存在(errCode=1102)时跳过,其他错误记录日志后继续。 + *

+ * + * @param users 用户列表,每个 Map 包含 userID、nickname、faceURL + */ + private void userRegisterOneByOne(List> users) { + String url = openIMProperties.resolveServerApiUrl() + "/user/user_register"; + int successCount = 0; + int skipCount = 0; + int failCount = 0; + for (Map user : users) { + Map body = new LinkedHashMap<>(); + body.put("users", Collections.singletonList(user)); + try { + OpenIMBaseResp resp = doPost(url, body); + if (isSuccess(resp)) { + successCount++; + } else if (resp.getErrCode() == 1102 + || (resp.getErrMsg() != null && resp.getErrMsg().contains("RegisteredAlreadyError"))) { + // 该用户已存在,跳过 + skipCount++; + log.debug("【OpenIM】逐条注册:用户已存在跳过: userID={}", user.get("userID")); + } else { + failCount++; + log.error("【OpenIM】逐条注册失败: userID={}, errCode={}, errMsg={}", + user.get("userID"), resp.getErrCode(), resp.getErrMsg()); + } + } catch (Exception e) { + failCount++; + log.error("【OpenIM】逐条注册异常: userID={}, error={}", user.get("userID"), e.getMessage()); + } + } + log.info("【OpenIM】逐条注册完成: success={}, skip(已存在)={}, fail={}", successCount, skipCount, failCount); + } + + /** + * 批量导入好友关系(迁移专用,直接建立关系,不走申请流程) + * 对应接口:POST /friend/import_friend + * + * @param ownerUserID 关系拥有者 + * @param friendUserIDs 好友ID列表 + */ + public void importFriend(String ownerUserID, List friendUserIDs) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/import_friend"; + + Map body = new LinkedHashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("friendUserIDs", friendUserIDs); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // RecordNotFoundError(errCode=1004):friendUserIDs 中有用户不存在于 OpenIM,整批失败。 + // E11000 duplicate key(errCode=500):好友关系已存在(重跑迁移时触发)。 + // 两种情况都需要降级为逐条导入,逐条跳过不存在或已存在的好友,确保其余好友不被漏掉。 + if (resp.getErrCode() == 1004 || errMsg.contains("RecordNotFoundError") + || (resp.getErrCode() == 500 && errMsg.contains("duplicate key"))) { + log.warn("【OpenIM】批量导入好友需降级逐条处理: ownerUserID={}, count={}, errCode={}, reason={}", + ownerUserID, friendUserIDs.size(), resp.getErrCode(), + errMsg.contains("duplicate key") ? "好友关系已存在" : "好友不存在"); + importFriendOneByOne(ownerUserID, friendUserIDs); + return; + } + log.error("【OpenIM】导入好友关系失败: ownerUserID={}, count={}, errCode={}, errMsg={}", + ownerUserID, friendUserIDs.size(), resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_ADD_FRIEND_FAILED, resp.getErrMsg()); + } + log.debug("【OpenIM】导入好友关系成功,ownerUserID={}, count={}", ownerUserID, friendUserIDs.size()); + } + + /** + * 逐条导入好友关系(importFriend 降级专用) + *

+ * 当批量导入遇到 RecordNotFoundError 时调用,逐条导入以确保其余好友不被漏掉。 + * 单条不存在(errCode=1004)时跳过,其他错误记录日志后继续。 + *

+ * + * @param ownerUserID 关系拥有者 + * @param friendUserIDs 好友 ID 列表 + */ + private void importFriendOneByOne(String ownerUserID, List friendUserIDs) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/import_friend"; + int successCount = 0; + int skipCount = 0; + int failCount = 0; + for (String friendUserID : friendUserIDs) { + Map body = new LinkedHashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("friendUserIDs", Collections.singletonList(friendUserID)); + try { + OpenIMBaseResp resp = doPost(url, body); + if (isSuccess(resp)) { + successCount++; + } else { + String singleErrMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + if (resp.getErrCode() == 1004 || singleErrMsg.contains("RecordNotFoundError")) { + // 该好友不存在于 OpenIM,跳过(等该好友登录时会触发兜底注册) + skipCount++; + log.debug("【OpenIM】逐条导入好友:好友不存在跳过: ownerUserID={}, friendUserID={}", + ownerUserID, friendUserID); + } else if (resp.getErrCode() == 500 && singleErrMsg.contains("duplicate key")) { + // 好友关系已存在(重跑迁移),视为成功跳过 + skipCount++; + log.debug("【OpenIM】逐条导入好友:好友关系已存在跳过: ownerUserID={}, friendUserID={}", + ownerUserID, friendUserID); + } else { + failCount++; + log.error("【OpenIM】逐条导入好友失败: ownerUserID={}, friendUserID={}, errCode={}, errMsg={}", + ownerUserID, friendUserID, resp.getErrCode(), resp.getErrMsg()); + } + } + } catch (Exception e) { + failCount++; + log.error("【OpenIM】逐条导入好友异常: ownerUserID={}, friendUserID={}, error={}", + ownerUserID, friendUserID, e.getMessage()); + } + } + log.info("【OpenIM】逐条导入好友完成: ownerUserID={}, success={}, skip(不存在)={}, fail={}", + ownerUserID, successCount, skipCount, failCount); + } + + // ==================== 群组相关 ==================== + + /** + * 创建群组 + * 对应接口:POST /group/create_group + * + * @param groupID 群组 ID(使用业务侧 im_groups.id 的字符串形式) + * @param groupName 群组名称 + * @param ownerUserID 群主用户 ID + * @param memberUserIDs 初始成员 ID 列表(不含群主) + */ + public void createGroup(String groupID, String groupName, String ownerUserID, List memberUserIDs) { + createGroupInternal(groupID, groupName, ownerUserID, memberUserIDs, GROUP_TYPE_NORMAL, null, null); + } + + /** + * 创建群组(可带头像与简介) + * + * @param groupID 群组 ID + * @param groupName 群组名称 + * @param ownerUserID 群主用户 ID + * @param memberUserIDs 初始成员 ID 列表(不含群主) + * @param faceURL 群头像 URL,可为 null + * @param introduction 群简介,可为 null + */ + public void createGroup(String groupID, String groupName, String ownerUserID, List memberUserIDs, + String faceURL, String introduction) { + createGroupInternal(groupID, groupName, ownerUserID, memberUserIDs, GROUP_TYPE_NORMAL, faceURL, introduction); + } + + /** + * 创建直播群(超大群) + * 对应接口:POST /group/create_group + * + * @param groupID 直播群 ID(建议格式:live_group_{liveRoomId}) + * @param groupName 直播间标题 + * @param ownerUserID 主播用户 ID + */ + public void createLiveGroup(String groupID, String groupName, String ownerUserID) { + createGroupInternal(groupID, groupName, ownerUserID, Collections.emptyList(), GROUP_TYPE_LIVE, null, null); + } + + /** + * 创建群组的内部实现,供普通群与直播群共用 + *

+ * 优先使用群主的用户 token 发起请求,使 OpenIM 群通知显示群主为操作人。 + * 若获取群主 token 失败,则降级使用 adminToken。 + *

+ * + * @param groupID 群组 ID + * @param groupName 群组名称 + * @param ownerUserID 群主用户 ID + * @param memberUserIDs 初始成员 ID 列表(不含群主) + * @param groupType 群组类型(1=普通群,2=超大群) + * @param faceURL 群头像 URL,可为 null + * @param introduction 群简介,可为 null + */ + private void createGroupInternal(String groupID, String groupName, String ownerUserID, + List memberUserIDs, int groupType, + String faceURL, String introduction) { + String url = openIMProperties.resolveServerApiUrl() + "/group/create_group"; + + // groupInfo 只包含群基本信息,ownerUserID 在顶层(官方文档要求) + Map groupInfo = new HashMap<>(); + groupInfo.put("groupID", groupID); + groupInfo.put("groupName", groupName); + groupInfo.put("groupType", groupType); + if (faceURL != null && !faceURL.isBlank()) { + groupInfo.put("faceURL", faceURL); + } + if (introduction != null && !introduction.isBlank()) { + groupInfo.put("introduction", introduction); + } + + // memberUserIDs 是顶层字符串数组,不含群主(官方文档要求) + Map body = new HashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("groupInfo", groupInfo); + body.put("memberUserIDs", memberUserIDs); + + // 优先使用群主的用户 token,使 OpenIM 群通知显示群主为操作人(platformID=5 与 App 端隔离) + OpenIMBaseResp resp; + try { + String ownerToken = getUserToken(ownerUserID, OpenImConstants.PLATFORM_ID_SERVER_WEB); + resp = doPostWithUserToken(url, body, ownerToken); + if (isTokenAuthError(resp)) { + log.warn("【OpenIM】群主 token 鉴权失败,降级 adminToken 创建群组: groupID={}, ownerUserID={}, errCode={}, errMsg={}", + groupID, ownerUserID, resp.getErrCode(), resp.getErrMsg()); + resp = doPost(url, body); + } + } catch (Exception e) { + // 获取群主 token 失败,降级使用 adminToken + log.warn("【OpenIM】获取群主 token 失败,降级使用 adminToken 创建群组: groupID={}, ownerUserID={}, err={}", + groupID, ownerUserID, e.getMessage()); + resp = doPost(url, body); + } + + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 群组已存在(重跑迁移时触发),视为成功,不抛异常 + // errCode=1202(GroupIDExisted):OpenIM 3.8 群组已存在的标准错误码 + // errCode=1004 + "already exist"/"GroupIDExisted":旧版本 OpenIM 的错误码 + if (resp.getErrCode() == 1202 + || errMsg.contains("GroupIDExisted") + || (resp.getErrCode() == 1004 && errMsg.contains("already exist")) + || errMsg.contains("group already exists")) { + log.debug("【OpenIM】创建群组:群组已存在跳过: groupID={}", groupID); + return; + } + log.error("【OpenIM】创建群组失败: groupID={}, groupType={}, errCode={}, errMsg={}", + groupID, groupType, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_CREATE_GROUP_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】创建群组成功: groupID={}, groupType={}", groupID, groupType); + } + + /** + * 解散群组 + * 对应接口:POST /group/dismiss_group + * + * @param groupID 群组 ID + */ + public void dismissGroup(String groupID) { + String url = openIMProperties.resolveServerApiUrl() + "/group/dismiss_group"; + + Map body = new HashMap<>(); + body.put("groupID", groupID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 群组不存在或已解散(重复解散时触发),视为成功,不抛异常 + // errCode=1004(RecordNotFoundError):群组不存在 + // errCode=1204(DismissedAlreadyError):群组已解散 + if (resp.getErrCode() == 1004 + || resp.getErrCode() == 1204 + || errMsg.contains("RecordNotFoundError") + || errMsg.contains("DismissedAlreadyError") + || errMsg.contains("not found")) { + log.debug("【OpenIM】解散群组:群组不存在或已解散跳过: groupID={}, errCode={}", groupID, resp.getErrCode()); + return; + } + log.error("【OpenIM】解散群组失败: groupID={}, errCode={}, errMsg={}", + groupID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_DISMISS_GROUP_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】解散群组成功: groupID={}", groupID); + } + + /** + * 邀请用户入群(使用 adminToken,适用于不需要指定操作人的场景) + * 对应接口:POST /group/invite_user_to_group + * + * @param groupID 群组 ID + * @param userIDs 要邀请的用户 ID 列表 + */ + public void inviteUserToGroup(String groupID, List userIDs) { + doInviteUserToGroup(groupID, userIDs, null); + } + + /** + * 邀请用户入群(使用指定操作人的用户 token,使 OpenIM 群通知显示真实操作人) + * 对应接口:POST /group/invite_user_to_group + *

+ * 优先使用 operatorUserID 的用户 token 发起请求,使 OpenIM 群通知显示真实操作人。 + * 若获取 token 失败,则降级使用 adminToken。 + *

+ * + * @param groupID 群组 ID + * @param userIDs 要邀请的用户 ID 列表 + * @param operatorUserID 操作人用户 ID + */ + public void inviteUserToGroup(String groupID, List userIDs, String operatorUserID) { + doInviteUserToGroup(groupID, userIDs, operatorUserID); + } + + /** + * 邀请用户入群的内部实现 + * + * @param groupID 群组 ID + * @param userIDs 要邀请的用户 ID 列表 + * @param operatorUserID 操作人用户 ID,为 null 时使用 adminToken + */ + private void doInviteUserToGroup(String groupID, List userIDs, String operatorUserID) { + String url = openIMProperties.resolveServerApiUrl() + "/group/invite_user_to_group"; + + Map body = new HashMap<>(); + body.put("groupID", groupID); + body.put("invitedUserIDs", userIDs); + body.put("reason", "系统邀请入群"); + + // 有操作人时优先用其 Web 平台 token;TokenKicked 时降级 adminToken(会议弹幕并发入会易触发) + OpenIMBaseResp resp; + if (operatorUserID != null) { + try { + String operatorToken = getUserToken(operatorUserID, OpenImConstants.PLATFORM_ID_SERVER_WEB); + resp = doPostWithUserToken(url, body, operatorToken); + if (isTokenAuthError(resp)) { + log.warn("【OpenIM】操作人 token 鉴权失败,降级 adminToken 邀请入群: groupID={}, operatorUserID={}, errCode={}, errMsg={}", + groupID, operatorUserID, resp.getErrCode(), resp.getErrMsg()); + resp = doPost(url, body); + } + } catch (Exception e) { + // 获取操作人 token 失败,降级使用 adminToken + log.warn("【OpenIM】获取操作人 token 失败,降级使用 adminToken 邀请入群: groupID={}, operatorUserID={}, err={}", + groupID, operatorUserID, e.getMessage()); + resp = doPost(url, body); + } + } else { + resp = doPost(url, body); + } + + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 成员已在群中(重跑迁移/兜底时触发),视为成功,不抛异常 + // OpenIM 通常返回 errCode=1004 且 errMsg 含 "already in group" 或 duplicate key + if ((resp.getErrCode() == 1004 && errMsg.contains("already in group")) + || (resp.getErrCode() == 500 && errMsg.contains("duplicate key"))) { + log.debug("【OpenIM】邀请入群:成员已在群中跳过: groupID={}", groupID); + return; + } + log.error("【OpenIM】邀请入群失败: groupID={}, userIDs={}, errCode={}, errMsg={}", + groupID, userIDs, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_INVITE_GROUP_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】邀请入群成功: groupID={}, userCount={}", groupID, userIDs.size()); + } + + /** + * 踢出群成员 + *

+ * ⚠️ 注意:经实际 HTTP 测试(2026-07-14),该 OpenIM 实例的正确接口路径为 /group/kick_group, + * 而非 OpenIM 官方文档中的 /group/kick_group_member(该路径在此实例返回 HTTP 404)。 + * 幂等错误码:errCode=1101(UserIDNotFoundError),表示用户已不在群中,视为成功。 + *

+ * 对应接口:POST /group/kick_group + * + * @param groupID 群组 ID + * @param userIDs 要踢出的用户 ID 列表 + */ + public void kickGroupMember(String groupID, List userIDs) { + String url = openIMProperties.resolveServerApiUrl() + "/group/kick_group"; + + Map body = new HashMap<>(); + body.put("groupID", groupID); + body.put("kickedUserIDs", userIDs); + body.put("reason", "系统踢出"); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 用户不在群中(重复踢出时触发),视为成功,不抛异常 + // errCode=1101(UserIDNotFoundError):经实际测试,用户已不在群中时返回此错误码 + // errCode=1004(RecordNotFoundError):兼容旧版本 + // errCode=1206(NotInGroupError):OpenIM 3.8 标准错误码,兼容保留 + if (resp.getErrCode() == 1101 + || resp.getErrCode() == 1004 + || resp.getErrCode() == 1206 + || errMsg.contains("UserIDNotFoundError") + || errMsg.contains("NotInGroupError") + || errMsg.contains("not in group") + || errMsg.contains("RecordNotFoundError")) { + log.debug("【OpenIM】踢出群成员:用户不在群中跳过: groupID={}, userIDs={}, errCode={}", + groupID, userIDs, resp.getErrCode()); + return; + } + log.error("【OpenIM】踢出群成员失败: groupID={}, userIDs={}, errCode={}, errMsg={}", + groupID, userIDs, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_KICK_GROUP_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】踢出群成员成功: groupID={}, userCount={}", groupID, userIDs.size()); + } + + /** + * 设置群成员角色 + * 对应接口:POST /group/set_group_member_info + *

+ * roleLevel 枚举:1=普通成员,2=管理员,3=群主。 + *

+ * + * @param groupID 群组 ID + * @param userID 目标成员用户 ID + * @param roleLevel 角色级别(20=普通成员,60=管理员,100=群主) + */ + public void setGroupMemberRole(String groupID, String userID, int roleLevel) { + String url = openIMProperties.resolveServerApiUrl() + "/group/set_group_member_info"; + // OpenIM 接口要求将成员信息放在 members 数组中 + Map member = new HashMap<>(); + member.put("groupID", groupID); + member.put("userID", userID); + member.put("roleLevel", roleLevel); + Map body = new HashMap<>(); + body.put("members", Collections.singletonList(member)); + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】设置群成员角色失败: groupID={}, userID={}, roleLevel={}, errCode={}, errMsg={}", + groupID, userID, roleLevel, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_MEMBER_ROLE_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】设置群成员角色成功: groupID={}, userID={}, roleLevel={}", groupID, userID, roleLevel); + } + + /** + * 设置群成员群昵称 + * 对应接口:POST /group/set_group_member_info(nickName) + * + * @param groupID 群组 ID + * @param userID 成员用户 ID + * @param nickName 群昵称 + */ + public void setGroupMemberNickname(String groupID, String userID, String nickName) { + String url = openIMProperties.resolveServerApiUrl() + "/group/set_group_member_info"; + Map member = new HashMap<>(); + member.put("groupID", groupID); + member.put("userID", userID); + member.put("nickName", nickName); + Map body = new HashMap<>(); + body.put("members", Collections.singletonList(member)); + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】设置群成员昵称失败: groupID={}, userID={}, errCode={}, errMsg={}", + groupID, userID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_MEMBER_NICKNAME_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】设置群成员昵称成功: groupID={}, userID={}", groupID, userID); + } + + /** + * 设置好友备注 + * 对应接口:POST /friend/set_friend_remark + * + * @param ownerUserID 关系拥有者用户 ID + * @param friendUserID 好友用户 ID + * @param remark 备注(空串表示清空) + */ + public void setFriendRemark(String ownerUserID, String friendUserID, String remark) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/set_friend_remark"; + Map body = new HashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("friendUserID", friendUserID); + body.put("remark", remark != null ? remark : ""); + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】设置好友备注失败: owner={}, friend={}, errCode={}, errMsg={}", + ownerUserID, friendUserID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_FRIEND_REMARK_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】设置好友备注成功: owner={}, friend={}", ownerUserID, friendUserID); + } + + /** + * 合并写入用户官方标识到 OpenIM ex + *

+ * 读取现有 ex 失败时 fail-closed,禁止按空对象覆盖,避免抹掉其它键。 + *

+ * + * @param userID 用户 ID + * @param official true=设为官方,false=取消 + */ + public void setUserOfficialEx(String userID, boolean official) { + String existingEx = getUserExRequired(userID); + String newEx = mergeOfficialEx(existingEx, official); + String url = openIMProperties.resolveServerApiUrl() + "/user/update_user_info"; + Map userInfo = new HashMap<>(); + userInfo.put("userID", userID); + userInfo.put("ex", newEx); + Map body = new HashMap<>(); + body.put("userInfo", userInfo); + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】同步用户官方标识失败: userID={}, errCode={}, errMsg={}", + userID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】同步用户官方标识成功: userID={}, official={}", userID, official); + } + + /** + * 合并写入群官方标识到 OpenIM ex + *

+ * 读取现有 ex 失败时 fail-closed,禁止按空对象覆盖,避免抹掉其它键。 + *

+ * + * @param groupID 群组 ID + * @param official true=设为官方,false=取消 + */ + public void setGroupOfficialEx(String groupID, boolean official) { + String existingEx = getGroupExRequired(groupID); + String newEx = mergeOfficialEx(existingEx, official); + String url = openIMProperties.resolveServerApiUrl() + "/group/set_group_info"; + Map groupInfoForSet = new HashMap<>(); + groupInfoForSet.put("groupID", groupID); + groupInfoForSet.put("ex", newEx); + Map body = new HashMap<>(); + body.put("groupInfoForSet", groupInfoForSet); + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】同步群官方标识失败: groupID={}, errCode={}, errMsg={}", + groupID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】同步群官方标识成功: groupID={}, official={}", groupID, official); + } + + /** + * 读取 OpenIM 用户 ex;读失败抛业务异常(供官方标识 merge 使用)。 + * 成功且无 ex 字段时返回 null,表示可按空对象合并。 + */ + @SuppressWarnings("unchecked") + public String getUserExRequired(String userID) { + String url = openIMProperties.resolveServerApiUrl() + "/user/get_users_info"; + Map body = new HashMap<>(); + body.put("userIDs", Collections.singletonList(userID)); + try { + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp) || resp.getData() == null) { + log.error("【OpenIM】读取用户 ex 失败: userID={}, errCode={}, errMsg={}", + userID, resp != null ? resp.getErrCode() : null, resp != null ? resp.getErrMsg() : null); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, + resp != null ? resp.getErrMsg() : null); + } + Map data = (Map) resp.getData(); + Object usersInfoObj = data.get("usersInfo"); + if (!(usersInfoObj instanceof List usersInfo) || usersInfo.isEmpty()) { + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, "usersInfo empty"); + } + Object first = usersInfo.get(0); + if (!(first instanceof Map userInfo)) { + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, "invalid usersInfo"); + } + Object ex = userInfo.get("ex"); + return ex != null ? String.valueOf(ex) : null; + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("【OpenIM】读取用户 ex 异常: userID={}, error={}", userID, e.getMessage()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, e.getMessage()); + } + } + + /** + * 读取 OpenIM 群组 ex;读失败抛业务异常(供官方标识 merge 使用)。 + * 成功且无 ex 字段时返回 null,表示可按空对象合并。 + */ + @SuppressWarnings("unchecked") + public String getGroupExRequired(String groupID) { + String url = openIMProperties.resolveServerApiUrl() + "/group/get_groups_info"; + Map body = new HashMap<>(); + body.put("groupIDs", Collections.singletonList(groupID)); + try { + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp) || resp.getData() == null) { + log.error("【OpenIM】读取群组 ex 失败: groupID={}, errCode={}, errMsg={}", + groupID, resp != null ? resp.getErrCode() : null, resp != null ? resp.getErrMsg() : null); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, + resp != null ? resp.getErrMsg() : null); + } + Map data = (Map) resp.getData(); + Object groupInfoListObj = data.get("groupInfos"); + if (groupInfoListObj == null) { + groupInfoListObj = data.get("groupInfoList"); + } + if (!(groupInfoListObj instanceof List groupInfos) || groupInfos.isEmpty()) { + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, "groupInfos empty"); + } + Object first = groupInfos.get(0); + if (!(first instanceof Map groupInfo)) { + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, "invalid groupInfos"); + } + Object ex = groupInfo.get("ex"); + return ex != null ? String.valueOf(ex) : null; + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("【OpenIM】读取群组 ex 异常: groupID={}, error={}", groupID, e.getMessage()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, e.getMessage()); + } + } + + /** + * 合并 official 键到 ex JSON;取消官方时删除该键。 + * 已有 ex 无法解析时 fail-closed,避免覆盖丢失其它键。 + */ + private String mergeOfficialEx(String existingEx, boolean official) { + Map map = new HashMap<>(); + if (existingEx != null && !existingEx.isBlank() && !"null".equalsIgnoreCase(existingEx)) { + try { + map = objectMapper.readValue(existingEx, new TypeReference>() {}); + if (map == null) { + map = new HashMap<>(); + } + } catch (Exception e) { + log.error("【OpenIM】解析 ex 失败,拒绝覆盖: ex={}, error={}", existingEx, e.getMessage()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, e.getMessage()); + } + } + if (official) { + map.put("official", 1); + } else { + map.remove("official"); + } + try { + return objectMapper.writeValueAsString(map); + } catch (Exception e) { + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_OFFICIAL_EX_FAILED, e.getMessage()); + } + } + + /** + * 更新群组基本信息 + * 对应接口:POST /group/set_group_info + *

+ * 只传非 null 字段,避免覆盖 OpenIM 侧已有数据。关键路径:失败抛异常。 + *

+ * + * @param groupID 群组 ID + * @param groupName 群名称(null 则不传) + * @param faceURL 群头像 URL(null 则不传) + * @param introduction 群简介(null 则不传) + */ + public void setGroupInfo(String groupID, String groupName, String faceURL, String introduction) { + String url = openIMProperties.resolveServerApiUrl() + "/group/set_group_info"; + // 只组装非 null 字段,避免覆盖 OpenIM 侧已有数据 + Map groupInfoForSet = new HashMap<>(); + groupInfoForSet.put("groupID", groupID); + if (groupName != null) { + groupInfoForSet.put("groupName", groupName); + } + if (faceURL != null) { + groupInfoForSet.put("faceURL", faceURL); + } + if (introduction != null) { + groupInfoForSet.put("introduction", introduction); + } + Map body = new HashMap<>(); + body.put("groupInfoForSet", groupInfoForSet); + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】更新群组信息失败: groupID={}, errCode={}, errMsg={}", + groupID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SET_GROUP_INFO_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】更新群组信息成功: groupID={}", groupID); + } + + /** + * 转让群主 + * 对应接口:POST /group/transfer_group + *

+ * 关键路径:失败抛异常,由上层事务回滚本地转让。 + *

+ * + * @param groupID 群组 ID + * @param oldOwnerUserID 原群主用户 ID + * @param newOwnerUserID 新群主用户 ID + */ + public void transferGroupOwner(String groupID, String oldOwnerUserID, String newOwnerUserID) { + String url = openIMProperties.resolveServerApiUrl() + "/group/transfer_group"; + + Map body = new HashMap<>(); + body.put("groupID", groupID); + body.put("oldOwnerUserID", oldOwnerUserID); + body.put("newOwnerUserID", newOwnerUserID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】转让群主失败: groupID={}, oldOwnerUserID={}, newOwnerUserID={}, errCode={}, errMsg={}", + groupID, oldOwnerUserID, newOwnerUserID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_TRANSFER_OWNER_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】转让群主成功: groupID={}, oldOwnerUserID={}, newOwnerUserID={}", + groupID, oldOwnerUserID, newOwnerUserID); + } + + /** + * 用户主动退出群组 + * 对应接口:POST /group/quit_group + * + * @param groupID 群组 ID + * @param userID 退出用户 ID + */ + public void quitGroup(String groupID, String userID) { + String url = openIMProperties.resolveServerApiUrl() + "/group/quit_group"; + + Map body = new HashMap<>(); + body.put("groupID", groupID); + body.put("userID", userID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 已不在群中视为成功 + if (resp.getErrCode() == 1004 + || errMsg.contains("RecordNotFound") + || errMsg.contains("not in group") + || errMsg.contains("NotInGroup")) { + log.debug("【OpenIM】退出群组:用户已不在群中,跳过: groupID={}, userID={}", groupID, userID); + return; + } + log.error("【OpenIM】退出群组失败: groupID={}, userID={}, errCode={}, errMsg={}", + groupID, userID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_QUIT_GROUP_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】退出群组成功: groupID={}, userID={}", groupID, userID); + } + + /** + * 构建 OpenIM 原生群聊会话 ID + * + * @param openimGroupId OpenIM 群组 ID + * @return {@code sg_{groupId}} + */ + public String buildGroupConversationId(String openimGroupId) { + return OpenImConstants.OPENIM_NATIVE_GROUP_CONVERSATION_PREFIX + openimGroupId; + } + + /** + * 删除用户名下指定会话(退群/踢人后清理群会话) + *

+ * 目标接口:{@code POST /conversation/delete_conversations}(仅较新 OpenIM 提供)。 + *

+ *

+ * 现网对齐:{@code openim-server:v3.8.3-patch.12} 官方路由表 conversation 组仅有 + * get/set/incremental 等,不含 {@code delete_conversations}(已核对 + * https://github.com/openimsdk/open-im-server/blob/v3.8.3-patch.12/internal/api/router.go )。 + * 同版本可清理消息的接口是 {@code POST /msg/clear_conversation_msg}(清消息,不删会话), + * 语义不同,故退群/踢人后的会话列表清理交由端侧 SDK(被踢/退群回调)处理。 + *

+ *

+ * 本方法在 {@link #deleteConversationsSupported}=false 时直接 no-op;为 true 时 soft 调用, + * 遇 HTTP 404 再关闭开关。失败不抛异常,不阻断 quit/kick。 + *

+ * + * @param ownerUserID 会话所属用户 ID + * @param conversationIDs 会话 ID 列表(群聊为 {@code sg_{groupId}}) + */ + public void deleteConversationsBestEffort(String ownerUserID, List conversationIDs) { + if (ownerUserID == null || ownerUserID.isBlank() + || conversationIDs == null || conversationIDs.isEmpty()) { + return; + } + if (!deleteConversationsSupported.get()) { + log.debug("【OpenIM】跳过服务端删会话(v3.8.3-patch.12 无 /conversation/delete_conversations): owner={}, ids={}", + ownerUserID, conversationIDs); + return; + } + String url = openIMProperties.resolveServerApiUrl() + "/conversation/delete_conversations"; + Map body = new LinkedHashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("conversationIDs", conversationIDs); + + OpenIMBaseResp resp = doPostSoft(url, body); + if (isHttpNotFound(resp)) { + if (deleteConversationsSupported.compareAndSet(true, false)) { + log.warn("【OpenIM】当前实例不支持 /conversation/delete_conversations(HTTP 404)," + + "已关闭服务端删会话;退群/拉黑后由端侧清理会话列表"); + } + return; + } + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + if (resp.getErrCode() == 1004 + || errMsg.contains("RecordNotFound") + || errMsg.contains("not found") + || errMsg.contains("NotFound")) { + log.debug("【OpenIM】删除会话:不存在跳过 owner={}, conversationIDs={}", + ownerUserID, conversationIDs); + return; + } + log.warn("【OpenIM】删除会话失败(不阻断业务): owner={}, conversationIDs={}, errCode={}, errMsg={}", + ownerUserID, conversationIDs, resp.getErrCode(), resp.getErrMsg()); + return; + } + log.info("【OpenIM】删除会话成功: owner={}, conversationIDs={}", ownerUserID, conversationIDs); + } + + /** + * 删除用户对某群的 OpenIM 会话(best-effort) + * + * @param ownerUserID 用户 ID + * @param openimGroupId OpenIM 群组 ID + */ + public void deleteGroupConversationBestEffort(String ownerUserID, String openimGroupId) { + if (openimGroupId == null || openimGroupId.isBlank()) { + return; + } + deleteConversationsBestEffort(ownerUserID, Collections.singletonList(buildGroupConversationId(openimGroupId))); + } + + // ==================== 好友相关 ==================== + + /** + * 发起好友申请(OpenIM {@code ApplyToAddFriend}),不会直接成为好友 + * 对应接口:POST /friend/add_friend + *

+ * ⚠️ v3.8.3:{@code /friend/add_friend} = 发起申请;直接建好友请用 {@link #importFriend}。 + * 取消拉黑恢复好友、补偿建边等场景禁止调用本方法,否则会产生无业务 requestId 的孤儿申请。 + *

+ *

+ * ⚠️ 注意:OpenIM /friend/add_friend 接口要求扁平结构(fromUserID/toUserID/addSource 直接放顶层), + * 不能嵌套在 paramsInfo 字段中,否则服务端会报 ArgsError(toUserID is empty)。 + *

+ * + * @param fromUserID 发起方用户 ID + * @param toUserID 接收方用户 ID + */ + public void addFriend(String fromUserID, String toUserID) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/add_friend"; + + // OpenIM /friend/add_friend 要求扁平结构,fromUserID/toUserID/addSource 直接放顶层 + Map body = new HashMap<>(); + body.put("fromUserID", fromUserID); + body.put("toUserID", toUserID); + body.put("addSource", FRIEND_ADD_SOURCE_SEARCH); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 好友关系已存在,视为成功,不抛异常 + // errCode=1304(RelationshipAlreadyError):OpenIM 3.8 好友关系已存在的标准错误码 + // errCode=500 + duplicate key:旧版本 OpenIM 的 MongoDB 唯一索引冲突 + if (resp.getErrCode() == 1304 + || (resp.getErrCode() == 500 && errMsg.contains("duplicate key")) + || errMsg.contains("already friends") + || errMsg.contains("AlreadyFriends") + || errMsg.contains("RelationshipAlreadyError")) { + log.debug("【OpenIM】添加好友:好友关系已存在跳过: from={}, to={}", fromUserID, toUserID); + return; + } + log.error("【OpenIM】添加好友失败: from={}, to={}, errCode={}, errMsg={}", + fromUserID, toUserID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_ADD_FRIEND_FAILED, resp.getErrMsg()); + } + } + + /** + * 删除好友关系(单向,需调用两次实现双向删除) + * 对应接口:POST /friend/delete_friend + *

+ * ⚠️ 注意:OpenIM /friend/delete_friend 接口要求 friendUserID 为单数字符串, + * 不是复数数组 friendUserIDs,否则服务端会报 ArgsError(userID2 is empty)。 + * 已通过实际 HTTP 测试验证(2026-07-14)。 + *

+ * + * @param ownerUserID 操作方用户 ID + * @param friendUserID 被删除的好友用户 ID + */ + public void deleteFriend(String ownerUserID, String friendUserID) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/delete_friend"; + + // OpenIM /friend/delete_friend 要求 friendUserID 为单数字符串(非数组) + Map body = new HashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("friendUserID", friendUserID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 好友关系不存在(重复删除时触发),视为成功,不抛异常 + // errCode=1301(NotFriendError):OpenIM 3.8 好友关系不存在的标准错误码 + // errCode=1004 + "RecordNotFoundError":旧版本 OpenIM 的错误码 + if (resp.getErrCode() == 1301 + || errMsg.contains("NotFriendError") + || errMsg.contains("not friend") + || (resp.getErrCode() == 1004 && errMsg.contains("RecordNotFoundError"))) { + log.debug("【OpenIM】删除好友:好友关系不存在跳过: owner={}, friend={}", ownerUserID, friendUserID); + return; + } + log.error("【OpenIM】删除好友失败: owner={}, friend={}, errCode={}, errMsg={}", + ownerUserID, friendUserID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_DELETE_FRIEND_FAILED, resp.getErrMsg()); + } + log.debug("【OpenIM】删除好友成功: owner={}, friend={}", ownerUserID, friendUserID); + } + + /** + * 发起好友申请(供对端 OpenIM listener 实时感知) + * 对应接口:POST /friend/add_friend(带 reqMsg) + *

+ * 与同意后的双向 {@link #addFriend} 共用路径;此处携带申请附言,由 OpenIM 产生好友申请事件。 + * 关键路径:失败抛异常,由上层事务回滚本地申请。 + *

+ * + * @param fromUserID 发起方用户 ID + * @param toUserID 接收方用户 ID + * @param reqMsg 申请附言,可为 null + */ + public void addFriendApplication(String fromUserID, String toUserID, String reqMsg) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/add_friend"; + + Map body = new HashMap<>(); + body.put("fromUserID", fromUserID); + body.put("toUserID", toUserID); + body.put("addSource", FRIEND_ADD_SOURCE_SEARCH); + body.put("reqMsg", reqMsg != null ? reqMsg : ""); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + if (resp.getErrCode() == 1304 + || (resp.getErrCode() == 500 && errMsg.contains("duplicate key")) + || errMsg.contains("already friends") + || errMsg.contains("AlreadyFriends") + || errMsg.contains("RelationshipAlreadyError") + || (errMsg.contains("FriendApply") && errMsg.contains("Already"))) { + log.debug("【OpenIM】好友申请:已存在申请或好友关系,跳过: from={}, to={}", fromUserID, toUserID); + return; + } + log.error("【OpenIM】发起好友申请失败: from={}, to={}, errCode={}, errMsg={}", + fromUserID, toUserID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_ADD_FRIEND_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】发起好友申请成功: from={}, to={}", fromUserID, toUserID); + } + + /** + * 处理好友申请(同意/拒绝) + * 对应接口:POST /friend/add_friend_response + *

+ * handleResult:{@link #FRIEND_RESPONSE_AGREE}=1 同意,{@link #FRIEND_RESPONSE_REFUSE}=-1 拒绝。 + * fromUserID=申请发起方,toUserID=申请接收方(处理人)。 + * 同意成功时 OpenIM 会建立好友关系,调用方无需再双向 addFriend。 + *

+ * + * @param fromUserID 申请发起方用户 ID + * @param toUserID 申请接收方用户 ID + * @param handleResult 处理结果(1 同意 / -1 拒绝) + * @param handleMsg 处理附言,可为 null + * @return true=OpenIM 实际处理成功;false=申请不存在/已处理(幂等跳过,同意场景可 fallback 强制加好友) + */ + public boolean respondFriendApplication(String fromUserID, String toUserID, int handleResult, String handleMsg) { + return respondFriendApplication(fromUserID, toUserID, handleResult, handleMsg, false); + } + + /** + * 处理好友申请(同意/拒绝) + * + * @param quiet true 时成功/幂等跳过降为 debug,避免百万级历史对齐刷爆日志 + */ + public boolean respondFriendApplication(String fromUserID, String toUserID, int handleResult, + String handleMsg, boolean quiet) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/add_friend_response"; + + Map body = new HashMap<>(); + body.put("fromUserID", fromUserID); + body.put("toUserID", toUserID); + body.put("handleResult", handleResult); + body.put("handleMsg", handleMsg != null ? handleMsg : ""); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + // 申请不存在或已处理:视为可幂等跳过(由调用方决定是否 fallback 强制加好友) + if (resp.getErrCode() == 1004 + || errMsg.contains("RecordNotFound") + || errMsg.contains("not found") + || errMsg.contains("Already") + || errMsg.contains("handled") + || errMsg.contains("Handled")) { + if (quiet) { + log.debug("【OpenIM】处理好友申请:申请不存在或已处理,跳过: from={}, to={}, handleResult={}, errMsg={}", + fromUserID, toUserID, handleResult, errMsg); + } else { + log.warn("【OpenIM】处理好友申请:申请不存在或已处理,跳过: from={}, to={}, handleResult={}, errMsg={}", + fromUserID, toUserID, handleResult, errMsg); + } + return false; + } + log.error("【OpenIM】处理好友申请失败: from={}, to={}, handleResult={}, errCode={}, errMsg={}", + fromUserID, toUserID, handleResult, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_RESPOND_FRIEND_FAILED, resp.getErrMsg()); + } + if (quiet) { + log.debug("【OpenIM】处理好友申请成功: from={}, to={}, handleResult={}", fromUserID, toUserID, handleResult); + } else { + log.info("【OpenIM】处理好友申请成功: from={}, to={}, handleResult={}", fromUserID, toUserID, handleResult); + } + return true; + } + + /** + * 拉黑用户 + * 对应接口:POST /friend/add_black + * + * @param ownerUserID 操作方用户 ID + * @param blackUserID 被拉黑用户 ID + */ + public void addBlack(String ownerUserID, String blackUserID) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/add_black"; + + Map body = new HashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("blackUserID", blackUserID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + if (errMsg.contains("already") || errMsg.contains("Already") || errMsg.contains("Black")) { + log.debug("【OpenIM】拉黑:可能已在黑名单,跳过: owner={}, black={}", ownerUserID, blackUserID); + return; + } + log.error("【OpenIM】拉黑失败: owner={}, black={}, errCode={}, errMsg={}", + ownerUserID, blackUserID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_ADD_BLACK_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】拉黑成功: owner={}, black={}", ownerUserID, blackUserID); + } + + /** + * 取消拉黑 + * 对应接口:POST /friend/remove_black + * + * @param ownerUserID 操作方用户 ID + * @param blackUserID 被取消拉黑的用户 ID + */ + public void removeBlack(String ownerUserID, String blackUserID) { + String url = openIMProperties.resolveServerApiUrl() + "/friend/remove_black"; + + Map body = new HashMap<>(); + body.put("ownerUserID", ownerUserID); + body.put("blackUserID", blackUserID); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + String errMsg = resp.getErrMsg() != null ? resp.getErrMsg() : ""; + if (resp.getErrCode() == 1004 + || errMsg.contains("RecordNotFound") + || errMsg.contains("not in black") + || errMsg.contains("NotInBlack")) { + log.debug("【OpenIM】取消拉黑:黑名单中不存在,跳过: owner={}, black={}", ownerUserID, blackUserID); + return; + } + log.error("【OpenIM】取消拉黑失败: owner={}, black={}, errCode={}, errMsg={}", + ownerUserID, blackUserID, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_REMOVE_BLACK_FAILED, resp.getErrMsg()); + } + log.info("【OpenIM】取消拉黑成功: owner={}, black={}", ownerUserID, blackUserID); + } + + // ==================== 消息相关 ==================== + // 注:历史消息拉取不在此实现。 + // OpenIM 不通过 REST API 暴露"分页拉取历史消息"接口(实测 35+ 变种路径均 404, + // 官方 v3.7/v3.8 源码 MessageApi 类也未注册对应路由)。 + // 历史消息由客户端通过 OpenIM SDK 的 WebSocket 实时同步(PullMsgBySeqs RPC)实现。 + // 如需服务端介入(例如合规审计),请直接查询业务库的消息归档表,不调用 OpenIM。 + + /** + * 通过服务端发送消息(用于系统消息、弹幕代发等场景) + * 对应接口:POST /msg/send_msg(v3.8.3-patch.12 路由存在) + *

+ * ⚠️ 注意:OpenIM /msg/send_msg 接口中,群聊消息(sessionType=3)必须使用 groupID 字段, + * 单聊消息(sessionType=1)使用 recvID 字段。两者不能混用,否则服务端无法路由消息。 + *

+ *

+ * ⚠️ {@code content} 类型必须是 JSON object:官方 {@code SendMsg.Content} 为 + * {@code map[string]any}(见 v3.8.3 {@code pkg/apistruct/manage.go}),传 JSON 字符串会 ArgsError 1001。 + *

+ * + * @param sendID 发送方用户 ID(系统消息可用管理员用户 ID) + * @param recvID 接收方 ID(单聊为用户 ID,群聊为群 ID) + * @param sessionType 会话类型(1=单聊,3=群聊,以 OpenIM 枚举为准) + * @param contentType 消息内容类型(101=文本,200+=自定义消息,以 OpenIM 枚举为准) + * @param content 消息内容对象(必须是 JSON object:文本消息传 Map{"content":"xxx"},自定义消息传对应 Map) + * 若误传 JSON 字符串,{@link #normalizeSendMsgContent} 会尝试解析为对象。 + * ⚠️ 文本消息(contentType=101)字段名必须为 "content",不能用 "text", + * 否则 OpenIM 服务端报 Field validation for 'Content' failed on the 'required' tag。 + * 已通过实际 HTTP 测试验证(2026-07-14)。 + */ + public void sendMsg(String sendID, String recvID, int sessionType, int contentType, Object content) { + String url = openIMProperties.resolveServerApiUrl() + "/msg/send_msg"; + + Map body = new HashMap<>(); + body.put("sendID", sendID); + body.put("senderPlatformID", 1); // 1=管理端 + body.put("sessionType", sessionType); + body.put("contentType", contentType); + // OpenIM 要求 content 为 JSON object(map),不能是 JSON 字符串,否则 ArgsError 1001 + body.put("content", normalizeSendMsgContent(content)); + body.put("isOnlineOnly", false); + body.put("notOfflinePush", false); + body.put("sendTime", 0); + + // 群聊(sessionType=3)使用 groupID 字段,单聊(sessionType=1)使用 recvID 字段 + if (sessionType == 3) { + body.put("groupID", recvID); + } else { + body.put("recvID", recvID); + } + + // 离线推送信息(群聊消息必填,否则部分版本 OpenIM 会报参数错误) + Map offlinePushInfo = new HashMap<>(); + offlinePushInfo.put("title", ""); + offlinePushInfo.put("desc", ""); + offlinePushInfo.put("ex", ""); + offlinePushInfo.put("iOSPushSound", ""); + offlinePushInfo.put("iOSBadgeCount", false); + body.put("offlinePushInfo", offlinePushInfo); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】发送消息失败: sendID={}, recvID={}, sessionType={}, errCode={}, errMsg={}", + sendID, recvID, sessionType, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SEND_MSG_FAILED, resp.getErrMsg()); + } + log.debug("【OpenIM】发送消息成功: sendID={}, recvID={}, sessionType={}", sendID, recvID, sessionType); + } + + /** + * 以用户身份发送消息(批量转发专用,支持 clientMsgID) + *

+ * 与 {@link #sendMsg} 隔离:不修改旧方法行为;失败同样抛 {@link ErrorCodeEnum#IM_OPENIM_SEND_MSG_FAILED}。 + *

+ * + * @param sendID 发送者用户 ID + * @param recvID 单聊接收者 / 群聊 groupID + * @param sessionType 1=单聊 3=群聊 + * @param contentType OpenIM contentType + * @param content 消息内容(Map 或 JSON 字符串,按类型) + * @param clientMsgId 客户端消息 ID(幂等) + * @param platformId 发送平台号(建议 1) + */ + public void sendMsgAsUser(String sendID, String recvID, int sessionType, int contentType, + Object content, String clientMsgId, int platformId) { + String url = openIMProperties.resolveServerApiUrl() + "/msg/send_msg"; + + Map body = new HashMap<>(); + body.put("sendID", sendID); + body.put("senderPlatformID", platformId); + body.put("sessionType", sessionType); + body.put("contentType", contentType); + body.put("content", normalizeSendMsgContent(content)); + body.put("clientMsgID", clientMsgId); + body.put("isOnlineOnly", false); + body.put("notOfflinePush", false); + body.put("sendTime", 0); + + if (sessionType == 3) { + body.put("groupID", recvID); + } else { + body.put("recvID", recvID); + } + + Map offlinePushInfo = new HashMap<>(); + offlinePushInfo.put("title", ""); + offlinePushInfo.put("desc", ""); + offlinePushInfo.put("ex", ""); + offlinePushInfo.put("iOSPushSound", ""); + offlinePushInfo.put("iOSBadgeCount", false); + body.put("offlinePushInfo", offlinePushInfo); + + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp)) { + log.error("【OpenIM】sendMsgAsUser 失败: sendID={}, recvID={}, sessionType={}, clientMsgID={}, errCode={}, errMsg={}", + sendID, recvID, sessionType, clientMsgId, resp.getErrCode(), resp.getErrMsg()); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_SEND_MSG_FAILED, resp.getErrMsg()); + } + log.debug("【OpenIM】sendMsgAsUser 成功: sendID={}, recvID={}, clientMsgID={}", sendID, recvID, clientMsgId); + } + + /** + * 将 send_msg 的 content 规范为 OpenIM 可反序列化的对象 + *

+ * OpenIM {@code SendMsgReq.content} 类型为 {@code map[string]interface{}}, + * 若传入已序列化的 JSON 字符串会报 ArgsError。 + *

+ * + * @param content 调用方传入的 content(Map / POJO / JSON 字符串) + * @return 规范化后的 content + */ + private Object normalizeSendMsgContent(Object content) { + if (content == null) { + return new HashMap<>(); + } + if (!(content instanceof String raw)) { + return content; + } + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + return new HashMap<>(); + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return objectMapper.readValue(trimmed, Object.class); + } catch (Exception e) { + log.warn("【OpenIM】content JSON 字符串解析失败,降级为文本包装: err={}", e.getMessage()); + } + } + // 纯文本:按文本消息结构包装 + Map textContent = new HashMap<>(); + textContent.put("content", raw); + return textContent; + } + + /** + * 撤回一条消息(管理端 token + 指定撤回者 userID) + * 对应接口:POST /msg/revoke_msg + *

+ * 群聊中 {@code userID} 为操作者时,OpenIM 按角色等级校验:高等级可撤低等级成员消息。 + *

+ * + * @param revokerUserId 撤回者用户 ID(群主/管理员) + * @param conversationId OpenIM 会话 ID(群聊 {@code sg_{groupId}}) + * @param seq 消息 seq + * @return null 表示成功;非 null 为错误信息 + */ + public String revokeMsg(String revokerUserId, String conversationId, long seq) { + String url = openIMProperties.resolveServerApiUrl() + "/msg/revoke_msg"; + Map body = new LinkedHashMap<>(); + body.put("userID", revokerUserId); + body.put("conversationID", conversationId); + body.put("seq", seq); + try { + OpenIMBaseResp resp = doPost(url, body); + if (isSuccess(resp)) { + log.info("【OpenIM】撤回消息成功: revoker={}, conversationID={}, seq={}", + revokerUserId, conversationId, seq); + return null; + } + String err = resp.getErrMsg() != null ? resp.getErrMsg() : ("errCode=" + resp.getErrCode()); + log.warn("【OpenIM】撤回消息失败: revoker={}, conversationID={}, seq={}, err={}", + revokerUserId, conversationId, seq, err); + return err; + } catch (BusinessException ex) { + log.warn("【OpenIM】撤回消息异常: revoker={}, conversationID={}, seq={}, err={}", + revokerUserId, conversationId, seq, ex.getMessage()); + return ex.getMessage() != null ? ex.getMessage() : "revoke failed"; + } + } + + /** + * 批量查询 OpenIM 中存在的用户 ID 集合(验证专用) + * 对应接口:POST /user/get_users_info + *

+ * OpenIM 只返回存在的用户,不存在的不会出现在结果中。 + * data 为 null 或解析失败时返回空 Set,不抛异常,避免影响验证主流程。 + *

+ * + * @param userIDs 待查询的用户 ID 列表(单次建议不超过 200) + * @return 在 OpenIM 中存在的用户 ID 集合 + */ + @SuppressWarnings("unchecked") + public Set getUsersExistInOpenIM(List userIDs) { + String url = openIMProperties.resolveServerApiUrl() + "/user/get_users_info"; + + Map body = new HashMap<>(); + body.put("userIDs", userIDs); + + try { + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp) || resp.getData() == null) { + log.warn("【OpenIM】批量查询用户信息失败或 data 为空: errCode={}, errMsg={}", + resp != null ? resp.getErrCode() : -1, + resp != null ? resp.getErrMsg() : "null"); + return new HashSet<>(); + } + // 解析 data -> usersInfo -> List -> 取每个 map 的 userID + Map data = (Map) resp.getData(); + Object usersInfoObj = data.get("usersInfo"); + if (usersInfoObj == null) { + return new HashSet<>(); + } + List> usersInfo = (List>) usersInfoObj; + Set existSet = new HashSet<>(); + for (Map userInfo : usersInfo) { + Object uid = userInfo.get("userID"); + if (uid != null) { + existSet.add(String.valueOf(uid)); + } + } + return existSet; + } catch (Exception e) { + // 验证阶段不能影响主流程,解析失败返回空 Set + log.warn("【OpenIM】批量查询用户信息异常,返回空集合: error={}", e.getMessage()); + return new HashSet<>(); + } + } + + /** + * 批量查询 OpenIM 中存在的群组 ID 集合(验证专用) + * 对应接口:POST /group/get_groups_info + *

+ * data 为 null 或解析失败时返回空 Set,不抛异常,避免影响验证主流程。 + *

+ * + * @param groupIDs 待查询的群组 ID 列表(单次建议不超过 100) + * @return 在 OpenIM 中存在的群组 ID 集合 + */ + @SuppressWarnings("unchecked") + public Set getGroupsExistInOpenIM(List groupIDs) { + String url = openIMProperties.resolveServerApiUrl() + "/group/get_groups_info"; + + Map body = new HashMap<>(); + body.put("groupIDs", groupIDs); + + try { + OpenIMBaseResp resp = doPost(url, body); + if (!isSuccess(resp) || resp.getData() == null) { + log.warn("【OpenIM】批量查询群组信息失败或 data 为空: errCode={}, errMsg={}", + resp != null ? resp.getErrCode() : -1, + resp != null ? resp.getErrMsg() : "null"); + return new HashSet<>(); + } + // 解析 data -> groupInfos -> List -> 取每个 map 的 groupID + // ⚠️ 注意:OpenIM 3.8 /group/get_groups_info 响应字段名为 groupInfos(复数 s), + // 不是 groupInfoList,字段名错误会导致解析结果为空 Set。 + Map data = (Map) resp.getData(); + // 兼容 groupInfos(OpenIM 3.8)和 groupInfoList(旧版本)两种字段名 + Object groupInfoListObj = data.get("groupInfos"); + if (groupInfoListObj == null) { + groupInfoListObj = data.get("groupInfoList"); + } + if (groupInfoListObj == null) { + return new HashSet<>(); + } + List> groupInfoList = (List>) groupInfoListObj; + Set existSet = new HashSet<>(); + for (Map groupInfo : groupInfoList) { + Object gid = groupInfo.get("groupID"); + if (gid != null) { + existSet.add(String.valueOf(gid)); + } + } + return existSet; + } catch (Exception e) { + // 验证阶段不能影响主流程,解析失败返回空 Set + log.warn("【OpenIM】批量查询群组信息异常,返回空集合: error={}", e.getMessage()); + return new HashSet<>(); + } + } + + // ==================== 内部工具方法 ==================== + + /** + * 执行 POST 请求(自动携带 adminToken 请求头) + *

+ * 若返回 TokenKicked / TokenExpired 等鉴权错误(多实例刷新 adminToken 互相顶掉时常见), + * 立即刷新 adminToken 并重试一次。 + *

+ * + * @param url 请求地址 + * @param body 请求体 + * @return OpenIM 统一响应体 + */ + private OpenIMBaseResp doPost(String url, Map body) { + OpenIMBaseResp resp = doPostWithToken(url, body, adminTokenManager.getAdminToken()); + if (isTokenAuthError(resp)) { + log.warn("【OpenIM】adminToken 鉴权失败,刷新后重试: url={}, errCode={}, errMsg={}", + url, resp.getErrCode(), resp.getErrMsg()); + adminTokenManager.refreshToken(); + resp = doPostWithToken(url, body, adminTokenManager.getAdminToken()); + } + return resp; + } + + /** + * 使用指定 token 发起一次 POST(不做刷新重试) + * + * @param url 请求地址 + * @param body 请求体 + * @param token 请求头 token + * @return OpenIM 统一响应体 + */ + private OpenIMBaseResp doPostWithToken(String url, Map body, String token) { + try { + return doPostWithTokenRaw(url, body, token); + } catch (HttpStatusCodeException e) { + log.error("【OpenIM】HTTP 请求异常: url={}, error={}", url, e.getMessage(), e); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_HTTP_ERROR, e.getMessage(), e); + } catch (Exception e) { + log.error("【OpenIM】HTTP 请求异常: url={}, error={}", url, e.getMessage(), e); + throw new BusinessException(ErrorCodeEnum.IM_OPENIM_HTTP_ERROR, e.getMessage(), e); + } + } + + /** + * Best-effort POST:HTTP 4xx/5xx 转为响应体,不抛异常、不打 ERROR(供删会话等非关键路径) + * + * @param url 请求地址 + * @param body 请求体 + * @return OpenIM 响应;HTTP 失败时 errCode=HTTP 状态码 + */ + private OpenIMBaseResp doPostSoft(String url, Map body) { + try { + OpenIMBaseResp resp = doPostWithTokenRaw(url, body, adminTokenManager.getAdminToken()); + if (isTokenAuthError(resp)) { + log.warn("【OpenIM】adminToken 鉴权失败(soft),刷新后重试: url={}, errCode={}, errMsg={}", + url, resp.getErrCode(), resp.getErrMsg()); + adminTokenManager.refreshToken(); + resp = doPostWithTokenRaw(url, body, adminTokenManager.getAdminToken()); + } + return resp; + } catch (HttpStatusCodeException e) { + return new OpenIMBaseResp(e.getStatusCode().value(), + "HTTP_" + e.getStatusCode().value() + ": " + e.getStatusText(), null); + } catch (Exception e) { + return new OpenIMBaseResp(-1, e.getMessage(), null); + } + } + + /** + * 原始 POST(优先返回业务响应;纯 HTTP 失败再抛出) + *

+ * OpenIM 在参数校验失败时可能返回 HTTP 400,但 body 仍是标准 + * {@code {errCode,errMsg,errDlt,data}}。此处解析 body,避免误当成传输层错误刷 ERROR 堆栈。 + *

+ * + * @param url 请求地址 + * @param body 请求体 + * @param token 请求头 token + * @return OpenIM 统一响应体 + */ + private OpenIMBaseResp doPostWithTokenRaw(String url, Map body, String token) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("token", token != null ? token : ""); + headers.set("operationID", UUID.randomUUID().toString()); + + HttpEntity> entity = new HttpEntity<>(body, headers); + try { + ResponseEntity response = restTemplate.postForEntity(url, entity, OpenIMBaseResp.class); + OpenIMBaseResp resp = response.getBody(); + return resp != null ? resp : new OpenIMBaseResp(-1, "响应体为空", null); + } catch (HttpStatusCodeException e) { + OpenIMBaseResp parsed = tryParseOpenIMErrorBody(e.getResponseBodyAsString()); + if (parsed != null) { + log.warn("【OpenIM】HTTP {} 含业务错误体: url={}, errCode={}, errMsg={}", + e.getStatusCode().value(), url, parsed.getErrCode(), parsed.getErrMsg()); + return parsed; + } + throw e; + } + } + + /** + * 尝试将 HTTP 错误响应体解析为 OpenIM 标准错误结构 + * + * @param responseBody 响应文本 + * @return 解析成功返回响应对象,否则 null + */ + private OpenIMBaseResp tryParseOpenIMErrorBody(String responseBody) { + if (responseBody == null || responseBody.isBlank()) { + return null; + } + String trimmed = responseBody.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + try { + OpenIMBaseResp parsed = objectMapper.readValue(trimmed, OpenIMBaseResp.class); + // 无 errCode 字段时 Jackson 默认 0,避免把非 OpenIM 响应误判为成功 + if (parsed == null) { + return null; + } + if (parsed.getErrCode() == 0 && (parsed.getErrMsg() == null || parsed.getErrMsg().isBlank()) + && parsed.getData() == null) { + return null; + } + return parsed; + } catch (Exception ignore) { + return null; + } + } + + /** + * 是否为 HTTP 404(路由不存在) + * + * @param resp soft POST 响应 + * @return true 表示路由不存在 + */ + private boolean isHttpNotFound(OpenIMBaseResp resp) { + return resp != null && resp.getErrCode() == HttpStatus.NOT_FOUND.value(); + } + + /** + * 执行 POST 请求(使用指定的用户 token 替代 adminToken) + *

+ * 用于以真实用户身份发起请求,使 OpenIM 群通知显示真实操作人而非 imAdmin。 + *

+ * + * @param url 请求地址 + * @param body 请求体 + * @param userToken 用户 token + * @return OpenIM 统一响应体 + */ + private OpenIMBaseResp doPostWithUserToken(String url, Map body, String userToken) { + return doPostWithToken(url, body, userToken); + } + + /** + * 判断 OpenIM 响应是否成功 + * + * @param resp OpenIM 统一响应体 + * @return 是否成功(errCode == 0) + */ + private boolean isSuccess(OpenIMBaseResp resp) { + return resp != null && resp.getErrCode() == 0; + } + + /** + * 是否为 Token 鉴权类错误(可刷新/降级重试) + * + * @param resp OpenIM 响应 + * @return true 表示 token 失效/被踢等 + */ + private boolean isTokenAuthError(OpenIMBaseResp resp) { + if (resp == null || isSuccess(resp)) { + return false; + } + int code = resp.getErrCode(); + if (code >= OpenImConstants.ERR_TOKEN_EXPIRED && code <= OpenImConstants.ERR_TOKEN_NOT_VALID_YET) { + return true; + } + String errMsg = resp.getErrMsg(); + if (errMsg == null || errMsg.isBlank()) { + return false; + } + return errMsg.contains("TokenKicked") + || errMsg.contains("TokenExpired") + || errMsg.contains("TokenNotExist") + || errMsg.contains("TokenInvalid") + || errMsg.contains("TokenMalformed"); + } + + /** + * OpenIM 统一响应体 + *

+ * 对应 OpenIM Server 所有 REST 接口的通用返回结构。 + *

+ */ + @Data + public static class OpenIMBaseResp { + + /** + * 错误码,0 表示成功 + */ + private int errCode; + + /** + * 错误描述信息 + */ + private String errMsg; + + /** + * 业务数据,具体结构因接口而异 + */ + private Object data; + + /** + * 默认构造方法(供 JSON 反序列化使用) + */ + public OpenIMBaseResp() { + } + + /** + * 全参构造方法 + * + * @param errCode 错误码 + * @param errMsg 错误描述信息 + * @param data 业务数据 + */ + public OpenIMBaseResp(int errCode, String errMsg, Object data) { + this.errCode = errCode; + this.errMsg = errMsg; + this.data = data; + } + } +} diff --git a/src/main/java/com/tailbet/openim/OpenIMClient.java b/src/main/java/com/tailbet/openim/OpenIMClient.java deleted file mode 100644 index 41716fe..0000000 --- a/src/main/java/com/tailbet/openim/OpenIMClient.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.tailbet.openim; - -import cn.hutool.json.JSON; -import cn.hutool.json.JSONUtil; -import com.tailbet.config.OpenIMConfig; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.HashMap; -import java.util.Map; - -/** - * OpenIM客户端 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OpenIMClient { - - private final OpenIMConfig config; - private final RestTemplate restTemplate; - - /** - * 发送群消息 - */ - public void sendGroupMessage(String groupId, String message) { - String url = config.getApiUrl() + "/msg/send_msg"; - - Map body = new HashMap<>(); - body.put("groupId", groupId); - body.put("msg", message); - body.put("msgType", "text"); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("token", config.getAdminToken()); - - HttpEntity> request = new HttpEntity<>(body, headers); - - try { - restTemplate.postForEntity(url, request, String.class); - log.info("OpenIM群消息发送成功: groupId={}, message={}", groupId, message); - } catch (Exception e) { - log.error("OpenIM群消息发送失败: groupId={}, message={}, error={}", - groupId, message, e.getMessage()); - } - } - - /** - * 发送单聊消息 - */ - public void sendUserMessage(String toUserId, String message) { - String url = config.getApiUrl() + "/msg/send_msg"; - - Map body = new HashMap<>(); - body.put("to", toUserId); - body.put("msg", message); - body.put("msgType", "text"); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("token", config.getAdminToken()); - - HttpEntity> request = new HttpEntity<>(body, headers); - - try { - restTemplate.postForEntity(url, request, String.class); - log.info("OpenIM单聊消息发送成功: to={}, message={}", toUserId, message); - } catch (Exception e) { - log.error("OpenIM单聊消息发送失败: to={}, message={}, error={}", - toUserId, message, e.getMessage()); - } - } - - /** - * 创建用户 - */ - public boolean createUser(String userId, String nickname) { - String url = config.getApiUrl() + "/user/user_register"; - - Map body = new HashMap<>(); - body.put("userID", userId); - body.put("nickname", nickname); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("token", config.getAdminToken()); - - HttpEntity> request = new HttpEntity<>(body, headers); - - try { - restTemplate.postForEntity(url, request, String.class); - return true; - } catch (Exception e) { - log.error("创建OpenIM用户失败: userId={}, error={}", userId, e.getMessage()); - return false; - } - } - - /** - * 创建群组 - */ - public String createGroup(String groupName) { - String url = config.getApiUrl() + "/group/create_group"; - - Map body = new HashMap<>(); - body.put("groupName", groupName); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("token", config.getAdminToken()); - - HttpEntity> request = new HttpEntity<>(body, headers); - - try { - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - if (response.getBody() != null) { - return (String) response.getBody().get("groupID"); - } - } catch (Exception e) { - log.error("创建OpenIM群组失败: groupName={}, error={}", groupName, e.getMessage()); - } - return null; - } - - /** - * 拉用户入群 - */ - public boolean inviteUserToGroup(String groupId, String userId) { - String url = config.getApiUrl() + "/group/invite_user_to_group"; - - Map body = new HashMap<>(); - body.put("groupId", groupId); - body.put("userIDs", new String[]{userId}); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("token", config.getAdminToken()); - - HttpEntity> request = new HttpEntity<>(body, headers); - - try { - restTemplate.postForEntity(url, request, String.class); - return true; - } catch (Exception e) { - log.error("拉用户入群失败: groupId={}, userId={}, error={}", - groupId, userId, e.getMessage()); - return false; - } - } - - /** - * 获取用户Token - */ - public String getUserToken(String userId) { - String url = config.getApiUrl() + "/auth/user_token"; - - Map body = new HashMap<>(); - body.put("userID", userId); - body.put("expireTimeSeconds", 604800); // 7天有效期 - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("token", config.getAdminToken()); - headers.set("operationID", userId + System.currentTimeMillis()); - - HttpEntity> request = new HttpEntity<>(body, headers); - - try { - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - log.info("getUserToken:{}", JSONUtil.toJsonStr(response.getBody())); - if (response.getBody() != null && response.getBody().get("data") != null) { - Map data = (Map) response.getBody().get("data"); - return (String) data.get("token"); - } - } catch (Exception e) { - log.error("获取OpenIM用户Token失败: userId={}, error={}", userId, e.getMessage()); - } - return null; - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f39605f..732efe4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,7 +13,7 @@ spring: host: ${REDIS_HOST:127.0.0.1} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:123456} -# password: ${REDIS_PASSWORD:ejgheyg2@eHSY2!.} + # password: ${REDIS_PASSWORD:ejgheyg2@eHSY2!.} database: ${REDIS_DB:0} timeout: 5000ms lettuce: @@ -39,12 +39,36 @@ mybatis-plus: logic-delete-value: 1 logic-not-delete-value: 0 +#openim: +## api-url: ${OPENIM_API_URL:http://172.31.5.16:10002} +# api-url: ${OPENIM_API_URL:https://openim.xiu6688.com} +# ws-url: ${OPENIM_WS_URL:wss://im.xiu6688.com} +# admin-user: ${OPENIM_ADMIN_USER:imAdmin} +# admin-token: ${OPENIM_ADMIN_TOKEN:d71c0861267DM23adg} + openim: -# api-url: ${OPENIM_API_URL:http://172.31.5.16:10002} - api-url: ${OPENIM_API_URL:https://openim.xiu6688.com} - ws-url: ${OPENIM_WS_URL:wss://im.xiu6688.com} - admin-user: ${OPENIM_ADMIN_USER:imAdmin} - admin-token: ${OPENIM_ADMIN_TOKEN:d71c0861267DM23adg} + # OpenIM REST API 外网地址(必填),下发给客户端 httpUrl + apiUrl: https://openim.xiu6688.com + # OpenIM REST API 内网地址(可选),服务端调用,未配置则回落 apiUrl +# internalApiUrl: http://172.31.5.16:10002 + # OpenIM WebSocket 外网地址(必填),下发给客户端用于 IM SDK 初始化 + wsUrl: wss://im.xiu6688.com + # OpenIM 管理员密钥(必填),用于获取 adminToken + secret: d71c0861267DM23adg + # OpenIM 管理员用户 ID(默认 imAdmin) + adminUserId: imAdmin + # 应用标识(默认 socialapp) + appId: socialapp + # adminToken 有效期秒数(默认 82800,即 23 小时) + tokenExpireSeconds: 82800 + # 调用 OpenIM 接口的连接超时时间毫秒(默认 5000) + connectTimeoutMs: 5000 + # 调用 OpenIM 接口的读取超时时间毫秒(默认 10000) + readTimeoutMs: 10000 + # Webhook 签名密钥(可选),用于校验 OpenIM 回调请求 + webhookSecret: your-webhook-secret + + jwt: secret: ${JWT_SECRET:your-256-bit-secret-key-for-jwt-token-generation}