bug:修复消息不能撤回

This commit is contained in:
xiao12feng8 2025-12-30 11:11:07 +08:00
parent 7d1896d9d2
commit e83568f5fb
10 changed files with 951 additions and 163 deletions

View File

@ -10,7 +10,27 @@ export function sessionDetailApi(id) {
return request({ url: '/admin/session/detail/' + id, method: 'get' })
}
// 会话消息列表
export function sessionMessagesApi(conversationId, params) {
return request({ url: '/admin/session/' + conversationId + '/messages', method: 'get', params })
}
// 删除会话
export function sessionDeleteApi(id) {
return request({ url: '/admin/session/delete/' + id, method: 'post' })
}
// 删除消息
export function sessionMessageDeleteApi(messageId) {
return request({ url: '/admin/session/message/delete/' + messageId, method: 'post' })
}
// 撤回消息
export function sessionMessageRecallApi(messageId) {
return request({ url: '/admin/session/message/recall/' + messageId, method: 'post' })
}
// 会话统计
export function sessionStatisticsApi() {
return request({ url: '/admin/session/statistics', method: 'get' })
}

View File

@ -1,164 +1,602 @@
<template>
<div class="divBox">
<el-card shadow="never" class="ivu-mt">
<div class="divBox relative">
<el-card :bordered="false" shadow="never" class="ivu-mt" :body-style="{ padding: 0 }">
<div class="padding-add">
<!-- 搜索表单 -->
<el-form :inline="true" :model="searchForm" size="small" class="mb20">
<el-form-item label="发送方昵称">
<el-input v-model="searchForm.senderNickname" placeholder="请输入发送方昵称" clearable class="selWidth" />
<el-form inline size="small" :model="queryForm" ref="queryForm" label-width="90px">
<div class="acea-row search-form row-between">
<div class="search-form-box">
<el-form-item label="发送方昵称:">
<el-input v-model="queryForm.senderNickname" placeholder="请输入发送方昵称" clearable class="selWidth" />
</el-form-item>
<el-form-item label="发送方电话">
<el-input v-model="searchForm.senderPhone" placeholder="请输入发送方电话" clearable class="selWidth" />
<el-form-item label="发送方电话">
<el-input v-model="queryForm.senderPhone" placeholder="请输入发送方电话" clearable class="selWidth" />
</el-form-item>
<el-form-item label="分类时间">
<el-form-item label="时间选择:">
<el-date-picker
v-model="searchForm.startTime"
type="datetime"
placeholder="开始时间"
v-model="dateRange"
align="right"
unlink-panels
value-format="yyyy-MM-dd HH:mm:ss"
format="yyyy-MM-dd"
size="small"
type="daterange"
placeholder="选择时间范围"
class="selWidth"
@change="onDateChange"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item label="起止时间">
<el-date-picker
v-model="searchForm.endTime"
type="datetime"
placeholder="结束时间"
value-format="yyyy-MM-dd HH:mm:ss"
class="selWidth"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<el-form-item class="search-form-sub">
<el-button type="primary" icon="el-icon-search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset" size="small">重置</el-button>
</el-form-item>
</div>
</el-form>
</div>
</el-card>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="tableData" border size="small">
<el-table-column prop="id" label="id" width="80" align="center" />
<el-table-column label="发送者头像" width="100" align="center">
<template slot-scope="scope">
<el-image
v-if="scope.row.sender_avatar"
:src="scope.row.sender_avatar"
:preview-src-list="[scope.row.sender_avatar]"
style="width: 50px; height: 50px; cursor: pointer"
fit="cover"
/>
<el-card class="box-card mt14">
<div slot="header" class="clearfix">
<span class="card-title">会话管理</span>
<div class="header-right">
<el-tag type="info">总会话数{{ statistics.totalConversations || 0 }}</el-tag>
<el-tag type="success" class="ml10">总消息数{{ statistics.totalMessages || 0 }}</el-tag>
<el-tag type="warning" class="ml10">今日消息{{ statistics.todayMessages || 0 }}</el-tag>
</div>
</div>
<el-table
ref="table"
v-loading="loading"
:data="tableData"
style="width: 100%"
size="mini"
highlight-current-row
>
<el-table-column type="expand">
<template slot-scope="props">
<div class="message-preview" v-loading="props.row.messagesLoading">
<div class="message-header">
<span>最近消息记录</span>
<el-button type="text" size="mini" @click="loadMessages(props.row)">刷新</el-button>
</div>
<div class="message-list" v-if="props.row.messages && props.row.messages.length">
<div
class="message-item"
v-for="msg in props.row.messages"
:key="msg.id"
:class="{ 'message-right': msg.senderId === props.row.user1_id }"
>
<div class="message-sender">{{ msg.senderName || '用户' + msg.senderId }}</div>
<div class="message-content">{{ msg.content }}</div>
<div class="message-time">{{ msg.createTime }}</div>
</div>
</div>
<el-empty v-else description="暂无消息记录" :image-size="60"></el-empty>
</div>
</template>
</el-table-column>
<el-table-column prop="sender_nickname" label="发送者昵称" width="120" align="center" />
<el-table-column prop="sender_phone" label="发送者电话" width="130" align="center" />
<el-table-column label="对方头像" width="100" align="center">
<el-table-column prop="id" label="会话ID" width="80" />
<el-table-column label="发送者" min-width="150">
<template slot-scope="scope">
<el-image
v-if="scope.row.receiver_avatar"
:src="scope.row.receiver_avatar"
:preview-src-list="[scope.row.receiver_avatar]"
style="width: 50px; height: 50px; cursor: pointer"
fit="cover"
/>
<div class="user-info">
<el-avatar :size="32" :src="scope.row.sender_avatar" icon="el-icon-user"></el-avatar>
<div class="user-detail">
<span class="user-name">{{ scope.row.sender_nickname || '用户' + scope.row.user1_id }}</span>
<span class="user-id">{{ scope.row.sender_phone || 'ID: ' + scope.row.user1_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="receiver_nickname" label="对方昵称" width="120" align="center" />
<el-table-column prop="receiver_phone" label="对方昵称电话" width="130" align="center" />
<el-table-column prop="create_time" label="创建时间" width="160" align="center" />
<el-table-column label="操作" width="100" align="center" fixed="right">
<el-table-column label="对方" min-width="150">
<template slot-scope="scope">
<el-button type="text" size="small" @click="handleDelete(scope.row.id)">删除</el-button>
<div class="user-info">
<el-avatar :size="32" :src="scope.row.receiver_avatar" icon="el-icon-user"></el-avatar>
<div class="user-detail">
<span class="user-name">{{ scope.row.receiver_nickname || '用户' + scope.row.user2_id }}</span>
<span class="user-id">{{ scope.row.receiver_phone || 'ID: ' + scope.row.user2_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="last_message" label="最后消息" min-width="200" show-overflow-tooltip />
<el-table-column prop="last_message_time" label="最后消息时间" width="160" />
<el-table-column label="消息数" width="80">
<template slot-scope="scope">
<el-tag size="mini">{{ scope.row.message_count || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="160" />
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="viewMessages(scope.row)">查看</el-button>
<el-divider direction="vertical"></el-divider>
<el-popconfirm title="确定删除该会话及所有消息吗?" @confirm="deleteConversation(scope.row)">
<el-button slot="reference" type="text" size="mini" class="text-danger">删除</el-button>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="acea-row row-center page mt20">
<span class="mr10"> {{ total }} </span>
<div class="block">
<el-pagination
:current-page="page"
:page-sizes="[10, 20, 50, 100]"
:page-size="limit"
:page-size="queryForm.limit"
:current-page="queryForm.page"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
layout="prev, pager, next, sizes, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@current-change="handlePageChange"
background
/>
</div>
</div>
</el-card>
<!-- 消息详情对话框 -->
<el-dialog
:title="'会话详情 - ' + (currentConversation ? currentConversation.id : '')"
:visible.sync="dialogVisible"
width="700px"
top="5vh"
>
<div class="dialog-users" v-if="currentConversation">
<div class="dialog-user">
<el-avatar :size="48" :src="currentConversation.sender_avatar" icon="el-icon-user"></el-avatar>
<div>
<div class="user-name">{{ currentConversation.sender_nickname || '用户' + currentConversation.user1_id }}</div>
<div class="user-id">{{ currentConversation.sender_phone || 'ID: ' + currentConversation.user1_id }}</div>
</div>
</div>
<div class="dialog-arrow">
<i class="el-icon-sort"></i>
</div>
<div class="dialog-user">
<el-avatar :size="48" :src="currentConversation.receiver_avatar" icon="el-icon-user"></el-avatar>
<div>
<div class="user-name">{{ currentConversation.receiver_nickname || '用户' + currentConversation.user2_id }}</div>
<div class="user-id">{{ currentConversation.receiver_phone || 'ID: ' + currentConversation.user2_id }}</div>
</div>
</div>
</div>
<div class="dialog-messages" v-loading="messagesLoading">
<div
class="dialog-message"
v-for="msg in currentMessages"
:key="msg.id"
:class="{ 'message-from-user1': currentConversation && msg.senderId === currentConversation.user1_id }"
>
<div class="msg-avatar">
<el-avatar
:size="36"
:src="getMessageAvatar(msg)"
icon="el-icon-user"
></el-avatar>
</div>
<div class="msg-body">
<div class="msg-header">
<span class="msg-sender">{{ msg.senderName || '用户' + msg.senderId }}</span>
<el-tag v-if="msg.isRecalled" type="warning" size="mini" class="recalled-tag">已撤回</el-tag>
<span class="msg-time">{{ msg.createTime }}</span>
<el-popconfirm
v-if="!msg.isRecalled"
title="确定要撤回这条消息吗?"
@confirm="recallMessage(msg)"
class="msg-recall"
>
<el-button slot="reference" type="text" size="mini" class="recall-btn">撤回</el-button>
</el-popconfirm>
</div>
<!-- 如果消息已撤回显示原始内容 -->
<div v-if="msg.isRecalled && msg.originalContent" class="msg-content recalled-content">
<div class="original-label">原始内容</div>
<div class="original-text">{{ msg.originalContent }}</div>
</div>
<div v-else class="msg-content">{{ msg.content }}</div>
</div>
</div>
<el-empty v-if="!messagesLoading && currentMessages.length === 0" description="暂无消息记录"></el-empty>
</div>
<div class="dialog-pagination" v-if="messageTotal > messagePageSize">
<el-pagination
small
layout="prev, pager, next"
:total="messageTotal"
:page-size="messagePageSize"
:current-page="messagePage"
@current-change="handleMessagePageChange"
/>
</div>
</el-dialog>
</div>
</template>
<script>
import { sessionListApi, sessionDeleteApi } from '@/api/session';
import { sessionListApi, sessionMessagesApi, sessionDeleteApi, sessionStatisticsApi, sessionMessageRecallApi } from '@/api/session';
export default {
name: 'SessionList',
data() {
return {
loading: false,
searchForm: {
tableData: [],
total: 0,
queryForm: {
senderNickname: '',
senderPhone: '',
startTime: '',
endTime: '',
},
tableData: [],
page: 1,
limit: 10,
total: 0,
},
dateRange: [],
statistics: {
totalConversations: 0,
totalMessages: 0,
todayMessages: 0,
},
dialogVisible: false,
currentConversation: null,
currentMessages: [],
messagesLoading: false,
messagePage: 1,
messagePageSize: 20,
messageTotal: 0,
};
},
mounted() {
this.getList();
this.getStatistics();
},
methods: {
getList() {
async getList() {
this.loading = true;
sessionListApi({
...this.searchForm,
page: this.page,
limit: this.limit
})
.then((res) => {
this.tableData = res.data.list || [];
this.total = res.data.total || 0;
this.loading = false;
})
.catch(() => {
try {
const res = await sessionListApi(this.queryForm);
this.tableData = (res.list || []).map(item => ({
...item,
messages: [],
messagesLoading: false,
}));
this.total = res.total || 0;
} catch (error) {
console.error('获取会话列表失败:', error);
this.$message.error('获取会话列表失败');
} finally {
this.loading = false;
}
},
async getStatistics() {
try {
const res = await sessionStatisticsApi();
this.statistics = res || {};
} catch (error) {
console.error('获取统计数据失败:', error);
}
},
async loadMessages(row) {
row.messagesLoading = true;
try {
const res = await sessionMessagesApi(row.id, { page: 1, pageSize: 5 });
row.messages = res.list || [];
} catch (error) {
console.error('加载消息失败:', error);
} finally {
row.messagesLoading = false;
}
},
async viewMessages(row) {
this.currentConversation = row;
this.dialogVisible = true;
this.messagePage = 1;
await this.loadDialogMessages();
},
async loadDialogMessages() {
if (!this.currentConversation) return;
this.messagesLoading = true;
try {
const res = await sessionMessagesApi(this.currentConversation.id, {
page: this.messagePage,
pageSize: this.messagePageSize,
});
this.currentMessages = res.list || [];
this.messageTotal = res.total || 0;
} catch (error) {
console.error('加载消息失败:', error);
this.$message.error('加载消息失败');
} finally {
this.messagesLoading = false;
}
},
handleSearch() {
this.page = 1;
this.getList();
},
handleSizeChange(val) {
this.limit = val;
this.page = 1;
this.getList();
},
handleCurrentChange(val) {
this.page = val;
this.getList();
},
handleDelete(id) {
this.$confirm('确定要删除该会话吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
sessionDeleteApi(id).then(() => {
async deleteConversation(row) {
try {
await sessionDeleteApi(row.id);
this.$message.success('删除成功');
this.getList();
});
});
this.getStatistics();
} catch (error) {
console.error('删除会话失败:', error);
this.$message.error('删除失败');
}
},
async recallMessage(msg) {
try {
await sessionMessageRecallApi(msg.id);
this.$message.success('撤回成功');
//
await this.loadDialogMessages();
} catch (error) {
console.error('撤回消息失败:', error);
this.$message.error('撤回失败');
}
},
handleSearch() {
this.queryForm.page = 1;
this.getList();
},
handleReset() {
this.queryForm = {
senderNickname: '',
senderPhone: '',
startTime: '',
endTime: '',
page: 1,
limit: 10,
};
this.dateRange = [];
this.getList();
},
onDateChange(val) {
if (val && val.length === 2) {
this.queryForm.startTime = val[0];
this.queryForm.endTime = val[1];
} else {
this.queryForm.startTime = '';
this.queryForm.endTime = '';
}
},
handleSizeChange(val) {
this.queryForm.limit = val;
this.getList();
},
handlePageChange(val) {
this.queryForm.page = val;
this.getList();
},
handleMessagePageChange(val) {
this.messagePage = val;
this.loadDialogMessages();
},
getMessageAvatar(msg) {
if (!this.currentConversation) return '';
if (msg.senderId === this.currentConversation.user1_id) {
return this.currentConversation.sender_avatar || '';
}
return this.currentConversation.receiver_avatar || '';
},
},
};
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
.padding-add {
padding: 20px;
}
.selWidth {
width: 180px;
width: 200px;
}
.mt14 {
margin-top: 14px;
}
.ml10 {
margin-left: 10px;
}
.card-title {
font-size: 16px;
font-weight: 500;
}
.header-right {
float: right;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.user-detail {
display: flex;
flex-direction: column;
}
.user-name {
font-size: 13px;
color: #303133;
}
.user-id {
font-size: 12px;
color: #909399;
}
.text-danger {
color: #f56c6c !important;
}
.message-preview {
padding: 10px 20px;
background: #fafafa;
border-radius: 4px;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: #606266;
}
.message-list {
max-height: 200px;
overflow-y: auto;
}
.message-item {
padding: 8px 12px;
margin-bottom: 8px;
background: #fff;
border-radius: 4px;
border-left: 3px solid #409eff;
}
.message-item.message-right {
border-left-color: #67c23a;
}
.message-sender {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.message-content {
font-size: 13px;
color: #303133;
word-break: break-all;
}
.message-time {
font-size: 11px;
color: #c0c4cc;
margin-top: 4px;
}
.dialog-users {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
margin-bottom: 20px;
}
.dialog-user {
display: flex;
align-items: center;
gap: 12px;
}
.dialog-arrow {
font-size: 24px;
color: #909399;
transform: rotate(90deg);
}
.dialog-messages {
max-height: 400px;
overflow-y: auto;
padding: 10px;
}
.dialog-message {
display: flex;
gap: 10px;
margin-bottom: 16px;
padding: 10px;
background: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.dialog-message.message-from-user1 {
background: #ecf5ff;
border-color: #b3d8ff;
}
.msg-avatar {
flex-shrink: 0;
}
.msg-body {
flex: 1;
}
.msg-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.msg-sender {
font-size: 13px;
font-weight: 500;
color: #303133;
}
.msg-time {
font-size: 12px;
color: #909399;
margin-left: auto;
margin-right: 10px;
}
.recall-btn {
color: #f56c6c !important;
font-size: 12px;
padding: 0;
}
.msg-content {
font-size: 14px;
color: #606266;
line-height: 1.5;
word-break: break-all;
}
.recalled-tag {
margin-left: 8px;
}
.recalled-content {
background: #fff3e0;
padding: 8px;
border-radius: 4px;
border-left: 3px solid #ff9800;
}
.original-label {
font-size: 12px;
color: #ff9800;
margin-bottom: 4px;
font-weight: 500;
}
.original-text {
color: #666;
}
.dialog-pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.block {
margin-top: 20px;
text-align: right;
}
</style>

View File

@ -10,80 +10,302 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 会话管理控制器社交互动模块
* 与私聊管理使用相同的数据表eb_conversation eb_private_message
*/
@Slf4j
@RestController
@RequestMapping("api/admin/session")
@Api(tags = "会话管理")
@Api(tags = "社交互动 - 会话管理")
@Validated
public class SessionController {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 获取会话列表
*/
@ApiOperation(value = "会话列表")
@RequestMapping(value = "/list", method = RequestMethod.GET)
@GetMapping("/list")
public CommonResult<CommonPage<Map<String, Object>>> getList(
@RequestParam(value = "senderNickname", required = false) String senderNickname,
@RequestParam(value = "senderPhone", required = false) String senderPhone,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "startTime", required = false) String startTime,
@RequestParam(value = "endTime", required = false) String endTime,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "limit", defaultValue = "10") Integer limit) {
StringBuilder sql = new StringBuilder("SELECT * FROM eb_session WHERE 1=1");
StringBuilder countSql = new StringBuilder("SELECT COUNT(*) FROM eb_session WHERE 1=1");
StringBuilder sql = new StringBuilder();
sql.append("SELECT c.id, c.user1_id, c.user2_id, c.last_message, c.last_message_time, c.create_time, ");
sql.append("u1.nickname as sender_nickname, u1.avatar as sender_avatar, u1.phone as sender_phone, ");
sql.append("u2.nickname as receiver_nickname, u2.avatar as receiver_avatar, u2.phone as receiver_phone, ");
sql.append("(SELECT COUNT(*) FROM eb_private_message WHERE conversation_id = c.id) as message_count ");
sql.append("FROM eb_conversation c ");
sql.append("LEFT JOIN eb_user u1 ON c.user1_id = u1.uid ");
sql.append("LEFT JOIN eb_user u2 ON c.user2_id = u2.uid ");
sql.append("WHERE 1=1 ");
if (senderNickname != null && !senderNickname.isEmpty()) {
String safeName = senderNickname.replace("'", "''");
sql.append(" AND sender_nickname LIKE '%").append(safeName).append("%'");
countSql.append(" AND sender_nickname LIKE '%").append(safeName).append("%'");
}
if (senderPhone != null && !senderPhone.isEmpty()) {
String safePhone = senderPhone.replace("'", "''");
sql.append(" AND sender_phone LIKE '%").append(safePhone).append("%'");
countSql.append(" AND sender_phone LIKE '%").append(safePhone).append("%'");
}
if (startTime != null && !startTime.isEmpty()) {
sql.append(" AND create_time >= '").append(startTime).append("'");
countSql.append(" AND create_time >= '").append(startTime).append("'");
}
if (endTime != null && !endTime.isEmpty()) {
sql.append(" AND create_time <= '").append(endTime).append("'");
countSql.append(" AND create_time <= '").append(endTime).append("'");
StringBuilder countSql = new StringBuilder();
countSql.append("SELECT COUNT(*) FROM eb_conversation c ");
countSql.append("LEFT JOIN eb_user u1 ON c.user1_id = u1.uid ");
countSql.append("LEFT JOIN eb_user u2 ON c.user2_id = u2.uid ");
countSql.append("WHERE 1=1 ");
// 发送方昵称搜索
if (senderNickname != null && !senderNickname.trim().isEmpty()) {
String condition = " AND (u1.nickname LIKE '%" + senderNickname.replace("'", "''") + "%' OR u2.nickname LIKE '%" + senderNickname.replace("'", "''") + "%') ";
sql.append(condition);
countSql.append(condition);
}
sql.append(" ORDER BY id DESC");
Long total = jdbcTemplate.queryForObject(countSql.toString(), Long.class);
// 发送方电话搜索
if (senderPhone != null && !senderPhone.trim().isEmpty()) {
String condition = " AND (u1.phone LIKE '%" + senderPhone.replace("'", "''") + "%' OR u2.phone LIKE '%" + senderPhone.replace("'", "''") + "%') ";
sql.append(condition);
countSql.append(condition);
}
// 关键词搜索兼容私聊管理的参数
if (keyword != null && !keyword.trim().isEmpty()) {
String condition = " AND (u1.nickname LIKE '%" + keyword + "%' OR u2.nickname LIKE '%" + keyword + "%' "
+ "OR c.user1_id = '" + keyword + "' OR c.user2_id = '" + keyword + "') ";
sql.append(condition);
countSql.append(condition);
}
// 时间范围
if (startTime != null && !startTime.trim().isEmpty()) {
String condition = " AND c.create_time >= '" + startTime + "' ";
sql.append(condition);
countSql.append(condition);
}
if (endTime != null && !endTime.trim().isEmpty()) {
String condition = " AND c.create_time <= '" + endTime + "' ";
sql.append(condition);
countSql.append(condition);
}
// 排序
sql.append("ORDER BY c.last_message_time DESC ");
// 统计总数
Long total = 0L;
try {
total = jdbcTemplate.queryForObject(countSql.toString(), Long.class);
} catch (Exception e) {
log.error("查询会话总数失败: {}", e.getMessage());
}
// 分页
int offset = (page - 1) * limit;
sql.append("LIMIT ").append(offset).append(", ").append(limit);
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql.toString());
List<Map<String, Object>> list;
try {
list = jdbcTemplate.queryForList(sql.toString());
} catch (Exception e) {
log.error("查询会话列表失败: {}", e.getMessage());
list = java.util.Collections.emptyList();
}
CommonPage<Map<String, Object>> result = new CommonPage<>();
result.setList(list);
result.setTotal(total);
result.setTotal(total != null ? total : 0L);
result.setPage(page);
result.setLimit(limit);
result.setTotalPage((int) Math.ceil((double) total / limit));
result.setTotalPage((int) Math.ceil((double) (total != null ? total : 0) / limit));
return CommonResult.success(result);
}
/**
* 获取会话详情
*/
@ApiOperation(value = "会话详情")
@RequestMapping(value = "/detail/{id}", method = RequestMethod.GET)
public CommonResult<Map<String, Object>> getDetail(@PathVariable Integer id) {
String sql = "SELECT * FROM eb_session WHERE id = ?";
Map<String, Object> detail = jdbcTemplate.queryForMap(sql, id);
@GetMapping("/detail/{id}")
public CommonResult<Map<String, Object>> getDetail(@PathVariable Long id) {
try {
StringBuilder sql = new StringBuilder();
sql.append("SELECT c.*, ");
sql.append("u1.nickname as sender_nickname, u1.avatar as sender_avatar, u1.phone as sender_phone, ");
sql.append("u2.nickname as receiver_nickname, u2.avatar as receiver_avatar, u2.phone as receiver_phone ");
sql.append("FROM eb_conversation c ");
sql.append("LEFT JOIN eb_user u1 ON c.user1_id = u1.uid ");
sql.append("LEFT JOIN eb_user u2 ON c.user2_id = u2.uid ");
sql.append("WHERE c.id = ?");
Map<String, Object> detail = jdbcTemplate.queryForMap(sql.toString(), id);
return CommonResult.success(detail);
} catch (Exception e) {
log.error("获取会话详情失败: {}", e.getMessage());
return CommonResult.failed("获取会话详情失败");
}
}
/**
* 获取会话消息列表
*/
@ApiOperation(value = "会话消息列表")
@GetMapping("/{conversationId}/messages")
public CommonResult<CommonPage<Map<String, Object>>> getMessages(
@PathVariable Long conversationId,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
StringBuilder sql = new StringBuilder();
sql.append("SELECT m.*, u.nickname as sender_name, u.avatar as sender_avatar ");
sql.append("FROM eb_private_message m ");
sql.append("LEFT JOIN eb_user u ON m.sender_id = u.uid ");
sql.append("WHERE m.conversation_id = ? ");
sql.append("ORDER BY m.create_time DESC ");
Long total = 0L;
try {
String countSql = "SELECT COUNT(*) FROM eb_private_message WHERE conversation_id = ?";
total = jdbcTemplate.queryForObject(countSql, Long.class, conversationId);
} catch (Exception e) {
log.error("查询消息总数失败: {}", e.getMessage());
}
int offset = (page - 1) * pageSize;
sql.append("LIMIT ").append(offset).append(", ").append(pageSize);
List<Map<String, Object>> list;
try {
list = jdbcTemplate.queryForList(sql.toString(), conversationId);
// 转换字段名
list.forEach(item -> {
item.put("senderId", item.get("sender_id"));
item.put("receiverId", item.get("receiver_id"));
item.put("senderName", item.get("sender_name"));
item.put("senderAvatar", item.get("sender_avatar"));
item.put("messageType", item.get("message_type"));
item.put("createTime", item.get("create_time"));
item.put("isRecalled", item.get("is_recalled"));
item.put("originalContent", item.get("original_content"));
item.put("recallTime", item.get("recall_time"));
});
} catch (Exception e) {
log.error("查询消息列表失败: {}", e.getMessage());
list = java.util.Collections.emptyList();
}
CommonPage<Map<String, Object>> result = new CommonPage<>();
result.setList(list);
result.setTotal(total != null ? total : 0L);
result.setPage(page);
result.setLimit(pageSize);
result.setTotalPage((int) Math.ceil((double) (total != null ? total : 0) / pageSize));
return CommonResult.success(result);
}
/**
* 删除会话包括所有消息
*/
@ApiOperation(value = "删除会话")
@RequestMapping(value = "/delete/{id}", method = RequestMethod.POST)
public CommonResult<String> delete(@PathVariable Integer id) {
jdbcTemplate.update("DELETE FROM eb_session WHERE id = ?", id);
@PostMapping("/delete/{id}")
public CommonResult<String> delete(@PathVariable Long id) {
try {
// 先删除消息
jdbcTemplate.update("DELETE FROM eb_private_message WHERE conversation_id = ?", id);
// 删除会话
jdbcTemplate.update("DELETE FROM eb_conversation WHERE id = ?", id);
return CommonResult.success("删除成功");
} catch (Exception e) {
log.error("删除会话失败: {}", e.getMessage());
return CommonResult.failed("删除失败: " + e.getMessage());
}
}
/**
* 删除单条消息
*/
@ApiOperation(value = "删除消息")
@PostMapping("/message/delete/{messageId}")
public CommonResult<String> deleteMessage(@PathVariable Long messageId) {
try {
jdbcTemplate.update("DELETE FROM eb_private_message WHERE id = ?", messageId);
return CommonResult.success("删除成功");
} catch (Exception e) {
log.error("删除消息失败: {}", e.getMessage());
return CommonResult.failed("删除失败: " + e.getMessage());
}
}
/**
* 撤回消息管理员可以撤回任意消息
*/
@ApiOperation(value = "撤回消息")
@PostMapping("/message/recall/{messageId}")
public CommonResult<String> recallMessage(@PathVariable Long messageId) {
try {
// 先获取原始消息内容
Map<String, Object> message = jdbcTemplate.queryForMap(
"SELECT content FROM eb_private_message WHERE id = ?", messageId);
String originalContent = (String) message.get("content");
// 保存原始内容并标记为撤回
int updated = jdbcTemplate.update(
"UPDATE eb_private_message SET is_recalled = 1, original_content = ?, recall_time = NOW(), content = '[消息已被管理员撤回]' WHERE id = ?",
originalContent, messageId
);
if (updated > 0) {
return CommonResult.success("撤回成功");
} else {
return CommonResult.failed("消息不存在");
}
} catch (Exception e) {
log.error("撤回消息失败: {}", e.getMessage());
return CommonResult.failed("撤回失败: " + e.getMessage());
}
}
/**
* 获取会话统计数据
*/
@ApiOperation(value = "会话统计")
@GetMapping("/statistics")
public CommonResult<Map<String, Object>> getStatistics() {
Map<String, Object> stats = new HashMap<>();
try {
// 总会话数
Long totalConversations = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM eb_conversation", Long.class);
stats.put("totalConversations", totalConversations != null ? totalConversations : 0);
// 总消息数
Long totalMessages = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM eb_private_message", Long.class);
stats.put("totalMessages", totalMessages != null ? totalMessages : 0);
// 今日消息数
Long todayMessages = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM eb_private_message WHERE DATE(create_time) = CURDATE()", Long.class);
stats.put("todayMessages", todayMessages != null ? todayMessages : 0);
// 活跃会话数最近7天有消息的会话
Long activeConversations = jdbcTemplate.queryForObject(
"SELECT COUNT(DISTINCT conversation_id) FROM eb_private_message WHERE create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
Long.class);
stats.put("activeConversations", activeConversations != null ? activeConversations : 0);
} catch (Exception e) {
log.error("获取统计数据失败: {}", e.getMessage());
stats.put("totalConversations", 0);
stats.put("totalMessages", 0);
stats.put("todayMessages", 0);
stats.put("activeConversations", 0);
}
return CommonResult.success(stats);
}
}

View File

@ -82,4 +82,12 @@ public class PrivateMessage implements Serializable {
@ApiModelProperty(value = "是否已撤回")
@Column(name = "is_recalled", columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean isRecalled;
@ApiModelProperty(value = "原始消息内容(撤回前)")
@Column(name = "original_content", columnDefinition = "TEXT")
private String originalContent;
@ApiModelProperty(value = "撤回时间")
@Column(name = "recall_time")
private Date recallTime;
}

View File

@ -229,3 +229,4 @@ public class CallController {
return response;
}
}

View File

@ -145,4 +145,15 @@ public class ConversationController {
Integer userId = userService.getUserIdException();
return CommonResult.success(conversationService.deleteMessage(id, userId));
}
/**
* 撤回消息
*/
@ApiOperation(value = "撤回消息")
@ApiImplicitParam(name = "id", value = "消息ID", required = true)
@PostMapping("/messages/{id}/recall")
public CommonResult<Boolean> recallMessage(@PathVariable Long id) {
Integer userId = userService.getUserIdException();
return CommonResult.success(conversationService.recallMessage(id, userId));
}
}

View File

@ -412,10 +412,12 @@ public class ConversationServiceImpl extends ServiceImpl<ConversationDao, Conver
if (diffMinutes > 2) {
throw new CrmebException("消息发送超过2分钟无法撤回");
}
// 标记消息为已撤回
// 保存原始内容然后标记消息为已撤回
LambdaUpdateWrapper<PrivateMessage> uw = new LambdaUpdateWrapper<>();
uw.eq(PrivateMessage::getId, messageId)
.set(PrivateMessage::getIsRecalled, true)
.set(PrivateMessage::getOriginalContent, message.getContent()) // 保存原始内容
.set(PrivateMessage::getRecallTime, new Date()) // 记录撤回时间
.set(PrivateMessage::getContent, "[消息已撤回]");
return privateMessageDao.update(null, uw) > 0;
}

View File

@ -0,0 +1,8 @@
-- 为私信消息表添加撤回相关字段
-- 执行此脚本前请备份数据库
-- 添加原始内容字段(保存撤回前的消息内容)
ALTER TABLE eb_private_message ADD COLUMN original_content TEXT COMMENT '原始消息内容(撤回前)';
-- 添加撤回时间字段
ALTER TABLE eb_private_message ADD COLUMN recall_time DATETIME COMMENT '撤回时间';

View File

@ -109,6 +109,9 @@ public class ConversationActivity extends AppCompatActivity {
setupMessages();
setupInput();
// 确保输入框显示正确的提示文本
binding.messageInput.setHint("输入消息...");
// 标记会话为已读
if (initialUnreadCount > 0 && conversationId != null) {
markConversationAsRead();
@ -414,9 +417,16 @@ public class ConversationActivity extends AppCompatActivity {
PopupMenu popupMenu = new PopupMenu(this, anchorView);
popupMenu.getMenu().add(0, 0, 0, "复制");
popupMenu.getMenu().add(0, 2, 0, "表情回应");
// 只有自己发送的消息才能删除
if ("".equals(message.getUsername())) {
// 只有自己发送的消息才能删除和撤回
if ("".equals(message.getUsername()) || message.isOutgoing()) {
popupMenu.getMenu().add(0, 1, 0, "删除");
// 检查是否在2分钟内可以撤回
long messageTime = message.getTimestamp();
long now = System.currentTimeMillis();
long diffMinutes = (now - messageTime) / (1000 * 60);
if (diffMinutes <= 2) {
popupMenu.getMenu().add(0, 3, 0, "撤回");
}
}
popupMenu.setOnMenuItemClickListener(item -> {
@ -429,6 +439,9 @@ public class ConversationActivity extends AppCompatActivity {
} else if (item.getItemId() == 2) {
showEmojiPicker(message);
return true;
} else if (item.getItemId() == 3) {
recallMessage(message, position);
return true;
}
return false;
});
@ -497,6 +510,68 @@ public class ConversationActivity extends AppCompatActivity {
});
}
/**
* 撤回消息
*/
private void recallMessage(ChatMessage message, int position) {
if (position < 0 || position >= messages.size()) return;
new AlertDialog.Builder(this)
.setTitle("撤回消息")
.setMessage("确定要撤回这条消息吗?")
.setPositiveButton("撤回", (dialog, which) -> recallMessageFromServer(message, position))
.setNegativeButton("取消", null)
.show();
}
/**
* 调用服务器撤回消息接口
*/
private void recallMessageFromServer(ChatMessage message, int position) {
String token = AuthStore.getToken(this);
if (token == null) return;
String messageId = message.getMessageId();
String url = ApiConfig.getBaseUrl() + "/api/front/conversations/messages/" + messageId + "/recall";
Log.d(TAG, "撤回消息: " + url);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create("", MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "撤回消息失败", e);
runOnUiThread(() -> Snackbar.make(binding.getRoot(), "撤回失败", Snackbar.LENGTH_SHORT).show());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String body = response.body() != null ? response.body().string() : "";
Log.d(TAG, "撤回消息响应: " + body);
runOnUiThread(() -> {
try {
JSONObject json = new JSONObject(body);
if (json.optInt("code", -1) == 200) {
// 更新本地消息显示为已撤回
message.setMessage("[消息已撤回]");
adapter.notifyItemChanged(position);
Snackbar.make(binding.getRoot(), "消息已撤回", Snackbar.LENGTH_SHORT).show();
} else {
String errorMsg = json.optString("message", "撤回失败");
Snackbar.make(binding.getRoot(), errorMsg, Snackbar.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析撤回响应失败", e);
}
});
}
});
}
private void setupInput() {
binding.sendButton.setOnClickListener(new DebounceClickListener(300) {

View File

@ -123,7 +123,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textfield.TextInputEditText
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"
android:layout_height="40dp"
@ -136,7 +136,10 @@
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:textColor="#111111"
android:textSize="14sp" />
android:textColorHint="#999999"
android:textSize="14sp"
android:importantForAutofill="no"
android:autofillHints="" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sendButton"