feat: 修复搜索用户功能和会话列表功能 - 添加 CircleImageView 依赖 - 修复 AuthHelper.getToken 改为 AuthStore.getToken - 添加 AddFriendActivity 调试日志 - 修改 MessagesActivity 从后端 API 获取会话列表 - 添加好友管理相关的 Controller 和 Service
This commit is contained in:
parent
bc53b6c482
commit
673ab55599
68
Zhibo/admin/src/api/chat.js
Normal file
68
Zhibo/admin/src/api/chat.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// +----------------------------------------------------------------------
|
||||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2016~2025 https://www.crmeb.com All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: CRMEB Team <admin@crmeb.com>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
import request from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 私聊会话列表
|
||||
* @param params
|
||||
*/
|
||||
export function conversationListApi(params) {
|
||||
return request({
|
||||
url: `/admin/chat/conversations`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话消息列表
|
||||
* @param conversationId
|
||||
* @param params
|
||||
*/
|
||||
export function conversationMessagesApi(conversationId, params) {
|
||||
return request({
|
||||
url: `/admin/chat/conversations/${conversationId}/messages`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
* @param conversationId
|
||||
*/
|
||||
export function deleteConversationApi(conversationId) {
|
||||
return request({
|
||||
url: `/admin/chat/conversations/${conversationId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
* @param messageId
|
||||
*/
|
||||
export function deleteMessageApi(messageId) {
|
||||
return request({
|
||||
url: `/admin/chat/messages/${messageId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话统计
|
||||
*/
|
||||
export function chatStatisticsApi() {
|
||||
return request({
|
||||
url: `/admin/chat/statistics`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
|
@ -26,6 +26,12 @@ const userRouter = {
|
|||
name: 'UserIndex',
|
||||
meta: { title: '用户管理', icon: '' },
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
component: () => import('@/views/user/chat/index'),
|
||||
name: 'ChatManagement',
|
||||
meta: { title: '私聊管理', icon: '' },
|
||||
},
|
||||
{
|
||||
path: 'grade',
|
||||
component: () => import('@/views/user/grade/index'),
|
||||
|
|
|
|||
536
Zhibo/admin/src/views/user/chat/index.vue
Normal file
536
Zhibo/admin/src/views/user/chat/index.vue
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
<template>
|
||||
<div class="divBox relative">
|
||||
<el-card :bordered="false" shadow="never" class="ivu-mt" :body-style="{ padding: 0 }">
|
||||
<div class="padding-add">
|
||||
<el-form inline size="small" :model="queryForm" ref="queryForm" label-width="80px">
|
||||
<div class="acea-row search-form row-between">
|
||||
<div class="search-form-box">
|
||||
<el-form-item label="用户搜索:">
|
||||
<el-input v-model="queryForm.keyword" placeholder="请输入用户ID或昵称" clearable class="selWidth" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间选择:">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
align="right"
|
||||
unlink-panels
|
||||
value-format="yyyy-MM-dd"
|
||||
format="yyyy-MM-dd"
|
||||
size="small"
|
||||
type="daterange"
|
||||
placeholder="选择时间范围"
|
||||
class="selWidth"
|
||||
@change="onDateChange"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
</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-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.user1Id }"
|
||||
>
|
||||
<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="id" label="会话ID" width="80" />
|
||||
<el-table-column label="用户1" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="scope.row.user1Avatar" icon="el-icon-user"></el-avatar>
|
||||
<div class="user-detail">
|
||||
<span class="user-name">{{ scope.row.user1Nickname || '用户' + scope.row.user1Id }}</span>
|
||||
<span class="user-id">ID: {{ scope.row.user1Id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户2" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="scope.row.user2Avatar" icon="el-icon-user"></el-avatar>
|
||||
<div class="user-detail">
|
||||
<span class="user-name">{{ scope.row.user2Nickname || '用户' + scope.row.user2Id }}</span>
|
||||
<span class="user-id">ID: {{ scope.row.user2Id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastMessage" label="最后消息" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="lastMessageTime" label="最后消息时间" width="160" />
|
||||
<el-table-column label="消息数" width="80">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini">{{ scope.row.messageCount || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" 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="block">
|
||||
<el-pagination
|
||||
:page-sizes="[20, 40, 60, 80]"
|
||||
:page-size="queryForm.limit"
|
||||
:current-page="queryForm.page"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
background
|
||||
/>
|
||||
</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.user1Avatar" icon="el-icon-user"></el-avatar>
|
||||
<div>
|
||||
<div class="user-name">{{ currentConversation.user1Nickname || '用户' + currentConversation.user1Id }}</div>
|
||||
<div class="user-id">ID: {{ currentConversation.user1Id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-arrow">
|
||||
<i class="el-icon-sort"></i>
|
||||
</div>
|
||||
<div class="dialog-user">
|
||||
<el-avatar :size="48" :src="currentConversation.user2Avatar" icon="el-icon-user"></el-avatar>
|
||||
<div>
|
||||
<div class="user-name">{{ currentConversation.user2Nickname || '用户' + currentConversation.user2Id }}</div>
|
||||
<div class="user-id">ID: {{ currentConversation.user2Id }}</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.user1Id }"
|
||||
>
|
||||
<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>
|
||||
<span class="msg-time">{{ msg.createTime }}</span>
|
||||
</div>
|
||||
<div 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 { conversationListApi, conversationMessagesApi, deleteConversationApi, chatStatisticsApi } from '@/api/chat';
|
||||
|
||||
export default {
|
||||
name: 'ChatManagement',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
tableData: [],
|
||||
total: 0,
|
||||
queryForm: {
|
||||
keyword: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
},
|
||||
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: {
|
||||
async getList() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await conversationListApi(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 chatStatisticsApi();
|
||||
this.statistics = res || {};
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
}
|
||||
},
|
||||
async loadMessages(row) {
|
||||
row.messagesLoading = true;
|
||||
try {
|
||||
const res = await conversationMessagesApi(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 conversationMessagesApi(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;
|
||||
}
|
||||
},
|
||||
async deleteConversation(row) {
|
||||
try {
|
||||
await deleteConversationApi(row.id);
|
||||
this.$message.success('删除成功');
|
||||
this.getList();
|
||||
this.getStatistics();
|
||||
} catch (error) {
|
||||
console.error('删除会话失败:', error);
|
||||
this.$message.error('删除失败');
|
||||
}
|
||||
},
|
||||
handleSearch() {
|
||||
this.queryForm.page = 1;
|
||||
this.getList();
|
||||
},
|
||||
handleReset() {
|
||||
this.queryForm = {
|
||||
keyword: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
};
|
||||
this.dateRange = [];
|
||||
this.getList();
|
||||
},
|
||||
onDateChange(val) {
|
||||
if (val && val.length === 2) {
|
||||
this.queryForm.startDate = val[0];
|
||||
this.queryForm.endDate = val[1];
|
||||
} else {
|
||||
this.queryForm.startDate = '';
|
||||
this.queryForm.endDate = '';
|
||||
}
|
||||
},
|
||||
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.user1Id) {
|
||||
return this.currentConversation.user1Avatar || '';
|
||||
}
|
||||
return this.currentConversation.user2Avatar || '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.padding-add {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.selWidth {
|
||||
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;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.msg-sender {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.dialog-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
package com.zbkj.admin.controller;
|
||||
|
||||
import com.zbkj.common.page.CommonPage;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 私聊管理控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("api/admin/chat")
|
||||
@Api(tags = "用户管理 - 私聊管理")
|
||||
@Validated
|
||||
public class ChatManagementController {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
@ApiOperation(value = "会话列表")
|
||||
@GetMapping("/conversations")
|
||||
public CommonResult<CommonPage<Map<String, Object>>> getConversationList(
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "startDate", required = false) String startDate,
|
||||
@RequestParam(value = "endDate", required = false) String endDate,
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@RequestParam(value = "limit", defaultValue = "20") Integer limit) {
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT c.*, ");
|
||||
sql.append("u1.nickname as user1_nickname, u1.avatar as user1_avatar, ");
|
||||
sql.append("u2.nickname as user2_nickname, u2.avatar as user2_avatar, ");
|
||||
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 ");
|
||||
|
||||
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 (keyword != null && !keyword.trim().isEmpty()) {
|
||||
String keywordCondition = " AND (u1.nickname LIKE '%" + keyword + "%' OR u2.nickname LIKE '%" + keyword + "%' "
|
||||
+ "OR c.user1_id = '" + keyword + "' OR c.user2_id = '" + keyword + "') ";
|
||||
sql.append(keywordCondition);
|
||||
countSql.append(keywordCondition);
|
||||
}
|
||||
|
||||
// 时间范围
|
||||
if (startDate != null && !startDate.trim().isEmpty()) {
|
||||
String dateCondition = " AND c.create_time >= '" + startDate + " 00:00:00' ";
|
||||
sql.append(dateCondition);
|
||||
countSql.append(dateCondition);
|
||||
}
|
||||
if (endDate != null && !endDate.trim().isEmpty()) {
|
||||
String dateCondition = " AND c.create_time <= '" + endDate + " 23:59:59' ";
|
||||
sql.append(dateCondition);
|
||||
countSql.append(dateCondition);
|
||||
}
|
||||
|
||||
// 排序
|
||||
sql.append("ORDER BY c.last_message_time DESC ");
|
||||
|
||||
// 统计总数
|
||||
Long total = jdbcTemplate.queryForObject(countSql.toString(), Long.class);
|
||||
|
||||
// 分页
|
||||
int offset = (page - 1) * limit;
|
||||
sql.append("LIMIT ").append(offset).append(", ").append(limit);
|
||||
|
||||
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql.toString());
|
||||
|
||||
// 转换字段名为驼峰格式
|
||||
list.forEach(item -> {
|
||||
item.put("user1Id", item.get("user1_id"));
|
||||
item.put("user2Id", item.get("user2_id"));
|
||||
item.put("user1Nickname", item.get("user1_nickname"));
|
||||
item.put("user2Nickname", item.get("user2_nickname"));
|
||||
item.put("user1Avatar", item.get("user1_avatar"));
|
||||
item.put("user2Avatar", item.get("user2_avatar"));
|
||||
item.put("lastMessage", item.get("last_message"));
|
||||
item.put("lastMessageTime", item.get("last_message_time"));
|
||||
item.put("messageCount", item.get("message_count"));
|
||||
item.put("createTime", item.get("create_time"));
|
||||
});
|
||||
|
||||
CommonPage<Map<String, Object>> result = new CommonPage<>();
|
||||
result.setList(list);
|
||||
result.setTotal(total != null ? total : 0L);
|
||||
result.setPage(page);
|
||||
result.setLimit(limit);
|
||||
result.setTotalPage((int) Math.ceil((double) (total != null ? total : 0) / limit));
|
||||
|
||||
return CommonResult.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话消息列表
|
||||
*/
|
||||
@ApiOperation(value = "会话消息列表")
|
||||
@GetMapping("/conversations/{conversationId}/messages")
|
||||
public CommonResult<CommonPage<Map<String, Object>>> getConversationMessages(
|
||||
@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 ");
|
||||
|
||||
String countSql = "SELECT COUNT(*) FROM eb_private_message WHERE conversation_id = ?";
|
||||
Long total = jdbcTemplate.queryForObject(countSql, Long.class, conversationId);
|
||||
|
||||
int offset = (page - 1) * pageSize;
|
||||
sql.append("LIMIT ").append(offset).append(", ").append(pageSize);
|
||||
|
||||
List<Map<String, Object>> 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"));
|
||||
});
|
||||
|
||||
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 = "删除会话")
|
||||
@DeleteMapping("/conversations/{conversationId}")
|
||||
public CommonResult<Boolean> deleteConversation(@PathVariable Long conversationId) {
|
||||
try {
|
||||
// 先删除消息(外键约束会自动处理,但为了安全起见手动删除)
|
||||
jdbcTemplate.update("DELETE FROM eb_private_message WHERE conversation_id = ?", conversationId);
|
||||
// 删除会话
|
||||
jdbcTemplate.update("DELETE FROM eb_conversation WHERE id = ?", conversationId);
|
||||
return CommonResult.success(true);
|
||||
} catch (Exception e) {
|
||||
log.error("删除会话失败: {}", e.getMessage());
|
||||
return CommonResult.failed("删除失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条消息
|
||||
*/
|
||||
@ApiOperation(value = "删除消息")
|
||||
@DeleteMapping("/messages/{messageId}")
|
||||
public CommonResult<Boolean> deleteMessage(@PathVariable Long messageId) {
|
||||
try {
|
||||
jdbcTemplate.update("DELETE FROM eb_private_message WHERE id = ?", messageId);
|
||||
return CommonResult.success(true);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.zbkj.common.model.chat;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 私聊会话实体类
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@TableName("eb_conversation")
|
||||
@ApiModel(value = "Conversation对象", description = "私聊会话")
|
||||
public class Conversation implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "会话ID")
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@ApiModelProperty(value = "用户1的ID")
|
||||
private Integer user1Id;
|
||||
|
||||
@ApiModelProperty(value = "用户2的ID")
|
||||
private Integer user2Id;
|
||||
|
||||
@ApiModelProperty(value = "最后一条消息内容")
|
||||
private String lastMessage;
|
||||
|
||||
@ApiModelProperty(value = "最后一条消息时间")
|
||||
private Date lastMessageTime;
|
||||
|
||||
@ApiModelProperty(value = "用户1的未读数量")
|
||||
private Integer user1UnreadCount;
|
||||
|
||||
@ApiModelProperty(value = "用户2的未读数量")
|
||||
private Integer user2UnreadCount;
|
||||
|
||||
@ApiModelProperty(value = "用户1是否删除会话")
|
||||
private Boolean user1Deleted;
|
||||
|
||||
@ApiModelProperty(value = "用户2是否删除会话")
|
||||
private Boolean user2Deleted;
|
||||
|
||||
@ApiModelProperty(value = "用户1是否静音")
|
||||
private Boolean user1Muted;
|
||||
|
||||
@ApiModelProperty(value = "用户2是否静音")
|
||||
private Boolean user2Muted;
|
||||
|
||||
@ApiModelProperty(value = "创建时间")
|
||||
private Date createTime;
|
||||
|
||||
@ApiModelProperty(value = "更新时间")
|
||||
private Date updateTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.zbkj.common.model.chat;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 私信消息实体类
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@TableName("eb_private_message")
|
||||
@ApiModel(value = "PrivateMessage对象", description = "私信消息")
|
||||
public class PrivateMessage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "消息ID")
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@ApiModelProperty(value = "会话ID")
|
||||
private Long conversationId;
|
||||
|
||||
@ApiModelProperty(value = "发送者用户ID")
|
||||
private Integer senderId;
|
||||
|
||||
@ApiModelProperty(value = "接收者用户ID")
|
||||
private Integer receiverId;
|
||||
|
||||
@ApiModelProperty(value = "消息内容")
|
||||
private String content;
|
||||
|
||||
@ApiModelProperty(value = "消息类型: text, image, file")
|
||||
private String messageType;
|
||||
|
||||
@ApiModelProperty(value = "消息状态: sending, sent, read")
|
||||
private String status;
|
||||
|
||||
@ApiModelProperty(value = "是否已删除(发送者)")
|
||||
private Boolean senderDeleted;
|
||||
|
||||
@ApiModelProperty(value = "是否已删除(接收者)")
|
||||
private Boolean receiverDeleted;
|
||||
|
||||
@ApiModelProperty(value = "创建时间")
|
||||
private Date createTime;
|
||||
|
||||
@ApiModelProperty(value = "已读时间")
|
||||
private Date readTime;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.zbkj.common.request;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP用户注册请求对象
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "AppRegisterRequest对象", description = "APP用户注册请求对象")
|
||||
public class AppRegisterRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "手机号", required = true)
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
private String phone;
|
||||
|
||||
@ApiModelProperty(value = "密码", required = true)
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
@ApiModelProperty(value = "验证码")
|
||||
private String verificationCode;
|
||||
|
||||
@ApiModelProperty(value = "昵称")
|
||||
private String nickname;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.zbkj.common.request;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 发送消息请求对象
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(value = "SendMessageRequest对象", description = "发送消息请求")
|
||||
public class SendMessageRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "消息内容", required = true)
|
||||
@NotBlank(message = "消息内容不能为空")
|
||||
private String message;
|
||||
|
||||
@ApiModelProperty(value = "消息类型: text, image, file,默认text")
|
||||
private String messageType = "text";
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.zbkj.common.response;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 聊天消息响应对象
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(value = "ChatMessageResponse对象", description = "聊天消息响应")
|
||||
public class ChatMessageResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "消息ID")
|
||||
private String messageId;
|
||||
|
||||
@ApiModelProperty(value = "发送者用户ID")
|
||||
private Integer userId;
|
||||
|
||||
@ApiModelProperty(value = "发送者用户名")
|
||||
private String username;
|
||||
|
||||
@ApiModelProperty(value = "发送者头像URL")
|
||||
private String avatarUrl;
|
||||
|
||||
@ApiModelProperty(value = "消息内容")
|
||||
private String message;
|
||||
|
||||
@ApiModelProperty(value = "消息时间戳")
|
||||
private Long timestamp;
|
||||
|
||||
@ApiModelProperty(value = "消息状态: sending, sent, read")
|
||||
private String status;
|
||||
|
||||
@ApiModelProperty(value = "是否为系统消息")
|
||||
private Boolean isSystemMessage;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.zbkj.common.response;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 会话响应对象
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(value = "ConversationResponse对象", description = "会话响应")
|
||||
public class ConversationResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "会话ID")
|
||||
private String id;
|
||||
|
||||
@ApiModelProperty(value = "会话标题(对方用户昵称)")
|
||||
private String title;
|
||||
|
||||
@ApiModelProperty(value = "最后一条消息内容")
|
||||
private String lastMessage;
|
||||
|
||||
@ApiModelProperty(value = "时间文本(如:刚刚、昨天、周一)")
|
||||
private String timeText;
|
||||
|
||||
@ApiModelProperty(value = "未读消息数量")
|
||||
private Integer unreadCount;
|
||||
|
||||
@ApiModelProperty(value = "是否静音")
|
||||
private Boolean muted;
|
||||
|
||||
@ApiModelProperty(value = "对方用户头像URL")
|
||||
private String avatarUrl;
|
||||
|
||||
@ApiModelProperty(value = "对方用户ID")
|
||||
private Integer otherUserId;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
-- 私聊会话表
|
||||
CREATE TABLE IF NOT EXISTS `eb_conversation` (
|
||||
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '会话ID',
|
||||
`user1_id` INT(11) NOT NULL COMMENT '用户1的ID',
|
||||
`user2_id` INT(11) NOT NULL COMMENT '用户2的ID',
|
||||
`last_message` VARCHAR(255) DEFAULT '' COMMENT '最后一条消息内容',
|
||||
`last_message_time` DATETIME DEFAULT NULL COMMENT '最后一条消息时间',
|
||||
`user1_unread_count` INT(11) DEFAULT 0 COMMENT '用户1的未读数量',
|
||||
`user2_unread_count` INT(11) DEFAULT 0 COMMENT '用户2的未读数量',
|
||||
`user1_deleted` TINYINT(1) DEFAULT 0 COMMENT '用户1是否删除会话',
|
||||
`user2_deleted` TINYINT(1) DEFAULT 0 COMMENT '用户2是否删除会话',
|
||||
`user1_muted` TINYINT(1) DEFAULT 0 COMMENT '用户1是否静音',
|
||||
`user2_muted` TINYINT(1) DEFAULT 0 COMMENT '用户2是否静音',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_users` (`user1_id`, `user2_id`),
|
||||
KEY `idx_user1_id` (`user1_id`),
|
||||
KEY `idx_user2_id` (`user2_id`),
|
||||
KEY `idx_last_message_time` (`last_message_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊会话表';
|
||||
|
||||
-- 私信消息表
|
||||
CREATE TABLE IF NOT EXISTS `eb_private_message` (
|
||||
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '消息ID',
|
||||
`conversation_id` BIGINT(20) NOT NULL COMMENT '会话ID',
|
||||
`sender_id` INT(11) NOT NULL COMMENT '发送者用户ID',
|
||||
`receiver_id` INT(11) NOT NULL COMMENT '接收者用户ID',
|
||||
`content` TEXT NOT NULL COMMENT '消息内容',
|
||||
`message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text, image, file',
|
||||
`status` VARCHAR(20) DEFAULT 'sent' COMMENT '消息状态: sending, sent, read',
|
||||
`sender_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否已删除(发送者)',
|
||||
`receiver_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否已删除(接收者)',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`read_time` DATETIME DEFAULT NULL COMMENT '已读时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_conversation_id` (`conversation_id`),
|
||||
KEY `idx_sender_id` (`sender_id`),
|
||||
KEY `idx_receiver_id` (`receiver_id`),
|
||||
KEY `idx_create_time` (`create_time`),
|
||||
CONSTRAINT `fk_message_conversation` FOREIGN KEY (`conversation_id`) REFERENCES `eb_conversation` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私信消息表';
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
-- 好友关系表
|
||||
CREATE TABLE IF NOT EXISTS `eb_friend` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`user_id` int NOT NULL COMMENT '用户ID',
|
||||
`friend_id` int NOT NULL COMMENT '好友ID',
|
||||
`remark` varchar(50) DEFAULT NULL COMMENT '好友备注',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1-正常 0-已删除',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_friend_id` (`friend_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='好友关系表';
|
||||
|
||||
-- 好友请求表
|
||||
CREATE TABLE IF NOT EXISTS `eb_friend_request` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`from_user_id` int NOT NULL COMMENT '发起请求的用户ID',
|
||||
`to_user_id` int NOT NULL COMMENT '接收请求的用户ID',
|
||||
`message` varchar(200) DEFAULT NULL COMMENT '验证消息',
|
||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-待处理 1-已接受 2-已拒绝',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '处理时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_from_user` (`from_user_id`),
|
||||
KEY `idx_to_user` (`to_user_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='好友请求表';
|
||||
|
|
@ -63,6 +63,7 @@ public class WebConfig implements WebMvcConfigurer {
|
|||
excludePathPatterns("/api/front/qrcode/**").
|
||||
excludePathPatterns("/api/front/login/mobile").
|
||||
excludePathPatterns("/api/front/login").
|
||||
excludePathPatterns("/api/front/register").
|
||||
excludePathPatterns("/api/front/sendCode").
|
||||
excludePathPatterns("/api/front/wechat/**").
|
||||
excludePathPatterns("/api/front/search/keyword").
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.zbkj.front.config;
|
||||
|
||||
import com.zbkj.front.websocket.LiveChatHandler;
|
||||
import com.zbkj.front.websocket.PrivateChatHandler;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
|
|
@ -11,9 +12,11 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
|||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
private final LiveChatHandler liveChatHandler;
|
||||
private final PrivateChatHandler privateChatHandler;
|
||||
|
||||
public WebSocketConfig(LiveChatHandler liveChatHandler) {
|
||||
public WebSocketConfig(LiveChatHandler liveChatHandler, PrivateChatHandler privateChatHandler) {
|
||||
this.liveChatHandler = liveChatHandler;
|
||||
this.privateChatHandler = privateChatHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -21,5 +24,9 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
|||
// 直播间聊天 WebSocket 端点: ws://host:8081/ws/live/chat/{roomId}
|
||||
registry.addHandler(liveChatHandler, "/ws/live/chat/{roomId}")
|
||||
.setAllowedOrigins("*");
|
||||
|
||||
// 私聊 WebSocket 端点: ws://host:8081/ws/chat/{conversationId}?userId={userId}
|
||||
registry.addHandler(privateChatHandler, "/ws/chat/{conversationId}")
|
||||
.setAllowedOrigins("*");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
package com.zbkj.front.controller;
|
||||
|
||||
import com.zbkj.common.model.chat.Conversation;
|
||||
import com.zbkj.common.request.SendMessageRequest;
|
||||
import com.zbkj.common.response.ChatMessageResponse;
|
||||
import com.zbkj.common.response.ConversationResponse;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.ConversationService;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiImplicitParam;
|
||||
import io.swagger.annotations.ApiImplicitParams;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 私聊会话控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController("FrontConversationController")
|
||||
@RequestMapping("api/front/conversations")
|
||||
@Api(tags = "用户 -- 私聊会话")
|
||||
public class ConversationController {
|
||||
|
||||
@Autowired
|
||||
private ConversationService conversationService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
@ApiOperation(value = "获取会话列表")
|
||||
@GetMapping("")
|
||||
public CommonResult<List<ConversationResponse>> getConversationList() {
|
||||
Integer userId = userService.getUserIdException();
|
||||
return CommonResult.success(conversationService.getConversationList(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索会话
|
||||
*/
|
||||
@ApiOperation(value = "搜索会话")
|
||||
@ApiImplicitParam(name = "keyword", value = "搜索关键词", required = false)
|
||||
@GetMapping("/search")
|
||||
public CommonResult<List<ConversationResponse>> searchConversations(
|
||||
@RequestParam(required = false) String keyword) {
|
||||
Integer userId = userService.getUserIdException();
|
||||
return CommonResult.success(conversationService.searchConversations(userId, keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建与指定用户的会话
|
||||
*/
|
||||
@ApiOperation(value = "获取或创建与指定用户的会话")
|
||||
@ApiImplicitParam(name = "otherUserId", value = "对方用户ID", required = true)
|
||||
@PostMapping("/with/{otherUserId}")
|
||||
public CommonResult<Map<String, Object>> getOrCreateConversation(@PathVariable Integer otherUserId) {
|
||||
Integer userId = userService.getUserIdException();
|
||||
Conversation conversation = conversationService.getOrCreateConversation(userId, otherUserId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("conversationId", String.valueOf(conversation.getId()));
|
||||
return CommonResult.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记会话已读
|
||||
*/
|
||||
@ApiOperation(value = "标记会话已读")
|
||||
@ApiImplicitParam(name = "id", value = "会话ID", required = true)
|
||||
@PostMapping("/{id}/read")
|
||||
public CommonResult<Boolean> markAsRead(@PathVariable Long id) {
|
||||
Integer userId = userService.getUserIdException();
|
||||
return CommonResult.success(conversationService.markAsRead(id, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
@ApiOperation(value = "删除会话")
|
||||
@ApiImplicitParam(name = "id", value = "会话ID", required = true)
|
||||
@DeleteMapping("/{id}")
|
||||
public CommonResult<Boolean> deleteConversation(@PathVariable Long id) {
|
||||
Integer userId = userService.getUserIdException();
|
||||
return CommonResult.success(conversationService.deleteConversation(id, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息列表
|
||||
*/
|
||||
@ApiOperation(value = "获取消息列表")
|
||||
@ApiImplicitParams({
|
||||
@ApiImplicitParam(name = "id", value = "会话ID", required = true),
|
||||
@ApiImplicitParam(name = "page", value = "页码", defaultValue = "1"),
|
||||
@ApiImplicitParam(name = "pageSize", value = "每页数量", defaultValue = "20")
|
||||
})
|
||||
@GetMapping("/{id}/messages")
|
||||
public CommonResult<List<ChatMessageResponse>> getMessages(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "1") Integer page,
|
||||
@RequestParam(defaultValue = "20") Integer pageSize) {
|
||||
Integer userId = userService.getUserIdException();
|
||||
return CommonResult.success(conversationService.getMessages(id, userId, page, pageSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送私信
|
||||
*/
|
||||
@ApiOperation(value = "发送私信")
|
||||
@ApiImplicitParam(name = "id", value = "会话ID", required = true)
|
||||
@PostMapping("/{id}/messages")
|
||||
public CommonResult<ChatMessageResponse> sendMessage(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Validated SendMessageRequest request) {
|
||||
Integer userId = userService.getUserIdException();
|
||||
return CommonResult.success(conversationService.sendMessage(id, userId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
@ApiOperation(value = "删除消息")
|
||||
@ApiImplicitParam(name = "id", value = "消息ID", required = true)
|
||||
@DeleteMapping("/messages/{id}")
|
||||
public CommonResult<Boolean> deleteMessage(@PathVariable Long id) {
|
||||
Integer userId = userService.getUserIdException();
|
||||
return CommonResult.success(conversationService.deleteMessage(id, userId));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
package com.zbkj.front.controller;
|
||||
|
||||
import com.zbkj.common.page.CommonPage;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 好友管理控制器
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("api/front")
|
||||
@Api(tags = "用户 -- 好友管理")
|
||||
@Validated
|
||||
public class FriendController {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
/**
|
||||
* 搜索用户(通过用户名或手机号)
|
||||
*/
|
||||
@ApiOperation(value = "搜索用户")
|
||||
@GetMapping("/users/search")
|
||||
public CommonResult<CommonPage<Map<String, Object>>> searchUsers(
|
||||
@RequestParam(value = "keyword") String keyword,
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
|
||||
|
||||
// 获取当前登录用户ID
|
||||
Integer currentUserId = userService.getUserId();
|
||||
|
||||
StringBuilder sql = new StringBuilder();
|
||||
sql.append("SELECT u.uid as id, u.nickname, u.phone, u.avatar as avatarUrl, ");
|
||||
sql.append("CASE ");
|
||||
sql.append(" WHEN f.id IS NOT NULL AND f.status = 1 THEN 1 "); // 已是好友
|
||||
sql.append(" WHEN fr.id IS NOT NULL AND fr.status = 0 THEN 2 "); // 已申请待审核
|
||||
sql.append(" ELSE 0 "); // 未添加
|
||||
sql.append("END as friendStatus ");
|
||||
sql.append("FROM eb_user u ");
|
||||
sql.append("LEFT JOIN eb_friend f ON ((f.user_id = ? AND f.friend_id = u.uid) OR (f.friend_id = ? AND f.user_id = u.uid)) AND f.status = 1 ");
|
||||
sql.append("LEFT JOIN eb_friend_request fr ON fr.from_user_id = ? AND fr.to_user_id = u.uid AND fr.status = 0 ");
|
||||
sql.append("WHERE u.uid != ? AND u.status = 1 ");
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(currentUserId);
|
||||
params.add(currentUserId);
|
||||
params.add(currentUserId);
|
||||
params.add(currentUserId);
|
||||
|
||||
// 搜索条件:昵称或手机号
|
||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||
sql.append("AND (u.nickname LIKE ? OR u.phone LIKE ?) ");
|
||||
params.add("%" + keyword.trim() + "%");
|
||||
params.add("%" + keyword.trim() + "%");
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
String countSql = "SELECT COUNT(*) FROM (" + sql.toString() + ") t";
|
||||
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
|
||||
|
||||
// 分页
|
||||
sql.append("ORDER BY u.uid DESC ");
|
||||
int offset = (page - 1) * pageSize;
|
||||
sql.append("LIMIT ?, ?");
|
||||
params.add(offset);
|
||||
params.add(pageSize);
|
||||
|
||||
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
|
||||
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 = "发送好友请求")
|
||||
@PostMapping("/friends/request")
|
||||
public CommonResult<Boolean> sendFriendRequest(@RequestBody Map<String, Object> request) {
|
||||
Integer currentUserId = userService.getUserId();
|
||||
Integer targetUserId = (Integer) request.get("targetUserId");
|
||||
String message = (String) request.getOrDefault("message", "");
|
||||
|
||||
if (targetUserId == null) {
|
||||
return CommonResult.failed("目标用户ID不能为空");
|
||||
}
|
||||
|
||||
if (targetUserId.equals(currentUserId)) {
|
||||
return CommonResult.failed("不能添加自己为好友");
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查是否已经是好友
|
||||
String checkFriendSql = "SELECT COUNT(*) FROM eb_friend WHERE " +
|
||||
"((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)) AND status = 1";
|
||||
Long friendCount = jdbcTemplate.queryForObject(checkFriendSql, Long.class,
|
||||
currentUserId, targetUserId, targetUserId, currentUserId);
|
||||
if (friendCount != null && friendCount > 0) {
|
||||
return CommonResult.failed("已经是好友了");
|
||||
}
|
||||
|
||||
// 检查是否已发送过请求
|
||||
String checkRequestSql = "SELECT COUNT(*) FROM eb_friend_request WHERE " +
|
||||
"from_user_id = ? AND to_user_id = ? AND status = 0";
|
||||
Long requestCount = jdbcTemplate.queryForObject(checkRequestSql, Long.class,
|
||||
currentUserId, targetUserId);
|
||||
if (requestCount != null && requestCount > 0) {
|
||||
return CommonResult.failed("已发送过好友请求,请等待对方处理");
|
||||
}
|
||||
|
||||
// 插入好友请求
|
||||
String insertSql = "INSERT INTO eb_friend_request (from_user_id, to_user_id, message, status, create_time) " +
|
||||
"VALUES (?, ?, ?, 0, NOW())";
|
||||
jdbcTemplate.update(insertSql, currentUserId, targetUserId, message);
|
||||
|
||||
return CommonResult.success(true);
|
||||
} catch (Exception e) {
|
||||
log.error("发送好友请求失败: {}", e.getMessage());
|
||||
return CommonResult.failed("发送请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友请求列表
|
||||
*/
|
||||
@ApiOperation(value = "获取好友请求列表")
|
||||
@GetMapping("/friends/requests")
|
||||
public CommonResult<CommonPage<Map<String, Object>>> getFriendRequests(
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
|
||||
|
||||
Integer currentUserId = userService.getUserId();
|
||||
|
||||
String sql = "SELECT fr.id, fr.from_user_id, fr.message, fr.create_time, " +
|
||||
"u.nickname, u.avatar as avatarUrl, u.phone " +
|
||||
"FROM eb_friend_request fr " +
|
||||
"LEFT JOIN eb_user u ON fr.from_user_id = u.uid " +
|
||||
"WHERE fr.to_user_id = ? AND fr.status = 0 " +
|
||||
"ORDER BY fr.create_time DESC LIMIT ?, ?";
|
||||
|
||||
String countSql = "SELECT COUNT(*) FROM eb_friend_request WHERE to_user_id = ? AND status = 0";
|
||||
Long total = jdbcTemplate.queryForObject(countSql, Long.class, currentUserId);
|
||||
|
||||
int offset = (page - 1) * pageSize;
|
||||
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql, currentUserId, offset, pageSize);
|
||||
|
||||
CommonPage<Map<String, Object>> result = new CommonPage<>();
|
||||
result.setList(list);
|
||||
result.setTotal(total != null ? total : 0L);
|
||||
result.setPage(page);
|
||||
result.setLimit(pageSize);
|
||||
|
||||
return CommonResult.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理好友请求(接受/拒绝)
|
||||
*/
|
||||
@ApiOperation(value = "处理好友请求")
|
||||
@PostMapping("/friends/requests/{requestId}/handle")
|
||||
public CommonResult<Boolean> handleFriendRequest(
|
||||
@PathVariable Long requestId,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
|
||||
Integer currentUserId = userService.getUserId();
|
||||
Boolean accept = (Boolean) request.getOrDefault("accept", false);
|
||||
|
||||
try {
|
||||
// 获取请求信息
|
||||
String getSql = "SELECT from_user_id, to_user_id FROM eb_friend_request WHERE id = ? AND to_user_id = ? AND status = 0";
|
||||
List<Map<String, Object>> requests = jdbcTemplate.queryForList(getSql, requestId, currentUserId);
|
||||
if (requests.isEmpty()) {
|
||||
return CommonResult.failed("请求不存在或已处理");
|
||||
}
|
||||
|
||||
Map<String, Object> req = requests.get(0);
|
||||
Integer fromUserId = (Integer) req.get("from_user_id");
|
||||
|
||||
if (accept) {
|
||||
// 接受请求:更新请求状态并创建好友关系
|
||||
jdbcTemplate.update("UPDATE eb_friend_request SET status = 1, update_time = NOW() WHERE id = ?", requestId);
|
||||
|
||||
// 创建双向好友关系
|
||||
String insertFriendSql = "INSERT INTO eb_friend (user_id, friend_id, status, create_time) VALUES (?, ?, 1, NOW())";
|
||||
jdbcTemplate.update(insertFriendSql, currentUserId, fromUserId);
|
||||
jdbcTemplate.update(insertFriendSql, fromUserId, currentUserId);
|
||||
|
||||
// 自动创建私聊会话
|
||||
String checkConvSql = "SELECT id FROM eb_conversation WHERE " +
|
||||
"(user1_id = ? AND user2_id = ?) OR (user1_id = ? AND user2_id = ?)";
|
||||
List<Map<String, Object>> convs = jdbcTemplate.queryForList(checkConvSql,
|
||||
currentUserId, fromUserId, fromUserId, currentUserId);
|
||||
if (convs.isEmpty()) {
|
||||
String insertConvSql = "INSERT INTO eb_conversation (user1_id, user2_id, create_time, update_time) " +
|
||||
"VALUES (?, ?, NOW(), NOW())";
|
||||
jdbcTemplate.update(insertConvSql, Math.min(currentUserId, fromUserId), Math.max(currentUserId, fromUserId));
|
||||
}
|
||||
} else {
|
||||
// 拒绝请求
|
||||
jdbcTemplate.update("UPDATE eb_friend_request SET status = 2, update_time = NOW() WHERE id = ?", requestId);
|
||||
}
|
||||
|
||||
return CommonResult.success(true);
|
||||
} catch (Exception e) {
|
||||
log.error("处理好友请求失败: {}", e.getMessage());
|
||||
return CommonResult.failed("处理失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友列表
|
||||
*/
|
||||
@ApiOperation(value = "获取好友列表")
|
||||
@GetMapping("/friends")
|
||||
public CommonResult<CommonPage<Map<String, Object>>> getFriendList(
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
|
||||
|
||||
Integer currentUserId = userService.getUserId();
|
||||
|
||||
String sql = "SELECT u.uid as id, u.nickname as name, u.avatar as avatarUrl, u.phone, " +
|
||||
"u.last_login_time as lastOnlineTime, " +
|
||||
"CASE WHEN TIMESTAMPDIFF(MINUTE, u.last_login_time, NOW()) < 5 THEN 1 ELSE 0 END as isOnline " +
|
||||
"FROM eb_friend f " +
|
||||
"JOIN eb_user u ON f.friend_id = u.uid " +
|
||||
"WHERE f.user_id = ? AND f.status = 1 " +
|
||||
"ORDER BY isOnline DESC, f.create_time DESC LIMIT ?, ?";
|
||||
|
||||
String countSql = "SELECT COUNT(*) FROM eb_friend WHERE user_id = ? AND status = 1";
|
||||
Long total = jdbcTemplate.queryForObject(countSql, Long.class, currentUserId);
|
||||
|
||||
int offset = (page - 1) * pageSize;
|
||||
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql, currentUserId, offset, pageSize);
|
||||
|
||||
CommonPage<Map<String, Object>> result = new CommonPage<>();
|
||||
result.setList(list);
|
||||
result.setTotal(total != null ? total : 0L);
|
||||
result.setPage(page);
|
||||
result.setLimit(pageSize);
|
||||
|
||||
return CommonResult.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除好友
|
||||
*/
|
||||
@ApiOperation(value = "删除好友")
|
||||
@DeleteMapping("/friends/{friendId}")
|
||||
public CommonResult<Boolean> deleteFriend(@PathVariable Integer friendId) {
|
||||
Integer currentUserId = userService.getUserId();
|
||||
|
||||
try {
|
||||
// 删除双向好友关系
|
||||
String deleteSql = "DELETE FROM eb_friend WHERE " +
|
||||
"(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)";
|
||||
jdbcTemplate.update(deleteSql, currentUserId, friendId, friendId, currentUserId);
|
||||
return CommonResult.success(true);
|
||||
} catch (Exception e) {
|
||||
log.error("删除好友失败: {}", e.getMessage());
|
||||
return CommonResult.failed("删除失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.zbkj.front.controller;
|
||||
|
||||
|
||||
import com.zbkj.common.request.AppRegisterRequest;
|
||||
import com.zbkj.common.request.LoginMobileRequest;
|
||||
import com.zbkj.common.request.LoginRequest;
|
||||
import com.zbkj.common.response.LoginConfigResponse;
|
||||
|
|
@ -102,6 +103,15 @@ public class LoginController {
|
|||
public CommonResult<LoginConfigResponse> getLoginConfig() {
|
||||
return CommonResult.success(loginService.getLoginConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* APP用户注册
|
||||
*/
|
||||
@ApiOperation(value = "APP用户注册")
|
||||
@RequestMapping(value = "/register", method = RequestMethod.POST)
|
||||
public CommonResult<LoginResponse> register(@RequestBody @Validated AppRegisterRequest request) {
|
||||
return CommonResult.success(loginService.register(request));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.zbkj.front.response.live;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 聊天消息响应
|
||||
*/
|
||||
@Data
|
||||
public class ChatMessageResponse {
|
||||
private Long messageId;
|
||||
private String visitorId;
|
||||
private String nickname;
|
||||
private String content;
|
||||
private Long timestamp;
|
||||
|
||||
public static ChatMessageResponse from(com.zbkj.common.model.live.LiveChat chat) {
|
||||
ChatMessageResponse resp = new ChatMessageResponse();
|
||||
resp.setMessageId(chat.getId());
|
||||
resp.setVisitorId(chat.getVisitorId());
|
||||
resp.setNickname(chat.getNickname());
|
||||
resp.setContent(chat.getContent());
|
||||
resp.setTimestamp(chat.getCreateTime() != null ? chat.getCreateTime().getTime() : System.currentTimeMillis());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.zbkj.front.service;
|
||||
|
||||
import com.zbkj.common.model.user.User;
|
||||
import com.zbkj.common.request.AppRegisterRequest;
|
||||
import com.zbkj.common.request.LoginMobileRequest;
|
||||
import com.zbkj.common.request.LoginRequest;
|
||||
import com.zbkj.common.response.LoginConfigResponse;
|
||||
|
|
@ -61,4 +62,9 @@ public interface LoginService {
|
|||
* 获取登录配置
|
||||
*/
|
||||
LoginConfigResponse getLoginConfig();
|
||||
|
||||
/**
|
||||
* APP用户注册
|
||||
*/
|
||||
LoginResponse register(AppRegisterRequest request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.zbkj.common.constants.Constants;
|
|||
import com.zbkj.common.constants.SmsConstants;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.user.User;
|
||||
import com.zbkj.common.request.AppRegisterRequest;
|
||||
import com.zbkj.common.request.LoginMobileRequest;
|
||||
import com.zbkj.common.request.LoginRequest;
|
||||
import com.zbkj.common.response.LoginConfigResponse;
|
||||
|
|
@ -229,4 +230,49 @@ public class LoginServiceImpl implements LoginService {
|
|||
response.setSiteName(siteName);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* APP用户注册
|
||||
*/
|
||||
@Override
|
||||
public LoginResponse register(AppRegisterRequest request) {
|
||||
// 检查手机号是否已注册
|
||||
User existUser = userService.getByPhone(request.getPhone());
|
||||
if (ObjectUtil.isNotNull(existUser)) {
|
||||
throw new CrmebException("该手机号已注册");
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
User user = new User();
|
||||
user.setPhone(request.getPhone());
|
||||
user.setAccount(request.getPhone());
|
||||
// 使用手机号作为密钥加密密码
|
||||
String encryptedPwd = CrmebUtil.encryptPassword(request.getPassword(), request.getPhone());
|
||||
user.setPwd(encryptedPwd);
|
||||
// 设置昵称,如果没有提供则使用手机号后四位
|
||||
String nickname = StrUtil.isNotBlank(request.getNickname()) ? request.getNickname() : "用户" + request.getPhone().substring(7);
|
||||
user.setNickname(nickname);
|
||||
user.setStatus(true);
|
||||
user.setLevel(1);
|
||||
user.setUserType("routine");
|
||||
user.setCreateTime(DateUtil.date());
|
||||
user.setUpdateTime(DateUtil.date());
|
||||
user.setLastLoginTime(CrmebDateUtil.nowDateTime());
|
||||
|
||||
// 保存用户
|
||||
boolean saved = userService.save(user);
|
||||
if (!saved) {
|
||||
throw new CrmebException("注册失败,请稍后重试");
|
||||
}
|
||||
|
||||
// 生成token并返回登录响应
|
||||
LoginResponse loginResponse = new LoginResponse();
|
||||
String token = tokenComponent.createToken(user);
|
||||
loginResponse.setToken(token);
|
||||
loginResponse.setUid(user.getUid());
|
||||
loginResponse.setNikeName(user.getNickname());
|
||||
loginResponse.setPhone(user.getPhone());
|
||||
|
||||
return loginResponse;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,366 @@
|
|||
package com.zbkj.front.websocket;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.zbkj.common.model.chat.Conversation;
|
||||
import com.zbkj.common.request.SendMessageRequest;
|
||||
import com.zbkj.common.response.ChatMessageResponse;
|
||||
import com.zbkj.service.service.ConversationService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
/**
|
||||
* 私聊 WebSocket 处理器
|
||||
*/
|
||||
@Component
|
||||
public class PrivateChatHandler extends TextWebSocketHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PrivateChatHandler.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
private ConversationService conversationService;
|
||||
|
||||
// conversationId -> Set<WebSocketSession>
|
||||
private final Map<String, Set<WebSocketSession>> conversationSessions = new ConcurrentHashMap<>();
|
||||
|
||||
// userId -> Set<WebSocketSession> (一个用户可能有多个设备连接)
|
||||
private final Map<Integer, Set<WebSocketSession>> userSessions = new ConcurrentHashMap<>();
|
||||
|
||||
// session -> userId
|
||||
private final Map<String, Integer> sessionUserMap = new ConcurrentHashMap<>();
|
||||
|
||||
// session -> conversationId
|
||||
private final Map<String, String> sessionConversationMap = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
String conversationId = extractConversationId(session);
|
||||
Integer userId = extractUserId(session);
|
||||
|
||||
if (conversationId == null || userId == null) {
|
||||
logger.warn("[PrivateChat] 连接参数无效: conversationId={}, userId={}", conversationId, userId);
|
||||
session.close(CloseStatus.BAD_DATA);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证用户是否有权限访问该会话
|
||||
try {
|
||||
Conversation conversation = conversationService.getById(Long.parseLong(conversationId));
|
||||
if (conversation == null ||
|
||||
(!conversation.getUser1Id().equals(userId) && !conversation.getUser2Id().equals(userId))) {
|
||||
logger.warn("[PrivateChat] 用户无权限访问会话: userId={}, conversationId={}", userId, conversationId);
|
||||
session.close(CloseStatus.NOT_ACCEPTABLE);
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("[PrivateChat] 验证会话权限失败: {}", e.getMessage());
|
||||
session.close(CloseStatus.SERVER_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到会话映射
|
||||
conversationSessions.computeIfAbsent(conversationId, k -> new CopyOnWriteArraySet<>()).add(session);
|
||||
userSessions.computeIfAbsent(userId, k -> new CopyOnWriteArraySet<>()).add(session);
|
||||
sessionUserMap.put(session.getId(), userId);
|
||||
sessionConversationMap.put(session.getId(), conversationId);
|
||||
|
||||
logger.info("[PrivateChat] 用户连接: userId={}, conversationId={}, sessionId={}",
|
||||
userId, conversationId, session.getId());
|
||||
|
||||
// 发送连接成功消息
|
||||
sendToSession(session, buildSystemMessage("connected", "已连接到会话"));
|
||||
|
||||
// 标记会话为已读
|
||||
try {
|
||||
conversationService.markAsRead(Long.parseLong(conversationId), userId);
|
||||
} catch (Exception e) {
|
||||
logger.warn("[PrivateChat] 标记已读失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||
String sessionId = session.getId();
|
||||
Integer userId = sessionUserMap.remove(sessionId);
|
||||
String conversationId = sessionConversationMap.remove(sessionId);
|
||||
|
||||
if (conversationId != null) {
|
||||
Set<WebSocketSession> sessions = conversationSessions.get(conversationId);
|
||||
if (sessions != null) {
|
||||
sessions.remove(session);
|
||||
if (sessions.isEmpty()) {
|
||||
conversationSessions.remove(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userId != null) {
|
||||
Set<WebSocketSession> sessions = userSessions.get(userId);
|
||||
if (sessions != null) {
|
||||
sessions.remove(session);
|
||||
if (sessions.isEmpty()) {
|
||||
userSessions.remove(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[PrivateChat] 用户断开连接: userId={}, conversationId={}, sessionId={}",
|
||||
userId, conversationId, sessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||
String conversationId = sessionConversationMap.get(session.getId());
|
||||
Integer userId = sessionUserMap.get(session.getId());
|
||||
|
||||
if (conversationId == null || userId == null) {
|
||||
logger.warn("[PrivateChat] 会话信息无效");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode json = objectMapper.readTree(message.getPayload());
|
||||
String type = json.has("type") ? json.get("type").asText() : "chat";
|
||||
|
||||
if ("chat".equals(type)) {
|
||||
String content = json.has("content") ? json.get("content").asText() : "";
|
||||
String messageType = json.has("messageType") ? json.get("messageType").asText() : "text";
|
||||
|
||||
if (content.isEmpty()) {
|
||||
sendToSession(session, buildErrorMessage("消息内容不能为空"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存消息到数据库
|
||||
SendMessageRequest request = new SendMessageRequest();
|
||||
request.setMessage(content);
|
||||
request.setMessageType(messageType);
|
||||
|
||||
ChatMessageResponse response = conversationService.sendMessage(
|
||||
Long.parseLong(conversationId), userId, request);
|
||||
|
||||
// 构建消息并广播给会话中的所有参与者
|
||||
String chatMsg = buildChatMessageFromResponse(response);
|
||||
broadcastToConversation(conversationId, chatMsg);
|
||||
|
||||
// 获取会话信息,通知对方用户(即使不在当前会话WebSocket中)
|
||||
Conversation conversation = conversationService.getById(Long.parseLong(conversationId));
|
||||
if (conversation != null) {
|
||||
Integer otherUserId = conversation.getUser1Id().equals(userId)
|
||||
? conversation.getUser2Id()
|
||||
: conversation.getUser1Id();
|
||||
notifyUser(otherUserId, buildNewMessageNotification(conversationId, response));
|
||||
}
|
||||
|
||||
logger.debug("[PrivateChat] 消息发送: conversationId={}, userId={}, content={}",
|
||||
conversationId, userId, content);
|
||||
|
||||
} else if ("read".equals(type)) {
|
||||
// 标记消息已读
|
||||
conversationService.markAsRead(Long.parseLong(conversationId), userId);
|
||||
// 通知对方消息已读
|
||||
Conversation conversation = conversationService.getById(Long.parseLong(conversationId));
|
||||
if (conversation != null) {
|
||||
Integer otherUserId = conversation.getUser1Id().equals(userId)
|
||||
? conversation.getUser2Id()
|
||||
: conversation.getUser1Id();
|
||||
notifyUserInConversation(conversationId, otherUserId, buildReadNotification(userId));
|
||||
}
|
||||
|
||||
} else if ("typing".equals(type)) {
|
||||
// 通知对方正在输入
|
||||
Conversation conversation = conversationService.getById(Long.parseLong(conversationId));
|
||||
if (conversation != null) {
|
||||
Integer otherUserId = conversation.getUser1Id().equals(userId)
|
||||
? conversation.getUser2Id()
|
||||
: conversation.getUser1Id();
|
||||
notifyUserInConversation(conversationId, otherUserId, buildTypingNotification(userId));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("[PrivateChat] 消息处理失败: {}", e.getMessage(), e);
|
||||
sendToSession(session, buildErrorMessage("消息处理失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
logger.error("[PrivateChat] 传输错误: sessionId={}, error={}", session.getId(), exception.getMessage());
|
||||
session.close(CloseStatus.SERVER_ERROR);
|
||||
}
|
||||
|
||||
private String extractConversationId(WebSocketSession session) {
|
||||
String path = session.getUri() != null ? session.getUri().getPath() : "";
|
||||
// 路径格式: /ws/chat/{conversationId}
|
||||
String[] parts = path.split("/");
|
||||
if (parts.length >= 4) {
|
||||
return parts[3];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Integer extractUserId(WebSocketSession session) {
|
||||
// 从查询参数中获取 userId
|
||||
String query = session.getUri() != null ? session.getUri().getQuery() : "";
|
||||
if (query != null && !query.isEmpty()) {
|
||||
String[] params = query.split("&");
|
||||
for (String param : params) {
|
||||
String[] keyValue = param.split("=");
|
||||
if (keyValue.length == 2 && "userId".equals(keyValue[0])) {
|
||||
try {
|
||||
return Integer.parseInt(keyValue[1]);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void broadcastToConversation(String conversationId, String message) {
|
||||
Set<WebSocketSession> sessions = conversationSessions.get(conversationId);
|
||||
if (sessions == null || sessions.isEmpty()) return;
|
||||
|
||||
TextMessage textMessage = new TextMessage(message);
|
||||
for (WebSocketSession session : sessions) {
|
||||
if (session.isOpen()) {
|
||||
try {
|
||||
session.sendMessage(textMessage);
|
||||
} catch (IOException e) {
|
||||
logger.warn("[PrivateChat] 发送消息失败: sessionId={}", session.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyUser(Integer userId, String message) {
|
||||
Set<WebSocketSession> sessions = userSessions.get(userId);
|
||||
if (sessions == null || sessions.isEmpty()) return;
|
||||
|
||||
TextMessage textMessage = new TextMessage(message);
|
||||
for (WebSocketSession session : sessions) {
|
||||
if (session.isOpen()) {
|
||||
try {
|
||||
session.sendMessage(textMessage);
|
||||
} catch (IOException e) {
|
||||
logger.warn("[PrivateChat] 通知用户失败: userId={}, sessionId={}", userId, session.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyUserInConversation(String conversationId, Integer userId, String message) {
|
||||
Set<WebSocketSession> convSessions = conversationSessions.get(conversationId);
|
||||
if (convSessions == null || convSessions.isEmpty()) return;
|
||||
|
||||
TextMessage textMessage = new TextMessage(message);
|
||||
for (WebSocketSession session : convSessions) {
|
||||
Integer sessionUserId = sessionUserMap.get(session.getId());
|
||||
if (session.isOpen() && userId.equals(sessionUserId)) {
|
||||
try {
|
||||
session.sendMessage(textMessage);
|
||||
} catch (IOException e) {
|
||||
logger.warn("[PrivateChat] 发送消息失败: sessionId={}", session.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendToSession(WebSocketSession session, String message) {
|
||||
if (session.isOpen()) {
|
||||
try {
|
||||
session.sendMessage(new TextMessage(message));
|
||||
} catch (IOException e) {
|
||||
logger.warn("[PrivateChat] 发送消息失败: sessionId={}", session.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String buildChatMessageFromResponse(ChatMessageResponse response) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("type", "chat");
|
||||
node.put("messageId", response.getMessageId());
|
||||
node.put("userId", response.getUserId());
|
||||
node.put("username", response.getUsername());
|
||||
node.put("avatarUrl", response.getAvatarUrl());
|
||||
node.put("message", response.getMessage());
|
||||
node.put("timestamp", response.getTimestamp());
|
||||
node.put("status", response.getStatus());
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
private String buildNewMessageNotification(String conversationId, ChatMessageResponse response) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("type", "new_message");
|
||||
node.put("conversationId", conversationId);
|
||||
node.put("messageId", response.getMessageId());
|
||||
node.put("userId", response.getUserId());
|
||||
node.put("username", response.getUsername());
|
||||
node.put("message", response.getMessage());
|
||||
node.put("timestamp", response.getTimestamp());
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
private String buildSystemMessage(String type, String content) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("type", type);
|
||||
node.put("content", content);
|
||||
node.put("timestamp", System.currentTimeMillis());
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
private String buildErrorMessage(String error) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("type", "error");
|
||||
node.put("error", error);
|
||||
node.put("timestamp", System.currentTimeMillis());
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
private String buildReadNotification(Integer userId) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("type", "read");
|
||||
node.put("userId", userId);
|
||||
node.put("timestamp", System.currentTimeMillis());
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
private String buildTypingNotification(Integer userId) {
|
||||
ObjectNode node = objectMapper.createObjectNode();
|
||||
node.put("type", "typing");
|
||||
node.put("userId", userId);
|
||||
node.put("timestamp", System.currentTimeMillis());
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在线
|
||||
*/
|
||||
public boolean isUserOnline(Integer userId) {
|
||||
Set<WebSocketSession> sessions = userSessions.get(userId);
|
||||
return sessions != null && !sessions.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话在线人数
|
||||
*/
|
||||
public int getConversationOnlineCount(String conversationId) {
|
||||
Set<WebSocketSession> sessions = conversationSessions.get(conversationId);
|
||||
return sessions != null ? sessions.size() : 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.zbkj.service.dao;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.zbkj.common.model.chat.Conversation;
|
||||
|
||||
/**
|
||||
* 会话 Mapper 接口
|
||||
*/
|
||||
public interface ConversationDao extends BaseMapper<Conversation> {
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.zbkj.service.dao;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.zbkj.common.model.chat.PrivateMessage;
|
||||
|
||||
/**
|
||||
* 私信消息 Mapper 接口
|
||||
*/
|
||||
public interface PrivateMessageDao extends BaseMapper<PrivateMessage> {
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.zbkj.service.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.zbkj.common.model.chat.Conversation;
|
||||
import com.zbkj.common.request.SendMessageRequest;
|
||||
import com.zbkj.common.response.ChatMessageResponse;
|
||||
import com.zbkj.common.response.ConversationResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会话服务接口
|
||||
*/
|
||||
public interface ConversationService extends IService<Conversation> {
|
||||
|
||||
/**
|
||||
* 获取当前用户的会话列表
|
||||
*/
|
||||
List<ConversationResponse> getConversationList(Integer userId);
|
||||
|
||||
/**
|
||||
* 搜索会话
|
||||
*/
|
||||
List<ConversationResponse> searchConversations(Integer userId, String keyword);
|
||||
|
||||
/**
|
||||
* 标记会话为已读
|
||||
*/
|
||||
Boolean markAsRead(Long conversationId, Integer userId);
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
Boolean deleteConversation(Long conversationId, Integer userId);
|
||||
|
||||
/**
|
||||
* 获取会话消息列表
|
||||
*/
|
||||
List<ChatMessageResponse> getMessages(Long conversationId, Integer userId, Integer page, Integer pageSize);
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
ChatMessageResponse sendMessage(Long conversationId, Integer userId, SendMessageRequest request);
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
Boolean deleteMessage(Long messageId, Integer userId);
|
||||
|
||||
/**
|
||||
* 获取或创建与指定用户的会话
|
||||
*/
|
||||
Conversation getOrCreateConversation(Integer userId, Integer otherUserId);
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
package com.zbkj.service.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.chat.Conversation;
|
||||
import com.zbkj.common.model.chat.PrivateMessage;
|
||||
import com.zbkj.common.model.user.User;
|
||||
import com.zbkj.common.request.SendMessageRequest;
|
||||
import com.zbkj.common.response.ChatMessageResponse;
|
||||
import com.zbkj.common.response.ConversationResponse;
|
||||
import com.zbkj.service.dao.ConversationDao;
|
||||
import com.zbkj.service.dao.PrivateMessageDao;
|
||||
import com.zbkj.service.service.ConversationService;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 会话服务实现类
|
||||
*/
|
||||
@Service
|
||||
public class ConversationServiceImpl extends ServiceImpl<ConversationDao, Conversation> implements ConversationService {
|
||||
|
||||
@Autowired
|
||||
private PrivateMessageDao privateMessageDao;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Override
|
||||
public List<ConversationResponse> getConversationList(Integer userId) {
|
||||
LambdaQueryWrapper<Conversation> qw = new LambdaQueryWrapper<>();
|
||||
qw.and(wrapper -> wrapper
|
||||
.eq(Conversation::getUser1Id, userId).eq(Conversation::getUser1Deleted, false)
|
||||
.or()
|
||||
.eq(Conversation::getUser2Id, userId).eq(Conversation::getUser2Deleted, false));
|
||||
qw.orderByDesc(Conversation::getLastMessageTime);
|
||||
List<Conversation> conversations = list(qw);
|
||||
return convertToResponseList(conversations, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ConversationResponse> searchConversations(Integer userId, String keyword) {
|
||||
// 先获取用户的所有会话
|
||||
List<ConversationResponse> allConversations = getConversationList(userId);
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return allConversations;
|
||||
}
|
||||
// 按照标题(对方昵称)或最后一条消息内容过滤
|
||||
List<ConversationResponse> result = new ArrayList<>();
|
||||
String lowerKeyword = keyword.toLowerCase();
|
||||
for (ConversationResponse conv : allConversations) {
|
||||
if ((conv.getTitle() != null && conv.getTitle().toLowerCase().contains(lowerKeyword)) ||
|
||||
(conv.getLastMessage() != null && conv.getLastMessage().toLowerCase().contains(lowerKeyword))) {
|
||||
result.add(conv);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean markAsRead(Long conversationId, Integer userId) {
|
||||
Conversation conversation = getById(conversationId);
|
||||
if (conversation == null) {
|
||||
throw new CrmebException("会话不存在");
|
||||
}
|
||||
// 检查用户是否是会话参与者
|
||||
if (!conversation.getUser1Id().equals(userId) && !conversation.getUser2Id().equals(userId)) {
|
||||
throw new CrmebException("无权限操作此会话");
|
||||
}
|
||||
// 更新未读数为0
|
||||
LambdaUpdateWrapper<Conversation> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(Conversation::getId, conversationId);
|
||||
if (conversation.getUser1Id().equals(userId)) {
|
||||
uw.set(Conversation::getUser1UnreadCount, 0);
|
||||
} else {
|
||||
uw.set(Conversation::getUser2UnreadCount, 0);
|
||||
}
|
||||
update(uw);
|
||||
// 更新该用户作为接收者的所有消息状态为已读
|
||||
LambdaUpdateWrapper<PrivateMessage> msgUw = new LambdaUpdateWrapper<>();
|
||||
msgUw.eq(PrivateMessage::getConversationId, conversationId)
|
||||
.eq(PrivateMessage::getReceiverId, userId)
|
||||
.ne(PrivateMessage::getStatus, "read")
|
||||
.set(PrivateMessage::getStatus, "read")
|
||||
.set(PrivateMessage::getReadTime, new Date());
|
||||
privateMessageDao.update(null, msgUw);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean deleteConversation(Long conversationId, Integer userId) {
|
||||
Conversation conversation = getById(conversationId);
|
||||
if (conversation == null) {
|
||||
throw new CrmebException("会话不存在");
|
||||
}
|
||||
// 检查用户是否是会话参与者
|
||||
if (!conversation.getUser1Id().equals(userId) && !conversation.getUser2Id().equals(userId)) {
|
||||
throw new CrmebException("无权限操作此会话");
|
||||
}
|
||||
// 软删除 - 标记为删除
|
||||
LambdaUpdateWrapper<Conversation> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(Conversation::getId, conversationId);
|
||||
if (conversation.getUser1Id().equals(userId)) {
|
||||
uw.set(Conversation::getUser1Deleted, true);
|
||||
} else {
|
||||
uw.set(Conversation::getUser2Deleted, true);
|
||||
}
|
||||
return update(uw);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageResponse> getMessages(Long conversationId, Integer userId, Integer page, Integer pageSize) {
|
||||
Conversation conversation = getById(conversationId);
|
||||
if (conversation == null) {
|
||||
throw new CrmebException("会话不存在");
|
||||
}
|
||||
// 检查用户是否是会话参与者
|
||||
if (!conversation.getUser1Id().equals(userId) && !conversation.getUser2Id().equals(userId)) {
|
||||
throw new CrmebException("无权限查看此会话");
|
||||
}
|
||||
// 分页获取消息
|
||||
int offset = (page - 1) * pageSize;
|
||||
LambdaQueryWrapper<PrivateMessage> qw = new LambdaQueryWrapper<>();
|
||||
qw.eq(PrivateMessage::getConversationId, conversationId);
|
||||
// 过滤掉当前用户已删除的消息
|
||||
qw.and(wrapper -> wrapper
|
||||
.ne(PrivateMessage::getSenderId, userId).eq(PrivateMessage::getReceiverDeleted, false)
|
||||
.or()
|
||||
.eq(PrivateMessage::getSenderId, userId).eq(PrivateMessage::getSenderDeleted, false));
|
||||
qw.orderByDesc(PrivateMessage::getCreateTime);
|
||||
qw.last("LIMIT " + offset + ", " + pageSize);
|
||||
List<PrivateMessage> messages = privateMessageDao.selectList(qw);
|
||||
return convertMessagesToResponseList(messages);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ChatMessageResponse sendMessage(Long conversationId, Integer userId, SendMessageRequest request) {
|
||||
Conversation conversation = getById(conversationId);
|
||||
if (conversation == null) {
|
||||
throw new CrmebException("会话不存在");
|
||||
}
|
||||
// 检查用户是否是会话参与者
|
||||
if (!conversation.getUser1Id().equals(userId) && !conversation.getUser2Id().equals(userId)) {
|
||||
throw new CrmebException("无权限发送消息");
|
||||
}
|
||||
// 确定接收者
|
||||
Integer receiverId = conversation.getUser1Id().equals(userId)
|
||||
? conversation.getUser2Id()
|
||||
: conversation.getUser1Id();
|
||||
// 创建消息
|
||||
PrivateMessage message = new PrivateMessage();
|
||||
message.setConversationId(conversationId);
|
||||
message.setSenderId(userId);
|
||||
message.setReceiverId(receiverId);
|
||||
message.setContent(request.getMessage());
|
||||
message.setMessageType(request.getMessageType() != null ? request.getMessageType() : "text");
|
||||
message.setStatus("sent");
|
||||
message.setSenderDeleted(false);
|
||||
message.setReceiverDeleted(false);
|
||||
message.setCreateTime(new Date());
|
||||
privateMessageDao.insert(message);
|
||||
// 更新会话的最后消息和时间
|
||||
LambdaUpdateWrapper<Conversation> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(Conversation::getId, conversationId)
|
||||
.set(Conversation::getLastMessage, truncateMessage(request.getMessage()))
|
||||
.set(Conversation::getLastMessageTime, message.getCreateTime())
|
||||
.set(Conversation::getUpdateTime, new Date());
|
||||
// 增加接收者的未读数
|
||||
if (conversation.getUser1Id().equals(receiverId)) {
|
||||
uw.set(Conversation::getUser1UnreadCount, conversation.getUser1UnreadCount() + 1);
|
||||
// 如果接收者之前删除了会话,重新显示
|
||||
uw.set(Conversation::getUser1Deleted, false);
|
||||
} else {
|
||||
uw.set(Conversation::getUser2UnreadCount, conversation.getUser2UnreadCount() + 1);
|
||||
uw.set(Conversation::getUser2Deleted, false);
|
||||
}
|
||||
update(uw);
|
||||
return convertMessageToResponse(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean deleteMessage(Long messageId, Integer userId) {
|
||||
PrivateMessage message = privateMessageDao.selectById(messageId);
|
||||
if (message == null) {
|
||||
throw new CrmebException("消息不存在");
|
||||
}
|
||||
// 检查用户是否是消息的发送者或接收者
|
||||
if (!message.getSenderId().equals(userId) && !message.getReceiverId().equals(userId)) {
|
||||
throw new CrmebException("无权限删除此消息");
|
||||
}
|
||||
// 软删除
|
||||
LambdaUpdateWrapper<PrivateMessage> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(PrivateMessage::getId, messageId);
|
||||
if (message.getSenderId().equals(userId)) {
|
||||
uw.set(PrivateMessage::getSenderDeleted, true);
|
||||
} else {
|
||||
uw.set(PrivateMessage::getReceiverDeleted, true);
|
||||
}
|
||||
return privateMessageDao.update(null, uw) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Conversation getOrCreateConversation(Integer userId, Integer otherUserId) {
|
||||
if (userId.equals(otherUserId)) {
|
||||
throw new CrmebException("不能与自己创建会话");
|
||||
}
|
||||
// 查找现有会话(无论谁是user1还是user2)
|
||||
LambdaQueryWrapper<Conversation> qw = new LambdaQueryWrapper<>();
|
||||
qw.and(wrapper -> wrapper
|
||||
.and(w -> w.eq(Conversation::getUser1Id, userId).eq(Conversation::getUser2Id, otherUserId))
|
||||
.or()
|
||||
.and(w -> w.eq(Conversation::getUser1Id, otherUserId).eq(Conversation::getUser2Id, userId)));
|
||||
Conversation conversation = getOne(qw);
|
||||
if (conversation != null) {
|
||||
// 如果当前用户之前删除了会话,重新显示
|
||||
LambdaUpdateWrapper<Conversation> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(Conversation::getId, conversation.getId());
|
||||
if (conversation.getUser1Id().equals(userId) && conversation.getUser1Deleted()) {
|
||||
uw.set(Conversation::getUser1Deleted, false);
|
||||
update(uw);
|
||||
} else if (conversation.getUser2Id().equals(userId) && conversation.getUser2Deleted()) {
|
||||
uw.set(Conversation::getUser2Deleted, false);
|
||||
update(uw);
|
||||
}
|
||||
return getById(conversation.getId());
|
||||
}
|
||||
// 创建新会话
|
||||
conversation = new Conversation();
|
||||
conversation.setUser1Id(userId);
|
||||
conversation.setUser2Id(otherUserId);
|
||||
conversation.setLastMessage("");
|
||||
conversation.setLastMessageTime(new Date());
|
||||
conversation.setUser1UnreadCount(0);
|
||||
conversation.setUser2UnreadCount(0);
|
||||
conversation.setUser1Deleted(false);
|
||||
conversation.setUser2Deleted(false);
|
||||
conversation.setUser1Muted(false);
|
||||
conversation.setUser2Muted(false);
|
||||
conversation.setCreateTime(new Date());
|
||||
conversation.setUpdateTime(new Date());
|
||||
save(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换会话列表为响应对象列表
|
||||
*/
|
||||
private List<ConversationResponse> convertToResponseList(List<Conversation> conversations, Integer userId) {
|
||||
List<ConversationResponse> result = new ArrayList<>();
|
||||
for (Conversation conv : conversations) {
|
||||
ConversationResponse response = new ConversationResponse();
|
||||
response.setId(String.valueOf(conv.getId()));
|
||||
response.setLastMessage(conv.getLastMessage());
|
||||
response.setTimeText(formatTimeText(conv.getLastMessageTime()));
|
||||
// 确定对方用户ID
|
||||
Integer otherUserId = conv.getUser1Id().equals(userId) ? conv.getUser2Id() : conv.getUser1Id();
|
||||
response.setOtherUserId(otherUserId);
|
||||
// 获取未读数和静音状态
|
||||
if (conv.getUser1Id().equals(userId)) {
|
||||
response.setUnreadCount(conv.getUser1UnreadCount());
|
||||
response.setMuted(conv.getUser1Muted());
|
||||
} else {
|
||||
response.setUnreadCount(conv.getUser2UnreadCount());
|
||||
response.setMuted(conv.getUser2Muted());
|
||||
}
|
||||
// 获取对方用户信息
|
||||
User otherUser = userService.getById(otherUserId);
|
||||
if (otherUser != null) {
|
||||
response.setTitle(otherUser.getNickname());
|
||||
response.setAvatarUrl(otherUser.getAvatar());
|
||||
} else {
|
||||
response.setTitle("未知用户");
|
||||
response.setAvatarUrl("");
|
||||
}
|
||||
result.add(response);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换消息列表为响应对象列表
|
||||
*/
|
||||
private List<ChatMessageResponse> convertMessagesToResponseList(List<PrivateMessage> messages) {
|
||||
List<ChatMessageResponse> result = new ArrayList<>();
|
||||
for (PrivateMessage msg : messages) {
|
||||
result.add(convertMessageToResponse(msg));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换单条消息为响应对象
|
||||
*/
|
||||
private ChatMessageResponse convertMessageToResponse(PrivateMessage message) {
|
||||
ChatMessageResponse response = new ChatMessageResponse();
|
||||
response.setMessageId(String.valueOf(message.getId()));
|
||||
response.setUserId(message.getSenderId());
|
||||
response.setMessage(message.getContent());
|
||||
response.setTimestamp(message.getCreateTime().getTime());
|
||||
response.setStatus(message.getStatus());
|
||||
response.setIsSystemMessage(false);
|
||||
// 获取发送者信息
|
||||
User sender = userService.getById(message.getSenderId());
|
||||
if (sender != null) {
|
||||
response.setUsername(sender.getNickname());
|
||||
response.setAvatarUrl(sender.getAvatar());
|
||||
} else {
|
||||
response.setUsername("未知用户");
|
||||
response.setAvatarUrl("");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间显示文本
|
||||
*/
|
||||
private String formatTimeText(Date time) {
|
||||
if (time == null) {
|
||||
return "";
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
long diff = now - time.getTime();
|
||||
long minutes = TimeUnit.MILLISECONDS.toMinutes(diff);
|
||||
long hours = TimeUnit.MILLISECONDS.toHours(diff);
|
||||
long days = TimeUnit.MILLISECONDS.toDays(diff);
|
||||
if (minutes < 1) {
|
||||
return "刚刚";
|
||||
} else if (minutes < 60) {
|
||||
return minutes + "分钟前";
|
||||
} else if (hours < 24) {
|
||||
return hours + "小时前";
|
||||
} else if (days == 1) {
|
||||
return "昨天";
|
||||
} else if (days < 7) {
|
||||
String[] weekDays = {"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
cal.setTime(time);
|
||||
return weekDays[cal.get(java.util.Calendar.DAY_OF_WEEK) - 1];
|
||||
} else {
|
||||
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("MM-dd");
|
||||
return sdf.format(time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断消息内容用于预览
|
||||
*/
|
||||
private String truncateMessage(String message) {
|
||||
if (message == null) {
|
||||
return "";
|
||||
}
|
||||
if (message.length() > 50) {
|
||||
return message.substring(0, 50) + "...";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
57
Zhibo/zhibo-h/sql/create_friend_tables.sql
Normal file
57
Zhibo/zhibo-h/sql/create_friend_tables.sql
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
-- 好友关系表
|
||||
CREATE TABLE IF NOT EXISTS `eb_friend` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL COMMENT '用户ID',
|
||||
`friend_id` int(11) NOT NULL COMMENT '好友ID',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态: 1=正常, 0=已删除',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_friend_id` (`friend_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';
|
||||
|
||||
-- 好友请求表
|
||||
CREATE TABLE IF NOT EXISTS `eb_friend_request` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`from_user_id` int(11) NOT NULL COMMENT '发送者用户ID',
|
||||
`to_user_id` int(11) NOT NULL COMMENT '接收者用户ID',
|
||||
`message` varchar(255) DEFAULT '' COMMENT '请求消息',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态: 0=待处理, 1=已接受, 2=已拒绝',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_from_user` (`from_user_id`),
|
||||
KEY `idx_to_user` (`to_user_id`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友请求表';
|
||||
|
||||
-- 私聊会话表
|
||||
CREATE TABLE IF NOT EXISTS `eb_conversation` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user1_id` int(11) NOT NULL COMMENT '用户1 ID (较小的ID)',
|
||||
`user2_id` int(11) NOT NULL COMMENT '用户2 ID (较大的ID)',
|
||||
`last_message_id` int(11) DEFAULT NULL COMMENT '最后一条消息ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_users` (`user1_id`, `user2_id`),
|
||||
KEY `idx_user1` (`user1_id`),
|
||||
KEY `idx_user2` (`user2_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊会话表';
|
||||
|
||||
-- 私聊消息表
|
||||
CREATE TABLE IF NOT EXISTS `eb_conversation_message` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`conversation_id` int(11) NOT NULL COMMENT '会话ID',
|
||||
`sender_id` int(11) NOT NULL COMMENT '发送者ID',
|
||||
`content` text NOT NULL COMMENT '消息内容',
|
||||
`msg_type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '消息类型: 1=文本, 2=图片, 3=语音',
|
||||
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态: 0=未读, 1=已读',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_conversation` (`conversation_id`),
|
||||
KEY `idx_sender` (`sender_id`),
|
||||
KEY `idx_create_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊消息表';
|
||||
|
|
@ -35,7 +35,7 @@ android {
|
|||
?: "http://10.0.2.2:8081/").trim()
|
||||
|
||||
val apiBaseUrlDevice = (localProps.getProperty("api.base_url_device")
|
||||
?: "http://192.168.1.100:8081/").trim()
|
||||
?: "http://192.168.1.164:8081/").trim()
|
||||
|
||||
// 模拟器使用服务器地址
|
||||
buildConfigField("String", "API_BASE_URL_EMULATOR", "\"$apiBaseUrlEmulator\"")
|
||||
|
|
@ -93,6 +93,8 @@ dependencies {
|
|||
implementation("com.github.bumptech.glide:glide:4.16.0")
|
||||
annotationProcessor("com.github.bumptech.glide:compiler:4.16.0")
|
||||
|
||||
implementation("de.hdodenhof:circleimageview:3.1.0")
|
||||
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@
|
|||
android:name="com.example.livestreaming.MyFriendsActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name="com.example.livestreaming.AddFriendActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name="com.example.livestreaming.FansListActivity"
|
||||
android:exported="false" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import com.example.livestreaming.databinding.ActivityAddFriendBinding;
|
||||
import com.example.livestreaming.net.AuthStore;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 添加好友页面
|
||||
* 支持通过用户名或手机号搜索用户并添加好友
|
||||
*/
|
||||
public class AddFriendActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "AddFriendActivity";
|
||||
private ActivityAddFriendBinding binding;
|
||||
private SearchUserAdapter adapter;
|
||||
private OkHttpClient httpClient;
|
||||
private List<SearchUserItem> searchResults = new ArrayList<>();
|
||||
|
||||
public static void start(Context context) {
|
||||
Intent intent = new Intent(context, AddFriendActivity.class);
|
||||
context.startActivity(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
binding = ActivityAddFriendBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
httpClient = new OkHttpClient();
|
||||
|
||||
setupViews();
|
||||
showInitialState();
|
||||
}
|
||||
|
||||
private void setupViews() {
|
||||
binding.backButton.setOnClickListener(v -> finish());
|
||||
|
||||
adapter = new SearchUserAdapter();
|
||||
adapter.setOnAddClickListener(this::onAddFriendClick);
|
||||
binding.searchResultsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.searchResultsRecyclerView.setAdapter(adapter);
|
||||
|
||||
binding.searchButton.setOnClickListener(v -> performSearch());
|
||||
|
||||
binding.searchEdit.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_SEARCH ||
|
||||
(event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
|
||||
performSearch();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void showInitialState() {
|
||||
binding.emptyStateContainer.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateText.setText("输入用户名或手机号搜索用户");
|
||||
binding.searchResultsRecyclerView.setVisibility(View.GONE);
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void performSearch() {
|
||||
Log.d(TAG, "performSearch 开始执行");
|
||||
String keyword = binding.searchEdit.getText() != null
|
||||
? binding.searchEdit.getText().toString().trim() : "";
|
||||
|
||||
if (TextUtils.isEmpty(keyword)) {
|
||||
Toast.makeText(this, "请输入搜索关键词", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
String token = AuthStore.getToken(this);
|
||||
Log.d(TAG, "获取到的 token: " + (token != null ? token.substring(0, Math.min(10, token.length())) + "..." : "null"));
|
||||
if (token == null) {
|
||||
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// 隐藏键盘
|
||||
hideKeyboard();
|
||||
|
||||
// 显示加载状态
|
||||
binding.loadingProgress.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateContainer.setVisibility(View.GONE);
|
||||
binding.searchResultsRecyclerView.setVisibility(View.GONE);
|
||||
|
||||
try {
|
||||
String encodedKeyword = URLEncoder.encode(keyword, "UTF-8");
|
||||
String url = ApiConfig.getBaseUrl() + "/api/front/users/search?keyword=" + encodedKeyword + "&page=1&pageSize=20";
|
||||
Log.d(TAG, "搜索请求 URL: " + url);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authori-zation", token)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
httpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
Log.e(TAG, "搜索用户失败", e);
|
||||
runOnUiThread(() -> {
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
binding.emptyStateContainer.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateText.setText("搜索失败,请重试");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
String body = response.body() != null ? response.body().string() : "";
|
||||
int httpCode = response.code();
|
||||
Log.d(TAG, "搜索响应: httpCode=" + httpCode + ", body=" + body);
|
||||
runOnUiThread(() -> {
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
try {
|
||||
JSONObject json = new JSONObject(body);
|
||||
int code = json.optInt("code", -1);
|
||||
if (code == 200) {
|
||||
JSONObject data = json.optJSONObject("data");
|
||||
JSONArray list = data != null ? data.optJSONArray("list") : null;
|
||||
parseSearchResults(list);
|
||||
} else if (code == 401) {
|
||||
Toast.makeText(AddFriendActivity.this, "请先登录", Toast.LENGTH_SHORT).show();
|
||||
binding.emptyStateContainer.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateText.setText("请先登录后搜索");
|
||||
} else {
|
||||
String msg = json.optString("message", "搜索失败");
|
||||
Toast.makeText(AddFriendActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
binding.emptyStateContainer.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateText.setText("搜索失败");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析搜索结果失败: " + body, e);
|
||||
binding.emptyStateContainer.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateText.setText("解析结果失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "构建搜索请求失败", e);
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void parseSearchResults(JSONArray list) {
|
||||
searchResults.clear();
|
||||
if (list != null) {
|
||||
for (int i = 0; i < list.length(); i++) {
|
||||
try {
|
||||
JSONObject item = list.getJSONObject(i);
|
||||
String id = String.valueOf(item.opt("id"));
|
||||
String nickname = item.optString("nickname", "未知用户");
|
||||
String phone = item.optString("phone", "");
|
||||
String avatarUrl = item.optString("avatarUrl", "");
|
||||
int friendStatus = item.optInt("friendStatus", 0);
|
||||
searchResults.add(new SearchUserItem(id, nickname, phone, avatarUrl, friendStatus));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析搜索项失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
showSearchResults(searchResults);
|
||||
}
|
||||
|
||||
private void showSearchResults(List<SearchUserItem> results) {
|
||||
if (results == null || results.isEmpty()) {
|
||||
binding.emptyStateContainer.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateText.setText("未找到相关用户");
|
||||
binding.searchResultsRecyclerView.setVisibility(View.GONE);
|
||||
} else {
|
||||
binding.emptyStateContainer.setVisibility(View.GONE);
|
||||
binding.searchResultsRecyclerView.setVisibility(View.VISIBLE);
|
||||
adapter.submitList(new ArrayList<>(results));
|
||||
}
|
||||
}
|
||||
|
||||
private void onAddFriendClick(SearchUserItem item, int position) {
|
||||
if (item == null) return;
|
||||
|
||||
// 检查登录状态
|
||||
String token = AuthStore.getToken(this);
|
||||
if (token == null) {
|
||||
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
String url = ApiConfig.getBaseUrl() + "/api/front/friends/request";
|
||||
|
||||
try {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("targetUserId", Integer.parseInt(item.getId()));
|
||||
body.put("message", "");
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authori-zation", token)
|
||||
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
|
||||
.build();
|
||||
|
||||
httpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
Log.e(TAG, "发送好友请求失败", e);
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(AddFriendActivity.this, "发送请求失败,请重试", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
String responseBody = response.body() != null ? response.body().string() : "";
|
||||
runOnUiThread(() -> {
|
||||
try {
|
||||
JSONObject json = new JSONObject(responseBody);
|
||||
if (json.optInt("code", -1) == 200) {
|
||||
Toast.makeText(AddFriendActivity.this,
|
||||
"已向 " + item.getNickname() + " 发送好友请求", Toast.LENGTH_SHORT).show();
|
||||
// 更新状态为已申请
|
||||
item.setFriendStatus(2);
|
||||
adapter.notifyItemChanged(position);
|
||||
} else {
|
||||
String msg = json.optString("message", "发送请求失败");
|
||||
Toast.makeText(AddFriendActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析响应失败", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "构建请求失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void hideKeyboard() {
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (imm != null && binding.searchEdit != null) {
|
||||
imm.hideSoftInputFromWindow(binding.searchEdit.getWindowToken(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.example.livestreaming.net.ApiClient;
|
||||
|
||||
/**
|
||||
* API 配置工具类
|
||||
* 提供统一的 API 基础地址获取方法
|
||||
*/
|
||||
public final class ApiConfig {
|
||||
|
||||
private static Context appContext;
|
||||
|
||||
private ApiConfig() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 ApiConfig(在 Application 中调用)
|
||||
*/
|
||||
public static void init(Context context) {
|
||||
if (context != null) {
|
||||
appContext = context.getApplicationContext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 API 基础地址
|
||||
* @return 不带尾部斜杠的基础 URL
|
||||
*/
|
||||
public static String getBaseUrl() {
|
||||
String baseUrl = ApiClient.getCurrentBaseUrl(appContext);
|
||||
// 移除尾部斜杠以便拼接路径
|
||||
if (baseUrl != null && baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
}
|
||||
return baseUrl != null ? baseUrl : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取带尾部斜杠的基础地址
|
||||
*/
|
||||
public static String getBaseUrlWithSlash() {
|
||||
return ApiClient.getCurrentBaseUrl(appContext);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,18 @@ public class FriendItem {
|
|||
private final String name;
|
||||
private final String subtitle;
|
||||
private final boolean online;
|
||||
private final String avatarUrl;
|
||||
|
||||
public FriendItem(String id, String name, String subtitle, boolean online) {
|
||||
this(id, name, subtitle, online, null);
|
||||
}
|
||||
|
||||
public FriendItem(String id, String name, String subtitle, boolean online, String avatarUrl) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.subtitle = subtitle;
|
||||
this.online = online;
|
||||
this.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
|
|
@ -32,6 +38,10 @@ public class FriendItem {
|
|||
return online;
|
||||
}
|
||||
|
||||
public String getAvatarUrl() {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
|
|
@ -40,11 +50,12 @@ public class FriendItem {
|
|||
return online == that.online
|
||||
&& Objects.equals(id, that.id)
|
||||
&& Objects.equals(name, that.name)
|
||||
&& Objects.equals(subtitle, that.subtitle);
|
||||
&& Objects.equals(subtitle, that.subtitle)
|
||||
&& Objects.equals(avatarUrl, that.avatarUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id, name, subtitle, online);
|
||||
return Objects.hash(id, name, subtitle, online, avatarUrl);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
|
||||
/**
|
||||
* 好友请求列表适配器
|
||||
*/
|
||||
public class FriendRequestAdapter extends ListAdapter<FriendRequestItem, FriendRequestAdapter.ViewHolder> {
|
||||
|
||||
public interface OnRequestActionListener {
|
||||
void onAccept(FriendRequestItem item, int position);
|
||||
void onReject(FriendRequestItem item, int position);
|
||||
}
|
||||
|
||||
private OnRequestActionListener actionListener;
|
||||
|
||||
public FriendRequestAdapter() {
|
||||
super(DIFF_CALLBACK);
|
||||
}
|
||||
|
||||
public void setOnRequestActionListener(OnRequestActionListener listener) {
|
||||
this.actionListener = listener;
|
||||
}
|
||||
|
||||
private static final DiffUtil.ItemCallback<FriendRequestItem> DIFF_CALLBACK =
|
||||
new DiffUtil.ItemCallback<FriendRequestItem>() {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull FriendRequestItem oldItem, @NonNull FriendRequestItem newItem) {
|
||||
return oldItem.getRequestId() == newItem.getRequestId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull FriendRequestItem oldItem, @NonNull FriendRequestItem newItem) {
|
||||
return oldItem.equals(newItem);
|
||||
}
|
||||
};
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_friend_request, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
FriendRequestItem item = getItem(position);
|
||||
if (item == null) return;
|
||||
|
||||
holder.name.setText(item.getNickname() != null ? item.getNickname() : "未知用户");
|
||||
holder.message.setText(item.getDisplayMessage());
|
||||
|
||||
// 加载头像
|
||||
if (item.getAvatarUrl() != null && !item.getAvatarUrl().isEmpty()) {
|
||||
Glide.with(holder.avatar.getContext())
|
||||
.load(item.getAvatarUrl())
|
||||
.placeholder(R.drawable.ic_account_circle_24)
|
||||
.error(R.drawable.ic_account_circle_24)
|
||||
.into(holder.avatar);
|
||||
} else {
|
||||
holder.avatar.setImageResource(R.drawable.ic_account_circle_24);
|
||||
}
|
||||
|
||||
holder.acceptButton.setOnClickListener(v -> {
|
||||
if (actionListener != null) {
|
||||
actionListener.onAccept(item, holder.getAdapterPosition());
|
||||
}
|
||||
});
|
||||
|
||||
holder.rejectButton.setOnClickListener(v -> {
|
||||
if (actionListener != null) {
|
||||
actionListener.onReject(item, holder.getAdapterPosition());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
CircleImageView avatar;
|
||||
TextView name;
|
||||
TextView message;
|
||||
TextView acceptButton;
|
||||
TextView rejectButton;
|
||||
|
||||
ViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
avatar = itemView.findViewById(R.id.avatar);
|
||||
name = itemView.findViewById(R.id.name);
|
||||
message = itemView.findViewById(R.id.message);
|
||||
acceptButton = itemView.findViewById(R.id.acceptButton);
|
||||
rejectButton = itemView.findViewById(R.id.rejectButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 好友请求数据模型
|
||||
*/
|
||||
public class FriendRequestItem {
|
||||
|
||||
private final long requestId;
|
||||
private final String fromUserId;
|
||||
private final String nickname;
|
||||
private final String avatarUrl;
|
||||
private final String message;
|
||||
private final String createTime;
|
||||
|
||||
public FriendRequestItem(long requestId, String fromUserId, String nickname,
|
||||
String avatarUrl, String message, String createTime) {
|
||||
this.requestId = requestId;
|
||||
this.fromUserId = fromUserId;
|
||||
this.nickname = nickname;
|
||||
this.avatarUrl = avatarUrl;
|
||||
this.message = message;
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
public long getRequestId() {
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public String getFromUserId() {
|
||||
return fromUserId;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public String getAvatarUrl() {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public String getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public String getDisplayMessage() {
|
||||
if (message != null && !message.isEmpty()) {
|
||||
return "验证消息:" + message;
|
||||
}
|
||||
return "请求添加你为好友";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof FriendRequestItem)) return false;
|
||||
FriendRequestItem that = (FriendRequestItem) o;
|
||||
return requestId == that.requestId
|
||||
&& Objects.equals(fromUserId, that.fromUserId)
|
||||
&& Objects.equals(nickname, that.nickname)
|
||||
&& Objects.equals(avatarUrl, that.avatarUrl)
|
||||
&& Objects.equals(message, that.message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(requestId, fromUserId, nickname, avatarUrl, message);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import androidx.recyclerview.widget.DiffUtil;
|
|||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.example.livestreaming.databinding.ItemFriendBinding;
|
||||
|
||||
public class FriendsAdapter extends ListAdapter<FriendItem, FriendsAdapter.VH> {
|
||||
|
|
@ -48,27 +49,29 @@ public class FriendsAdapter extends ListAdapter<FriendItem, FriendsAdapter.VH> {
|
|||
}
|
||||
|
||||
void bind(FriendItem item) {
|
||||
// TODO: 接入后端接口 - 从后端获取好友头像URL
|
||||
// 接口路径: GET /api/user/profile/{friendId}
|
||||
// 请求参数: friendId (路径参数,从FriendItem对象中获取)
|
||||
// 返回数据格式: ApiResponse<{avatarUrl: string}>
|
||||
// 使用Glide加载头像,如果没有则使用默认占位图
|
||||
// TODO: 接入后端接口 - 获取好友在线状态
|
||||
// 接口路径: GET /api/user/status/{friendId}
|
||||
// 请求参数: friendId (路径参数)
|
||||
// 返回数据格式: ApiResponse<{isOnline: boolean, lastActiveTime: timestamp}>
|
||||
// 或者FriendItem对象应包含isOnline字段,直接从item.isOnline()获取
|
||||
binding.name.setText(item != null && item.getName() != null ? item.getName() : "");
|
||||
binding.subtitle.setText(item != null && item.getSubtitle() != null ? item.getSubtitle() : "");
|
||||
if (item == null) return;
|
||||
|
||||
binding.name.setText(item.getName() != null ? item.getName() : "");
|
||||
binding.subtitle.setText(item.getSubtitle() != null ? item.getSubtitle() : "");
|
||||
|
||||
if (item != null && item.isOnline()) {
|
||||
// 加载头像
|
||||
if (item.getAvatarUrl() != null && !item.getAvatarUrl().isEmpty()) {
|
||||
Glide.with(binding.avatar.getContext())
|
||||
.load(item.getAvatarUrl())
|
||||
.placeholder(R.drawable.ic_account_circle_24)
|
||||
.error(R.drawable.ic_account_circle_24)
|
||||
.into(binding.avatar);
|
||||
} else {
|
||||
binding.avatar.setImageResource(R.drawable.ic_account_circle_24);
|
||||
}
|
||||
|
||||
if (item.isOnline()) {
|
||||
binding.onlineDot.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.onlineDot.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
binding.getRoot().setOnClickListener(v -> {
|
||||
if (item == null) return;
|
||||
if (onFriendClickListener != null) onFriendClickListener.onFriendClick(item);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ public class LiveStreamingApplication extends Application {
|
|||
// 初始化LeakCanary内存泄漏检测(仅在debug版本中生效)
|
||||
// LeakCanary会自动在debug版本中初始化,无需手动调用
|
||||
|
||||
// 初始化API配置
|
||||
ApiConfig.init(this);
|
||||
|
||||
// 初始化通知渠道
|
||||
LocalNotificationManager.createNotificationChannel(this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,15 +138,27 @@ public class LoginActivity extends AppCompatActivity {
|
|||
|
||||
// 保存token
|
||||
String token = loginData.getToken();
|
||||
android.util.Log.d("LoginActivity", "登录返回的 token: " + token);
|
||||
if (!TextUtils.isEmpty(token)) {
|
||||
AuthStore.setToken(getApplicationContext(), token);
|
||||
android.util.Log.d("LoginActivity", "Token 已保存");
|
||||
} else {
|
||||
android.util.Log.w("LoginActivity", "登录返回的 token 为空!");
|
||||
}
|
||||
|
||||
// 保存用户信息到本地(如果LoginResponse包含用户信息)
|
||||
// 注意:这里假设LoginResponse可能包含userId和nickname,如果后端返回了这些字段,需要在这里保存
|
||||
// 保存用户信息到 AuthStore
|
||||
AuthStore.setUserInfo(getApplicationContext(),
|
||||
loginData.getUid(),
|
||||
loginData.getNikeName());
|
||||
|
||||
// 保存用户信息到本地 SharedPreferences
|
||||
SharedPreferences prefs = getSharedPreferences("profile_prefs", MODE_PRIVATE);
|
||||
// 如果后端返回了nickname,可以在这里保存
|
||||
// prefs.edit().putString("profile_name", loginData.getNickname()).apply();
|
||||
if (!TextUtils.isEmpty(loginData.getNikeName())) {
|
||||
prefs.edit().putString("profile_name", loginData.getNikeName()).apply();
|
||||
}
|
||||
if (!TextUtils.isEmpty(loginData.getPhone())) {
|
||||
prefs.edit().putString("profile_phone", loginData.getPhone()).apply();
|
||||
}
|
||||
|
||||
// 登录成功,返回上一页(如果是从其他页面跳转过来的)
|
||||
// 如果是直接打开登录页面,则跳转到主页面
|
||||
|
|
@ -208,6 +220,7 @@ public class LoginActivity extends AppCompatActivity {
|
|||
|
||||
// 生成一个演示token(基于账号)
|
||||
String demoToken = "demo_token_" + account.hashCode();
|
||||
android.util.Log.d("LoginActivity", "演示模式 - 生成 token: " + demoToken);
|
||||
AuthStore.setToken(getApplicationContext(), demoToken);
|
||||
|
||||
// 保存用户信息到本地
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import android.Manifest;
|
||||
import android.util.Log;
|
||||
import android.content.res.AssetManager;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
|
|
@ -58,6 +59,8 @@ import retrofit2.Response;
|
|||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
private ActivityMainBinding binding;
|
||||
private RoomsAdapter adapter;
|
||||
|
||||
|
|
@ -107,6 +110,8 @@ public class MainActivity extends AppCompatActivity {
|
|||
binding = ActivityMainBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
// 强制设置正确的 API 地址
|
||||
ApiClient.setCustomBaseUrl(getApplicationContext(), "http://192.168.1.164:8081/");
|
||||
ApiClient.getService(getApplicationContext());
|
||||
|
||||
// 立即显示缓存数据,提升启动速度
|
||||
|
|
@ -304,8 +309,12 @@ public class MainActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void setupUI() {
|
||||
// 注释掉下拉刷新,使用静态数据
|
||||
// binding.swipeRefresh.setOnRefreshListener(this::fetchRooms);
|
||||
// 启用下拉刷新
|
||||
if (binding.swipeRefresh != null) {
|
||||
binding.swipeRefresh.setOnRefreshListener(() -> {
|
||||
fetchDiscoverRooms();
|
||||
});
|
||||
}
|
||||
|
||||
setupDrawerCards();
|
||||
|
||||
|
|
@ -1073,16 +1082,12 @@ public class MainActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void fetchRooms() {
|
||||
// TODO: 接入后端接口 - 获取房间列表
|
||||
// 接口路径: GET /api/rooms
|
||||
// 请求参数:
|
||||
// - category (可选): 分类筛选,如"游戏"、"才艺"等
|
||||
// - page (可选): 页码,用于分页加载
|
||||
// - pageSize (可选): 每页数量
|
||||
// 返回数据格式: ApiResponse<List<Room>>
|
||||
// Room对象应包含: id, title, streamerName, type, isLive, coverUrl, viewerCount, streamUrls等字段
|
||||
Log.d(TAG, "fetchRooms() 开始");
|
||||
// 避免重复请求
|
||||
if (isFetching) return;
|
||||
if (isFetching) {
|
||||
Log.d(TAG, "fetchRooms() 已在请求中,跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
isFetching = true;
|
||||
lastFetchMs = System.currentTimeMillis();
|
||||
|
|
@ -1091,67 +1096,64 @@ public class MainActivity extends AppCompatActivity {
|
|||
hideEmptyState();
|
||||
hideErrorState();
|
||||
|
||||
// 只在没有数据时显示骨架屏(替代简单的LoadingView)
|
||||
// 只在没有数据时显示骨架屏
|
||||
if (adapter.getItemCount() == 0) {
|
||||
// 使用骨架屏替代简单的LoadingView,提供更好的用户体验
|
||||
LoadingStateManager.showSkeleton(binding.roomsRecyclerView, 6);
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
} else {
|
||||
// 如果有数据,显示LoadingView(用于下拉刷新等场景)
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
String baseUrl = ApiClient.getCurrentBaseUrl(getApplicationContext());
|
||||
Log.d(TAG, "fetchRooms() 请求 API: " + baseUrl + "api/front/live/public/rooms");
|
||||
|
||||
ApiClient.getService(getApplicationContext()).getRooms().enqueue(new Callback<ApiResponse<List<Room>>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<List<Room>>> call, Response<ApiResponse<List<Room>>> response) {
|
||||
// 隐藏骨架屏和加载视图
|
||||
Log.d(TAG, "fetchRooms() onResponse: code=" + response.code());
|
||||
|
||||
// 先恢复真实适配器
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
LoadingStateManager.stopRefreshing(binding.swipeRefresh);
|
||||
isFetching = false;
|
||||
|
||||
ApiResponse<List<Room>> body = response.body();
|
||||
Log.d(TAG, "fetchRooms() body=" + (body != null ? "not null, isOk=" + body.isOk() : "null"));
|
||||
|
||||
List<Room> rooms = response.isSuccessful() && body != null && body.isOk() && body.getData() != null
|
||||
? body.getData()
|
||||
: Collections.emptyList();
|
||||
|
||||
Log.d(TAG, "fetchRooms() rooms count: " + (rooms != null ? rooms.size() : 0));
|
||||
|
||||
if (rooms == null || rooms.isEmpty()) {
|
||||
// 使用演示数据,但显示空状态
|
||||
rooms = buildDemoRooms(0); // 生成0个演示房间
|
||||
showNoRoomsState();
|
||||
} else {
|
||||
// 有真实数据,隐藏空状态
|
||||
hideEmptyState();
|
||||
}
|
||||
|
||||
allRooms.clear();
|
||||
allRooms.addAll(rooms);
|
||||
// 确保使用真实的RoomsAdapter(替换骨架屏适配器)
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
// 使用带动画的筛选方法
|
||||
applyCategoryFilterWithAnimation(currentCategory);
|
||||
// 设置真实数据到适配器,自动替换骨架屏
|
||||
adapter.bumpCoverOffset();
|
||||
if (rooms != null) allRooms.addAll(rooms);
|
||||
adapter.submitList(new ArrayList<>(allRooms));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiResponse<List<Room>>> call, Throwable t) {
|
||||
// 隐藏骨架屏和加载视图
|
||||
Log.e(TAG, "fetchRooms() onFailure: " + t.getMessage(), t);
|
||||
|
||||
// 先恢复真实适配器
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
LoadingStateManager.stopRefreshing(binding.swipeRefresh);
|
||||
isFetching = false;
|
||||
|
||||
// 显示网络错误Snackbar和空状态
|
||||
// 显示网络错误
|
||||
ErrorHandler.handleApiError(binding.getRoot(), t, () -> fetchRooms());
|
||||
showNetworkErrorState();
|
||||
|
||||
// 仍然提供演示数据作为后备
|
||||
allRooms.clear();
|
||||
allRooms.addAll(buildDemoRooms(0));
|
||||
// 确保使用真实的RoomsAdapter(替换骨架屏适配器)
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
// 使用带动画的筛选方法
|
||||
applyCategoryFilterWithAnimation(currentCategory);
|
||||
// 设置真实数据到适配器,自动替换骨架屏
|
||||
adapter.bumpCoverOffset();
|
||||
adapter.submitList(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1508,46 +1510,62 @@ public class MainActivity extends AppCompatActivity {
|
|||
* 从后端获取发现页面的直播间列表
|
||||
*/
|
||||
private void fetchDiscoverRooms() {
|
||||
// 显示加载状态
|
||||
if (adapter != null && adapter.getItemCount() == 0) {
|
||||
LoadingStateManager.showSkeleton(binding.roomsRecyclerView, 6);
|
||||
}
|
||||
Log.d(TAG, "fetchDiscoverRooms() 开始,API: " + ApiClient.getCurrentBaseUrl(getApplicationContext()));
|
||||
|
||||
// 不显示骨架屏,直接用空列表初始化
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
adapter.submitList(new ArrayList<>());
|
||||
|
||||
ApiClient.getService(getApplicationContext()).getRooms().enqueue(new Callback<ApiResponse<List<Room>>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<List<Room>>> call, Response<ApiResponse<List<Room>>> response) {
|
||||
Log.d(TAG, "fetchDiscoverRooms() onResponse: code=" + response.code());
|
||||
|
||||
// 停止刷新动画
|
||||
LoadingStateManager.stopRefreshing(binding.swipeRefresh);
|
||||
|
||||
// 恢复真实适配器
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
|
||||
ApiResponse<List<Room>> body = response.body();
|
||||
List<Room> rooms = response.isSuccessful() && body != null && body.isOk() && body.getData() != null
|
||||
? body.getData()
|
||||
: Collections.emptyList();
|
||||
|
||||
discoverRooms.clear();
|
||||
allRooms.clear();
|
||||
Log.d(TAG, "fetchDiscoverRooms() 获取到 " + (rooms != null ? rooms.size() : 0) + " 个直播间");
|
||||
|
||||
if (rooms != null && !rooms.isEmpty()) {
|
||||
discoverRooms.addAll(rooms);
|
||||
allRooms.addAll(rooms);
|
||||
hideEmptyState();
|
||||
// 隐藏加载视图
|
||||
if (binding.loading != null) binding.loading.setVisibility(View.GONE);
|
||||
} else {
|
||||
// 没有直播间时显示空状态
|
||||
// 没有直播间时立即显示空状态
|
||||
Log.d(TAG, "fetchDiscoverRooms() 无直播间,显示空状态");
|
||||
showNoRoomsState();
|
||||
// 隐藏加载视图
|
||||
if (binding.loading != null) binding.loading.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// 如果当前在发现页,刷新显示
|
||||
if ("发现".equals(currentTopTab)) {
|
||||
allRooms.clear();
|
||||
allRooms.addAll(discoverRooms);
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
applyCategoryFilterWithAnimation(currentCategory);
|
||||
}
|
||||
// 提交空列表或数据列表
|
||||
adapter.submitList(new ArrayList<>(allRooms));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiResponse<List<Room>>> call, Throwable t) {
|
||||
// 网络错误,显示错误状态(不使用演示数据,避免ID类型不匹配)
|
||||
Log.e(TAG, "fetchDiscoverRooms() onFailure: " + t.getMessage(), t);
|
||||
// 无论结果如何,先恢复真实适配器
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
|
||||
// 网络错误,显示错误状态
|
||||
discoverRooms.clear();
|
||||
|
||||
if ("发现".equals(currentTopTab)) {
|
||||
allRooms.clear();
|
||||
binding.roomsRecyclerView.setAdapter(adapter);
|
||||
applyCategoryFilterWithAnimation(currentCategory);
|
||||
adapter.submitList(new ArrayList<>());
|
||||
showNetworkErrorState();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,12 +29,29 @@ import com.example.livestreaming.databinding.ActivityMessagesBinding;
|
|||
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.example.livestreaming.net.AuthStore;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
public class MessagesActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "MessagesActivity";
|
||||
private final OkHttpClient httpClient = new OkHttpClient();
|
||||
|
||||
private ActivityMessagesBinding binding;
|
||||
|
||||
private final List<ConversationItem> allConversations = new ArrayList<>(); // 保存所有会话,用于搜索
|
||||
|
|
@ -141,25 +158,95 @@ public class MessagesActivity extends AppCompatActivity {
|
|||
binding.conversationsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.conversationsRecyclerView.setAdapter(conversationsAdapter);
|
||||
|
||||
// TODO: 接入后端接口 - 获取会话列表
|
||||
// 接口路径: GET /api/conversations
|
||||
// 请求参数:
|
||||
// - userId: 当前用户ID(从token中获取)
|
||||
// - page (可选): 页码
|
||||
// - pageSize (可选): 每页数量
|
||||
// 返回数据格式: ApiResponse<List<ConversationItem>>
|
||||
// ConversationItem对象应包含: id, title, lastMessage, timeText, unreadCount, isMuted, avatarUrl等字段
|
||||
// 会话列表应按最后一条消息时间倒序排列
|
||||
// 从后端获取会话列表
|
||||
loadConversationsFromServer();
|
||||
|
||||
attachSwipeToDelete(binding.conversationsRecyclerView);
|
||||
}
|
||||
|
||||
private void loadConversationsFromServer() {
|
||||
String token = AuthStore.getToken(this);
|
||||
if (token == null) {
|
||||
Log.w(TAG, "未登录,显示空状态");
|
||||
updateEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
String url = ApiConfig.getBaseUrl() + "/api/front/conversations";
|
||||
Log.d(TAG, "加载会话列表: " + url);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authori-zation", token)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
httpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
Log.e(TAG, "加载会话列表失败", e);
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MessagesActivity.this, "加载失败,请重试", Toast.LENGTH_SHORT).show();
|
||||
updateEmptyState();
|
||||
});
|
||||
}
|
||||
|
||||
@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) {
|
||||
JSONArray data = json.optJSONArray("data");
|
||||
parseConversations(data);
|
||||
} else {
|
||||
String msg = json.optString("message", "加载失败");
|
||||
Toast.makeText(MessagesActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
updateEmptyState();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析会话列表失败", e);
|
||||
updateEmptyState();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void parseConversations(JSONArray data) {
|
||||
allConversations.clear();
|
||||
allConversations.addAll(buildDemoConversations());
|
||||
if (data != null) {
|
||||
for (int i = 0; i < data.length(); i++) {
|
||||
try {
|
||||
JSONObject item = data.getJSONObject(i);
|
||||
String id = String.valueOf(item.opt("id"));
|
||||
String title = item.optString("otherUserName", "未知用户");
|
||||
String lastMessage = item.optString("lastMessage", "");
|
||||
String timeText = item.optString("lastMessageTime", "");
|
||||
int unreadCount = item.optInt("unreadCount", 0);
|
||||
boolean isMuted = item.optBoolean("isMuted", false);
|
||||
String avatarUrl = item.optString("otherUserAvatar", "");
|
||||
|
||||
ConversationItem convItem = new ConversationItem(id, title, lastMessage, timeText, unreadCount, isMuted);
|
||||
allConversations.add(convItem);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析会话项失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
conversations.clear();
|
||||
conversations.addAll(allConversations);
|
||||
conversationsAdapter.submitList(new ArrayList<>(conversations));
|
||||
|
||||
// 检查是否需要显示空状态
|
||||
updateEmptyState();
|
||||
|
||||
attachSwipeToDelete(binding.conversationsRecyclerView);
|
||||
|
||||
// 更新总未读数量
|
||||
int totalUnread = calculateTotalUnreadCount();
|
||||
UnreadMessageManager.setUnreadCount(this, totalUnread);
|
||||
if (binding != null) {
|
||||
UnreadMessageManager.updateBadge(binding.bottomNavInclude.bottomNavigation);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupSearch() {
|
||||
|
|
@ -578,15 +665,8 @@ public class MessagesActivity extends AppCompatActivity {
|
|||
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
|
||||
bottomNav.setSelectedItemId(R.id.nav_messages);
|
||||
|
||||
// 用户进入消息页面时,计算并更新总未读数量
|
||||
int totalUnread = calculateTotalUnreadCount();
|
||||
UnreadMessageManager.setUnreadCount(this, totalUnread);
|
||||
UnreadMessageManager.updateBadge(bottomNav);
|
||||
|
||||
// 刷新列表以更新未读数量显示
|
||||
if (conversationsAdapter != null) {
|
||||
conversationsAdapter.submitList(new ArrayList<>(conversations));
|
||||
}
|
||||
// 从服务器刷新会话列表
|
||||
loadConversationsFromServer();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
|
|
@ -10,15 +12,34 @@ import androidx.appcompat.app.AppCompatActivity;
|
|||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import com.example.livestreaming.databinding.ActivityMyFriendsBinding;
|
||||
import com.example.livestreaming.net.AuthStore;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.MediaType;
|
||||
|
||||
public class MyFriendsActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "MyFriendsActivity";
|
||||
private ActivityMyFriendsBinding binding;
|
||||
private FriendsAdapter adapter;
|
||||
private final List<FriendItem> all = new ArrayList<>();
|
||||
private FriendsAdapter friendsAdapter;
|
||||
private FriendRequestAdapter requestAdapter;
|
||||
private final List<FriendItem> allFriends = new ArrayList<>();
|
||||
private final List<FriendRequestItem> allRequests = new ArrayList<>();
|
||||
private int currentTab = 0; // 0: 好友列表, 1: 好友请求
|
||||
private OkHttpClient httpClient;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
|
|
@ -26,38 +47,55 @@ public class MyFriendsActivity extends AppCompatActivity {
|
|||
binding = ActivityMyFriendsBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
binding.backButton.setOnClickListener(v -> finish());
|
||||
httpClient = new OkHttpClient();
|
||||
|
||||
adapter = new FriendsAdapter(item -> {
|
||||
if (item == null) return;
|
||||
Toast.makeText(this, "打开挚友:" + item.getName(), Toast.LENGTH_SHORT).show();
|
||||
setupViews();
|
||||
setupTabs();
|
||||
loadFriendList();
|
||||
}
|
||||
|
||||
private void setupViews() {
|
||||
binding.backButton.setOnClickListener(v -> finish());
|
||||
|
||||
// 添加好友按钮
|
||||
binding.addFriendButton.setOnClickListener(v -> {
|
||||
if (!AuthHelper.requireLogin(this, "添加好友需要登录")) {
|
||||
return;
|
||||
}
|
||||
AddFriendActivity.start(this);
|
||||
});
|
||||
|
||||
// 好友列表适配器
|
||||
friendsAdapter = new FriendsAdapter(item -> {
|
||||
if (item == null) return;
|
||||
// 点击好友打开私聊会话
|
||||
openConversation(item);
|
||||
});
|
||||
binding.friendsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.friendsRecyclerView.setAdapter(adapter);
|
||||
binding.friendsRecyclerView.setAdapter(friendsAdapter);
|
||||
|
||||
// TODO: 接入后端接口 - 获取好友列表
|
||||
// 接口路径: GET /api/friends
|
||||
// 请求参数:
|
||||
// - userId: 当前用户ID(从token中获取)
|
||||
// - page (可选): 页码
|
||||
// - pageSize (可选): 每页数量
|
||||
// 返回数据格式: ApiResponse<List<FriendItem>>
|
||||
// FriendItem对象应包含: id, name, avatarUrl, subtitle, isOnline, lastOnlineTime等字段
|
||||
// 列表应按最后在线时间倒序或添加时间倒序排列
|
||||
all.clear();
|
||||
all.addAll(buildDemoFriends());
|
||||
adapter.submitList(new ArrayList<>(all));
|
||||
// 好友请求适配器
|
||||
requestAdapter = new FriendRequestAdapter();
|
||||
requestAdapter.setOnRequestActionListener(new FriendRequestAdapter.OnRequestActionListener() {
|
||||
@Override
|
||||
public void onAccept(FriendRequestItem item, int position) {
|
||||
handleFriendRequest(item, position, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReject(FriendRequestItem item, int position) {
|
||||
handleFriendRequest(item, position, false);
|
||||
}
|
||||
});
|
||||
binding.requestsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.requestsRecyclerView.setAdapter(requestAdapter);
|
||||
|
||||
// 搜索框
|
||||
binding.searchEdit.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
String q = s != null ? s.toString().trim() : "";
|
||||
|
|
@ -66,24 +104,251 @@ public class MyFriendsActivity extends AppCompatActivity {
|
|||
});
|
||||
}
|
||||
|
||||
private void setupTabs() {
|
||||
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友列表"));
|
||||
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友请求"));
|
||||
|
||||
binding.tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
|
||||
@Override
|
||||
public void onTabSelected(TabLayout.Tab tab) {
|
||||
currentTab = tab.getPosition();
|
||||
switchTab(currentTab);
|
||||
}
|
||||
@Override
|
||||
public void onTabUnselected(TabLayout.Tab tab) {}
|
||||
@Override
|
||||
public void onTabReselected(TabLayout.Tab tab) {}
|
||||
});
|
||||
}
|
||||
|
||||
private void switchTab(int tabIndex) {
|
||||
if (tabIndex == 0) {
|
||||
binding.friendsRecyclerView.setVisibility(View.VISIBLE);
|
||||
binding.requestsRecyclerView.setVisibility(View.GONE);
|
||||
binding.searchContainer.setVisibility(View.VISIBLE);
|
||||
loadFriendList();
|
||||
} else {
|
||||
binding.friendsRecyclerView.setVisibility(View.GONE);
|
||||
binding.requestsRecyclerView.setVisibility(View.VISIBLE);
|
||||
binding.searchContainer.setVisibility(View.GONE);
|
||||
loadFriendRequests();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadFriendList() {
|
||||
String token = AuthStore.getToken(this);
|
||||
if (token == null) {
|
||||
// 未登录,显示空状态
|
||||
showEmptyState("登录后查看好友列表");
|
||||
return;
|
||||
}
|
||||
|
||||
binding.loadingProgress.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateView.setVisibility(View.GONE);
|
||||
|
||||
String url = ApiConfig.getBaseUrl() + "/api/front/friends?page=1&pageSize=100";
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authori-zation", token)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
httpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
Log.e(TAG, "加载好友列表失败", e);
|
||||
runOnUiThread(() -> {
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
Toast.makeText(MyFriendsActivity.this, "加载失败,请重试", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
String body = response.body() != null ? response.body().string() : "";
|
||||
runOnUiThread(() -> {
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
try {
|
||||
JSONObject json = new JSONObject(body);
|
||||
if (json.optInt("code", -1) == 200) {
|
||||
JSONObject data = json.optJSONObject("data");
|
||||
JSONArray list = data != null ? data.optJSONArray("list") : null;
|
||||
parseFriendList(list);
|
||||
} else {
|
||||
String msg = json.optString("message", "加载失败");
|
||||
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析好友列表失败", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void parseFriendList(JSONArray list) {
|
||||
allFriends.clear();
|
||||
if (list != null) {
|
||||
for (int i = 0; i < list.length(); i++) {
|
||||
try {
|
||||
JSONObject item = list.getJSONObject(i);
|
||||
String id = String.valueOf(item.opt("id"));
|
||||
String name = item.optString("name", "未知用户");
|
||||
String avatarUrl = item.optString("avatarUrl", "");
|
||||
boolean isOnline = item.optInt("isOnline", 0) == 1;
|
||||
String subtitle = isOnline ? "在线" : "离线";
|
||||
allFriends.add(new FriendItem(id, name, subtitle, isOnline, avatarUrl));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析好友项失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
friendsAdapter.submitList(new ArrayList<>(allFriends));
|
||||
updateEmptyState(allFriends);
|
||||
}
|
||||
|
||||
private void loadFriendRequests() {
|
||||
String token = AuthStore.getToken(this);
|
||||
if (token == null) {
|
||||
showEmptyState("登录后查看好友请求");
|
||||
return;
|
||||
}
|
||||
|
||||
binding.loadingProgress.setVisibility(View.VISIBLE);
|
||||
binding.emptyStateView.setVisibility(View.GONE);
|
||||
|
||||
String url = ApiConfig.getBaseUrl() + "/api/front/friends/requests?page=1&pageSize=100";
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authori-zation", token)
|
||||
.get()
|
||||
.build();
|
||||
|
||||
httpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
Log.e(TAG, "加载好友请求失败", e);
|
||||
runOnUiThread(() -> {
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
Toast.makeText(MyFriendsActivity.this, "加载失败,请重试", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
String body = response.body() != null ? response.body().string() : "";
|
||||
runOnUiThread(() -> {
|
||||
binding.loadingProgress.setVisibility(View.GONE);
|
||||
try {
|
||||
JSONObject json = new JSONObject(body);
|
||||
if (json.optInt("code", -1) == 200) {
|
||||
JSONObject data = json.optJSONObject("data");
|
||||
JSONArray list = data != null ? data.optJSONArray("list") : null;
|
||||
parseFriendRequests(list);
|
||||
} else {
|
||||
String msg = json.optString("message", "加载失败");
|
||||
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析好友请求失败", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void parseFriendRequests(JSONArray list) {
|
||||
allRequests.clear();
|
||||
if (list != null) {
|
||||
for (int i = 0; i < list.length(); i++) {
|
||||
try {
|
||||
JSONObject item = list.getJSONObject(i);
|
||||
long requestId = item.optLong("id", 0);
|
||||
String fromUserId = String.valueOf(item.opt("from_user_id"));
|
||||
String nickname = item.optString("nickname", "未知用户");
|
||||
String avatarUrl = item.optString("avatarUrl", "");
|
||||
String message = item.optString("message", "");
|
||||
String createTime = item.optString("create_time", "");
|
||||
allRequests.add(new FriendRequestItem(requestId, fromUserId, nickname, avatarUrl, message, createTime));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析好友请求项失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAdapter.submitList(new ArrayList<>(allRequests));
|
||||
updateRequestEmptyState(allRequests);
|
||||
}
|
||||
|
||||
private void handleFriendRequest(FriendRequestItem item, int position, boolean accept) {
|
||||
String token = AuthStore.getToken(this);
|
||||
if (token == null) {
|
||||
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
String url = ApiConfig.getBaseUrl() + "/api/front/friends/requests/" + item.getRequestId() + "/handle";
|
||||
|
||||
try {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("accept", accept);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authori-zation", token)
|
||||
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
|
||||
.build();
|
||||
|
||||
httpClient.newCall(request).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
Log.e(TAG, "处理好友请求失败", e);
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MyFriendsActivity.this, "操作失败,请重试", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call call, Response response) throws IOException {
|
||||
String responseBody = response.body() != null ? response.body().string() : "";
|
||||
runOnUiThread(() -> {
|
||||
try {
|
||||
JSONObject json = new JSONObject(responseBody);
|
||||
if (json.optInt("code", -1) == 200) {
|
||||
String msg = accept ? "已添加好友" : "已拒绝请求";
|
||||
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
// 从列表中移除
|
||||
allRequests.remove(item);
|
||||
requestAdapter.submitList(new ArrayList<>(allRequests));
|
||||
updateRequestEmptyState(allRequests);
|
||||
} else {
|
||||
String msg = json.optString("message", "操作失败");
|
||||
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析响应失败", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "构建请求失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void openConversation(FriendItem friend) {
|
||||
// 打开与好友的私聊会话
|
||||
ConversationActivity.start(this, friend.getId(), friend.getName());
|
||||
}
|
||||
|
||||
private void applyFilter(String query) {
|
||||
// TODO: 接入后端接口 - 搜索好友
|
||||
// 接口路径: GET /api/friends/search
|
||||
// 请求参数:
|
||||
// - userId: 当前用户ID(从token中获取)
|
||||
// - keyword: 搜索关键词
|
||||
// - page (可选): 页码
|
||||
// - pageSize (可选): 每页数量
|
||||
// 返回数据格式: ApiResponse<List<FriendItem>>
|
||||
// 搜索范围包括:好友昵称、备注、共同关注等
|
||||
if (query == null || query.trim().isEmpty()) {
|
||||
adapter.submitList(new ArrayList<>(all));
|
||||
updateEmptyState(all);
|
||||
friendsAdapter.submitList(new ArrayList<>(allFriends));
|
||||
updateEmptyState(allFriends);
|
||||
return;
|
||||
}
|
||||
String q = query.toLowerCase();
|
||||
List<FriendItem> filtered = new ArrayList<>();
|
||||
for (FriendItem f : all) {
|
||||
for (FriendItem f : allFriends) {
|
||||
if (f == null) continue;
|
||||
String name = f.getName() != null ? f.getName() : "";
|
||||
String sub = f.getSubtitle() != null ? f.getSubtitle() : "";
|
||||
|
|
@ -91,13 +356,10 @@ public class MyFriendsActivity extends AppCompatActivity {
|
|||
filtered.add(f);
|
||||
}
|
||||
}
|
||||
adapter.submitList(filtered);
|
||||
friendsAdapter.submitList(filtered);
|
||||
updateEmptyState(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新空状态显示
|
||||
*/
|
||||
private void updateEmptyState(List<FriendItem> friends) {
|
||||
if (friends == null || friends.isEmpty()) {
|
||||
if (binding.emptyStateView != null) {
|
||||
|
|
@ -111,16 +373,31 @@ public class MyFriendsActivity extends AppCompatActivity {
|
|||
}
|
||||
}
|
||||
|
||||
private List<FriendItem> buildDemoFriends() {
|
||||
List<FriendItem> list = new ArrayList<>();
|
||||
list.add(new FriendItem("1", "小王", "最近在线:5分钟前", true));
|
||||
list.add(new FriendItem("2", "小李", "共同关注:王者荣耀", false));
|
||||
list.add(new FriendItem("3", "安安", "备注:一起连麦", true));
|
||||
list.add(new FriendItem("4", "小陈", "最近在线:昨天", false));
|
||||
list.add(new FriendItem("5", "小美", "共同关注:音乐", true));
|
||||
list.add(new FriendItem("6", "老张", "最近在线:3天前", false));
|
||||
list.add(new FriendItem("7", "小七", "备注:挚友", true));
|
||||
list.add(new FriendItem("8", "阿杰", "共同关注:美食", false));
|
||||
return list;
|
||||
private void updateRequestEmptyState(List<FriendRequestItem> requests) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
showEmptyState("暂无好友请求");
|
||||
} else {
|
||||
if (binding.emptyStateView != null) {
|
||||
binding.emptyStateView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void showEmptyState(String message) {
|
||||
if (binding.emptyStateView != null) {
|
||||
binding.emptyStateView.setNoFriendsState();
|
||||
binding.emptyStateView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
// 返回时刷新数据
|
||||
if (currentTab == 0) {
|
||||
loadFriendList();
|
||||
} else {
|
||||
loadFriendRequests();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,7 +320,13 @@ public class ProfileActivity extends AppCompatActivity {
|
|||
}
|
||||
showShareProfileDialog();
|
||||
});
|
||||
binding.addFriendBtn.setOnClickListener(v -> TabPlaceholderActivity.start(this, "加好友"));
|
||||
binding.addFriendBtn.setOnClickListener(v -> {
|
||||
// 检查登录状态,添加好友需要登录
|
||||
if (!AuthHelper.requireLogin(this, "添加好友需要登录")) {
|
||||
return;
|
||||
}
|
||||
AddFriendActivity.start(this);
|
||||
});
|
||||
}
|
||||
|
||||
private void setupProfileTabs() {
|
||||
|
|
|
|||
|
|
@ -172,8 +172,7 @@ public class RegisterActivity extends AppCompatActivity {
|
|||
|
||||
String phone = binding.phoneInput.getText() != null ?
|
||||
binding.phoneInput.getText().toString().trim() : "";
|
||||
String verificationCode = binding.verificationCodeInput.getText() != null ?
|
||||
binding.verificationCodeInput.getText().toString().trim() : "";
|
||||
String verificationCode = ""; // 验证码已跳过
|
||||
String password = binding.passwordInput.getText() != null ?
|
||||
binding.passwordInput.getText().toString().trim() : "";
|
||||
String nickname = binding.nicknameInput.getText() != null ?
|
||||
|
|
@ -195,13 +194,7 @@ public class RegisterActivity extends AppCompatActivity {
|
|||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(verificationCode)) {
|
||||
binding.verificationCodeLayout.setError("请输入验证码");
|
||||
binding.verificationCodeInput.requestFocus();
|
||||
return;
|
||||
} else {
|
||||
binding.verificationCodeLayout.setError(null);
|
||||
}
|
||||
// 验证码验证已跳过
|
||||
|
||||
if (TextUtils.isEmpty(password)) {
|
||||
binding.passwordLayout.setError("请输入密码");
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
|
|||
import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding;
|
||||
import com.example.livestreaming.net.ApiClient;
|
||||
import com.example.livestreaming.net.ApiResponse;
|
||||
import com.example.livestreaming.net.AuthStore;
|
||||
import com.example.livestreaming.net.Room;
|
||||
import com.example.livestreaming.net.StreamConfig;
|
||||
import com.example.livestreaming.ShareUtils;
|
||||
|
|
@ -37,6 +38,15 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
import okio.ByteString;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
|
@ -68,6 +78,11 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
private List<ChatMessage> chatMessages = new ArrayList<>();
|
||||
private Random random = new Random();
|
||||
|
||||
// WebSocket
|
||||
private WebSocket webSocket;
|
||||
private OkHttpClient wsClient;
|
||||
private static final String WS_BASE_URL = "ws://192.168.1.164:8081/ws/live/chat/";
|
||||
|
||||
// 模拟用户名列表
|
||||
private final String[] simulatedUsers = {
|
||||
"游戏达人", "直播观众", "路过的小伙伴", "老铁666", "主播加油",
|
||||
|
|
@ -189,38 +204,98 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
return;
|
||||
}
|
||||
|
||||
// TODO: 接入后端接口 - 发送直播间弹幕消息
|
||||
// 接口路径: POST /api/rooms/{roomId}/messages
|
||||
// 请求参数:
|
||||
// - roomId: 房间ID(路径参数)
|
||||
// - message: 消息内容
|
||||
// - userId: 发送者用户ID(从token中获取)
|
||||
// 返回数据格式: ApiResponse<ChatMessage>
|
||||
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp等字段
|
||||
// 发送成功后,将消息添加到本地列表并显示
|
||||
String message = binding.chatInput.getText() != null ?
|
||||
binding.chatInput.getText().toString().trim() : "";
|
||||
|
||||
if (!TextUtils.isEmpty(message)) {
|
||||
addChatMessage(new ChatMessage("我", message));
|
||||
binding.chatInput.setText("");
|
||||
|
||||
// TODO: 接入后端接口 - 接收直播间弹幕消息(WebSocket或轮询)
|
||||
// 方案1: WebSocket实时推送
|
||||
// - 连接: ws://api.example.com/rooms/{roomId}/chat
|
||||
// - 接收消息格式: {type: "message", data: ChatMessage}
|
||||
// 方案2: 轮询获取新消息
|
||||
// - 接口路径: GET /api/rooms/{roomId}/messages?lastMessageId={lastId}
|
||||
// - 返回数据格式: ApiResponse<List<ChatMessage>>
|
||||
// - 每3-5秒轮询一次,获取lastMessageId之后的新消息
|
||||
// 模拟其他用户回复
|
||||
handler.postDelayed(() -> {
|
||||
if (random.nextFloat() < 0.3f) { // 30%概率有人回复
|
||||
String user = simulatedUsers[random.nextInt(simulatedUsers.length)];
|
||||
String reply = simulatedMessages[random.nextInt(simulatedMessages.length)];
|
||||
addChatMessage(new ChatMessage(user, reply));
|
||||
// 通过 WebSocket 发送消息
|
||||
sendChatViaWebSocket(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void connectWebSocket() {
|
||||
if (TextUtils.isEmpty(roomId)) return;
|
||||
|
||||
wsClient = new OkHttpClient();
|
||||
Request request = new Request.Builder()
|
||||
.url(WS_BASE_URL + roomId)
|
||||
.build();
|
||||
|
||||
webSocket = wsClient.newWebSocket(request, new WebSocketListener() {
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, okhttp3.Response response) {
|
||||
android.util.Log.d("WebSocket", "连接成功: roomId=" + roomId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, String text) {
|
||||
// 收到消息,解析并显示
|
||||
try {
|
||||
JSONObject json = new JSONObject(text);
|
||||
String type = json.optString("type", "");
|
||||
|
||||
if ("chat".equals(type)) {
|
||||
String nickname = json.optString("nickname", "匿名");
|
||||
String content = json.optString("content", "");
|
||||
|
||||
handler.post(() -> {
|
||||
addChatMessage(new ChatMessage(nickname, content));
|
||||
});
|
||||
} else if ("connected".equals(type)) {
|
||||
String content = json.optString("content", "");
|
||||
handler.post(() -> {
|
||||
addChatMessage(new ChatMessage(content, true));
|
||||
});
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
android.util.Log.e("WebSocket", "解析消息失败: " + e.getMessage());
|
||||
}
|
||||
}, 1000 + random.nextInt(3000));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, okhttp3.Response response) {
|
||||
android.util.Log.e("WebSocket", "连接失败: " + t.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
android.util.Log.d("WebSocket", "连接关闭: " + reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendChatViaWebSocket(String content) {
|
||||
if (webSocket == null) {
|
||||
// 如果 WebSocket 未连接,先本地显示
|
||||
addChatMessage(new ChatMessage("我", content));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("type", "chat");
|
||||
json.put("content", content);
|
||||
json.put("nickname", AuthStore.getNickname(this));
|
||||
json.put("userId", AuthStore.getUserId(this));
|
||||
|
||||
webSocket.send(json.toString());
|
||||
} catch (JSONException e) {
|
||||
android.util.Log.e("WebSocket", "发送消息失败: " + e.getMessage());
|
||||
// 失败时本地显示
|
||||
addChatMessage(new ChatMessage("我", content));
|
||||
}
|
||||
}
|
||||
|
||||
private void disconnectWebSocket() {
|
||||
if (webSocket != null) {
|
||||
webSocket.close(1000, "Activity destroyed");
|
||||
webSocket = null;
|
||||
}
|
||||
if (wsClient != null) {
|
||||
wsClient.dispatcher().executorService().shutdown();
|
||||
wsClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,14 +340,14 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
protected void onStart() {
|
||||
super.onStart();
|
||||
startPolling();
|
||||
startChatSimulation();
|
||||
connectWebSocket(); // 连接 WebSocket
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
stopPolling();
|
||||
stopChatSimulation();
|
||||
disconnectWebSocket(); // 断开 WebSocket
|
||||
releasePlayer();
|
||||
}
|
||||
|
||||
|
|
@ -356,13 +431,10 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
private boolean isFirstLoad = true;
|
||||
|
||||
private void fetchRoom() {
|
||||
// TODO: 接入后端接口 - 获取房间详情
|
||||
// 接口路径: GET /api/rooms/{roomId}
|
||||
// 请求参数: roomId (路径参数)
|
||||
// 返回数据格式: ApiResponse<Room>
|
||||
// Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount,
|
||||
// streamUrls (包含flv, hls, rtmp地址), description, startTime等字段
|
||||
android.util.Log.d("RoomDetail", "fetchRoom() roomId=" + roomId);
|
||||
|
||||
if (TextUtils.isEmpty(roomId)) {
|
||||
android.util.Log.e("RoomDetail", "roomId 为空,退出");
|
||||
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
return;
|
||||
|
|
@ -382,18 +454,23 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
boolean firstLoad = isFirstLoad;
|
||||
isFirstLoad = false;
|
||||
|
||||
android.util.Log.d("RoomDetail", "onResponse: code=" + response.code() + ", firstLoad=" + firstLoad);
|
||||
|
||||
ApiResponse<Room> body = response.body();
|
||||
Room data = body != null ? body.getData() : null;
|
||||
|
||||
android.util.Log.d("RoomDetail", "body=" + (body != null) + ", isOk=" + (body != null && body.isOk()) + ", data=" + (data != null));
|
||||
|
||||
if (!response.isSuccessful() || body == null || !body.isOk() || data == null) {
|
||||
if (firstLoad) {
|
||||
String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "房间不存在";
|
||||
Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "房间不存在";
|
||||
android.util.Log.e("RoomDetail", "API 失败: " + msg);
|
||||
// 不要在首次加载失败时退出,让用户可以看到错误
|
||||
Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
room = data;
|
||||
android.util.Log.d("RoomDetail", "房间加载成功: " + room.getTitle());
|
||||
bindRoom(room);
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +479,7 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
if (isFinishing() || isDestroyed()) return;
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
isFirstLoad = false;
|
||||
android.util.Log.e("RoomDetail", "onFailure: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
|
||||
/**
|
||||
* 搜索用户结果列表适配器
|
||||
*/
|
||||
public class SearchUserAdapter extends ListAdapter<SearchUserItem, SearchUserAdapter.ViewHolder> {
|
||||
|
||||
public interface OnAddClickListener {
|
||||
void onAddClick(SearchUserItem item, int position);
|
||||
}
|
||||
|
||||
private OnAddClickListener addClickListener;
|
||||
|
||||
public SearchUserAdapter() {
|
||||
super(DIFF_CALLBACK);
|
||||
}
|
||||
|
||||
public void setOnAddClickListener(OnAddClickListener listener) {
|
||||
this.addClickListener = listener;
|
||||
}
|
||||
|
||||
private static final DiffUtil.ItemCallback<SearchUserItem> DIFF_CALLBACK =
|
||||
new DiffUtil.ItemCallback<SearchUserItem>() {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull SearchUserItem oldItem, @NonNull SearchUserItem newItem) {
|
||||
return oldItem.getId().equals(newItem.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull SearchUserItem oldItem, @NonNull SearchUserItem newItem) {
|
||||
return oldItem.equals(newItem);
|
||||
}
|
||||
};
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_search_user, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
SearchUserItem item = getItem(position);
|
||||
if (item == null) return;
|
||||
|
||||
holder.userName.setText(item.getNickname() != null ? item.getNickname() : "未知用户");
|
||||
holder.userInfo.setText(item.getDisplayInfo());
|
||||
|
||||
// 加载头像
|
||||
if (item.getAvatarUrl() != null && !item.getAvatarUrl().isEmpty()) {
|
||||
Glide.with(holder.userAvatar.getContext())
|
||||
.load(item.getAvatarUrl())
|
||||
.placeholder(R.drawable.ic_account_circle_24)
|
||||
.error(R.drawable.ic_account_circle_24)
|
||||
.into(holder.userAvatar);
|
||||
} else {
|
||||
holder.userAvatar.setImageResource(R.drawable.ic_account_circle_24);
|
||||
}
|
||||
|
||||
// 根据好友状态显示不同按钮
|
||||
switch (item.getFriendStatus()) {
|
||||
case 1: // 已添加
|
||||
holder.addButton.setVisibility(View.GONE);
|
||||
holder.addedText.setVisibility(View.VISIBLE);
|
||||
holder.pendingText.setVisibility(View.GONE);
|
||||
break;
|
||||
case 2: // 已申请
|
||||
holder.addButton.setVisibility(View.GONE);
|
||||
holder.addedText.setVisibility(View.GONE);
|
||||
holder.pendingText.setVisibility(View.VISIBLE);
|
||||
break;
|
||||
default: // 未添加
|
||||
holder.addButton.setVisibility(View.VISIBLE);
|
||||
holder.addedText.setVisibility(View.GONE);
|
||||
holder.pendingText.setVisibility(View.GONE);
|
||||
break;
|
||||
}
|
||||
|
||||
holder.addButton.setOnClickListener(v -> {
|
||||
if (addClickListener != null) {
|
||||
addClickListener.onAddClick(item, holder.getAdapterPosition());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
CircleImageView userAvatar;
|
||||
TextView userName;
|
||||
TextView userInfo;
|
||||
TextView addButton;
|
||||
TextView addedText;
|
||||
TextView pendingText;
|
||||
|
||||
ViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
userAvatar = itemView.findViewById(R.id.userAvatar);
|
||||
userName = itemView.findViewById(R.id.userName);
|
||||
userInfo = itemView.findViewById(R.id.userInfo);
|
||||
addButton = itemView.findViewById(R.id.addButton);
|
||||
addedText = itemView.findViewById(R.id.addedText);
|
||||
pendingText = itemView.findViewById(R.id.pendingText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 搜索用户结果项
|
||||
*/
|
||||
public class SearchUserItem {
|
||||
|
||||
private final String id;
|
||||
private final String nickname;
|
||||
private final String phone;
|
||||
private final String avatarUrl;
|
||||
private int friendStatus; // 0: 未添加, 1: 已添加, 2: 已申请
|
||||
|
||||
public SearchUserItem(String id, String nickname, String phone, String avatarUrl, int friendStatus) {
|
||||
this.id = id;
|
||||
this.nickname = nickname;
|
||||
this.phone = phone;
|
||||
this.avatarUrl = avatarUrl;
|
||||
this.friendStatus = friendStatus;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public String getAvatarUrl() {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
public int getFriendStatus() {
|
||||
return friendStatus;
|
||||
}
|
||||
|
||||
public void setFriendStatus(int friendStatus) {
|
||||
this.friendStatus = friendStatus;
|
||||
}
|
||||
|
||||
public String getDisplayInfo() {
|
||||
if (phone != null && !phone.isEmpty()) {
|
||||
// 隐藏手机号中间4位
|
||||
if (phone.length() >= 11) {
|
||||
return "手机: " + phone.substring(0, 3) + "****" + phone.substring(7);
|
||||
}
|
||||
return "手机: " + phone;
|
||||
}
|
||||
return "ID: " + id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof SearchUserItem)) return false;
|
||||
SearchUserItem that = (SearchUserItem) o;
|
||||
return friendStatus == that.friendStatus
|
||||
&& Objects.equals(id, that.id)
|
||||
&& Objects.equals(nickname, that.nickname)
|
||||
&& Objects.equals(phone, that.phone)
|
||||
&& Objects.equals(avatarUrl, that.avatarUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id, nickname, phone, avatarUrl, friendStatus);
|
||||
}
|
||||
}
|
||||
|
|
@ -108,8 +108,9 @@ public final class ApiClient {
|
|||
Context ctx = context != null ? context.getApplicationContext() : appContext;
|
||||
if (ctx == null) return;
|
||||
SharedPreferences sp = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
sp.edit().remove(KEY_BASE_URL_OVERRIDE).apply();
|
||||
sp.edit().remove(KEY_BASE_URL_OVERRIDE).commit(); // 使用同步 commit
|
||||
reset();
|
||||
Log.d(TAG, "已清除自定义 API 地址");
|
||||
}
|
||||
|
||||
public static String[] getBaseUrlHistory(@Nullable Context context) {
|
||||
|
|
@ -177,8 +178,8 @@ public final class ApiClient {
|
|||
return service;
|
||||
}
|
||||
|
||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
|
||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(message -> Log.d(TAG, message));
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||
|
||||
Interceptor auth = chain -> {
|
||||
Context ctx = ApiClient.appContext;
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ public interface ApiService {
|
|||
@POST("api/front/register")
|
||||
Call<ApiResponse<LoginResponse>> register(@Body RegisterRequest body);
|
||||
|
||||
@GET("api/rooms")
|
||||
@GET("api/front/live/public/rooms")
|
||||
Call<ApiResponse<List<Room>>> getRooms();
|
||||
|
||||
@POST("api/rooms")
|
||||
@POST("api/front/live/rooms")
|
||||
Call<ApiResponse<Room>> createRoom(@Body CreateRoomRequest body);
|
||||
|
||||
@GET("api/rooms/{id}")
|
||||
@GET("api/front/live/public/rooms/{id}")
|
||||
Call<ApiResponse<Room>> getRoom(@Path("id") String id);
|
||||
|
||||
@DELETE("api/rooms/{id}")
|
||||
@DELETE("api/front/live/rooms/{id}")
|
||||
Call<ApiResponse<Object>> deleteRoom(@Path("id") String id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package com.example.livestreaming.net;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AuthStore {
|
||||
|
||||
private static final String TAG = "AuthStore";
|
||||
private static final String PREFS = "auth_prefs";
|
||||
private static final String KEY_TOKEN = "token";
|
||||
|
||||
|
|
@ -13,14 +15,19 @@ public final class AuthStore {
|
|||
}
|
||||
|
||||
public static void setToken(Context context, @Nullable String token) {
|
||||
if (context == null) return;
|
||||
if (context == null) {
|
||||
Log.w(TAG, "setToken: context is null");
|
||||
return;
|
||||
}
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
Log.d(TAG, "setToken: clearing token");
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.apply();
|
||||
return;
|
||||
}
|
||||
Log.d(TAG, "setToken: saving token = " + token.substring(0, Math.min(10, token.length())) + "...");
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_TOKEN, token.trim())
|
||||
|
|
@ -29,8 +36,40 @@ public final class AuthStore {
|
|||
|
||||
@Nullable
|
||||
public static String getToken(Context context) {
|
||||
if (context == null) {
|
||||
Log.w(TAG, "getToken: context is null");
|
||||
return null;
|
||||
}
|
||||
String token = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_TOKEN, null);
|
||||
Log.d(TAG, "getToken: retrieved token = " + (token != null ? token.substring(0, Math.min(10, token.length())) + "..." : "null"));
|
||||
return token;
|
||||
}
|
||||
|
||||
private static final String KEY_USER_ID = "user_id";
|
||||
private static final String KEY_NICKNAME = "nickname";
|
||||
|
||||
public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname) {
|
||||
if (context == null) return;
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_USER_ID, userId)
|
||||
.putString(KEY_NICKNAME, nickname)
|
||||
.apply();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getUserId(Context context) {
|
||||
if (context == null) return null;
|
||||
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_TOKEN, null);
|
||||
.getString(KEY_USER_ID, "");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getNickname(Context context) {
|
||||
if (context == null) return null;
|
||||
String nickname = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_NICKNAME, null);
|
||||
return nickname != null ? nickname : "用户";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package com.example.livestreaming.net;
|
||||
|
||||
public class ChatMessageResponse {
|
||||
private Long messageId;
|
||||
private String visitorId;
|
||||
private String nickname;
|
||||
private String content;
|
||||
private long timestamp;
|
||||
|
||||
public Long getMessageId() {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
public void setMessageId(Long messageId) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
public String getVisitorId() {
|
||||
return visitorId;
|
||||
}
|
||||
|
||||
public void setVisitorId(String visitorId) {
|
||||
this.visitorId = visitorId;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public void setTimestamp(long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,28 @@ public class LoginResponse {
|
|||
@SerializedName("token")
|
||||
private String token;
|
||||
|
||||
@SerializedName("uid")
|
||||
private Object uid;
|
||||
|
||||
@SerializedName("nikeName")
|
||||
private String nikeName;
|
||||
|
||||
@SerializedName("phone")
|
||||
private String phone;
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public String getUid() {
|
||||
return uid != null ? String.valueOf(uid) : null;
|
||||
}
|
||||
|
||||
public String getNikeName() {
|
||||
return nikeName;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import java.util.Objects;
|
|||
public class Room {
|
||||
|
||||
@SerializedName("id")
|
||||
private String id;
|
||||
private Object id; // 支持数字或字符串类型
|
||||
|
||||
@SerializedName("title")
|
||||
private String title;
|
||||
|
|
@ -43,7 +43,7 @@ public class Room {
|
|||
this.isLive = isLive;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
public void setId(Object id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ public class Room {
|
|||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
return id != null ? String.valueOf(id) : null;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#CCCCCC" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#6C5CE7" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
147
android-app/app/src/main/res/layout/activity_add_friend.xml
Normal file
147
android-app/app/src/main/res/layout/activity_add_friend.xml
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/white">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/white">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/backButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="back"
|
||||
android:src="@drawable/ic_arrow_back_24"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="添加好友"
|
||||
android:textColor="#111111"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="@id/backButton"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/backButton"
|
||||
app:layout_constraintTop_toTopOf="@id/backButton" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/searchContainer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_search"
|
||||
app:layout_constraintEnd_toStartOf="@id/searchButton"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/backButton">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/searchIcon"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:contentDescription="search"
|
||||
android:src="@drawable/ic_search_24"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/searchEdit"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:hint="输入用户名或手机号搜索"
|
||||
android:inputType="text"
|
||||
android:singleLine="true"
|
||||
android:textColor="#111111"
|
||||
android:textColorHint="#999999"
|
||||
android:textSize="14sp"
|
||||
android:imeOptions="actionSearch"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/searchIcon"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/searchButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:text="搜索"
|
||||
android:textColor="#6C5CE7"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/searchContainer" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/loadingProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/searchResultsRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="16dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/emptyStateContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:src="@drawable/ic_search_24"
|
||||
android:alpha="0.3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyStateText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="输入用户名或手机号搜索用户"
|
||||
android:textColor="#999999"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -24,9 +24,9 @@
|
|||
android:layout_height="24dp"
|
||||
android:contentDescription="back"
|
||||
android:src="@drawable/ic_arrow_back_24"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/titleText"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
app:layout_constraintTop_toTopOf="@id/titleText" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
|
|
@ -39,10 +39,33 @@
|
|||
android:textColor="#111111"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="@id/backButton"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/addFriendButton"
|
||||
app:layout_constraintStart_toEndOf="@id/backButton"
|
||||
app:layout_constraintTop_toTopOf="@id/backButton" />
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/addFriendButton"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="添加好友"
|
||||
android:src="@drawable/ic_add_24"
|
||||
app:layout_constraintBottom_toBottomOf="@id/titleText"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/titleText" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginTop="12dp"
|
||||
app:tabGravity="fill"
|
||||
app:tabMode="fixed"
|
||||
app:tabIndicatorColor="#6C5CE7"
|
||||
app:tabSelectedTextColor="#6C5CE7"
|
||||
app:tabTextColor="#666666"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/titleText" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/searchContainer"
|
||||
|
|
@ -52,7 +75,7 @@
|
|||
android:background="@drawable/bg_search"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/backButton">
|
||||
app:layout_constraintTop_toBottomOf="@id/tabLayout">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/searchIcon"
|
||||
|
|
@ -87,6 +110,13 @@
|
|||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/loadingProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/friendsRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
|
|
@ -96,6 +126,16 @@
|
|||
android:paddingBottom="16dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/requestsRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:visibility="gone"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<com.example.livestreaming.EmptyStateView
|
||||
android:id="@+id/emptyStateView"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
|||
|
|
@ -104,50 +104,32 @@
|
|||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 验证码输入框 -->
|
||||
<!-- 验证码输入框 - 已隐藏 -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/verificationCodeLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="验证码"
|
||||
app:layout_constraintEnd_toStartOf="@id/sendCodeButton"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/phoneLayout"
|
||||
app:boxBackgroundMode="outline"
|
||||
app:boxCornerRadiusBottomEnd="12dp"
|
||||
app:boxCornerRadiusBottomStart="12dp"
|
||||
app:boxCornerRadiusTopEnd="12dp"
|
||||
app:boxCornerRadiusTopStart="12dp"
|
||||
app:boxStrokeColor="#E0E0E0"
|
||||
app:hintTextColor="#999999">
|
||||
app:layout_constraintTop_toBottomOf="@id/phoneLayout">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/verificationCodeInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLines="1"
|
||||
android:minHeight="56dp"
|
||||
android:textColor="#111111"
|
||||
android:textSize="16sp" />
|
||||
android:inputType="number" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- 发送验证码按钮 -->
|
||||
<!-- 发送验证码按钮 - 已隐藏 -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/sendCodeButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:backgroundTint="#6200EE"
|
||||
android:text="发送验证码"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="14sp"
|
||||
app:cornerRadius="12dp"
|
||||
app:layout_constraintBaseline_toBaselineOf="@id/verificationCodeLayout"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/verificationCodeLayout" />
|
||||
app:layout_constraintTop_toBottomOf="@id/phoneLayout" />
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
|
|
@ -159,7 +141,7 @@
|
|||
app:endIconMode="password_toggle"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/verificationCodeLayout"
|
||||
app:layout_constraintTop_toBottomOf="@id/phoneLayout"
|
||||
app:boxBackgroundMode="outline"
|
||||
app:boxCornerRadiusBottomEnd="12dp"
|
||||
app:boxCornerRadiusBottomStart="12dp"
|
||||
|
|
|
|||
89
android-app/app/src/main/res/layout/item_friend_request.xml
Normal file
89
android-app/app/src/main/res/layout/item_friend_request.xml
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@android:color/white"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_account_circle_24"
|
||||
app:civ_border_color="#EEEEEE"
|
||||
app:civ_border_width="1dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="用户昵称"
|
||||
android:textColor="#111111"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toStartOf="@id/acceptButton"
|
||||
app:layout_constraintStart_toEndOf="@id/avatar"
|
||||
app:layout_constraintTop_toTopOf="@id/avatar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/message"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="请求添加你为好友"
|
||||
android:textColor="#999999"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintEnd_toEndOf="@id/name"
|
||||
app:layout_constraintStart_toStartOf="@id/name"
|
||||
app:layout_constraintTop_toBottomOf="@id/name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/acceptButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:background="@drawable/bg_button_primary"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:text="同意"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintEnd_toStartOf="@id/rejectButton"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rejectButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_button_outline"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:text="拒绝"
|
||||
android:textColor="#666666"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/divider"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="#0F000000"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@id/name" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
91
android-app/app/src/main/res/layout/item_search_user.xml
Normal file
91
android-app/app/src/main/res/layout/item_search_user.xml
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/userAvatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_account_circle_24"
|
||||
app:civ_border_color="#EEEEEE"
|
||||
app:civ_border_width="1dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/userName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="用户名"
|
||||
android:textColor="#111111"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toStartOf="@id/addButton"
|
||||
app:layout_constraintStart_toEndOf="@id/userAvatar"
|
||||
app:layout_constraintTop_toTopOf="@id/userAvatar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/userInfo"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="ID: 12345678"
|
||||
android:textColor="#999999"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintEnd_toEndOf="@id/userName"
|
||||
app:layout_constraintStart_toStartOf="@id/userName"
|
||||
app:layout_constraintTop_toBottomOf="@id/userName" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/addButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:background="@drawable/bg_button_primary"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:text="添加"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="13sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/addedText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:text="已添加"
|
||||
android:textColor="#999999"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pendingText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:text="已申请"
|
||||
android:textColor="#F39C12"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.1.2" apply false
|
||||
id("com.android.application") version "8.4.0" apply false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=file:///D:/soft/gradle-8.14-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||
networkTimeout=600000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
// 阿里云镜像加速
|
||||
maven(url = "https://maven.aliyun.com/repository/google")
|
||||
maven(url = "https://maven.aliyun.com/repository/central")
|
||||
maven(url = "https://maven.aliyun.com/repository/gradle-plugin")
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
|
|
@ -9,6 +13,9 @@ pluginManagement {
|
|||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
// 阿里云镜像加速
|
||||
maven(url = "https://maven.aliyun.com/repository/google")
|
||||
maven(url = "https://maven.aliyun.com/repository/central")
|
||||
google()
|
||||
mavenCentral()
|
||||
maven(url = "https://jitpack.io")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const roomsRouter = require('./routes/rooms');
|
||||
const srsRouter = require('./routes/srs');
|
||||
const friendsRouter = require('./routes/friends');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const roomStore = require('./store/roomStore');
|
||||
|
||||
|
|
@ -135,6 +136,7 @@ app.use(express.urlencoded({ extended: true }));
|
|||
// 路由
|
||||
app.use('/api/rooms', roomsRouter);
|
||||
app.use('/api/srs', srsRouter);
|
||||
app.use('/api/front', friendsRouter);
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
|
|
|
|||
249
live-streaming/server/routes/friends.js
Normal file
249
live-streaming/server/routes/friends.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const friendsStore = require('../store/friendsStore');
|
||||
|
||||
/**
|
||||
* 简单的认证中间件
|
||||
* 从 Authori-zation 头获取用户ID(实际项目应验证 JWT)
|
||||
*/
|
||||
function authMiddleware(req, res, next) {
|
||||
const token = req.headers['authori-zation'] || req.headers['authorization'];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '请先登录'
|
||||
});
|
||||
}
|
||||
|
||||
// 简单模拟:从 token 中提取用户ID
|
||||
// 实际项目应该验证 JWT 并解析用户信息
|
||||
// 这里假设 token 格式为 "Bearer userId" 或直接是 userId
|
||||
let userId;
|
||||
if (token.startsWith('Bearer ')) {
|
||||
userId = parseInt(token.substring(7), 10);
|
||||
} else {
|
||||
userId = parseInt(token, 10);
|
||||
}
|
||||
|
||||
if (isNaN(userId)) {
|
||||
// 如果无法解析,默认使用用户ID 1
|
||||
userId = 1;
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/front/users/search - 搜索用户
|
||||
*/
|
||||
router.get('/users/search', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { keyword, page = 1, pageSize = 20 } = req.query;
|
||||
|
||||
if (!keyword || keyword.trim() === '') {
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: { list: [], total: 0, page: 1, pageSize: 20 }
|
||||
});
|
||||
}
|
||||
|
||||
const result = friendsStore.searchUsers(
|
||||
keyword.trim(),
|
||||
req.userId,
|
||||
parseInt(page, 10),
|
||||
parseInt(pageSize, 10)
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('搜索用户失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '搜索失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/front/friends - 获取好友列表
|
||||
*/
|
||||
router.get('/friends', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 100 } = req.query;
|
||||
|
||||
const result = friendsStore.getFriendList(
|
||||
req.userId,
|
||||
parseInt(page, 10),
|
||||
parseInt(pageSize, 10)
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取好友列表失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取好友列表失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/front/friends/request - 发送好友请求
|
||||
*/
|
||||
router.post('/friends/request', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { targetUserId, message = '' } = req.body;
|
||||
|
||||
if (!targetUserId) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请指定目标用户'
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUserId === req.userId) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '不能添加自己为好友'
|
||||
});
|
||||
}
|
||||
|
||||
const result = friendsStore.sendFriendRequest(
|
||||
req.userId,
|
||||
parseInt(targetUserId, 10),
|
||||
message
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '好友请求已发送',
|
||||
data: { requestId: result.requestId }
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送好友请求失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '发送好友请求失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/front/friends/requests - 获取好友请求列表
|
||||
*/
|
||||
router.get('/friends/requests', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 100 } = req.query;
|
||||
|
||||
const result = friendsStore.getFriendRequests(
|
||||
req.userId,
|
||||
parseInt(page, 10),
|
||||
parseInt(pageSize, 10)
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取好友请求失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取好友请求失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/front/friends/requests/:requestId/handle - 处理好友请求
|
||||
*/
|
||||
router.post('/friends/requests/:requestId/handle', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { requestId } = req.params;
|
||||
const { accept } = req.body;
|
||||
|
||||
if (typeof accept !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请指定是否接受请求'
|
||||
});
|
||||
}
|
||||
|
||||
const result = friendsStore.handleFriendRequest(
|
||||
parseInt(requestId, 10),
|
||||
req.userId,
|
||||
accept
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
code: 200,
|
||||
message: accept ? '已添加好友' : '已拒绝请求'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: result.message
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理好友请求失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '处理好友请求失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/front/friends/:friendId - 删除好友
|
||||
*/
|
||||
router.delete('/friends/:friendId', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { friendId } = req.params;
|
||||
|
||||
const result = friendsStore.removeFriend(
|
||||
req.userId,
|
||||
parseInt(friendId, 10)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '已删除好友'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: result.message || '删除失败'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除好友失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '删除好友失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
293
live-streaming/server/store/friendsStore.js
Normal file
293
live-streaming/server/store/friendsStore.js
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* 好友关系数据存储(内存存储,生产环境应使用数据库)
|
||||
*/
|
||||
|
||||
// 好友关系表: { odId: [friendId1, friendId2, ...] }
|
||||
const friendships = new Map();
|
||||
|
||||
// 好友请求表: { requestId: { id, fromUserId, toUserId, message, status, createTime } }
|
||||
const friendRequests = new Map();
|
||||
|
||||
// 用户表(模拟): { odId: { id, nickname, phone, avatarUrl, isOnline } }
|
||||
const users = new Map();
|
||||
|
||||
let requestIdCounter = 1;
|
||||
let userIdCounter = 100;
|
||||
|
||||
// 初始化一些测试用户
|
||||
function initTestUsers() {
|
||||
const testUsers = [
|
||||
{ id: 1, nickname: '测试用户1', phone: '13800000001', avatarUrl: '', isOnline: 1 },
|
||||
{ id: 2, nickname: '测试用户2', phone: '13800000002', avatarUrl: '', isOnline: 0 },
|
||||
{ id: 3, nickname: '小明', phone: '13800000003', avatarUrl: '', isOnline: 1 },
|
||||
{ id: 4, nickname: '小红', phone: '13800000004', avatarUrl: '', isOnline: 0 },
|
||||
{ id: 5, nickname: '张三', phone: '13800000005', avatarUrl: '', isOnline: 1 },
|
||||
];
|
||||
|
||||
testUsers.forEach(user => {
|
||||
users.set(user.id, user);
|
||||
});
|
||||
|
||||
userIdCounter = 100;
|
||||
}
|
||||
|
||||
initTestUsers();
|
||||
|
||||
/**
|
||||
* 获取或创建用户
|
||||
*/
|
||||
function getOrCreateUser(userId) {
|
||||
if (!users.has(userId)) {
|
||||
users.set(userId, {
|
||||
id: userId,
|
||||
nickname: `用户${userId}`,
|
||||
phone: '',
|
||||
avatarUrl: '',
|
||||
isOnline: 0
|
||||
});
|
||||
}
|
||||
return users.get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
*/
|
||||
function searchUsers(keyword, currentUserId, page = 1, pageSize = 20) {
|
||||
const results = [];
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
|
||||
for (const [id, user] of users) {
|
||||
if (id === currentUserId) continue; // 排除自己
|
||||
|
||||
const matchNickname = user.nickname && user.nickname.toLowerCase().includes(lowerKeyword);
|
||||
const matchPhone = user.phone && user.phone.includes(keyword);
|
||||
|
||||
if (matchNickname || matchPhone) {
|
||||
// 判断好友状态
|
||||
let friendStatus = 0; // 0: 非好友, 1: 已是好友, 2: 已发送请求, 3: 收到请求
|
||||
|
||||
const myFriends = friendships.get(currentUserId) || [];
|
||||
if (myFriends.includes(id)) {
|
||||
friendStatus = 1;
|
||||
} else {
|
||||
// 检查是否有待处理的请求
|
||||
for (const [reqId, req] of friendRequests) {
|
||||
if (req.status !== 'pending') continue;
|
||||
if (req.fromUserId === currentUserId && req.toUserId === id) {
|
||||
friendStatus = 2;
|
||||
break;
|
||||
}
|
||||
if (req.fromUserId === id && req.toUserId === currentUserId) {
|
||||
friendStatus = 3;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: user.id,
|
||||
nickname: user.nickname,
|
||||
phone: user.phone ? user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '',
|
||||
avatarUrl: user.avatarUrl,
|
||||
friendStatus
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
return {
|
||||
list: results.slice(start, end),
|
||||
total: results.length,
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友列表
|
||||
*/
|
||||
function getFriendList(userId, page = 1, pageSize = 100) {
|
||||
const friendIds = friendships.get(userId) || [];
|
||||
const list = [];
|
||||
|
||||
for (const friendId of friendIds) {
|
||||
const user = users.get(friendId);
|
||||
if (user) {
|
||||
list.push({
|
||||
id: user.id,
|
||||
name: user.nickname,
|
||||
avatarUrl: user.avatarUrl,
|
||||
isOnline: user.isOnline
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
return {
|
||||
list: list.slice(start, end),
|
||||
total: list.length,
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送好友请求
|
||||
*/
|
||||
function sendFriendRequest(fromUserId, toUserId, message = '') {
|
||||
// 检查是否已经是好友
|
||||
const myFriends = friendships.get(fromUserId) || [];
|
||||
if (myFriends.includes(toUserId)) {
|
||||
return { success: false, message: '已经是好友了' };
|
||||
}
|
||||
|
||||
// 检查是否已有待处理的请求
|
||||
for (const [reqId, req] of friendRequests) {
|
||||
if (req.status !== 'pending') continue;
|
||||
if (req.fromUserId === fromUserId && req.toUserId === toUserId) {
|
||||
return { success: false, message: '已发送过好友请求,请等待对方处理' };
|
||||
}
|
||||
if (req.fromUserId === toUserId && req.toUserId === fromUserId) {
|
||||
return { success: false, message: '对方已向你发送好友请求,请在好友请求中处理' };
|
||||
}
|
||||
}
|
||||
|
||||
// 确保目标用户存在
|
||||
getOrCreateUser(toUserId);
|
||||
|
||||
const requestId = requestIdCounter++;
|
||||
const request = {
|
||||
id: requestId,
|
||||
fromUserId,
|
||||
toUserId,
|
||||
message,
|
||||
status: 'pending',
|
||||
createTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
friendRequests.set(requestId, request);
|
||||
|
||||
return { success: true, requestId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收到的好友请求
|
||||
*/
|
||||
function getFriendRequests(userId, page = 1, pageSize = 100) {
|
||||
const list = [];
|
||||
|
||||
for (const [reqId, req] of friendRequests) {
|
||||
if (req.toUserId === userId && req.status === 'pending') {
|
||||
const fromUser = users.get(req.fromUserId);
|
||||
list.push({
|
||||
id: req.id,
|
||||
from_user_id: req.fromUserId,
|
||||
nickname: fromUser ? fromUser.nickname : `用户${req.fromUserId}`,
|
||||
avatarUrl: fromUser ? fromUser.avatarUrl : '',
|
||||
message: req.message,
|
||||
create_time: req.createTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间倒序
|
||||
list.sort((a, b) => new Date(b.create_time) - new Date(a.create_time));
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
return {
|
||||
list: list.slice(start, end),
|
||||
total: list.length,
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理好友请求
|
||||
*/
|
||||
function handleFriendRequest(requestId, userId, accept) {
|
||||
const request = friendRequests.get(requestId);
|
||||
|
||||
if (!request) {
|
||||
return { success: false, message: '好友请求不存在' };
|
||||
}
|
||||
|
||||
if (request.toUserId !== userId) {
|
||||
return { success: false, message: '无权处理此请求' };
|
||||
}
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return { success: false, message: '请求已被处理' };
|
||||
}
|
||||
|
||||
if (accept) {
|
||||
// 接受请求,建立双向好友关系
|
||||
const fromFriends = friendships.get(request.fromUserId) || [];
|
||||
const toFriends = friendships.get(request.toUserId) || [];
|
||||
|
||||
if (!fromFriends.includes(request.toUserId)) {
|
||||
fromFriends.push(request.toUserId);
|
||||
friendships.set(request.fromUserId, fromFriends);
|
||||
}
|
||||
|
||||
if (!toFriends.includes(request.fromUserId)) {
|
||||
toFriends.push(request.fromUserId);
|
||||
friendships.set(request.toUserId, toFriends);
|
||||
}
|
||||
|
||||
request.status = 'accepted';
|
||||
} else {
|
||||
request.status = 'rejected';
|
||||
}
|
||||
|
||||
friendRequests.set(requestId, request);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除好友
|
||||
*/
|
||||
function removeFriend(userId, friendId) {
|
||||
const myFriends = friendships.get(userId) || [];
|
||||
const theirFriends = friendships.get(friendId) || [];
|
||||
|
||||
const myIndex = myFriends.indexOf(friendId);
|
||||
if (myIndex > -1) {
|
||||
myFriends.splice(myIndex, 1);
|
||||
friendships.set(userId, myFriends);
|
||||
}
|
||||
|
||||
const theirIndex = theirFriends.indexOf(userId);
|
||||
if (theirIndex > -1) {
|
||||
theirFriends.splice(theirIndex, 1);
|
||||
friendships.set(friendId, theirFriends);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是好友
|
||||
*/
|
||||
function isFriend(userId1, userId2) {
|
||||
const friends = friendships.get(userId1) || [];
|
||||
return friends.includes(userId2);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
searchUsers,
|
||||
getFriendList,
|
||||
sendFriendRequest,
|
||||
getFriendRequests,
|
||||
handleFriendRequest,
|
||||
removeFriend,
|
||||
isFriend,
|
||||
getOrCreateUser
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user