From 2de8fa02b235b4299869f26da87c98e69b79528e Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 3 Nov 2026 19:08:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=81=8A=E5=A4=A9=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=E6=B6=88=E6=81=AF=20fix:=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=A6=81=E7=94=A8bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/mysql/system/chat_conversation_menu.sql | 80 +++++++ ..._private_message_moderation_permission.sql | 64 ++++++ .../module/member/enums/MemberErrorCode.java | 2 + .../module/member/client/service/ChatApi.java | 6 + .../member/client/service/ChatApiImpl.java | 176 +++++++++++--- .../member/client/tool/ChatApiTool.java | 2 + .../ChatPrivateConversationPageReqVO.java | 43 ++++ .../ChatPrivateConversationRespVO.java | 25 ++ .../ChatPrivateMessageModerationReqVO.java | 22 ++ .../ChatPrivateMessageModerationRespVO.java | 16 ++ .../privatemsg/ChatPrivateMessageReqVO.java | 11 + .../admin/chat/ChatPrivateMsgController.java | 31 ++- .../ChatApiImplCreateOrUpdateTest.java | 188 +++++++++++++++ .../service/ChatApiImplPrivatePageTest.java | 217 ++++++++++++++++++ ...tPrivateMessageModerationSecurityTest.java | 82 +++++++ .../chat/ChatPrivateMsgControllerTest.java | 57 +++++ 16 files changed, 975 insertions(+), 47 deletions(-) create mode 100644 sql/mysql/system/chat_conversation_menu.sql create mode 100644 sql/mysql/system/chat_private_message_moderation_permission.sql create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationPageReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationReqVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationRespVO.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPrivatePageTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java create mode 100644 yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgControllerTest.java diff --git a/sql/mysql/system/chat_conversation_menu.sql b/sql/mysql/system/chat_conversation_menu.sql new file mode 100644 index 0000000..d92eb6c --- /dev/null +++ b/sql/mysql/system/chat_conversation_menu.sql @@ -0,0 +1,80 @@ +-- 聊天列表菜单增量,MySQL 8;在后台业务库中手动执行。 +-- 复用已有 chat/list/index 菜单;否则创建为原私聊记录菜单的同级菜单。 +-- 不固定菜单/角色/租户 ID,不删除现有菜单或授权;执行后重新登录刷新菜单缓存。 +-- 原私聊菜单缺失、存在歧义或同级路由冲突时明确报错,整个 DML 事务回滚。 +-- 此文件包含存储过程 DDL,请单独执行,不要包在其它迁移事务中。 + +DELIMITER $$ +DROP PROCEDURE IF EXISTS migrate_chat_conversation_menu_20261103$$ +CREATE PROCEDURE migrate_chat_conversation_menu_20261103() +BEGIN + DECLARE source_id BIGINT; + DECLARE target_id BIGINT; + DECLARE source_count INT; + DECLARE target_count INT; + DECLARE migration_lock INT DEFAULT 0; + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + IF migration_lock = 1 THEN + DO RELEASE_LOCK('chat_conversation_menu_20261103'); + END IF; + RESIGNAL; + END; + + SELECT GET_LOCK('chat_conversation_menu_20261103', 10) INTO migration_lock; + IF COALESCE(migration_lock, 0) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Cannot acquire chat menu migration lock'; + END IF; + START TRANSACTION; + + SELECT COUNT(*), MIN(id) INTO source_count, source_id + FROM system_menu + WHERE deleted = b'0' AND type = 2 + AND TRIM(LEADING '/' FROM component) IN ('chat/private-msg/index', 'chat/private-msg/index.vue'); + IF source_count <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Expected exactly one active chat/private-msg/index menu'; + END IF; + + SELECT COUNT(*), MIN(id) INTO target_count, target_id + FROM system_menu + WHERE deleted = b'0' + AND TRIM(LEADING '/' FROM component) IN ('chat/list/index', 'chat/list/index.vue'); + IF target_count > 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Multiple active chat/list/index menus exist'; + END IF; + + IF target_count = 0 THEN + IF EXISTS (SELECT 1 FROM system_menu m JOIN system_menu s ON s.id = source_id + WHERE m.parent_id = s.parent_id AND m.path = 'list' AND m.deleted = b'0') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Sibling route list already exists; resolve menu path first'; + END IF; + INSERT INTO system_menu + (name, permission, type, sort, parent_id, path, icon, component, component_name, + status, visible, keep_alive, always_show, creator, updater, deleted) + SELECT '聊天列表', permission, 2, sort + 1, parent_id, 'list', 'ep:chat-dot-round', + 'chat/list/index', 'ChatList', status, visible, keep_alive, always_show, + 'migration', 'migration', b'0' + FROM system_menu WHERE id = source_id; + SET target_id = LAST_INSERT_ID(); + ELSE + -- 保留已有菜单的路径、父目录、权限和可见性,仅对齐组件位置与缓存组件名。 + UPDATE system_menu SET component = 'chat/list/index', component_name = 'ChatList', updater = 'migration' + WHERE id = target_id; + END IF; + + INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id) + SELECT DISTINCT source.role_id, target_id, 'migration', 'migration', b'0', source.tenant_id + FROM system_role_menu source + WHERE source.menu_id = source_id AND source.deleted = b'0' + AND NOT EXISTS (SELECT 1 FROM system_role_menu target + WHERE target.role_id = source.role_id AND target.tenant_id = source.tenant_id + AND target.menu_id = target_id AND target.deleted = b'0'); + + COMMIT; + DO RELEASE_LOCK('chat_conversation_menu_20261103'); + SELECT target_id AS menu_id, 'chat/list/index' AS component, 'ChatList' AS component_name; +END$$ +CALL migrate_chat_conversation_menu_20261103()$$ +DROP PROCEDURE migrate_chat_conversation_menu_20261103$$ +DELIMITER ; diff --git a/sql/mysql/system/chat_private_message_moderation_permission.sql b/sql/mysql/system/chat_private_message_moderation_permission.sql new file mode 100644 index 0000000..afead03 --- /dev/null +++ b/sql/mysql/system/chat_private_message_moderation_permission.sql @@ -0,0 +1,64 @@ +-- 管理员屏蔽私聊消息权限,MySQL 8;交付后在后台业务库手动执行。 +-- 不修改消息数据。给现有聊天记录/聊天列表页面的角色补充同一操作权限。 +-- SQL 不会触发服务端角色权限缓存失效;执行后请在角色管理中重新保存受影响角色的菜单权限, +-- 再重新登录刷新前端权限。应用发布需同时更新 API 与 IM,uni-app 无需更新。 +-- 复用现有权限按钮,不固定菜单/角色/租户 ID,不删除已有菜单或授权。 +-- 此脚本含存储过程 DDL,请单独执行,不要包在其它迁移事务中。 +DELIMITER $$ +DROP PROCEDURE IF EXISTS migrate_chat_message_moderation_permission_20261103$$ +CREATE PROCEDURE migrate_chat_message_moderation_permission_20261103() +BEGIN + DECLARE source_id BIGINT; + DECLARE target_id BIGINT; + DECLARE migration_lock INT DEFAULT 0; + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + IF migration_lock = 1 THEN + DO RELEASE_LOCK('chat_message_moderation_permission_20261103'); + END IF; + RESIGNAL; + END; + SELECT GET_LOCK('chat_message_moderation_permission_20261103', 10) INTO migration_lock; + IF COALESCE(migration_lock, 0) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Cannot acquire chat moderation permission migration lock'; + END IF; + START TRANSACTION; + SELECT id INTO source_id FROM system_menu + WHERE deleted = b'0' AND type = 2 + AND TRIM(LEADING '/' FROM component) IN + ('chat/private-msg/index', 'chat/private-msg/index.vue', 'chat/list/index', 'chat/list/index.vue') + ORDER BY CASE WHEN TRIM(LEADING '/' FROM component) LIKE 'chat/private-msg/%' THEN 0 ELSE 1 END, id + LIMIT 1; + IF source_id IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'No active private-message or chat-list menu found'; + END IF; + + SELECT MIN(id) INTO target_id FROM system_menu + WHERE deleted = b'0' AND type = 3 AND permission = 'chat:private-msg:moderate'; + IF target_id IS NULL THEN + INSERT INTO system_menu + (name, permission, type, sort, parent_id, path, icon, component, component_name, + status, visible, keep_alive, always_show, creator, updater, deleted) + VALUES ('屏蔽私聊消息', 'chat:private-msg:moderate', 3, 10, source_id, '', '#', NULL, NULL, + 0, b'1', b'1', b'1', 'migration', 'migration', b'0'); + SET target_id = LAST_INSERT_ID(); + END IF; + + INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id) + SELECT DISTINCT source.role_id, target_id, 'migration', 'migration', b'0', source.tenant_id + FROM system_role_menu source + JOIN system_menu page ON page.id = source.menu_id AND page.deleted = b'0' AND page.type = 2 + WHERE source.deleted = b'0' + AND TRIM(LEADING '/' FROM page.component) IN + ('chat/private-msg/index', 'chat/private-msg/index.vue', 'chat/list/index', 'chat/list/index.vue') + AND NOT EXISTS (SELECT 1 FROM system_role_menu target + WHERE target.role_id = source.role_id AND target.tenant_id = source.tenant_id + AND target.menu_id = target_id AND target.deleted = b'0'); + COMMIT; + DO RELEASE_LOCK('chat_message_moderation_permission_20261103'); + SELECT target_id AS permission_menu_id, 'chat:private-msg:moderate' AS permission; +END$$ +CALL migrate_chat_message_moderation_permission_20261103()$$ +DROP PROCEDURE migrate_chat_message_moderation_permission_20261103$$ +DELIMITER ; diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java index b0bee96..91f2126 100644 --- a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java @@ -91,6 +91,8 @@ public interface MemberErrorCode { ErrorCode CHAT_USER_COUNT_EXCEED = new ErrorCode(1_004_018_000, "当天免费聊天人数已用完"); ErrorCode CHAT_MSG_COUNT_EXCEED = new ErrorCode(1_004_018_001, "当天免费聊天次数已用完"); ErrorCode CHAT_TOKEN_CREATE_FAILED = new ErrorCode(1_004_018_002, "聊天登录凭证签发失败,请稍后重试"); + ErrorCode CHAT_HISTORY_QUERY_FAILED = new ErrorCode(1_004_018_003, "聊天记录加载失败,请稍后重试"); + ErrorCode CHAT_MESSAGE_MODERATION_FAILED = new ErrorCode(1_004_018_004, "消息屏蔽失败,请稍后重试"); //========== 航空账号 1-004-019-000 ========== ErrorCode AIRLINE_ACCOUNT_NOT_EXISTS = new ErrorCode(1_004_019_000, "航空账号不存在"); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java index e5c2789..f5537b5 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java @@ -107,6 +107,12 @@ public interface ChatApi { */ ChatTableRespVO privateMsgPage(ChatPrivateMessageReqVO dto); + /** 按无向用户对查询双人聊天列表。 */ + ChatTableRespVO privateConversationPage(ChatPrivateConversationPageReqVO dto); + + /** operatorId 必须取自管理后台登录态,不能由请求体指定。 */ + ChatPrivateMessageModerationRespVO moderatePrivateMessage(ChatPrivateMessageModerationReqVO dto, Long operatorId); + /** * 私聊消息详情 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java index 7ed76e8..7b74f16 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java @@ -3,7 +3,13 @@ package cn.iocoder.yudao.module.member.client.service; import cn.hutool.core.util.ObjectUtil; import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; import cn.hutool.json.JSONUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONArray; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; import cn.iocoder.yudao.module.member.client.constant.ChatConstant; import cn.iocoder.yudao.module.member.client.tool.ChatApiTool; import cn.iocoder.yudao.module.member.client.vo.chat.ChatBaseRespVO; @@ -21,6 +27,9 @@ import javax.annotation.Resource; import java.time.Instant; import java.util.*; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.CHAT_HISTORY_QUERY_FAILED; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.CHAT_MESSAGE_MODERATION_FAILED; + @Slf4j @Service public class ChatApiImpl implements ChatApi { @@ -102,25 +111,49 @@ public class ChatApiImpl implements ChatApi { ChatApiTool.API api = setApiConfig(ChatApiTool.USER_SAVE_OR_UPDATE); try { String body = JSONUtil.toJsonStr(dto); - String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body(); - log.debug("创建或者更新聊天用户,res:{}", res); - if (ObjectUtil.isEmpty(res)) { - log.error("创建或者更新聊天用户,res为空"); - return false; + try (HttpResponse response = HttpRequest.post(api.getFullPath()) + .header(apiCountersign(body)) + .header("Accept", "application/json") + .contentType("application/json") + .body(body).timeout(10000).execute()) { + String res = response.body(); + String responseSummary = summarizeChatResponse(res); + if (!response.isOk() || !JSONUtil.isTypeJSONObject(res)) { + log.error("创建或者更新聊天用户,接口响应异常,url={},userId={},httpStatus={},contentType={},response={}", + api.getFullPath(), dto.getUserId(), response.getStatus(), + response.header("Content-Type"), responseSummary); + return false; + } + ChatBaseRespVO baseRes; + try { + baseRes = JSONUtil.toBean(res, ChatBaseRespVO.class); + } catch (Exception e) { + log.error("创建或者更新聊天用户,接口响应解析失败,url={},userId={},httpStatus={},contentType={},response={}", + api.getFullPath(), dto.getUserId(), response.getStatus(), + response.header("Content-Type"), responseSummary, e); + return false; + } + if (baseRes == null || !baseRes.isSuccess()) { + log.error("创建或者更新聊天用户,接口业务失败,url={},userId={},httpStatus={},response={}", + api.getFullPath(), dto.getUserId(), response.getStatus(), responseSummary); + return false; + } + return true; } - ChatBaseRespVO baseRes = JSONUtil.toBean(res, ChatBaseRespVO.class); - if (ObjectUtil.isEmpty(baseRes) || !baseRes.isSuccess()) { - log.error("创建或者更新聊天用户,返回错误"); - return false; - } - return true; } catch (Exception e) { - e.printStackTrace(); - log.error("创建或者更新聊天用户,意外的错误", e); + log.error("创建或者更新聊天用户,请求失败,url={},userId={}", api.getFullPath(), dto.getUserId(), e); return false; } } + private String summarizeChatResponse(String responseBody) { + if (responseBody == null) { + return null; + } + String summary = responseBody.replace('\r', ' ').replace('\n', ' '); + return summary.length() > 500 ? summary.substring(0, 500) + "..." : summary; + } + /** * 封禁聊天用户并推送下线 */ @@ -469,35 +502,102 @@ public class ChatApiImpl implements ChatApi { */ @Override public ChatTableRespVO privateMsgPage(ChatPrivateMessageReqVO dto) { - ChatApiTool.API api = setApiConfig(ChatApiTool.PRIVATE_MSG_PAGE); + return privatePage(ChatApiTool.PRIVATE_MSG_PAGE, dto, ChatPrivateMessageRespVO.class); + } + + @Override + public ChatTableRespVO privateConversationPage(ChatPrivateConversationPageReqVO dto) { + return privatePage(ChatApiTool.PRIVATE_CONVERSATION_PAGE, dto, ChatPrivateConversationRespVO.class); + } + + @Override + public ChatPrivateMessageModerationRespVO moderatePrivateMessage(ChatPrivateMessageModerationReqVO dto, Long operatorId) { + if (operatorId == null || operatorId <= 0) { + throw new ServiceException(GlobalErrorCodeConstants.FORBIDDEN); + } + Map request = new LinkedHashMap<>(); + request.put("messageId", dto.getMessageId()); + request.put("operatorId", operatorId); + request.put("reason", dto.getReason() == null ? null : dto.getReason().trim()); + JSONObject data = privateData(ChatApiTool.PRIVATE_MSG_MODERATE, request, CHAT_MESSAGE_MODERATION_FAILED); + try { + ChatPrivateMessageModerationRespVO result = data.toBean(ChatPrivateMessageModerationRespVO.class); + if (!Objects.equals(result.getMessageId(), dto.getMessageId()) + || !Integer.valueOf(2).equals(result.getStatus()) || result.getRecallMessageId() == null + || result.getAlreadyHidden() == null || result.getNotificationSent() == null) { + throw new ServiceException(CHAT_MESSAGE_MODERATION_FAILED); + } + log.info("管理员屏蔽私聊消息,messageId={},operatorId={},alreadyHidden={},notificationSent={}", + dto.getMessageId(), operatorId, result.getAlreadyHidden(), result.getNotificationSent()); + return result; + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + throw new ServiceException(CHAT_MESSAGE_MODERATION_FAILED); + } + } + + /** 正常空页保持数组;分页结构损坏不能当作暂无记录。 */ + private ChatTableRespVO privatePage(ChatApiTool.API apiDefinition, Object dto, Class rowType) { + JSONObject data = privateData(apiDefinition, dto, CHAT_HISTORY_QUERY_FAILED); + try { + Long total = data.getLong("total"); + if (total == null || total < 0 || !data.containsKey("rows")) { + throw new ServiceException(CHAT_HISTORY_QUERY_FAILED); + } + List rows = Collections.emptyList(); + if (!data.isNull("rows")) { + Object rawRows = data.get("rows"); + if (!(rawRows instanceof JSONArray)) { + throw new ServiceException(CHAT_HISTORY_QUERY_FAILED); + } + JSONArray array = (JSONArray) rawRows; + for (Object row : array) { + if (!(row instanceof JSONObject)) { + throw new ServiceException(CHAT_HISTORY_QUERY_FAILED); + } + } + rows = JSONUtil.toList(array, rowType); + } + return new ChatTableRespVO<>(rows, total); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + throw new ServiceException(CHAT_HISTORY_QUERY_FAILED); + } + } + + /** 统一签名与响应校验;日志只记录路由、状态和错误类型,不记录正文或凭证。 */ + private JSONObject privateData(ChatApiTool.API apiDefinition, Object dto, ErrorCode failure) { + String url = chatProperties.getBaseUrl() + apiDefinition.getUrl(); try { String body = JSONUtil.toJsonStr(dto); - String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body(); - log.debug("私聊消息分页,res:{}", res); - if (ObjectUtil.isEmpty(res)) { - log.error("私聊消息分页,res为空"); - return null; + try (HttpResponse response = HttpRequest.post(url).header(apiCountersign(body)) + .header("Accept", "application/json").contentType("application/json") + .body(body).timeout(10000).execute()) { + String res = response.body(); + if (!response.isOk() || !JSONUtil.isTypeJSONObject(res)) { + log.warn("聊天接口响应异常,url={},httpStatus={}", url, response.getStatus()); + throw new ServiceException(failure); + } + JSONObject root = JSONUtil.parseObj(res); + if (!Integer.valueOf(200).equals(root.getInt("code"))) { + log.warn("聊天接口业务失败,url={},upstreamCode={}", url, root.getInt("code")); + throw new ServiceException(failure); + } + Object rawData = root.get("data"); + if (!JSONUtil.isTypeJSONObject(String.valueOf(rawData))) { + log.warn("聊天接口数据格式异常,url={}", url); + throw new ServiceException(failure); + } + return rawData instanceof JSONObject ? (JSONObject) rawData : JSONUtil.parseObj((String) rawData); } - ChatBaseRespVO baseRes = JSONUtil.toBean(res, ChatBaseRespVO.class); - if (ObjectUtil.isEmpty(baseRes) || !baseRes.isSuccess()) { - log.error("私聊消息分页,返回错误"); - return null; - } - ChatTableRespVO vo = JSONUtil.toBean(baseRes.getData(), ChatTableRespVO.class); - if (ObjectUtil.isEmpty(vo)) { - log.error("私聊消息分页,返回json解析错误"); - return null; - } - if (ObjectUtil.isEmpty(vo.getRows())) { - log.error("私聊消息分页,rows为空"); - return null; - } - vo.setRows(JSONUtil.toList(String.valueOf(vo.getRows()), ChatPrivateMessageRespVO.class)); - return vo; + } catch (ServiceException e) { + throw e; } catch (Exception e) { - e.printStackTrace(); - log.error("私聊消息分页,意外的错误", e); - return null; + // 解析异常可能包含响应原文,只记录异常类型。 + log.warn("聊天接口请求失败,url={},exceptionType={}", url, e.getClass().getSimpleName()); + throw new ServiceException(failure); } } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java index 3d2e0a4..577e533 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java @@ -30,6 +30,8 @@ public class ChatApiTool { // 私聊 public static final API PRIVATE_MSG_SEND = API.builder().summary("发送私聊消息").url("/third/privateMsg/send").method(HttpMethod.POST).build(); public static final API PRIVATE_MSG_PAGE = API.builder().summary("私聊分页").url("/third/privateMsg/page").method(HttpMethod.POST).build(); + public static final API PRIVATE_CONVERSATION_PAGE = API.builder().summary("双人聊天列表分页").url("/third/privateMsg/conversation/page").method(HttpMethod.POST).build(); + public static final API PRIVATE_MSG_MODERATE = API.builder().summary("管理员屏蔽私聊消息").url("/third/privateMsg/moderate").method(HttpMethod.POST).build(); public static final API PRIVATE_MSG_INFO = API.builder().summary("私聊详情").url("/third/privateMsg/getById").method(HttpMethod.POST).build(); public static final API PRIVATE_MSG_COUNT = API.builder().summary("私聊统计").url("/third/privateMsg/dailyCount").method(HttpMethod.POST).build(); public static final API PRIVATE_MSG_TOTAL = API.builder().summary("私聊消息发送消息的统计").url("/third/privateMsg/total").method(HttpMethod.POST).build(); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationPageReqVO.java new file mode 100644 index 0000000..e4c672b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationPageReqVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.member.client.vo.chat.privatemsg; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.util.Date; +import java.util.List; + +@Data +@Schema(description = "管理后台 - 双人聊天列表分页请求") +public class ChatPrivateConversationPageReqVO { + + @NotNull @Min(1) + private Integer pageNum = 1; + + @NotNull @Min(1) @Max(100) + private Integer pageSize = 20; + + @Size(max = 100) + @Schema(description = "参与用户的昵称、账号或用户 ID") + private String userKeyword; + + @Size(max = 100) + @Schema(description = "另一位用户的昵称、账号或用户 ID,不区分发送方向") + private String peerKeyword; + + @Schema(description = "最近聊天时间范围,毫秒时间戳,包含两端") + private List lastSendTimeList; + + @JsonIgnore + @AssertTrue(message = "最近聊天时间范围必须包含有效的开始、结束时间,且开始不晚于结束") + public boolean isLastSendTimeRangeValid() { + return lastSendTimeList == null || lastSendTimeList.isEmpty() || (lastSendTimeList.size() == 2 + && lastSendTimeList.get(0) != null && lastSendTimeList.get(1) != null + && !lastSendTimeList.get(0).after(lastSendTimeList.get(1))); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationRespVO.java new file mode 100644 index 0000000..ed7a99b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateConversationRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.member.client.vo.chat.privatemsg; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "管理后台 - 双人聊天会话") +public class ChatPrivateConversationRespVO { + + private User userA; + private User userB; + + @Schema(description = "双方全部存储记录条数,包含撤回事件") + private Long messageCount; + + private ChatPrivateMessageRespVO lastMessage; + + @Data + public static class User { + private Long id; + private String nickname; + private String username; + private String avatar; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationReqVO.java new file mode 100644 index 0000000..affdb93 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.client.vo.chat.privatemsg; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; +import javax.validation.constraints.Size; + +@Data +@Schema(description = "管理后台 - 屏蔽私聊消息请求") +public class ChatPrivateMessageModerationReqVO { + + @NotNull + @Positive + @Schema(description = "原消息 ID,屏蔽具体消息而非整个会话", requiredMode = Schema.RequiredMode.REQUIRED) + private Long messageId; + + @Size(max = 200) + @Schema(description = "处理原因,最多 200 字") + private String reason; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationRespVO.java new file mode 100644 index 0000000..9181567 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageModerationRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.member.client.vo.chat.privatemsg; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "管理后台 - 私聊消息屏蔽结果") +public class ChatPrivateMessageModerationRespVO { + private Long messageId; + private Integer status; + private Long recallMessageId; + private Boolean alreadyHidden; + + @Schema(description = "是否成功提交实时撤回通知,false 时仍已屏蔽,可重试同步;离线重连会补偿") + private Boolean notificationSent; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageReqVO.java index 126daf1..16f80c8 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/privatemsg/ChatPrivateMessageReqVO.java @@ -2,6 +2,9 @@ package cn.iocoder.yudao.module.member.client.vo.chat.privatemsg; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import javax.validation.constraints.AssertTrue; import java.util.Date; import java.util.HashMap; @@ -51,4 +54,12 @@ public class ChatPrivateMessageReqVO { @Schema(description = "请求参数") private Map params = new HashMap<>(); + @JsonIgnore + @AssertTrue(message = "发送时间范围必须包含有效的开始、结束时间,且开始不晚于结束") + public boolean isSendTimeRangeValid() { + return sendTimeList == null || sendTimeList.isEmpty() || (sendTimeList.size() == 2 + && sendTimeList.get(0) != null && sendTimeList.get(1) != null + && !sendTimeList.get(0).after(sendTimeList.get(1))); + } + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java index bd3d7fc..a3bfd6c 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgController.java @@ -1,6 +1,5 @@ package cn.iocoder.yudao.module.member.controller.admin.chat; -import cn.hutool.core.util.ObjectUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.member.client.service.ChatApi; @@ -9,10 +8,14 @@ import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessa import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageInfoReqVO; import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageReqVO; import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageRespVO; -import cn.iocoder.yudao.module.member.client.vo.chat.sensitive.ChatSensitiveRespVO; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateConversationPageReqVO; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateConversationRespVO; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageModerationReqVO; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageModerationRespVO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.validation.annotation.Validated; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; @@ -22,6 +25,7 @@ import java.util.List; import java.util.Map; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; @Tag(name = "管理后台 - 私聊") @RestController @@ -34,16 +38,25 @@ public class ChatPrivateMsgController { @PostMapping("/page") @Operation(summary = "查询私聊消息列表", description = "查询私聊消息列表") - public CommonResult> page(@Valid @RequestBody ChatPrivateMessageReqVO dto) { + public CommonResult> page(@Valid @RequestBody ChatPrivateMessageReqVO dto) { ChatTableRespVO vo = chatApi.privateMsgPage(dto); + return success(new PageResult<>(vo.getRows(), vo.getTotal())); + } - PageResult pageResult = new PageResult(); - if (ObjectUtil.isNotEmpty(vo) && ObjectUtil.isNotEmpty(vo.getRows())) { - pageResult.setTotal(vo.getTotal()); - pageResult.setList(vo.getRows()); - } + @PostMapping("/conversation/page") + @Operation(summary = "查询双人聊天列表") + public CommonResult> conversationPage( + @Valid @RequestBody ChatPrivateConversationPageReqVO dto) { + ChatTableRespVO vo = chatApi.privateConversationPage(dto); + return success(new PageResult<>(vo.getRows(), vo.getTotal())); + } - return success(pageResult); + @PostMapping("/moderate") + @PreAuthorize("@ss.hasPermission('chat:private-msg:moderate')") + @Operation(summary = "管理员屏蔽私聊消息", description = "保留后台记录,使用现有撤回协议隐藏 App 原内容") + public CommonResult moderate( + @Valid @RequestBody ChatPrivateMessageModerationReqVO dto) { + return success(chatApi.moderatePrivateMessage(dto, getLoginUserId())); } @GetMapping("/getById") diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java new file mode 100644 index 0000000..8418aa7 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java @@ -0,0 +1,188 @@ +package cn.iocoder.yudao.module.member.client.service; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatCreateOrUpdateUserReqVO; +import cn.iocoder.yudao.module.member.framework.web.config.ChatProperties; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class ChatApiImplCreateOrUpdateTest { + + private HttpServer server; + private ChatApiImpl chatApi; + private Logger logger; + private ListAppender logs; + private volatile int responseStatus = 200; + private volatile String responseType = "application/json"; + private volatile String responseBody = "{\"code\":200,\"message\":\"成功\",\"data\":null}"; + private final AtomicReference requestAccept = new AtomicReference<>(); + private final AtomicReference requestContentType = new AtomicReference<>(); + private final AtomicReference requestBody = new AtomicReference<>(); + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/third/user/createOrUpdate", this::respond); + server.start(); + + ChatProperties properties = new ChatProperties(); + properties.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + properties.setAppKey("test-app-key"); + properties.setAppSecret("test-app-secret"); + chatApi = new ChatApiImpl(); + ReflectionTestUtils.setField(chatApi, "chatProperties", properties); + + logger = (Logger) LoggerFactory.getLogger(ChatApiImpl.class); + logs = new ListAppender<>(); + logs.start(); + logger.addAppender(logs); + } + + @AfterEach + void tearDown() { + logger.detachAppender(logs); + logs.stop(); + server.stop(0); + } + + @Test + void successfulResponseIsAcceptedAndRequestUsesJson() { + assertTrue(chatApi.createOrUpdate(request())); + + assertEquals("application/json", requestAccept.get()); + assertTrue(requestContentType.get().startsWith("application/json")); + assertTrue(requestBody.get().contains("\"userId\":-100")); + } + + @Test + void gatewayHtmlIsRejectedWithStatusAndResponseInLogWithoutJsonException() { + responseStatus = 502; + responseType = "text/html"; + responseBody = "\r\n502 Bad Gateway"; + + assertFalse(chatApi.createOrUpdate(request())); + + ILoggingEvent event = lastLog(); + assertTrue(event.getFormattedMessage().contains("httpStatus=502")); + assertTrue(event.getFormattedMessage().contains("contentType=text/html")); + assertTrue(event.getFormattedMessage().contains("502 Bad Gateway")); + assertFalse(event.getFormattedMessage().contains("\n")); + assertNull(event.getThrowableProxy()); + } + + @Test + void htmlWithSuccessfulHttpStatusIsStillRejected() { + responseType = "text/html"; + responseBody = "Service unavailable"; + + assertFalse(chatApi.createOrUpdate(request())); + assertNull(lastLog().getThrowableProxy()); + } + + @Test + void errorHttpStatusCannotBeMaskedBySuccessBusinessCode() { + responseStatus = 500; + + assertFalse(chatApi.createOrUpdate(request())); + assertTrue(lastLog().getFormattedMessage().contains("httpStatus=500")); + } + + @Test + void businessErrorIsRejectedAndDetailsAreLogged() { + responseBody = "{\"code\":10003,\"message\":\"该用户名已注册\",\"data\":null}"; + + assertFalse(chatApi.createOrUpdate(request())); + assertTrue(lastLog().getFormattedMessage().contains("10003")); + } + + @Test + void emptyResponseIsRejectedWithoutJsonException() { + responseBody = ""; + + assertFalse(chatApi.createOrUpdate(request())); + assertNull(lastLog().getThrowableProxy()); + } + + @Test + void arrayResponseIsRejectedWithoutJsonException() { + responseBody = "[1,2,3]"; + + assertFalse(chatApi.createOrUpdate(request())); + assertNull(lastLog().getThrowableProxy()); + } + + @Test + void malformedObjectIsRejectedWithResponseContext() { + responseBody = "{invalid}"; + + assertFalse(chatApi.createOrUpdate(request())); + assertTrue(lastLog().getFormattedMessage().contains("response={invalid}")); + assertTrue(lastLog().getFormattedMessage().contains("httpStatus=200")); + } + + @Test + void longErrorResponseIsTruncatedInLog() { + responseStatus = 502; + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + body.append('x'); + } + responseBody = body.append("UNLOGGED_TAIL").toString(); + + assertFalse(chatApi.createOrUpdate(request())); + + String message = lastLog().getFormattedMessage(); + assertTrue(message.endsWith("...")); + assertFalse(message.contains("UNLOGGED_TAIL")); + assertTrue(message.length() < 1000); + } + + private void respond(HttpExchange exchange) throws IOException { + try { + requestAccept.set(exchange.getRequestHeaders().getFirst("Accept")); + requestContentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + byte[] input = new byte[4096]; + ByteArrayOutputStream requestBytes = new ByteArrayOutputStream(); + int count; + while ((count = exchange.getRequestBody().read(input)) != -1) { + requestBytes.write(input, 0, count); + } + requestBody.set(new String(requestBytes.toByteArray(), StandardCharsets.UTF_8)); + byte[] output = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", responseType + ";charset=UTF-8"); + exchange.sendResponseHeaders(responseStatus, output.length); + exchange.getResponseBody().write(output); + } finally { + exchange.close(); + } + } + + private ILoggingEvent lastLog() { + assertFalse(logs.list.isEmpty()); + return logs.list.get(logs.list.size() - 1); + } + + private ChatCreateOrUpdateUserReqVO request() { + ChatCreateOrUpdateUserReqVO dto = new ChatCreateOrUpdateUserReqVO(); + dto.setUserId(-100L); + dto.setUserName("message-notification"); + dto.setPhone("15555555555"); + dto.setNickName("消息通知"); + return dto; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPrivatePageTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPrivatePageTest.java new file mode 100644 index 0000000..6fb3387 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplPrivatePageTest.java @@ -0,0 +1,217 @@ +package cn.iocoder.yudao.module.member.client.service; + +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.member.client.vo.chat.ChatTableRespVO; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.*; +import cn.iocoder.yudao.module.member.framework.web.config.ChatProperties; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Date; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.CHAT_HISTORY_QUERY_FAILED; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.CHAT_MESSAGE_MODERATION_FAILED; +import static org.junit.jupiter.api.Assertions.*; + +class ChatApiImplPrivatePageTest { + private HttpServer server; + private ChatApiImpl chatApi; + private volatile String responseBody; + private volatile int responseStatus = 200; + private final AtomicReference requestBody = new AtomicReference<>(); + private final AtomicReference requestPath = new AtomicReference<>(); + private final AtomicReference sign = new AtomicReference<>(); + private final AtomicReference timestamp = new AtomicReference<>(); + private final AtomicReference contentType = new AtomicReference<>(); + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/third/privateMsg/", this::respond); + server.start(); + ChatProperties properties = new ChatProperties(); + properties.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + properties.setAppKey("test-key"); + properties.setAppSecret("test-secret"); + chatApi = new ChatApiImpl(); + ReflectionTestUtils.setField(chatApi, "chatProperties", properties); + responseBody = "{\"code\":200,\"data\":{\"rows\":[],\"total\":0}}"; + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + @Test + void conversationRequestIsSignedAndNestedRowsAreTyped() { + responseBody = "{\"code\":200,\"data\":{\"total\":1,\"rows\":[{" + + "\"userA\":{\"id\":1,\"nickname\":\"甲\",\"username\":\"account-a\",\"avatar\":\"a.png\"}," + + "\"userB\":{\"id\":2,\"nickname\":\"乙\"},\"messageCount\":12," + + "\"lastMessage\":{\"id\":35,\"sendId\":2,\"recvId\":1,\"content\":\"你好\"," + + "\"type\":0,\"status\":3,\"sendTime\":1793664000000}}]}}"; + ChatPrivateConversationPageReqVO request = new ChatPrivateConversationPageReqVO(); + request.setUserKeyword("甲"); + request.setPeerKeyword("account-b"); + request.setLastSendTimeList(Arrays.asList(new Date(1793635200000L), new Date(1793721599999L))); + + ChatTableRespVO result = chatApi.privateConversationPage(request); + + assertEquals("/third/privateMsg/conversation/page", requestPath.get()); + assertTrue(contentType.get().startsWith("application/json")); + assertEquals(DigestUtils.md5Hex("test-keytest-secret10" + timestamp.get() + requestBody.get()), sign.get()); + assertEquals("甲", JSONUtil.parseObj(requestBody.get()).getStr("userKeyword")); + assertEquals(2, JSONUtil.parseObj(requestBody.get()).getJSONArray("lastSendTimeList").size()); + assertEquals(1, result.getTotal()); + assertEquals("account-a", result.getRows().get(0).getUserA().getUsername()); + assertEquals(12L, result.getRows().get(0).getMessageCount()); + assertEquals("你好", result.getRows().get(0).getLastMessage().getContent()); + assertEquals(new Date(1793664000000L), result.getRows().get(0).getLastMessage().getSendTime()); + } + + @Test + void emptyAndOutOfRangePagesKeepNonNullRowsAndRealTotal() { + ChatTableRespVO empty = chatApi.privateMsgPage(messageRequest()); + assertNotNull(empty.getRows()); + assertTrue(empty.getRows().isEmpty()); + assertEquals(0, empty.getTotal()); + responseBody = "{\"code\":200,\"data\":{\"rows\":[],\"total\":41}}"; + ChatTableRespVO page = chatApi.privateConversationPage(new ChatPrivateConversationPageReqVO()); + assertTrue(page.getRows().isEmpty()); + assertEquals(41, page.getTotal()); + responseBody = "{\"code\":200,\"data\":{\"rows\":null,\"total\":0}}"; + assertNotNull(chatApi.privateMsgPage(messageRequest()).getRows()); + } + + @Test + void detailsForwardPairContentAndExactEndOfDay() { + ChatPrivateMessageReqVO request = messageRequest(); + request.setContent("hello"); + request.setSendTimeList(Arrays.asList(new Date(1793635200000L), new Date(1793721599999L))); + chatApi.privateMsgPage(request); + assertEquals("/third/privateMsg/page", requestPath.get()); + assertEquals(1L, JSONUtil.parseObj(requestBody.get()).getLong("sendId")); + assertEquals(2L, JSONUtil.parseObj(requestBody.get()).getLong("recvId")); + assertEquals("hello", JSONUtil.parseObj(requestBody.get()).getStr("content")); + ChatPrivateMessageReqVO forwarded = JSONUtil.toBean(requestBody.get(), ChatPrivateMessageReqVO.class); + assertEquals(request.getSendTimeList(), forwarded.getSendTimeList()); + } + + @Test + void httpAndMalformedOrBusinessFailuresAreNotEmptyResults() { + String[] failures = {"", "Gateway error", "{invalid}", "[]", + "{\"code\":500,\"data\":{\"rows\":[],\"total\":0}}", + "{\"code\":200,\"data\":null}", + "{\"code\":200,\"data\":{\"rows\":[]}}", + "{\"code\":200,\"data\":{\"rows\":[],\"total\":-1}}", + "{\"code\":200,\"data\":{\"rows\":{},\"total\":1}}", + "{\"code\":200,\"data\":{\"rows\":[null],\"total\":1}}"}; + for (String failure : failures) { + responseBody = failure; + ServiceException exception = assertThrows(ServiceException.class, () -> chatApi.privateMsgPage(messageRequest())); + assertEquals(CHAT_HISTORY_QUERY_FAILED.getCode(), exception.getCode()); + } + responseStatus = 502; + responseBody = "{\"code\":200,\"data\":{\"rows\":[],\"total\":0}}"; + assertThrows(ServiceException.class, () -> chatApi.privateConversationPage(new ChatPrivateConversationPageReqVO())); + } + + @Test + void connectionFailureIsVisible() { + server.stop(0); + assertThrows(ServiceException.class, () -> chatApi.privateMsgPage(messageRequest())); + } + + @Test + void moderationForwardsTrustedOperatorAndPreservesLargeMessageIds() { + long messageId = 9223372036854775806L; + responseBody = "{\"code\":200,\"data\":{\"messageId\":\"9223372036854775806\"," + + "\"status\":2,\"recallMessageId\":\"9223372036854775807\"," + + "\"alreadyHidden\":false,\"notificationSent\":true}}"; + ChatPrivateMessageModerationReqVO request = new ChatPrivateMessageModerationReqVO(); + request.setMessageId(messageId); + request.setReason(" 违规内容 "); + + ChatPrivateMessageModerationRespVO result = chatApi.moderatePrivateMessage(request, 42L); + + assertEquals("/third/privateMsg/moderate", requestPath.get()); + assertEquals(messageId, JSONUtil.parseObj(requestBody.get()).getLong("messageId")); + assertEquals(42L, JSONUtil.parseObj(requestBody.get()).getLong("operatorId")); + assertEquals("违规内容", JSONUtil.parseObj(requestBody.get()).getStr("reason")); + assertEquals(DigestUtils.md5Hex("test-keytest-secret10" + timestamp.get() + requestBody.get()), sign.get()); + assertEquals(messageId, result.getMessageId()); + assertEquals(Long.MAX_VALUE, result.getRecallMessageId()); + assertTrue(result.getNotificationSent()); + } + + @Test + void moderationKeepsCommittedButPendingNotificationResultRetryable() { + responseBody = "{\"code\":200,\"data\":{\"messageId\":1,\"status\":2," + + "\"recallMessageId\":3,\"alreadyHidden\":true,\"notificationSent\":false}}"; + ChatPrivateMessageModerationReqVO request = new ChatPrivateMessageModerationReqVO(); + request.setMessageId(1L); + ChatPrivateMessageModerationRespVO result = chatApi.moderatePrivateMessage(request, 42L); + assertTrue(result.getAlreadyHidden()); + assertFalse(result.getNotificationSent()); + assertEquals(2, result.getStatus()); + } + + @Test + void moderationRejectsUntrustedActorAndWrongMessageOrPartialResult() { + ChatPrivateMessageModerationReqVO request = new ChatPrivateMessageModerationReqVO(); + request.setMessageId(1L); + assertThrows(ServiceException.class, () -> chatApi.moderatePrivateMessage(request, null)); + assertNull(requestPath.get(), "没有管理员身份时不能调用 IM"); + String[] invalid = { + "{\"code\":500,\"message\":\"系统繁忙\",\"data\":null}", + "{\"code\":200,\"data\":{\"messageId\":2,\"status\":2,\"recallMessageId\":3,\"alreadyHidden\":false,\"notificationSent\":true}}", + "{\"code\":200,\"data\":{\"messageId\":1,\"status\":2,\"recallMessageId\":3,\"alreadyHidden\":false}}" + }; + for (String body : invalid) { + responseBody = body; + ServiceException exception = assertThrows(ServiceException.class, () -> chatApi.moderatePrivateMessage(request, 42L)); + assertEquals(CHAT_MESSAGE_MODERATION_FAILED.getCode(), exception.getCode()); + } + } + + private ChatPrivateMessageReqVO messageRequest() { + ChatPrivateMessageReqVO request = new ChatPrivateMessageReqVO(); + request.setPageNum(1); + request.setPageSize(20); + request.setSendId(1L); + request.setRecvId(2L); + return request; + } + + private void respond(HttpExchange exchange) throws IOException { + try { + requestPath.set(exchange.getRequestURI().getPath()); + contentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + sign.set(exchange.getRequestHeaders().getFirst("SIGN")); + timestamp.set(exchange.getRequestHeaders().getFirst("TIMESTAMP")); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = exchange.getRequestBody().read(buffer)) != -1) bytes.write(buffer, 0, read); + requestBody.set(new String(bytes.toByteArray(), StandardCharsets.UTF_8)); + byte[] output = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json;charset=UTF-8"); + exchange.sendResponseHeaders(responseStatus, output.length == 0 ? -1 : output.length); + if (output.length > 0) exchange.getResponseBody().write(output); + } finally { + exchange.close(); + } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java new file mode 100644 index 0000000..a3f072c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMessageModerationSecurityTest.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.member.controller.admin.chat; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.module.member.client.service.ChatApi; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.ChatPrivateMessageModerationReqVO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; + +import javax.validation.Validation; +import javax.validation.ValidatorFactory; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ChatPrivateMessageModerationSecurityTest { + + @AfterEach + void clearSecurity() { + SecurityContextHolder.clearContext(); + } + + @Test + void moderationRequiresPermissionAndOperatorComesFromLoginContext() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class)) { + LoginUser user = new LoginUser(); + user.setId(42L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList())); + ChatPrivateMsgController controller = context.getBean(ChatPrivateMsgController.class); + ChatApi api = context.getBean(ChatApi.class); + Permission permission = context.getBean(Permission.class); + ChatPrivateMessageModerationReqVO request = new ChatPrivateMessageModerationReqVO(); + request.setMessageId(7L); + + assertThrows(AccessDeniedException.class, () -> controller.moderate(request)); + verifyNoInteractions(api); + permission.allowed = true; + controller.moderate(request); + verify(api).moderatePrivateMessage(same(request), eq(42L)); + assertEquals("chat:private-msg:moderate", permission.checked); + } + } + + @Test + void moderationValidatesMessageIdAndReasonLength() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + ChatPrivateMessageModerationReqVO request = new ChatPrivateMessageModerationReqVO(); + request.setReason(String.join("", Collections.nCopies(201, "字"))); + assertEquals(2, factory.getValidator().validate(request).size()); + request.setMessageId(-1L); + request.setReason("违规"); + assertEquals(1, factory.getValidator().validate(request).size()); + request.setMessageId(1L); + assertTrue(factory.getValidator().validate(request).isEmpty()); + } + } + + public static class Permission { + boolean allowed; + String checked; + public boolean hasPermission(String value) { + checked = value; + return allowed; + } + } + + @Configuration + @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) + static class Config { + @Bean public ChatApi chatApi() { return mock(ChatApi.class); } + @Bean public ChatPrivateMsgController controller() { return new ChatPrivateMsgController(); } + @Bean(name = "ss") public Permission permission() { return new Permission(); } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgControllerTest.java new file mode 100644 index 0000000..93ecc60 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/chat/ChatPrivateMsgControllerTest.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.member.controller.admin.chat; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.client.service.ChatApi; +import cn.iocoder.yudao.module.member.client.vo.chat.ChatTableRespVO; +import cn.iocoder.yudao.module.member.client.vo.chat.privatemsg.*; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.validation.Validation; +import javax.validation.ValidatorFactory; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ChatPrivateMsgControllerTest { + + @Test + void emptyPagesPreserveTotalForMessagesAndConversations() { + ChatApi api = mock(ChatApi.class); + when(api.privateMsgPage(any())).thenReturn(new ChatTableRespVO<>(Collections.emptyList(), 23)); + when(api.privateConversationPage(any())).thenReturn(new ChatTableRespVO<>(Collections.emptyList(), 7)); + ChatPrivateMsgController controller = new ChatPrivateMsgController(); + ReflectionTestUtils.setField(controller, "chatApi", api); + PageResult messages = controller.page(new ChatPrivateMessageReqVO()).getData(); + assertTrue(messages.getList().isEmpty()); + assertEquals(23L, messages.getTotal()); + PageResult conversations = controller.conversationPage(new ChatPrivateConversationPageReqVO()).getData(); + assertTrue(conversations.getList().isEmpty()); + assertEquals(7L, conversations.getTotal()); + } + + @Test + void validationRejectsInvalidRangesAndPagination() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + ChatPrivateConversationPageReqVO request = new ChatPrivateConversationPageReqVO(); + assertTrue(factory.getValidator().validate(request).isEmpty()); + request.setPageNum(0); + request.setPageSize(101); + request.setLastSendTimeList(Collections.singletonList(new Date())); + assertEquals(3, factory.getValidator().validate(request).size()); + request.setPageNum(1); + request.setPageSize(20); + request.setLastSendTimeList(Arrays.asList(new Date(2), new Date(1))); + assertFalse(factory.getValidator().validate(request).isEmpty()); + ChatPrivateMessageReqVO detail = new ChatPrivateMessageReqVO(); + detail.setSendTimeList(Arrays.asList(new Date(), null)); + assertFalse(factory.getValidator().validate(detail).isEmpty()); + detail.setSendTimeList(Arrays.asList(new Date(1), new Date(1))); + assertTrue(factory.getValidator().validate(detail).isEmpty()); + } + } +}