From bc0713b4473bd057c34d9d1540f1162a829301c0 Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Thu, 25 Dec 2025 17:20:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../livestreaming/ConversationActivity.java | 390 +++++++++++++----- .../livestreaming/MessagesActivity.java | 150 ++++--- .../livestreaming/MyFriendsActivity.java | 53 ++- .../livestreaming/RoomDetailActivity.java | 116 +++++- 4 files changed, 557 insertions(+), 152 deletions(-) diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java index 84be4fae..47ba27dc 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java @@ -8,8 +8,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; +import android.util.Log; import android.view.KeyEvent; -import android.view.MenuItem; import android.view.View; import androidx.annotation.Nullable; @@ -19,22 +19,44 @@ import androidx.appcompat.widget.PopupMenu; import androidx.recyclerview.widget.LinearLayoutManager; import com.example.livestreaming.databinding.ActivityConversationBinding; +import com.example.livestreaming.net.AuthStore; import com.google.android.material.snackbar.Snackbar; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; 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 ConversationActivity extends AppCompatActivity { + private static final String TAG = "ConversationActivity"; private static final String EXTRA_CONVERSATION_ID = "extra_conversation_id"; private static final String EXTRA_CONVERSATION_TITLE = "extra_conversation_title"; + private static final String EXTRA_UNREAD_COUNT = "extra_unread_count"; + private static final int PAGE_SIZE = 20; private ActivityConversationBinding binding; + private final OkHttpClient httpClient = new OkHttpClient(); private ConversationMessagesAdapter adapter; private final List messages = new ArrayList<>(); private Handler handler; - private Runnable statusUpdateRunnable; + + private String conversationId; + private int currentPage = 1; + private int initialUnreadCount = 0; + private String currentUserId; public static void start(Context context, String conversationId, String title) { Intent intent = new Intent(context, ConversationActivity.class); @@ -43,10 +65,6 @@ public class ConversationActivity extends AppCompatActivity { context.startActivity(intent); } - private static final String EXTRA_UNREAD_COUNT = "extra_unread_count"; - - private int initialUnreadCount = 0; - @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -58,8 +76,9 @@ public class ConversationActivity extends AppCompatActivity { String title = getIntent() != null ? getIntent().getStringExtra(EXTRA_CONVERSATION_TITLE) : null; binding.titleText.setText(title != null ? title : "会话"); - // 获取该会话的初始未读数量 + conversationId = getIntent() != null ? getIntent().getStringExtra(EXTRA_CONVERSATION_ID) : null; initialUnreadCount = getIntent() != null ? getIntent().getIntExtra(EXTRA_UNREAD_COUNT, 0) : 0; + currentUserId = AuthStore.getUserId(this); binding.backButton.setOnClickListener(new DebounceClickListener() { @Override @@ -71,28 +90,19 @@ public class ConversationActivity extends AppCompatActivity { setupMessages(); setupInput(); - // TODO: 接入后端接口 - 标记会话消息为已读 - // 接口路径: POST /api/conversations/{conversationId}/read - // 请求参数: - // - conversationId: 会话ID(路径参数) - // - userId: 当前用户ID(从token中获取) - // 返回数据格式: ApiResponse<{success: boolean}> - // 用户进入会话时,标记该会话的所有消息为已读,减少未读数量 - if (initialUnreadCount > 0) { - UnreadMessageManager.decrementUnreadCount(this, initialUnreadCount); + // 标记会话为已读 + if (initialUnreadCount > 0 && conversationId != null) { + markConversationAsRead(); } } @Override protected void onPause() { super.onPause(); - // 当用户离开会话时,更新总未读数量(如果会话未读数量已减少) } private void setupMessages() { adapter = new ConversationMessagesAdapter(); - - // 设置长按监听 adapter.setOnMessageLongClickListener((message, position, view) -> { showMessageMenu(message, position, view); }); @@ -102,43 +112,159 @@ public class ConversationActivity extends AppCompatActivity { binding.messagesRecyclerView.setLayoutManager(layoutManager); binding.messagesRecyclerView.setAdapter(adapter); - // TODO: 接入后端接口 - 获取会话消息列表 - // 接口路径: GET /api/conversations/{conversationId}/messages - // 请求参数: - // - conversationId: 会话ID(路径参数) - // - page (可选): 页码,用于分页加载历史消息 - // - pageSize (可选): 每页数量,默认20 - // - beforeMessageId (可选): 获取指定消息ID之前的消息,用于上拉加载更多 - // 返回数据格式: ApiResponse> - // ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp, status等字段 - // 消息列表应按时间正序排列(最早的在前面) - messages.clear(); - String title = binding.titleText.getText() != null ? binding.titleText.getText().toString() : ""; - ChatMessage incomingMsg = new ChatMessage(title, "你好~"); - incomingMsg.setStatus(ChatMessage.MessageStatus.SENT); - messages.add(incomingMsg); - - ChatMessage outgoingMsg = new ChatMessage("我", "在的,有什么需要帮忙?"); - outgoingMsg.setStatus(ChatMessage.MessageStatus.READ); - messages.add(outgoingMsg); - - adapter.submitList(new ArrayList<>(messages)); - scrollToBottom(); + loadMessagesFromServer(); } - + + + /** + * 从服务器加载消息列表 + */ + private void loadMessagesFromServer() { + String token = AuthStore.getToken(this); + if (token == null || conversationId == null) { + Log.w(TAG, "未登录或会话ID为空"); + return; + } + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId + "/messages?page=" + currentPage + "&pageSize=" + PAGE_SIZE; + 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(() -> Snackbar.make(binding.getRoot(), "加载消息失败", Snackbar.LENGTH_SHORT).show()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "消息列表响应: " + body); + runOnUiThread(() -> parseMessages(body)); + } + }); + } + + private void parseMessages(String body) { + try { + JSONObject json = new JSONObject(body); + if (json.optInt("code", -1) == 200) { + JSONArray data = json.optJSONArray("data"); + messages.clear(); + if (data != null) { + for (int i = 0; i < data.length(); i++) { + JSONObject item = data.getJSONObject(i); + ChatMessage msg = parseChatMessage(item); + if (msg != null) { + messages.add(msg); + } + } + } + // 消息按时间倒序返回,需要反转为正序显示 + Collections.reverse(messages); + adapter.submitList(new ArrayList<>(messages)); + scrollToBottom(); + } else { + String msg = json.optString("message", "加载失败"); + Snackbar.make(binding.getRoot(), msg, Snackbar.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "解析消息列表失败", e); + } + } + + private ChatMessage parseChatMessage(JSONObject item) { + try { + String messageId = item.optString("messageId", ""); + int userId = item.optInt("userId", 0); + String username = item.optString("username", "未知用户"); + String message = item.optString("message", ""); + long timestamp = item.optLong("timestamp", System.currentTimeMillis()); + String status = item.optString("status", "sent"); + String avatarUrl = item.optString("avatarUrl", ""); + boolean isSystem = item.optBoolean("isSystemMessage", false); + + // 判断是否是自己发送的消息 + boolean isMine = currentUserId != null && currentUserId.equals(String.valueOf(userId)); + String displayName = isMine ? "我" : username; + + ChatMessage.MessageStatus msgStatus; + switch (status) { + case "sending": + msgStatus = ChatMessage.MessageStatus.SENDING; + break; + case "read": + msgStatus = ChatMessage.MessageStatus.READ; + break; + default: + msgStatus = ChatMessage.MessageStatus.SENT; + } + + ChatMessage chatMessage = new ChatMessage(messageId, displayName, message, timestamp, isSystem, msgStatus); + chatMessage.setAvatarUrl(avatarUrl); + return chatMessage; + } catch (Exception e) { + Log.e(TAG, "解析消息失败", e); + return null; + } + } + + + /** + * 标记会话为已读 + */ + private void markConversationAsRead() { + String token = AuthStore.getToken(this); + if (token == null || conversationId == null) return; + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId + "/read"; + Log.d(TAG, "标记已读: " + url); + + Request request = new Request.Builder() + .url(url) + .addHeader("Authori-zation", token) + .post(RequestBody.create("", MediaType.parse("application/json"))) + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "标记已读失败", e); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "标记已读响应: " + body); + runOnUiThread(() -> { + if (initialUnreadCount > 0) { + UnreadMessageManager.decrementUnreadCount(ConversationActivity.this, initialUnreadCount); + } + }); + } + }); + } + private void showMessageMenu(ChatMessage message, int position, View anchorView) { PopupMenu popupMenu = new PopupMenu(this, anchorView); popupMenu.getMenu().add(0, 0, 0, "复制"); - popupMenu.getMenu().add(0, 1, 0, "删除"); + // 只有自己发送的消息才能删除 + if ("我".equals(message.getUsername())) { + popupMenu.getMenu().add(0, 1, 0, "删除"); + } popupMenu.setOnMenuItemClickListener(item -> { if (item.getItemId() == 0) { - // 复制消息 copyMessage(message); return true; } else if (item.getItemId() == 1) { - // 删除消息 - deleteMessage(position); + deleteMessage(message, position); return true; } return false; @@ -154,30 +280,63 @@ public class ConversationActivity extends AppCompatActivity { Snackbar.make(binding.getRoot(), "已复制到剪贴板", Snackbar.LENGTH_SHORT).show(); } - private void deleteMessage(int position) { - // TODO: 接入后端接口 - 删除消息 - // 接口路径: DELETE /api/messages/{messageId} - // 请求参数: - // - messageId: 消息ID(路径参数) - // - userId: 当前用户ID(从token中获取,验证是否为消息发送者) - // 返回数据格式: ApiResponse<{success: boolean}> - // 删除成功后,从本地消息列表移除 + private void deleteMessage(ChatMessage message, int position) { if (position < 0 || position >= messages.size()) return; new AlertDialog.Builder(this) .setTitle("删除消息") .setMessage("确定要删除这条消息吗?") - .setPositiveButton("删除", (dialog, which) -> { - messages.remove(position); - adapter.submitList(new ArrayList<>(messages)); - Snackbar.make(binding.getRoot(), "消息已删除", Snackbar.LENGTH_SHORT).show(); - }) + .setPositiveButton("删除", (dialog, which) -> deleteMessageFromServer(message, position)) .setNegativeButton("取消", null) .show(); } + private void deleteMessageFromServer(ChatMessage message, int position) { + String token = AuthStore.getToken(this); + if (token == null) return; + + String messageId = message.getMessageId(); + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/messages/" + messageId; + Log.d(TAG, "删除消息: " + url); + + Request request = new Request.Builder() + .url(url) + .addHeader("Authori-zation", token) + .delete() + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "删除消息失败", e); + runOnUiThread(() -> Snackbar.make(binding.getRoot(), "删除失败", Snackbar.LENGTH_SHORT).show()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "删除消息响应: " + body); + runOnUiThread(() -> { + try { + JSONObject json = new JSONObject(body); + if (json.optInt("code", -1) == 200) { + messages.remove(position); + adapter.submitList(new ArrayList<>(messages)); + Snackbar.make(binding.getRoot(), "消息已删除", Snackbar.LENGTH_SHORT).show(); + } else { + Snackbar.make(binding.getRoot(), json.optString("message", "删除失败"), Snackbar.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "解析删除响应失败", e); + } + }); + } + }); + } + + private void setupInput() { - binding.sendButton.setOnClickListener(new DebounceClickListener(300) { // 发送按钮使用300ms防抖 + binding.sendButton.setOnClickListener(new DebounceClickListener(300) { @Override public void onDebouncedClick(View v) { sendMessage(); @@ -198,55 +357,91 @@ public class ConversationActivity extends AppCompatActivity { } private void sendMessage() { - // 检查登录状态,发送私信需要登录 if (!AuthHelper.requireLoginWithToast(this, "发送私信需要登录")) { return; } - // TODO: 接入后端接口 - 发送私信消息 - // 接口路径: POST /api/conversations/{conversationId}/messages - // 请求参数: - // - conversationId: 会话ID(路径参数) - // - message: 消息内容 - // - userId: 发送者用户ID(从token中获取) - // 返回数据格式: ApiResponse - // ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp, status等字段 - // 发送成功后,更新消息状态为SENT,并添加到消息列表 - // TODO: 接入后端接口 - 接收对方已读状态(WebSocket或轮询) - // 方案1: WebSocket实时推送 - // - 连接: ws://api.example.com/conversations/{conversationId} - // - 接收消息格式: {type: "read", messageId: "xxx"} 或 {type: "new_message", data: ChatMessage} - // 方案2: 轮询检查消息状态 - // - 接口路径: GET /api/conversations/{conversationId}/messages/{messageId}/status - // - 返回数据格式: ApiResponse<{status: "sent"|"read"}> String text = binding.messageInput.getText() != null ? binding.messageInput.getText().toString().trim() : ""; if (TextUtils.isEmpty(text)) return; + if (conversationId == null) { + Snackbar.make(binding.getRoot(), "会话ID无效", Snackbar.LENGTH_SHORT).show(); + return; + } + // 先在本地显示消息(发送中状态) ChatMessage newMessage = new ChatMessage("我", text); newMessage.setStatus(ChatMessage.MessageStatus.SENDING); messages.add(newMessage); adapter.submitList(new ArrayList<>(messages)); binding.messageInput.setText(""); scrollToBottom(); - - // 模拟消息发送过程:发送中 -> 已发送 -> 已读 - if (statusUpdateRunnable != null) { - handler.removeCallbacks(statusUpdateRunnable); + + // 发送到服务器 + sendMessageToServer(text, newMessage); + } + + private void sendMessageToServer(String text, ChatMessage localMessage) { + String token = AuthStore.getToken(this); + if (token == null) return; + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId + "/messages"; + Log.d(TAG, "发送消息: " + url); + + try { + JSONObject body = new JSONObject(); + body.put("message", text); + body.put("messageType", "text"); + + 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(() -> { + Snackbar.make(binding.getRoot(), "发送失败", Snackbar.LENGTH_SHORT).show(); + // 移除发送失败的消息 + messages.remove(localMessage); + adapter.submitList(new ArrayList<>(messages)); + }); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String responseBody = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "发送消息响应: " + responseBody); + runOnUiThread(() -> handleSendResponse(responseBody, localMessage)); + } + }); + } catch (Exception e) { + Log.e(TAG, "构建请求失败", e); + } + } + + private void handleSendResponse(String responseBody, ChatMessage localMessage) { + try { + JSONObject json = new JSONObject(responseBody); + if (json.optInt("code", -1) == 200) { + JSONObject data = json.optJSONObject("data"); + if (data != null) { + // 更新本地消息的ID和状态 + localMessage.setMessageId(data.optString("messageId", localMessage.getMessageId())); + localMessage.setStatus(ChatMessage.MessageStatus.SENT); + adapter.notifyItemChanged(messages.indexOf(localMessage)); + } + } else { + String msg = json.optString("message", "发送失败"); + Snackbar.make(binding.getRoot(), msg, Snackbar.LENGTH_SHORT).show(); + messages.remove(localMessage); + adapter.submitList(new ArrayList<>(messages)); + } + } catch (Exception e) { + Log.e(TAG, "解析发送响应失败", e); } - - statusUpdateRunnable = () -> { - // 1秒后更新为已发送 - newMessage.setStatus(ChatMessage.MessageStatus.SENT); - adapter.notifyItemChanged(messages.size() - 1); - - // 再2秒后更新为已读 - handler.postDelayed(() -> { - newMessage.setStatus(ChatMessage.MessageStatus.READ); - adapter.notifyItemChanged(messages.size() - 1); - }, 2000); - }; - - handler.postDelayed(statusUpdateRunnable, 1000); } private void scrollToBottom() { @@ -262,11 +457,6 @@ public class ConversationActivity extends AppCompatActivity { @Override protected void onDestroy() { super.onDestroy(); - // 清理Handler中的延迟任务,防止内存泄漏 - if (handler != null && statusUpdateRunnable != null) { - handler.removeCallbacks(statusUpdateRunnable); - } handler = null; - statusUpdateRunnable = null; } } 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 14b2cfb2..5cdf36c7 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 @@ -43,8 +43,10 @@ import org.json.JSONObject; import okhttp3.Call; import okhttp3.Callback; +import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; +import okhttp3.RequestBody; import okhttp3.Response; public class MessagesActivity extends AppCompatActivity { @@ -222,12 +224,13 @@ public class MessagesActivity extends AppCompatActivity { try { JSONObject item = data.getJSONObject(i); String id = String.valueOf(item.opt("id")); - String title = item.optString("otherUserName", "未知用户"); + // 后端返回的字段名是 title,不是 otherUserName + String title = item.optString("title", item.optString("otherUserName", "未知用户")); String lastMessage = item.optString("lastMessage", ""); - String timeText = item.optString("lastMessageTime", ""); + String timeText = item.optString("timeText", item.optString("lastMessageTime", "")); int unreadCount = item.optInt("unreadCount", 0); - boolean isMuted = item.optBoolean("isMuted", false); - String avatarUrl = item.optString("otherUserAvatar", ""); + boolean isMuted = item.optBoolean("muted", item.optBoolean("isMuted", false)); + String avatarUrl = item.optString("avatarUrl", item.optString("otherUserAvatar", "")); ConversationItem convItem = new ConversationItem(id, title, lastMessage, timeText, unreadCount, isMuted); allConversations.add(convItem); @@ -575,67 +578,116 @@ public class MessagesActivity extends AppCompatActivity { * 标记会话为已读 */ private void markAsReadAt(int position) { - // TODO: 接入后端接口 - 标记会话为已读 - // 接口路径: POST /api/conversations/{conversationId}/read - // 请求参数: - // - conversationId: 会话ID(路径参数) - // - userId: 当前用户ID(从token中获取) - // 返回数据格式: ApiResponse<{success: boolean}> - // 标记成功后,更新本地未读数量为0,并更新总未读数量 if (position < 0 || position >= conversations.size()) return; ConversationItem item = conversations.get(position); if (item == null) return; - // 获取该会话的未读数量 int unreadCount = item.getUnreadCount(); - - // 如果已经有未读消息,才需要更新 - if (unreadCount > 0) { - // 将该会话的未读数量设为0 - // updateConversationUnreadCount 方法会自动重新计算总未读数量并更新徽章 - updateConversationUnreadCount(item.getId(), 0); - } + if (unreadCount <= 0) return; + + String token = AuthStore.getToken(this); + if (token == null) return; + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + item.getId() + "/read"; + Log.d(TAG, "标记已读: " + url); + + Request request = new Request.Builder() + .url(url) + .addHeader("Authori-zation", token) + .post(RequestBody.create("", MediaType.parse("application/json"))) + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "标记已读失败", e); + runOnUiThread(() -> Toast.makeText(MessagesActivity.this, "标记已读失败", Toast.LENGTH_SHORT).show()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "标记已读响应: " + body); + runOnUiThread(() -> { + try { + JSONObject json = new JSONObject(body); + if (json.optInt("code", -1) == 200) { + updateConversationUnreadCount(item.getId(), 0); + Toast.makeText(MessagesActivity.this, "已标记为已读", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "解析标记已读响应失败", e); + } + }); + } + }); } /** * 删除会话 */ private void deleteConversationAt(int position) { - // TODO: 接入后端接口 - 删除会话 - // 接口路径: DELETE /api/conversations/{conversationId} - // 请求参数: - // - conversationId: 会话ID(路径参数) - // - userId: 当前用户ID(从token中获取) - // 返回数据格式: ApiResponse<{success: boolean}> - // 删除成功后,从本地列表移除,并更新总未读数量 if (position < 0 || position >= conversations.size()) return; - // 获取要删除的会话的未读数量 ConversationItem itemToDelete = conversations.get(position); - int unreadCountToRemove = itemToDelete != null ? itemToDelete.getUnreadCount() : 0; - String itemId = itemToDelete != null ? itemToDelete.getId() : null; - - // 从当前显示列表和全部列表中删除 - conversations.remove(position); - if (itemId != null) { - allConversations.removeIf(item -> item != null && item.getId().equals(itemId)); - } - - if (conversationsAdapter != null) { - conversationsAdapter.submitList(new ArrayList<>(conversations)); - } - - // 更新总未读数量:减少被删除会话的未读数量 - if (unreadCountToRemove > 0) { - UnreadMessageManager.decrementUnreadCount(this, unreadCountToRemove); - // 更新底部导航栏徽章 - if (binding != null) { - UnreadMessageManager.updateBadge(binding.bottomNavInclude.bottomNavigation); + if (itemToDelete == null) return; + + String token = AuthStore.getToken(this); + if (token == null) return; + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + itemToDelete.getId(); + Log.d(TAG, "删除会话: " + url); + + Request request = new Request.Builder() + .url(url) + .addHeader("Authori-zation", token) + .delete() + .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) { + int unreadCountToRemove = itemToDelete.getUnreadCount(); + String itemId = itemToDelete.getId(); + + conversations.remove(position); + allConversations.removeIf(item -> item != null && item.getId().equals(itemId)); + + if (conversationsAdapter != null) { + conversationsAdapter.submitList(new ArrayList<>(conversations)); + } + + if (unreadCountToRemove > 0) { + UnreadMessageManager.decrementUnreadCount(MessagesActivity.this, unreadCountToRemove); + if (binding != null) { + UnreadMessageManager.updateBadge(binding.bottomNavInclude.bottomNavigation); + } + } + + updateEmptyState(); + Toast.makeText(MessagesActivity.this, "会话已删除", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(MessagesActivity.this, json.optString("message", "删除失败"), Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "解析删除响应失败", e); + } + }); + } + }); } private float dp(float value) { 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 af60575f..6adfee90 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 @@ -336,8 +336,57 @@ public class MyFriendsActivity extends AppCompatActivity { } private void openConversation(FriendItem friend) { - // 打开与好友的私聊会话 - ConversationActivity.start(this, friend.getId(), friend.getName()); + // 先获取或创建与好友的会话,再打开私聊页面 + String token = AuthStore.getToken(this); + if (token == null) { + Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show(); + return; + } + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/with/" + friend.getId(); + Log.d(TAG, "获取或创建会话: " + url); + + Request request = new Request.Builder() + .url(url) + .addHeader("Authori-zation", token) + .post(RequestBody.create("", MediaType.parse("application/json"))) + .build(); + + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "获取会话失败", e); + runOnUiThread(() -> { + 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() : ""; + Log.d(TAG, "获取会话响应: " + body); + runOnUiThread(() -> { + try { + JSONObject json = new JSONObject(body); + if (json.optInt("code", -1) == 200) { + JSONObject data = json.optJSONObject("data"); + String conversationId = data != null ? data.optString("conversationId", "") : ""; + if (!conversationId.isEmpty()) { + ConversationActivity.start(MyFriendsActivity.this, conversationId, friend.getName()); + } else { + Toast.makeText(MyFriendsActivity.this, "获取会话ID失败", Toast.LENGTH_SHORT).show(); + } + } else { + String msg = json.optString("message", "打开会话失败"); + Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "解析会话响应失败", e); + Toast.makeText(MyFriendsActivity.this, "打开会话失败", Toast.LENGTH_SHORT).show(); + } + }); + } + }); } private void applyFilter(String query) { 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 5f670dc8..9b4868e2 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 @@ -85,6 +85,15 @@ public class RoomDetailActivity extends AppCompatActivity { private WebSocket webSocket; private OkHttpClient wsClient; private static final String WS_BASE_URL = "ws://192.168.1.164:8081/ws/live/chat/"; + + // WebSocket 心跳检测 + private Runnable heartbeatRunnable; + private Runnable reconnectRunnable; + private static final long HEARTBEAT_INTERVAL = 30000; // 30秒心跳间隔 + private static final long RECONNECT_DELAY = 5000; // 5秒重连延迟 + private int reconnectAttempts = 0; + private static final int MAX_RECONNECT_ATTEMPTS = 5; + private boolean isWebSocketConnected = false; // 礼物相关 private BottomSheetDialog giftDialog; @@ -231,7 +240,12 @@ public class RoomDetailActivity extends AppCompatActivity { private void connectWebSocket() { if (TextUtils.isEmpty(roomId)) return; - wsClient = new OkHttpClient(); + // 停止之前的重连任务 + stopReconnect(); + + wsClient = new OkHttpClient.Builder() + .pingInterval(30, java.util.concurrent.TimeUnit.SECONDS) // OkHttp 内置 ping + .build(); Request request = new Request.Builder() .url(WS_BASE_URL + roomId) .build(); @@ -240,6 +254,10 @@ public class RoomDetailActivity extends AppCompatActivity { @Override public void onOpen(WebSocket webSocket, okhttp3.Response response) { android.util.Log.d("WebSocket", "连接成功: roomId=" + roomId); + isWebSocketConnected = true; + reconnectAttempts = 0; + // 启动心跳检测 + startHeartbeat(); } @Override @@ -261,6 +279,9 @@ public class RoomDetailActivity extends AppCompatActivity { handler.post(() -> { addChatMessage(new ChatMessage(content, true)); }); + } else if ("pong".equals(type)) { + // 收到心跳响应 + android.util.Log.d("WebSocket", "收到心跳响应"); } } catch (JSONException e) { android.util.Log.e("WebSocket", "解析消息失败: " + e.getMessage()); @@ -270,15 +291,94 @@ public class RoomDetailActivity extends AppCompatActivity { @Override public void onFailure(WebSocket webSocket, Throwable t, okhttp3.Response response) { android.util.Log.e("WebSocket", "连接失败: " + t.getMessage()); + isWebSocketConnected = false; + stopHeartbeat(); + // 尝试重连 + scheduleReconnect(); } @Override public void onClosed(WebSocket webSocket, int code, String reason) { android.util.Log.d("WebSocket", "连接关闭: " + reason); + isWebSocketConnected = false; + stopHeartbeat(); + // 非正常关闭时尝试重连 + if (code != 1000) { + scheduleReconnect(); + } } }); } + /** + * 启动心跳检测 + */ + private void startHeartbeat() { + stopHeartbeat(); + heartbeatRunnable = new Runnable() { + @Override + public void run() { + if (webSocket != null && isWebSocketConnected) { + try { + JSONObject ping = new JSONObject(); + ping.put("type", "ping"); + webSocket.send(ping.toString()); + android.util.Log.d("WebSocket", "发送心跳"); + } catch (JSONException e) { + android.util.Log.e("WebSocket", "发送心跳失败: " + e.getMessage()); + } + handler.postDelayed(heartbeatRunnable, HEARTBEAT_INTERVAL); + } + } + }; + handler.postDelayed(heartbeatRunnable, HEARTBEAT_INTERVAL); + } + + /** + * 停止心跳检测 + */ + private void stopHeartbeat() { + if (heartbeatRunnable != null) { + handler.removeCallbacks(heartbeatRunnable); + heartbeatRunnable = null; + } + } + + /** + * 安排重连 + */ + private void scheduleReconnect() { + if (isFinishing() || isDestroyed()) return; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + android.util.Log.w("WebSocket", "达到最大重连次数,停止重连"); + handler.post(() -> { + addChatMessage(new ChatMessage("弹幕连接断开,请刷新页面重试", true)); + }); + return; + } + + reconnectAttempts++; + long delay = RECONNECT_DELAY * reconnectAttempts; // 递增延迟 + android.util.Log.d("WebSocket", "将在 " + delay + "ms 后尝试第 " + reconnectAttempts + " 次重连"); + + reconnectRunnable = () -> { + if (!isFinishing() && !isDestroyed()) { + connectWebSocket(); + } + }; + handler.postDelayed(reconnectRunnable, delay); + } + + /** + * 停止重连 + */ + private void stopReconnect() { + if (reconnectRunnable != null) { + handler.removeCallbacks(reconnectRunnable); + reconnectRunnable = null; + } + } + private void sendChatViaWebSocket(String content) { if (webSocket == null) { // 如果 WebSocket 未连接,先本地显示 @@ -302,6 +402,10 @@ public class RoomDetailActivity extends AppCompatActivity { } private void disconnectWebSocket() { + stopHeartbeat(); + stopReconnect(); + isWebSocketConnected = false; + if (webSocket != null) { webSocket.close(1000, "Activity destroyed"); webSocket = null; @@ -784,8 +888,18 @@ public class RoomDetailActivity extends AppCompatActivity { if (chatSimulationRunnable != null) { handler.removeCallbacks(chatSimulationRunnable); } + // 清理心跳和重连回调 + if (heartbeatRunnable != null) { + handler.removeCallbacks(heartbeatRunnable); + } + if (reconnectRunnable != null) { + handler.removeCallbacks(reconnectRunnable); + } } + // 断开 WebSocket 连接 + disconnectWebSocket(); + // 释放播放器资源 releasePlayer(); From cd2916170529853f9d94ee9a850356d499a95393 Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Thu, 25 Dec 2025 17:22:51 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=B7=BB=E5=8A=A0WebSocket=E5=BF=83?= =?UTF-8?q?=E8=B7=B3=E6=A3=80=E6=B5=8B=E5=92=8C=E6=96=AD=E7=BA=BF=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/create_conversation_tables.sql | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Zhibo/zhibo-h/sql/create_conversation_tables.sql diff --git a/Zhibo/zhibo-h/sql/create_conversation_tables.sql b/Zhibo/zhibo-h/sql/create_conversation_tables.sql new file mode 100644 index 00000000..c5537622 --- /dev/null +++ b/Zhibo/zhibo-h/sql/create_conversation_tables.sql @@ -0,0 +1,40 @@ +-- 私聊会话表 +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(500) 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`), + 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 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`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私信消息表'; From f4c9a429749ec5a7013d48b49f1ddd32722508e4 Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Thu, 25 Dec 2025 19:02:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-dev.yml | 10 ++ Zhibo/zhibo-h/crmeb-common/pom.xml | 6 + .../zbkj/common/model/chat/Conversation.java | 18 +++ .../common/model/chat/PrivateMessage.java | 21 ++++ .../zbkj/common/model/coupon/StoreCoupon.java | 1 + .../com/zbkj/front/CrmebFrontApplication.java | 4 + .../src/main/resources/application.yml | 13 ++ Zhibo/zhibo-h/pom.xml | 7 ++ .../livestreaming/ConversationActivity.java | 117 +++++++++++++++++- .../ConversationMessagesAdapter.java | 6 +- .../example/livestreaming/LoginActivity.java | 103 ++------------- .../example/livestreaming/net/AuthStore.java | 11 +- 12 files changed, 217 insertions(+), 100 deletions(-) diff --git a/Zhibo/zhibo-h/crmeb-admin/src/main/resources/application-dev.yml b/Zhibo/zhibo-h/crmeb-admin/src/main/resources/application-dev.yml index 132ef696..70994c76 100644 --- a/Zhibo/zhibo-h/crmeb-admin/src/main/resources/application-dev.yml +++ b/Zhibo/zhibo-h/crmeb-admin/src/main/resources/application-dev.yml @@ -29,6 +29,16 @@ spring: url: jdbc:mysql://1.15.149.240:3306/zhibo?characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=GMT%2B8 username: zhibo password: zCETFpGMwYN3CNeH + # JPA 配置 - 只新增字段,不删除已有字段 + jpa: + hibernate: + ddl-auto: update # update: 只新增表/字段,不删除 + show-sql: false + open-in-view: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL5InnoDBDialect + physical_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy redis: host: 127.0.0.1 #地址 port: 6379 #端口 diff --git a/Zhibo/zhibo-h/crmeb-common/pom.xml b/Zhibo/zhibo-h/crmeb-common/pom.xml index bb1eef62..d2016853 100644 --- a/Zhibo/zhibo-h/crmeb-common/pom.xml +++ b/Zhibo/zhibo-h/crmeb-common/pom.xml @@ -79,6 +79,12 @@ mybatis-plus-boot-starter + + + org.springframework.boot + spring-boot-starter-data-jpa + + org.springframework.boot 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 index 7d6aee5c..6588b67f 100644 --- 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 @@ -9,16 +9,20 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; +import javax.persistence.*; import java.io.Serializable; import java.util.Date; /** * 私聊会话实体类 + * 同时支持 MyBatis-Plus 和 JPA */ @Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("eb_conversation") +@Entity +@Table(name = "eb_conversation") @ApiModel(value = "Conversation对象", description = "私聊会话") public class Conversation implements Serializable { @@ -26,41 +30,55 @@ public class Conversation implements Serializable { @ApiModelProperty(value = "会话ID") @TableId(value = "id", type = IdType.AUTO) + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ApiModelProperty(value = "用户1的ID") + @Column(name = "user1_id", nullable = false) private Integer user1Id; @ApiModelProperty(value = "用户2的ID") + @Column(name = "user2_id", nullable = false) private Integer user2Id; @ApiModelProperty(value = "最后一条消息内容") + @Column(name = "last_message", length = 500) private String lastMessage; @ApiModelProperty(value = "最后一条消息时间") + @Column(name = "last_message_time") private Date lastMessageTime; @ApiModelProperty(value = "用户1的未读数量") + @Column(name = "user1_unread_count", columnDefinition = "INT DEFAULT 0") private Integer user1UnreadCount; @ApiModelProperty(value = "用户2的未读数量") + @Column(name = "user2_unread_count", columnDefinition = "INT DEFAULT 0") private Integer user2UnreadCount; @ApiModelProperty(value = "用户1是否删除会话") + @Column(name = "user1_deleted", columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean user1Deleted; @ApiModelProperty(value = "用户2是否删除会话") + @Column(name = "user2_deleted", columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean user2Deleted; @ApiModelProperty(value = "用户1是否静音") + @Column(name = "user1_muted", columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean user1Muted; @ApiModelProperty(value = "用户2是否静音") + @Column(name = "user2_muted", columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean user2Muted; @ApiModelProperty(value = "创建时间") + @Column(name = "create_time") private Date createTime; @ApiModelProperty(value = "更新时间") + @Column(name = "update_time") 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 index 8fc18ab7..758dbc6f 100644 --- 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 @@ -9,16 +9,25 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; +import javax.persistence.*; import java.io.Serializable; import java.util.Date; /** * 私信消息实体类 + * 同时支持 MyBatis-Plus 和 JPA */ @Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("eb_private_message") +@Entity +@Table(name = "eb_private_message", indexes = { + @Index(name = "idx_conversation_id", columnList = "conversation_id"), + @Index(name = "idx_sender_id", columnList = "sender_id"), + @Index(name = "idx_receiver_id", columnList = "receiver_id"), + @Index(name = "idx_create_time", columnList = "create_time") +}) @ApiModel(value = "PrivateMessage对象", description = "私信消息") public class PrivateMessage implements Serializable { @@ -26,35 +35,47 @@ public class PrivateMessage implements Serializable { @ApiModelProperty(value = "消息ID") @TableId(value = "id", type = IdType.AUTO) + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ApiModelProperty(value = "会话ID") + @Column(name = "conversation_id", nullable = false) private Long conversationId; @ApiModelProperty(value = "发送者用户ID") + @Column(name = "sender_id", nullable = false) private Integer senderId; @ApiModelProperty(value = "接收者用户ID") + @Column(name = "receiver_id", nullable = false) private Integer receiverId; @ApiModelProperty(value = "消息内容") + @Column(name = "content", columnDefinition = "TEXT") private String content; @ApiModelProperty(value = "消息类型: text, image, file") + @Column(name = "message_type", length = 20, columnDefinition = "VARCHAR(20) DEFAULT 'text'") private String messageType; @ApiModelProperty(value = "消息状态: sending, sent, read") + @Column(name = "status", length = 20, columnDefinition = "VARCHAR(20) DEFAULT 'sent'") private String status; @ApiModelProperty(value = "是否已删除(发送者)") + @Column(name = "sender_deleted", columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean senderDeleted; @ApiModelProperty(value = "是否已删除(接收者)") + @Column(name = "receiver_deleted", columnDefinition = "TINYINT(1) DEFAULT 0") private Boolean receiverDeleted; @ApiModelProperty(value = "创建时间") + @Column(name = "create_time") private Date createTime; @ApiModelProperty(value = "已读时间") + @Column(name = "read_time") private Date readTime; } diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/coupon/StoreCoupon.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/coupon/StoreCoupon.java index ecc4bb8e..ab514f58 100644 --- a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/coupon/StoreCoupon.java +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/coupon/StoreCoupon.java @@ -39,6 +39,7 @@ public class StoreCoupon implements Serializable { @TableId(value = "id", type = IdType.AUTO) private Integer id; + @ApiModelProperty(value = "优惠券名称") private String name; diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/CrmebFrontApplication.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/CrmebFrontApplication.java index f2e8a69a..902205b6 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/CrmebFrontApplication.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/CrmebFrontApplication.java @@ -4,8 +4,10 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; @@ -31,6 +33,8 @@ import springfox.documentation.swagger2.annotations.EnableSwagger2; @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) //去掉数据源 @ComponentScan(basePackages = {"com.zbkj", "com.zbkj.front"}) @MapperScan(basePackages = {"com.zbkj.**.dao"}) +@EntityScan(basePackages = {"com.zbkj.common.model"}) // JPA实体扫描 +@EnableJpaRepositories(basePackages = {"com.zbkj.service.repository"}) // JPA仓库扫描(可选) public class CrmebFrontApplication { public static void main(String[] args) { SpringApplication.run(CrmebFrontApplication.class, args); diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml b/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml index 094358aa..d41cbc6d 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml +++ b/Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml @@ -51,6 +51,19 @@ spring: url: jdbc:mysql://1.15.149.240:3306/zhibo?characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=GMT%2B8 username: zhibo password: zCETFpGMwYN3CNeH + # JPA 配置 - 只新增字段,不删除已有字段 + jpa: + hibernate: + ddl-auto: update # update: 只新增表/字段,不删除;none: 不做任何操作 + show-sql: false # 生产环境建议关闭 + open-in-view: false # 关闭懒加载视图 + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL5InnoDBDialect + # 命名策略:将驼峰转为下划线 + physical_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy + implicit_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy redis: host: 127.0.0.1 #地址 port: 6379 #端口 diff --git a/Zhibo/zhibo-h/pom.xml b/Zhibo/zhibo-h/pom.xml index 7bb7b86b..0a64429d 100644 --- a/Zhibo/zhibo-h/pom.xml +++ b/Zhibo/zhibo-h/pom.xml @@ -100,6 +100,13 @@ 3.3.1 + + + org.springframework.boot + spring-boot-starter-data-jpa + 2.2.6.RELEASE + + com.baomidou diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java index 47ba27dc..9db18dc2 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java @@ -45,6 +45,7 @@ public class ConversationActivity extends AppCompatActivity { private static final String EXTRA_CONVERSATION_TITLE = "extra_conversation_title"; private static final String EXTRA_UNREAD_COUNT = "extra_unread_count"; private static final int PAGE_SIZE = 20; + private static final long POLL_INTERVAL = 3000; // 3秒轮询一次 private ActivityConversationBinding binding; private final OkHttpClient httpClient = new OkHttpClient(); @@ -52,6 +53,8 @@ public class ConversationActivity extends AppCompatActivity { private ConversationMessagesAdapter adapter; private final List messages = new ArrayList<>(); private Handler handler; + private Runnable pollRunnable; + private boolean isPolling = false; private String conversationId; private int currentPage = 1; @@ -78,7 +81,15 @@ public class ConversationActivity extends AppCompatActivity { conversationId = getIntent() != null ? getIntent().getStringExtra(EXTRA_CONVERSATION_ID) : null; initialUnreadCount = getIntent() != null ? getIntent().getIntExtra(EXTRA_UNREAD_COUNT, 0) : 0; + + // 先尝试从 AuthStore 获取 userId currentUserId = AuthStore.getUserId(this); + Log.d(TAG, "从AuthStore获取的用户ID: " + currentUserId); + + // 如果 userId 为空,尝试从用户信息接口获取 + if (currentUserId == null || currentUserId.isEmpty()) { + fetchCurrentUserId(); + } binding.backButton.setOnClickListener(new DebounceClickListener() { @Override @@ -99,6 +110,42 @@ public class ConversationActivity extends AppCompatActivity { @Override protected void onPause() { super.onPause(); + stopPolling(); + } + + @Override + protected void onResume() { + super.onResume(); + startPolling(); + } + + /** + * 开始轮询新消息 + */ + private void startPolling() { + if (isPolling) return; + isPolling = true; + + pollRunnable = new Runnable() { + @Override + public void run() { + if (!isPolling || isFinishing() || isDestroyed()) return; + loadMessagesFromServer(); + handler.postDelayed(this, POLL_INTERVAL); + } + }; + // 延迟开始轮询,避免和初始加载冲突 + handler.postDelayed(pollRunnable, POLL_INTERVAL); + } + + /** + * 停止轮询 + */ + private void stopPolling() { + isPolling = false; + if (pollRunnable != null && handler != null) { + handler.removeCallbacks(pollRunnable); + } } private void setupMessages() { @@ -116,6 +163,58 @@ public class ConversationActivity extends AppCompatActivity { } + /** + * 从服务器获取当前用户ID + */ + private void fetchCurrentUserId() { + String token = AuthStore.getToken(this); + if (token == null) { + Log.w(TAG, "未登录,无法获取用户ID"); + return; + } + + String url = ApiConfig.getBaseUrl() + "/api/front/user/info"; + 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); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "用户信息响应: " + body); + try { + JSONObject json = new JSONObject(body); + if (json.optInt("code", -1) == 200) { + JSONObject data = json.optJSONObject("data"); + if (data != null) { + int uid = data.optInt("uid", 0); + if (uid > 0) { + currentUserId = String.valueOf(uid); + // 保存到 AuthStore + AuthStore.setUserInfo(ConversationActivity.this, currentUserId, data.optString("nickname", "")); + Log.d(TAG, "从服务器获取到用户ID: " + currentUserId); + // 重新加载消息以正确显示 + runOnUiThread(() -> loadMessagesFromServer()); + } + } + } + } catch (Exception e) { + Log.e(TAG, "解析用户信息失败", e); + } + } + }); + } + /** * 从服务器加载消息列表 */ @@ -182,7 +281,7 @@ public class ConversationActivity extends AppCompatActivity { private ChatMessage parseChatMessage(JSONObject item) { try { String messageId = item.optString("messageId", ""); - int userId = item.optInt("userId", 0); + int senderId = item.optInt("userId", 0); // 后端返回的 userId 实际是 senderId String username = item.optString("username", "未知用户"); String message = item.optString("message", ""); long timestamp = item.optLong("timestamp", System.currentTimeMillis()); @@ -191,7 +290,20 @@ public class ConversationActivity extends AppCompatActivity { boolean isSystem = item.optBoolean("isSystemMessage", false); // 判断是否是自己发送的消息 - boolean isMine = currentUserId != null && currentUserId.equals(String.valueOf(userId)); + // 每次都重新从 AuthStore 获取最新的 userId,确保登录后能正确获取 + String myUserId = AuthStore.getUserId(this); + if (myUserId == null || myUserId.isEmpty()) { + myUserId = currentUserId; + } else { + currentUserId = myUserId; // 同步更新 + } + + boolean isMine = false; + if (myUserId != null && !myUserId.isEmpty() && senderId > 0) { + isMine = myUserId.equals(String.valueOf(senderId)); + } + Log.d(TAG, "消息判断: myUserId=" + myUserId + ", senderId=" + senderId + ", isMine=" + isMine); + String displayName = isMine ? "我" : username; ChatMessage.MessageStatus msgStatus; @@ -457,6 +569,7 @@ public class ConversationActivity extends AppCompatActivity { @Override protected void onDestroy() { super.onDestroy(); + stopPolling(); handler = null; } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java index b6711049..22255597 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java @@ -300,17 +300,17 @@ public class ConversationMessagesAdapter extends ListAdapter { - handleDemoModeLogin(account); - }, 500); // 延迟500ms,模拟网络请求 - - // 以下代码在测试模式下被注释,接入后端后可以恢复 - /* - // TODO: 接入后端接口 - 用户登录 - // 接口路径: POST /api/front/login(ApiService中已定义) - // 请求参数: LoginRequest {account: string, password: string} - // 返回数据格式: ApiResponse - // LoginResponse对象应包含: token, userId, nickname, avatarUrl等字段 - // 登录成功后,保存token到AuthStore,并更新用户信息到本地SharedPreferences + // 调用后端登录接口 ApiClient.getService(getApplicationContext()).login(new LoginRequest(account, password)) .enqueue(new Callback>() { @Override @@ -128,15 +112,8 @@ public class LoginActivity extends AppCompatActivity { ApiResponse body = response.body(); LoginResponse loginData = body != null ? body.getData() : null; - // 如果响应不成功或数据无效,检查是否是后端未接入的情况 + // 如果响应不成功或数据无效 if (!response.isSuccessful() || body == null || !body.isOk() || loginData == null) { - // 如果是404、500等错误,可能是后端未接入,使用演示模式 - if (!response.isSuccessful() && (response.code() == 404 || response.code() == 500 || response.code() == 502 || response.code() == 503)) { - // 后端服务未启动或未接入,使用演示模式 - handleDemoModeLogin(account); - return; - } - String errorMsg = "登录失败"; if (body != null && !TextUtils.isEmpty(body.getMessage())) { errorMsg = body.getMessage(); @@ -158,9 +135,10 @@ public class LoginActivity extends AppCompatActivity { } // 保存用户信息到 AuthStore - AuthStore.setUserInfo(getApplicationContext(), - loginData.getUid(), - loginData.getNikeName()); + String uid = loginData.getUid(); + String nickname = loginData.getNikeName(); + android.util.Log.d("LoginActivity", "登录返回的 uid: " + uid + ", nickname: " + nickname); + AuthStore.setUserInfo(getApplicationContext(), uid, nickname); // 保存用户信息到本地 SharedPreferences SharedPreferences prefs = getSharedPreferences("profile_prefs", MODE_PRIVATE); @@ -171,8 +149,7 @@ public class LoginActivity extends AppCompatActivity { prefs.edit().putString("profile_phone", loginData.getPhone()).apply(); } - // 登录成功,返回上一页(如果是从其他页面跳转过来的) - // 如果是直接打开登录页面,则跳转到主页面 + // 登录成功 Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show(); // 检查是否有上一个Activity @@ -194,74 +171,14 @@ public class LoginActivity extends AppCompatActivity { binding.loginButton.setEnabled(true); binding.loadingProgress.setVisibility(View.GONE); - // 检测是否是网络连接错误(后端未启动) - boolean isNetworkError = false; String errorMsg = "网络错误"; - if (t != null) { - String msg = t.getMessage(); - if (msg != null) { - if (msg.contains("Unable to resolve host") || - msg.contains("timeout") || - msg.contains("Connection refused") || - msg.contains("Failed to connect")) { - isNetworkError = true; - errorMsg = "无法连接到服务器,已切换到演示模式"; - } else { - errorMsg = "网络错误:" + msg; - } - } - } - - // 如果是网络连接错误(后端未接入),使用演示模式登录 - if (isNetworkError) { - handleDemoModeLogin(account); - } else { - Toast.makeText(LoginActivity.this, errorMsg, Toast.LENGTH_LONG).show(); + if (t != null && t.getMessage() != null) { + errorMsg = "网络错误:" + t.getMessage(); } + Toast.makeText(LoginActivity.this, errorMsg, Toast.LENGTH_LONG).show(); } }); - */ } - /** - * 处理演示模式登录(测试模式:直接登录成功) - */ - private void handleDemoModeLogin(String account) { - // 恢复UI状态 - isLoggingIn = false; - binding.loginButton.setEnabled(true); - binding.loadingProgress.setVisibility(View.GONE); - - // 测试模式:允许任意账号密码登录(仅用于开发测试) - // 生成一个演示token(基于账号) - String demoToken = "demo_token_" + account.hashCode(); - android.util.Log.d("LoginActivity", "演示模式 - 生成 token: " + demoToken); - AuthStore.setToken(getApplicationContext(), demoToken); - - // 保存用户信息到本地 - SharedPreferences prefs = getSharedPreferences("profile_prefs", MODE_PRIVATE); - String displayName = account.length() > 0 ? account : "演示用户"; - prefs.edit() - .putString("profile_name", displayName) - .putString("user_id", "demo_user_" + account.hashCode()) - .apply(); - - // 登录成功提示 - Toast.makeText(this, "登录成功", Toast.LENGTH_SHORT).show(); - - // 登录成功,返回上一页(如果是从其他页面跳转过来的) - // 如果是直接打开登录页面,则跳转到主页面 - // 检查是否有上一个Activity - if (isTaskRoot()) { - // 如果没有上一个Activity,跳转到主页面 - Intent intent = new Intent(this, MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - finish(); - } else { - // 有上一个Activity,直接返回 - finish(); - } - } } 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 4cbff908..cbc551d0 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 @@ -51,6 +51,7 @@ public final class AuthStore { public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname) { if (context == null) return; + Log.d(TAG, "setUserInfo: userId=" + userId + ", nickname=" + nickname); context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit() .putString(KEY_USER_ID, userId) @@ -61,8 +62,14 @@ public final class AuthStore { @Nullable public static String getUserId(Context context) { if (context == null) return null; - return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getString(KEY_USER_ID, ""); + String userId = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_USER_ID, null); + // 确保空字符串也返回 null + if (userId != null && userId.trim().isEmpty()) { + userId = null; + } + Log.d(TAG, "getUserId: " + userId); + return userId; } @Nullable