From 673ab555993e640a8dffee3a9001fdf01bfb59ab Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Wed, 24 Dec 2025 18:11:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8A=9F=E8=83=BD=E5=92=8C=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8A=9F=E8=83=BD=20-=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20CircleImageView=20=E4=BE=9D=E8=B5=96=20-=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20AuthHelper.getToken=20=E6=94=B9=E4=B8=BA=20AuthStor?= =?UTF-8?q?e.getToken=20-=20=E6=B7=BB=E5=8A=A0=20AddFriendActivity=20?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97=20-=20=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=20MessagesActivity=20=E4=BB=8E=E5=90=8E=E7=AB=AF=20API=20?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E4=BC=9A=E8=AF=9D=E5=88=97=E8=A1=A8=20-=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=A5=BD=E5=8F=8B=E7=AE=A1=E7=90=86=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E7=9A=84=20Controller=20=E5=92=8C=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Zhibo/admin/src/api/chat.js | 68 +++ Zhibo/admin/src/router/modules/user.js | 6 + Zhibo/admin/src/views/user/chat/index.vue | 536 ++++++++++++++++++ .../controller/ChatManagementController.java | 232 ++++++++ .../zbkj/common/model/chat/Conversation.java | 66 +++ .../common/model/chat/PrivateMessage.java | 60 ++ .../common/request/AppRegisterRequest.java | 36 ++ .../common/request/SendMessageRequest.java | 25 + .../common/response/ChatMessageResponse.java | 41 ++ .../common/response/ConversationResponse.java | 41 ++ .../src/main/resources/sql/chat_tables.sql | 42 ++ .../src/main/resources/sql/friend_tables.sql | 29 + .../java/com/zbkj/front/config/WebConfig.java | 1 + .../zbkj/front/config/WebSocketConfig.java | 9 +- .../controller/ConversationController.java | 137 +++++ .../front/controller/FriendController.java | 285 ++++++++++ .../front/controller/LoginController.java | 10 + .../response/live/ChatMessageResponse.java | 25 + .../com/zbkj/front/service/LoginService.java | 6 + .../front/service/impl/LoginServiceImpl.java | 46 ++ .../front/websocket/PrivateChatHandler.java | 366 ++++++++++++ .../com/zbkj/service/dao/ConversationDao.java | 10 + .../zbkj/service/dao/PrivateMessageDao.java | 10 + .../service/service/ConversationService.java | 55 ++ .../service/impl/ConversationServiceImpl.java | 371 ++++++++++++ Zhibo/zhibo-h/sql/create_friend_tables.sql | 57 ++ android-app/app/build.gradle.kts | 4 +- android-app/app/src/main/AndroidManifest.xml | 4 + .../livestreaming/AddFriendActivity.java | 274 +++++++++ .../com/example/livestreaming/ApiConfig.java | 46 ++ .../com/example/livestreaming/FriendItem.java | 15 +- .../livestreaming/FriendRequestAdapter.java | 106 ++++ .../livestreaming/FriendRequestItem.java | 74 +++ .../example/livestreaming/FriendsAdapter.java | 31 +- .../LiveStreamingApplication.java | 3 + .../example/livestreaming/LoginActivity.java | 21 +- .../example/livestreaming/MainActivity.java | 118 ++-- .../livestreaming/MessagesActivity.java | 126 +++- .../livestreaming/MyFriendsActivity.java | 381 +++++++++++-- .../livestreaming/ProfileActivity.java | 8 +- .../livestreaming/RegisterActivity.java | 11 +- .../livestreaming/RoomDetailActivity.java | 154 +++-- .../livestreaming/SearchUserAdapter.java | 120 ++++ .../example/livestreaming/SearchUserItem.java | 75 +++ .../example/livestreaming/net/ApiClient.java | 7 +- .../example/livestreaming/net/ApiService.java | 8 +- .../example/livestreaming/net/AuthStore.java | 43 +- .../net/ChatMessageResponse.java | 49 ++ .../livestreaming/net/LoginResponse.java | 21 + .../com/example/livestreaming/net/Room.java | 6 +- .../main/res/drawable/bg_button_outline.xml | 9 + .../main/res/drawable/bg_button_primary.xml | 6 + .../main/res/layout/activity_add_friend.xml | 147 +++++ .../main/res/layout/activity_my_friends.xml | 52 +- .../src/main/res/layout/activity_register.xml | 38 +- .../main/res/layout/item_friend_request.xml | 89 +++ .../src/main/res/layout/item_search_user.xml | 91 +++ android-app/build.gradle.kts | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android-app/settings.gradle.kts | 7 + live-streaming/server/index.js | 2 + live-streaming/server/routes/friends.js | 249 ++++++++ live-streaming/server/store/friendsStore.js | 293 ++++++++++ 63 files changed, 5019 insertions(+), 243 deletions(-) create mode 100644 Zhibo/admin/src/api/chat.js create mode 100644 Zhibo/admin/src/views/user/chat/index.vue create mode 100644 Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/ChatManagementController.java create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/Conversation.java create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/PrivateMessage.java create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/AppRegisterRequest.java create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/SendMessageRequest.java create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ChatMessageResponse.java create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ConversationResponse.java create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/chat_tables.sql create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/friend_tables.sql create mode 100644 Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java create mode 100644 Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java create mode 100644 Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/response/live/ChatMessageResponse.java create mode 100644 Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/PrivateChatHandler.java create mode 100644 Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/ConversationDao.java create mode 100644 Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/PrivateMessageDao.java create mode 100644 Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java create mode 100644 Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java create mode 100644 Zhibo/zhibo-h/sql/create_friend_tables.sql create mode 100644 android-app/app/src/main/java/com/example/livestreaming/AddFriendActivity.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/ApiConfig.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/FriendRequestAdapter.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/FriendRequestItem.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/SearchUserAdapter.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/SearchUserItem.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/ChatMessageResponse.java create mode 100644 android-app/app/src/main/res/drawable/bg_button_outline.xml create mode 100644 android-app/app/src/main/res/drawable/bg_button_primary.xml create mode 100644 android-app/app/src/main/res/layout/activity_add_friend.xml create mode 100644 android-app/app/src/main/res/layout/item_friend_request.xml create mode 100644 android-app/app/src/main/res/layout/item_search_user.xml create mode 100644 live-streaming/server/routes/friends.js create mode 100644 live-streaming/server/store/friendsStore.js diff --git a/Zhibo/admin/src/api/chat.js b/Zhibo/admin/src/api/chat.js new file mode 100644 index 00000000..ac848a8d --- /dev/null +++ b/Zhibo/admin/src/api/chat.js @@ -0,0 +1,68 @@ +// +---------------------------------------------------------------------- +// | CRMEB [ CRMEB赋能开发者,助力企业发展 ] +// +---------------------------------------------------------------------- +// | Copyright (c) 2016~2025 https://www.crmeb.com All rights reserved. +// +---------------------------------------------------------------------- +// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权 +// +---------------------------------------------------------------------- +// | Author: CRMEB Team +// +---------------------------------------------------------------------- + +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', + }); +} diff --git a/Zhibo/admin/src/router/modules/user.js b/Zhibo/admin/src/router/modules/user.js index 2b8cf2a9..4d4b4373 100644 --- a/Zhibo/admin/src/router/modules/user.js +++ b/Zhibo/admin/src/router/modules/user.js @@ -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'), diff --git a/Zhibo/admin/src/views/user/chat/index.vue b/Zhibo/admin/src/views/user/chat/index.vue new file mode 100644 index 00000000..f0fd9fb2 --- /dev/null +++ b/Zhibo/admin/src/views/user/chat/index.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/ChatManagementController.java b/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/ChatManagementController.java new file mode 100644 index 00000000..5ff3e848 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/ChatManagementController.java @@ -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>> 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> 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> 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>> 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> 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> 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 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 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> getStatistics() { + Map 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); + } +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/Conversation.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/Conversation.java new file mode 100644 index 00000000..7d6aee5c --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/Conversation.java @@ -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; +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/PrivateMessage.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/PrivateMessage.java new file mode 100644 index 00000000..8fc18ab7 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/chat/PrivateMessage.java @@ -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; +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/AppRegisterRequest.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/AppRegisterRequest.java new file mode 100644 index 00000000..53250036 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/AppRegisterRequest.java @@ -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; +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/SendMessageRequest.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/SendMessageRequest.java new file mode 100644 index 00000000..f0e4a57b --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/SendMessageRequest.java @@ -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"; +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ChatMessageResponse.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ChatMessageResponse.java new file mode 100644 index 00000000..6f558e16 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ChatMessageResponse.java @@ -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; +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ConversationResponse.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ConversationResponse.java new file mode 100644 index 00000000..2ee3aa56 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/ConversationResponse.java @@ -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; +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/chat_tables.sql b/Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/chat_tables.sql new file mode 100644 index 00000000..96e28628 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/chat_tables.sql @@ -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='私信消息表'; diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/friend_tables.sql b/Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/friend_tables.sql new file mode 100644 index 00000000..46a1095b --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/resources/sql/friend_tables.sql @@ -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='好友请求表'; diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebConfig.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebConfig.java index 6343778b..35cb32a0 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebConfig.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebConfig.java @@ -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"). diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java index 21329d88..da4e9030 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/config/WebSocketConfig.java @@ -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("*"); } } diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java new file mode 100644 index 00000000..69ae360a --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ConversationController.java @@ -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> getConversationList() { + Integer userId = userService.getUserIdException(); + return CommonResult.success(conversationService.getConversationList(userId)); + } + + /** + * 搜索会话 + */ + @ApiOperation(value = "搜索会话") + @ApiImplicitParam(name = "keyword", value = "搜索关键词", required = false) + @GetMapping("/search") + public CommonResult> 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> getOrCreateConversation(@PathVariable Integer otherUserId) { + Integer userId = userService.getUserIdException(); + Conversation conversation = conversationService.getOrCreateConversation(userId, otherUserId); + Map 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 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 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> 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 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 deleteMessage(@PathVariable Long id) { + Integer userId = userService.getUserIdException(); + return CommonResult.success(conversationService.deleteMessage(id, userId)); + } +} diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java new file mode 100644 index 00000000..9bcb9374 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java @@ -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>> 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 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> list = jdbcTemplate.queryForList(sql.toString(), params.toArray()); + + CommonPage> 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 sendFriendRequest(@RequestBody Map 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>> 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> list = jdbcTemplate.queryForList(sql, currentUserId, offset, pageSize); + + CommonPage> 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 handleFriendRequest( + @PathVariable Long requestId, + @RequestBody Map 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> requests = jdbcTemplate.queryForList(getSql, requestId, currentUserId); + if (requests.isEmpty()) { + return CommonResult.failed("请求不存在或已处理"); + } + + Map 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> 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>> 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> list = jdbcTemplate.queryForList(sql, currentUserId, offset, pageSize); + + CommonPage> 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 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()); + } + } +} diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LoginController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LoginController.java index 46cb3ff4..d305b266 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LoginController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LoginController.java @@ -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 getLoginConfig() { return CommonResult.success(loginService.getLoginConfig()); } + + /** + * APP用户注册 + */ + @ApiOperation(value = "APP用户注册") + @RequestMapping(value = "/register", method = RequestMethod.POST) + public CommonResult register(@RequestBody @Validated AppRegisterRequest request) { + return CommonResult.success(loginService.register(request)); + } } diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/response/live/ChatMessageResponse.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/response/live/ChatMessageResponse.java new file mode 100644 index 00000000..905a4295 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/response/live/ChatMessageResponse.java @@ -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; + } +} diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/LoginService.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/LoginService.java index 40786cd8..d2f79247 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/LoginService.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/LoginService.java @@ -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); } diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/impl/LoginServiceImpl.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/impl/LoginServiceImpl.java index 2a02195b..2af86771 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/impl/LoginServiceImpl.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/service/impl/LoginServiceImpl.java @@ -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; + } } diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/PrivateChatHandler.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/PrivateChatHandler.java new file mode 100644 index 00000000..06ebd1a1 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/websocket/PrivateChatHandler.java @@ -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 + private final Map> conversationSessions = new ConcurrentHashMap<>(); + + // userId -> Set (一个用户可能有多个设备连接) + private final Map> userSessions = new ConcurrentHashMap<>(); + + // session -> userId + private final Map sessionUserMap = new ConcurrentHashMap<>(); + + // session -> conversationId + private final Map 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 sessions = conversationSessions.get(conversationId); + if (sessions != null) { + sessions.remove(session); + if (sessions.isEmpty()) { + conversationSessions.remove(conversationId); + } + } + } + + if (userId != null) { + Set 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 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 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 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 sessions = userSessions.get(userId); + return sessions != null && !sessions.isEmpty(); + } + + /** + * 获取会话在线人数 + */ + public int getConversationOnlineCount(String conversationId) { + Set sessions = conversationSessions.get(conversationId); + return sessions != null ? sessions.size() : 0; + } +} diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/ConversationDao.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/ConversationDao.java new file mode 100644 index 00000000..c6247a89 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/ConversationDao.java @@ -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 { +} diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/PrivateMessageDao.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/PrivateMessageDao.java new file mode 100644 index 00000000..384d49da --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/PrivateMessageDao.java @@ -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 { +} diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java new file mode 100644 index 00000000..a98fcefd --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/ConversationService.java @@ -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 { + + /** + * 获取当前用户的会话列表 + */ + List getConversationList(Integer userId); + + /** + * 搜索会话 + */ + List searchConversations(Integer userId, String keyword); + + /** + * 标记会话为已读 + */ + Boolean markAsRead(Long conversationId, Integer userId); + + /** + * 删除会话 + */ + Boolean deleteConversation(Long conversationId, Integer userId); + + /** + * 获取会话消息列表 + */ + List 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); +} diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java new file mode 100644 index 00000000..6ce8d979 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/ConversationServiceImpl.java @@ -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 implements ConversationService { + + @Autowired + private PrivateMessageDao privateMessageDao; + + @Autowired + private UserService userService; + + @Override + public List getConversationList(Integer userId) { + LambdaQueryWrapper 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 conversations = list(qw); + return convertToResponseList(conversations, userId); + } + + @Override + public List searchConversations(Integer userId, String keyword) { + // 先获取用户的所有会话 + List allConversations = getConversationList(userId); + if (keyword == null || keyword.trim().isEmpty()) { + return allConversations; + } + // 按照标题(对方昵称)或最后一条消息内容过滤 + List 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 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 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 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 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 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 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 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 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 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 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 convertToResponseList(List conversations, Integer userId) { + List 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 convertMessagesToResponseList(List messages) { + List 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; + } +} diff --git a/Zhibo/zhibo-h/sql/create_friend_tables.sql b/Zhibo/zhibo-h/sql/create_friend_tables.sql new file mode 100644 index 00000000..1fb288e6 --- /dev/null +++ b/Zhibo/zhibo-h/sql/create_friend_tables.sql @@ -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='私聊消息表'; diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 2551d5b4..cb46809d 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -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") diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 2e5a8202..fe53006f 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -56,6 +56,10 @@ android:name="com.example.livestreaming.MyFriendsActivity" android:exported="false" /> + + diff --git a/android-app/app/src/main/java/com/example/livestreaming/AddFriendActivity.java b/android-app/app/src/main/java/com/example/livestreaming/AddFriendActivity.java new file mode 100644 index 00000000..b3405684 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/AddFriendActivity.java @@ -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 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 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); + } + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/ApiConfig.java b/android-app/app/src/main/java/com/example/livestreaming/ApiConfig.java new file mode 100644 index 00000000..9909b87f --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/ApiConfig.java @@ -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); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/FriendItem.java b/android-app/app/src/main/java/com/example/livestreaming/FriendItem.java index 4334eeb5..c10e98e6 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FriendItem.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FriendItem.java @@ -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); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/FriendRequestAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/FriendRequestAdapter.java new file mode 100644 index 00000000..77309f2b --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/FriendRequestAdapter.java @@ -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 { + + 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 DIFF_CALLBACK = + new DiffUtil.ItemCallback() { + @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); + } + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/FriendRequestItem.java b/android-app/app/src/main/java/com/example/livestreaming/FriendRequestItem.java new file mode 100644 index 00000000..78c8e5b1 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/FriendRequestItem.java @@ -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); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java index ed229c6c..f0d665a8 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java @@ -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 { @@ -48,27 +49,29 @@ public class FriendsAdapter extends ListAdapter { } 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); }); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java b/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java index 88a43bb3..40eba177 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java +++ b/android-app/app/src/main/java/com/example/livestreaming/LiveStreamingApplication.java @@ -15,6 +15,9 @@ public class LiveStreamingApplication extends Application { // 初始化LeakCanary内存泄漏检测(仅在debug版本中生效) // LeakCanary会自动在debug版本中初始化,无需手动调用 + // 初始化API配置 + ApiConfig.init(this); + // 初始化通知渠道 LocalNotificationManager.createNotificationChannel(this); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java b/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java index f230d95a..27e11ccd 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java @@ -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); // 保存用户信息到本地 diff --git a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java index 1c9227bd..4b19e6c0 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java @@ -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> - // 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>>() { @Override public void onResponse(Call>> call, Response>> 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> body = response.body(); + Log.d(TAG, "fetchRooms() body=" + (body != null ? "not null, isOk=" + body.isOk() : "null")); + List 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>> 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>>() { @Override public void onResponse(Call>> call, Response>> response) { + Log.d(TAG, "fetchDiscoverRooms() onResponse: code=" + response.code()); + + // 停止刷新动画 + LoadingStateManager.stopRefreshing(binding.swipeRefresh); + + // 恢复真实适配器 + binding.roomsRecyclerView.setAdapter(adapter); + ApiResponse> body = response.body(); List 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>> 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(); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java index 779b9a24..14b2cfb2 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MessagesActivity.java @@ -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 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> - // 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(); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java index 1bb98cde..af60575f 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java @@ -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 all = new ArrayList<>(); + private FriendsAdapter friendsAdapter; + private FriendRequestAdapter requestAdapter; + private final List allFriends = new ArrayList<>(); + private final List 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> - // 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> - // 搜索范围包括:好友昵称、备注、共同关注等 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 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 friends) { if (friends == null || friends.isEmpty()) { if (binding.emptyStateView != null) { @@ -111,16 +373,31 @@ public class MyFriendsActivity extends AppCompatActivity { } } - private List buildDemoFriends() { - List 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 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(); + } } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java index 6b45f60d..a8ad8c1e 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java @@ -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() { diff --git a/android-app/app/src/main/java/com/example/livestreaming/RegisterActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RegisterActivity.java index f1ca967a..368cafd2 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RegisterActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RegisterActivity.java @@ -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("请输入密码"); diff --git a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java index 9ddc8a5d..fe855ae7 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java @@ -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 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对象应包含: 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> - // - 每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对象应包含: 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 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()); } }); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/SearchUserAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/SearchUserAdapter.java new file mode 100644 index 00000000..894876dc --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/SearchUserAdapter.java @@ -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 { + + 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 DIFF_CALLBACK = + new DiffUtil.ItemCallback() { + @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); + } + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/SearchUserItem.java b/android-app/app/src/main/java/com/example/livestreaming/SearchUserItem.java new file mode 100644 index 00000000..45bf9a46 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/SearchUserItem.java @@ -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); + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java b/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java index dcaeef45..c18dd6f3 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java @@ -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; diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java index daeffc2e..37a616cd 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java @@ -16,15 +16,15 @@ public interface ApiService { @POST("api/front/register") Call> register(@Body RegisterRequest body); - @GET("api/rooms") + @GET("api/front/live/public/rooms") Call>> getRooms(); - @POST("api/rooms") + @POST("api/front/live/rooms") Call> createRoom(@Body CreateRoomRequest body); - @GET("api/rooms/{id}") + @GET("api/front/live/public/rooms/{id}") Call> getRoom(@Path("id") String id); - @DELETE("api/rooms/{id}") + @DELETE("api/front/live/rooms/{id}") Call> deleteRoom(@Path("id") String id); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java b/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java index 1ce09365..4cbff908 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java @@ -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 : "用户"; } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ChatMessageResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/ChatMessageResponse.java new file mode 100644 index 00000000..a51db07e --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ChatMessageResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/LoginResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/LoginResponse.java index ea835cf5..1165d2d5 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/LoginResponse.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/LoginResponse.java @@ -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; + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/Room.java b/android-app/app/src/main/java/com/example/livestreaming/net/Room.java index b9616f7a..758ff92f 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/Room.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/Room.java @@ -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() { diff --git a/android-app/app/src/main/res/drawable/bg_button_outline.xml b/android-app/app/src/main/res/drawable/bg_button_outline.xml new file mode 100644 index 00000000..569251eb --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_button_outline.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android-app/app/src/main/res/drawable/bg_button_primary.xml b/android-app/app/src/main/res/drawable/bg_button_primary.xml new file mode 100644 index 00000000..7e557258 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_button_primary.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/layout/activity_add_friend.xml b/android-app/app/src/main/res/layout/activity_add_friend.xml new file mode 100644 index 00000000..0aaf7f97 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_add_friend.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_my_friends.xml b/android-app/app/src/main/res/layout/activity_my_friends.xml index a23a232d..1c0fbbf6 100644 --- a/android-app/app/src/main/res/layout/activity_my_friends.xml +++ b/android-app/app/src/main/res/layout/activity_my_friends.xml @@ -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" /> + app:layout_constraintTop_toTopOf="parent" /> + + + + + app:layout_constraintTop_toBottomOf="@id/tabLayout"> + + + + - + + app:layout_constraintTop_toBottomOf="@id/phoneLayout"> + android:inputType="number" /> - + + app:layout_constraintTop_toBottomOf="@id/phoneLayout" /> + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/item_search_user.xml b/android-app/app/src/main/res/layout/item_search_user.xml new file mode 100644 index 00000000..4b835473 --- /dev/null +++ b/android-app/app/src/main/res/layout/item_search_user.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts index 9d720661..eeaf423f 100644 --- a/android-app/build.gradle.kts +++ b/android-app/build.gradle.kts @@ -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 } diff --git a/android-app/gradle/wrapper/gradle-wrapper.properties b/android-app/gradle/wrapper/gradle-wrapper.properties index a82cac06..df38b5f9 100644 --- a/android-app/gradle/wrapper/gradle-wrapper.properties +++ b/android-app/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts index 8b15258a..ae6f3de8 100644 --- a/android-app/settings.gradle.kts +++ b/android-app/settings.gradle.kts @@ -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") diff --git a/live-streaming/server/index.js b/live-streaming/server/index.js index c2ea327e..54f41c14 100644 --- a/live-streaming/server/index.js +++ b/live-streaming/server/index.js @@ -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) => { diff --git a/live-streaming/server/routes/friends.js b/live-streaming/server/routes/friends.js new file mode 100644 index 00000000..eef24cb4 --- /dev/null +++ b/live-streaming/server/routes/friends.js @@ -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; diff --git a/live-streaming/server/store/friendsStore.js b/live-streaming/server/store/friendsStore.js new file mode 100644 index 00000000..b0551b1b --- /dev/null +++ b/live-streaming/server/store/friendsStore.js @@ -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 +};