feat:聊天后台撤回消息

fix:聊天禁用bug
This commit is contained in:
dzc committed 2026-11-03 19:08:57 +08:00
1 parent e4b079279b
commit 2de8fa02b2
16 files changed
+975 -47

No files matched your search

@@ -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 ;
@@ -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 ;
@@ -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, "航空账号不存在");
@@ -107,6 +107,12 @@ public interface ChatApi {
*/
ChatTableRespVO<ChatPrivateMessageRespVO> privateMsgPage(ChatPrivateMessageReqVO dto);
/** 按无向用户对查询双人聊天列表。 */
ChatTableRespVO<ChatPrivateConversationRespVO> privateConversationPage(ChatPrivateConversationPageReqVO dto);
/** operatorId 必须取自管理后台登录态,不能由请求体指定。 */
ChatPrivateMessageModerationRespVO moderatePrivateMessage(ChatPrivateMessageModerationReqVO dto, Long operatorId);
/**
* 私聊消息详情
*/
@@ -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<ChatPrivateMessageRespVO> privateMsgPage(ChatPrivateMessageReqVO dto) {
ChatApiTool.API api = setApiConfig(ChatApiTool.PRIVATE_MSG_PAGE);
return privatePage(ChatApiTool.PRIVATE_MSG_PAGE, dto, ChatPrivateMessageRespVO.class);
}
@Override
public ChatTableRespVO<ChatPrivateConversationRespVO> 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<String, Object> 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 <T> ChatTableRespVO<T> privatePage(ChatApiTool.API apiDefinition, Object dto, Class<T> 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<T> 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);
}
}
@@ -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();
@@ -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<Date> 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)));
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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<String, Object> 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)));
}
}
@@ -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<PageResult<ChatSensitiveRespVO>> page(@Valid @RequestBody ChatPrivateMessageReqVO dto) {
public CommonResult<PageResult<ChatPrivateMessageRespVO>> page(@Valid @RequestBody ChatPrivateMessageReqVO dto) {
ChatTableRespVO<ChatPrivateMessageRespVO> 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<PageResult<ChatPrivateConversationRespVO>> conversationPage(
@Valid @RequestBody ChatPrivateConversationPageReqVO dto) {
ChatTableRespVO<ChatPrivateConversationRespVO> 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<ChatPrivateMessageModerationRespVO> moderate(
@Valid @RequestBody ChatPrivateMessageModerationReqVO dto) {
return success(chatApi.moderatePrivateMessage(dto, getLoginUserId()));
}
@GetMapping("/getById")
@@ -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<ILoggingEvent> logs;
private volatile int responseStatus = 200;
private volatile String responseType = "application/json";
private volatile String responseBody = "{\"code\":200,\"message\":\"成功\",\"data\":null}";
private final AtomicReference<String> requestAccept = new AtomicReference<>();
private final AtomicReference<String> requestContentType = new AtomicReference<>();
private final AtomicReference<String> 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 = "<html>\r\n502 Bad Gateway</html>";
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 = "<html>Service unavailable</html>";
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;
}
}
@@ -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<String> requestBody = new AtomicReference<>();
private final AtomicReference<String> requestPath = new AtomicReference<>();
private final AtomicReference<String> sign = new AtomicReference<>();
private final AtomicReference<String> timestamp = new AtomicReference<>();
private final AtomicReference<String> 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<ChatPrivateConversationRespVO> 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<ChatPrivateMessageRespVO> empty = chatApi.privateMsgPage(messageRequest());
assertNotNull(empty.getRows());
assertTrue(empty.getRows().isEmpty());
assertEquals(0, empty.getTotal());
responseBody = "{\"code\":200,\"data\":{\"rows\":[],\"total\":41}}";
ChatTableRespVO<ChatPrivateConversationRespVO> 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 = {"", "<html>Gateway error</html>", "{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();
}
}
}
@@ -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(); }
}
}
@@ -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<ChatPrivateMessageRespVO> messages = controller.page(new ChatPrivateMessageReqVO()).getData();
assertTrue(messages.getList().isEmpty());
assertEquals(23L, messages.getTotal());
PageResult<ChatPrivateConversationRespVO> 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());
}
}
}