feat: 修复搜索用户功能和会话列表功能 - 添加 CircleImageView 依赖 - 修复 AuthHelper.getToken 改为 AuthStore.getToken - 添加 AddFriendActivity 调试日志 - 修改 MessagesActivity 从后端 API 获取会话列表 - 添加好友管理相关的 Controller 和 Service

This commit is contained in:
xiao12feng8 2025-12-24 18:11:39 +08:00
parent bc53b6c482
commit 673ab55599
63 changed files with 5019 additions and 243 deletions

View 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',
});
}

View File

@ -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'),

View 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>

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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='私信消息表';

View File

@ -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='好友请求表';

View File

@ -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").

View File

@ -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("*");
}
}

View File

@ -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));
}
}

View File

@ -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());
}
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -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);
}

View File

@ -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;
}
}

View 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='私聊消息表';

View File

@ -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")

View File

@ -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" />

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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);
});
}

View File

@ -15,6 +15,9 @@ public class LiveStreamingApplication extends Application {
// 初始化LeakCanary内存泄漏检测仅在debug版本中生效
// LeakCanary会自动在debug版本中初始化无需手动调用
// 初始化API配置
ApiConfig.init(this);
// 初始化通知渠道
LocalNotificationManager.createNotificationChannel(this);
}

View File

@ -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);
// 保存用户信息到本地

View File

@ -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();
}

View File

@ -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();
}
}

View File

@ -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();
}
}
}

View File

@ -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() {

View File

@ -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("请输入密码");

View File

@ -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());
}
});
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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 : "用户";
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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() {

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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"

View File

@ -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"

View 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>

View 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>

View File

@ -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
}

View File

@ -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

View File

@ -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")

View File

@ -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) => {

View 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;

View 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
};