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 7bd22a8d..26093153 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 63924abf..c0d27183 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/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='私信消息表';
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..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
@@ -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,47 @@ 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 static final long POLL_INTERVAL = 3000; // 3秒轮询一次
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 Runnable pollRunnable;
+ private boolean isPolling = false;
+
+ 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 +68,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 +79,17 @@ 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;
+
+ // 先尝试从 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
@@ -71,28 +101,55 @@ 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();
- // 当用户离开会话时,更新总未读数量(如果会话未读数量已减少)
+ 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() {
adapter = new ConversationMessagesAdapter();
-
- // 设置长按监听
adapter.setOnMessageLongClickListener((message, position, view) -> {
showMessageMenu(message, position, view);
});
@@ -102,43 +159,224 @@ 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();
}
-
+
+
+ /**
+ * 从服务器获取当前用户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);
+ }
+ }
+ });
+ }
+
+ /**
+ * 从服务器加载消息列表
+ */
+ 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 senderId = item.optInt("userId", 0); // 后端返回的 userId 实际是 senderId
+ 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);
+
+ // 判断是否是自己发送的消息
+ // 每次都重新从 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;
+ 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 +392,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 +469,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 +569,7 @@ public class ConversationActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
super.onDestroy();
- // 清理Handler中的延迟任务,防止内存泄漏
- if (handler != null && statusUpdateRunnable != null) {
- handler.removeCallbacks(statusUpdateRunnable);
- }
+ stopPolling();
handler = null;
- statusUpdateRunnable = 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/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();
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