feat:聊天后台撤回消息
fix:聊天禁用bug
This commit is contained in:
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 ;
|
||||
+2
@@ -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, "航空账号不存在");
|
||||
|
||||
+6
@@ -107,6 +107,12 @@ public interface ChatApi {
|
||||
*/
|
||||
ChatTableRespVO<ChatPrivateMessageRespVO> privateMsgPage(ChatPrivateMessageReqVO dto);
|
||||
|
||||
/** 按无向用户对查询双人聊天列表。 */
|
||||
ChatTableRespVO<ChatPrivateConversationRespVO> privateConversationPage(ChatPrivateConversationPageReqVO dto);
|
||||
|
||||
/** operatorId 必须取自管理后台登录态,不能由请求体指定。 */
|
||||
ChatPrivateMessageModerationRespVO moderatePrivateMessage(ChatPrivateMessageModerationReqVO dto, Long operatorId);
|
||||
|
||||
/**
|
||||
* 私聊消息详情
|
||||
*/
|
||||
|
||||
+138
-38
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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();
|
||||
|
||||
+43
@@ -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)));
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+11
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
+22
-9
@@ -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")
|
||||
|
||||
+188
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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(); }
|
||||
}
|
||||
}
|
||||
+57
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user