OpenIMApiClient
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
package com.tailbet.common.constant;
|
||||
|
||||
/**
|
||||
* OpenIM 协议相关常量
|
||||
* <p>
|
||||
* 统一管理 Webhook 回调命令、请求字段、响应字段、消息类型等协议魔法值,
|
||||
* 避免散落在 Controller / Service 中。
|
||||
* </p>
|
||||
*
|
||||
* @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";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
package com.tailbet.common.exception;
|
||||
|
||||
import com.tailbet.common.enums.ErrorCodeEnum;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 业务异常
|
||||
* <p>
|
||||
* 封装业务逻辑层面的异常,区别于系统运行时异常。
|
||||
* 业务异常通常有明确的错误码和提示信息,可直接返回给前端展示。
|
||||
* 使用时优先使用预定义的错误码枚举,也支持自定义消息。
|
||||
* </p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于错误码枚举和附加数据构造业务异常
|
||||
* <p>
|
||||
* 用于需要向前端返回额外数据的异常场景,
|
||||
* 如 LIVE_ALREADY_ACTIVE 时携带已有场次信息。
|
||||
* </p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 全局异常处理
|
||||
* 全局异常处理器
|
||||
* <p>
|
||||
* 统一捕获并处理Controller层抛出的各类异常,将异常转换为统一的Result响应格式。
|
||||
* 处理优先级:具体异常 > 通用异常 > 兜底异常。
|
||||
* 支持国际化:根据请求头 Accept-Language 自动返回对应语言的错误信息。
|
||||
* </p>
|
||||
*
|
||||
* @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<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<Void> handleIllegalState(IllegalStateException e, HttpServletRequest request) {
|
||||
log.warn("【非法状态】URI: {}, 错误: {}", request.getRequestURI(), e.getMessage());
|
||||
return R.fail(ErrorCodeEnum.OPERATION_NOT_ALLOWED, e.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底异常处理器(最低优先级)
|
||||
* <p>
|
||||
* 捕获所有未被前面处理器捕获的异常,避免异常信息直接暴露给客户端。
|
||||
* </p>
|
||||
*
|
||||
* @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<Void> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 集成配置属性类
|
||||
* <p>
|
||||
* 从 Nacos/application.yml 中读取 OpenIM 第三方即时通讯服务相关配置属性,
|
||||
* 对应配置前缀为 {@code openim}。OpenIM 是独立部署的外部第三方服务(非 Spring 微服务),
|
||||
* 不参与 Nacos 服务发现,因此配置项中直接使用完整的 HTTP/WS 地址。
|
||||
* </p>
|
||||
*
|
||||
* @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)
|
||||
* <p>
|
||||
* 历史配置中亦可能同时作为服务端调用地址;配置了 {@link #internalApiUrl} 后仅用于下发。
|
||||
* </p>
|
||||
*/
|
||||
private String apiUrl;
|
||||
|
||||
/**
|
||||
* OpenIM REST API 内网地址,服务端 {@code OpenIMApiClient} 调用(如 http://10.x.x.x:10002)
|
||||
* <p>
|
||||
* 未配置时回落 {@link #apiUrl},兼容旧 Nacos。
|
||||
* </p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<T> implements Serializable {
|
||||
public static <T> R<T> fail(int code, String msg) {
|
||||
return new R<>(code, msg, null);
|
||||
}
|
||||
public static <T> R<T> fail(ErrorCodeEnum errorCode, String msg) {
|
||||
return new R<>(errorCode.getCode(), msg, null);
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return code == SUCCESS;
|
||||
|
||||
@@ -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 管理器
|
||||
* <p>
|
||||
* 负责 OpenIM 管理员 Token 的获取与定期刷新:服务启动时通过 OpenIM
|
||||
* {@code /auth/get_admin_token} 接口获取一次,之后每 23 小时自动刷新一次
|
||||
* (OpenIM 管理员 Token 默认 24 小时过期,提前 1 小时刷新以规避临界失效)。
|
||||
* </p>
|
||||
* <p>
|
||||
* 本类不依赖 {@link OpenIMApiClient},避免出现
|
||||
* OpenIMApiClient → OpenIMAdminTokenManager → OpenIMApiClient 的循环依赖,
|
||||
* 内部直接使用专用 RestTemplate 调用 OpenIM 接口。
|
||||
* </p>
|
||||
*
|
||||
* @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<String> tokenRef = new AtomicReference<>("");
|
||||
|
||||
/**
|
||||
* 服务启动时初始化 adminToken
|
||||
* <p>
|
||||
* 若启动时获取失败,仅记录错误日志,不阻塞服务启动;
|
||||
* 后续定时任务会持续尝试刷新,业务调用方在 adminToken 为空时会因鉴权失败而收到明确异常。
|
||||
* </p>
|
||||
*/
|
||||
@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
|
||||
* <p>
|
||||
* 若 token 尚未初始化(null 或空),则尝试立即刷新一次,防止 NPE 及空 token 导致的鉴权失败。
|
||||
* </p>
|
||||
*
|
||||
* @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
|
||||
* <p>
|
||||
* 获取 adminToken 属于鉴权前置操作,请求本身不能携带 adminToken 请求头。
|
||||
* 方法加锁,避免同进程内并发刷新互相顶掉(OpenIM 同 user 新 token 会使旧 token 变为 TokenKicked)。
|
||||
* </p>
|
||||
*/
|
||||
public synchronized void refreshToken() {
|
||||
String url = openIMProperties.resolveServerApiUrl() + "/auth/get_admin_token";
|
||||
|
||||
Map<String, Object> 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<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
|
||||
OpenIMApiClient.OpenIMBaseResp resp;
|
||||
try {
|
||||
ResponseEntity<OpenIMApiClient.OpenIMBaseResp> 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<String, Object> data = (Map<String, Object>) 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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> body = new HashMap<>();
|
||||
body.put("groupName", groupName);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("token", config.getAdminToken());
|
||||
|
||||
HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
|
||||
|
||||
try {
|
||||
ResponseEntity<Map> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> request = new HttpEntity<>(body, headers);
|
||||
|
||||
try {
|
||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, request, Map.class);
|
||||
log.info("getUserToken:{}", JSONUtil.toJsonStr(response.getBody()));
|
||||
if (response.getBody() != null && response.getBody().get("data") != null) {
|
||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
||||
return (String) data.get("token");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("获取OpenIM用户Token失败: userId={}, error={}", userId, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user