From 717cd2a1ac87ffacc98f6326f9fc7d60e961f514 Mon Sep 17 00:00:00 2001 From: rjb <263303411@qq.com> Date: Sat, 7 Mar 2026 09:01:00 +0800 Subject: [PATCH] android app --- saars/README.md | 119 ++++++++++++ saars/android-app/app/build.gradle | 52 +++++ saars/android-app/app/proguard-rules.pro | 3 + .../app/src/main/AndroidManifest.xml | 27 +++ .../saars/chatplatform/ChatPlatformApp.java | 17 ++ .../chatplatform/data/local/AppDatabase.java | 35 ++++ .../chatplatform/data/local/SyncHelper.java | 95 ++++++++++ .../chatplatform/data/local/TokenStore.java | 42 +++++ .../data/local/dao/ConversationDao.java | 28 +++ .../data/local/dao/MessageDao.java | 34 ++++ .../data/local/entity/ConversationEntity.java | 15 ++ .../data/local/entity/MessageEntity.java | 20 ++ .../chatplatform/data/remote/ApiService.java | 133 +++++++++++++ .../data/remote/RetrofitClient.java | 67 +++++++ .../data/remote/SocketManager.java | 120 ++++++++++++ .../domain/repository/ChatRepository.java | 30 +++ .../fcm/ChatFirebaseMessagingService.java | 75 ++++++++ .../presentation/ConversationListAdapter.java | 59 ++++++ .../presentation/MainActivity.java | 147 +++++++++++++++ .../presentation/agent/AgentChatActivity.java | 158 ++++++++++++++++ .../presentation/chat/ChatActivity.java | 178 ++++++++++++++++++ .../presentation/chat/MessageListAdapter.java | 45 +++++ .../presentation/login/LoginActivity.java | 98 ++++++++++ .../main/res/layout/activity_agent_chat.xml | 39 ++++ .../app/src/main/res/layout/activity_chat.xml | 39 ++++ .../src/main/res/layout/activity_login.xml | 77 ++++++++ .../app/src/main/res/layout/activity_main.xml | 54 ++++++ .../src/main/res/layout/item_conversation.xml | 25 +++ .../app/src/main/res/layout/item_message.xml | 13 ++ .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 6 + saars/android-app/build.gradle | 3 + saars/android-app/gradle.properties | 3 + saars/android-app/settings.gradle | 16 ++ saars/backend/.gitignore | 7 + saars/backend/Dockerfile | 17 ++ saars/backend/app/__init__.py | 50 +++++ saars/backend/app/admin/__init__.py | 37 ++++ saars/backend/app/api/__init__.py | 16 ++ saars/backend/app/api/admin_api.py | 84 +++++++++ saars/backend/app/api/agent_proxy.py | 34 ++++ saars/backend/app/api/auth.py | 85 +++++++++ saars/backend/app/api/chat.py | 87 +++++++++ saars/backend/app/api/files.py | 25 +++ saars/backend/app/config.py | 60 ++++++ saars/backend/app/models/__init__.py | 4 + saars/backend/app/models/chat.py | 55 ++++++ saars/backend/app/models/user.py | 46 +++++ saars/backend/app/services/__init__.py | 4 + saars/backend/app/services/agent_proxy.py | 112 +++++++++++ saars/backend/app/services/chat_engine.py | 78 ++++++++ saars/backend/app/services/file_service.py | 32 ++++ saars/backend/app/socket_events.py | 63 +++++++ saars/backend/app/utils/__init__.py | 3 + saars/backend/app/utils/auth.py | 12 ++ saars/backend/migrations/env.py | 1 + saars/backend/requirements.txt | 42 +++++ saars/backend/run.py | 11 ++ saars/backend/tests/__init__.py | 1 + saars/backend/tests/test_auth.py | 38 ++++ saars/docker-compose.yml | 23 +++ 知你客服Agent智能聊天App接入方案.md | 160 ++++++++++++++++ 63 files changed, 3067 insertions(+) create mode 100644 saars/README.md create mode 100644 saars/android-app/app/build.gradle create mode 100644 saars/android-app/app/proguard-rules.pro create mode 100644 saars/android-app/app/src/main/AndroidManifest.xml create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/ChatPlatformApp.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/AppDatabase.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/SyncHelper.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/TokenStore.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/ConversationDao.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/MessageDao.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/ConversationEntity.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/MessageEntity.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/ApiService.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/RetrofitClient.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/SocketManager.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/domain/repository/ChatRepository.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/fcm/ChatFirebaseMessagingService.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/ConversationListAdapter.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/MainActivity.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/agent/AgentChatActivity.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/ChatActivity.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/MessageListAdapter.java create mode 100644 saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/login/LoginActivity.java create mode 100644 saars/android-app/app/src/main/res/layout/activity_agent_chat.xml create mode 100644 saars/android-app/app/src/main/res/layout/activity_chat.xml create mode 100644 saars/android-app/app/src/main/res/layout/activity_login.xml create mode 100644 saars/android-app/app/src/main/res/layout/activity_main.xml create mode 100644 saars/android-app/app/src/main/res/layout/item_conversation.xml create mode 100644 saars/android-app/app/src/main/res/layout/item_message.xml create mode 100644 saars/android-app/app/src/main/res/values/colors.xml create mode 100644 saars/android-app/app/src/main/res/values/strings.xml create mode 100644 saars/android-app/app/src/main/res/values/themes.xml create mode 100644 saars/android-app/build.gradle create mode 100644 saars/android-app/gradle.properties create mode 100644 saars/android-app/settings.gradle create mode 100644 saars/backend/.gitignore create mode 100644 saars/backend/Dockerfile create mode 100644 saars/backend/app/__init__.py create mode 100644 saars/backend/app/admin/__init__.py create mode 100644 saars/backend/app/api/__init__.py create mode 100644 saars/backend/app/api/admin_api.py create mode 100644 saars/backend/app/api/agent_proxy.py create mode 100644 saars/backend/app/api/auth.py create mode 100644 saars/backend/app/api/chat.py create mode 100644 saars/backend/app/api/files.py create mode 100644 saars/backend/app/config.py create mode 100644 saars/backend/app/models/__init__.py create mode 100644 saars/backend/app/models/chat.py create mode 100644 saars/backend/app/models/user.py create mode 100644 saars/backend/app/services/__init__.py create mode 100644 saars/backend/app/services/agent_proxy.py create mode 100644 saars/backend/app/services/chat_engine.py create mode 100644 saars/backend/app/services/file_service.py create mode 100644 saars/backend/app/socket_events.py create mode 100644 saars/backend/app/utils/__init__.py create mode 100644 saars/backend/app/utils/auth.py create mode 100644 saars/backend/migrations/env.py create mode 100644 saars/backend/requirements.txt create mode 100644 saars/backend/run.py create mode 100644 saars/backend/tests/__init__.py create mode 100644 saars/backend/tests/test_auth.py create mode 100644 saars/docker-compose.yml create mode 100644 知你客服Agent智能聊天App接入方案.md diff --git a/saars/README.md b/saars/README.md new file mode 100644 index 0000000..6cf3dff --- /dev/null +++ b/saars/README.md @@ -0,0 +1,119 @@ +# SAARS 全栈聊天助手平台 + +企业级聊天助手平台,前后端分离,支持实时聊天、用户管理、内容审核。技术规范见项目根目录《全栈聊天助手平台开发技术规范》。 + +## 仓库结构 + +``` +saars/ +├── backend/ # Flask 后端 +│ ├── app/ +│ │ ├── api/ # REST 端点 +│ │ ├── models/ # 数据模型 +│ │ ├── services/ # 业务逻辑 +│ │ ├── admin/ # Flask-Admin 管理 +│ │ └── utils/ +│ ├── tests/ +│ ├── requirements.txt +│ └── Dockerfile +├── android-app/ # Android 客户端 (Java, MVVM) +│ ├── app/ +│ │ ├── data/ # 数据层 (Retrofit, Room) +│ │ ├── domain/ # 领域层 +│ │ └── presentation/# 表现层 (Activity, ViewModel) +│ └── build.gradle +├── docker-compose.yml +└── README.md +``` + +## 后端 (Flask) + +- **框架**: Flask 2.3+, Flask-SocketIO, Flask-JWT-Extended, Flask-Admin +- **数据库**: 腾讯云 MySQL (liaotian_db),SQLAlchemy ORM + PyMySQL,Flask-Migrate +- **缓存/队列**: Redis 7+ +- **认证**: JWT + 刷新令牌 + +### 本地运行 + +```bash +cd backend +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +export FLASK_ENV=development +export DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/liaotian_db?charset=utf8mb4 +export REDIS_URL=redis://localhost:6379/0 +flask db upgrade # 需先创建数据库并执行迁移 +python run.py # 或 gunicorn -k eventlet -w 1 run:app +``` + +### Docker + +```bash +docker-compose up -d +# 后端监听 8052 端口(腾讯云已放行);首次需在 backend 容器内: flask db init && flask db migrate -m "init" && flask db upgrade +``` + +### API 概览 + +| 模块 | 路径前缀 | 说明 | +|----------|-----------------|----------------| +| 认证 | `/api/v1/auth` | 注册、登录、刷新、/me | +| 聊天 | `/api/v1/chat` | 会话列表、消息、撤回 | +| 文件 | `/api/v1/files` | 上传(图片/文档,最大 10MB) | +| 管理 | `/api/v1/admin` | 用户管理、会话审计、统计(需 admin 角色) | +| 知你客服 | `/api/v1/agent` | Agent 代理(方式一):`POST /chat` 与知你客服对话 | + +- 服务端口:**8052**(可在环境变量 `PORT` 或 docker-compose 中修改) +- 管理界面: `/admin`(需 Bearer Token 且角色为 admin) + +### 知你客服 Agent 代理(方式一) + +- 按《知你客服 Agent 智能聊天 App 接入方案》方式一:SAARS 后端用平台账号登录拿 Token,代 App 用户调平台执行 API,再返回回复。 +- **配置**:在环境变量或 `docker-compose.yml` 中设置 + `PLATFORM_BASE_URL`(如 `http://101.43.95.130:8037`)、`PLATFORM_USERNAME`、`PLATFORM_PASSWORD`、`PLATFORM_AGENT_ID`(知你客服的 Agent ID)。在低代码平台为对接创建一个账号,在 Agent 管理中复制「知你客服」的 ID 填入。 +- **接口**:`POST /api/v1/agent/chat`,Body `{ "message": "用户输入", "user_id": "可选" }`,响应 `{ "reply": "客服回复" }`。需登录 SAARS 的 JWT。 + +## Android 客户端 (Java) + +- **最小 SDK**: API 30 (Android 11) +- **架构**: MVVM, Architecture Components, Retrofit, Socket.IO Client, Room +- **UI**: Material Design 3, RecyclerView 消息列表 + +### Room 离线 + +- 会话列表、消息列表首次从本地 Room 加载(秒开),再拉取接口并写回 Room。 +- 发送成功的消息会写入 Room,Socket 收到的新消息也会写入 Room,下次进入直接读本地。 + +### Socket.IO 实时 + +- 登录/注册成功后自动连接 Socket(query 带 token),进入聊天页会 `join_conversation`,退出会 `leave_conversation`。 +- 后端发消息后通过 `broadcast_new_message` 推送到房间,客户端收到 `new_message` 后写入 Room 并刷新列表。 + +### FCM 推送 + +- 已接入 `firebase-messaging` 与 `ChatFirebaseMessagingService`,后台/进程被杀时由 FCM 下发通知。 +- **启用步骤**:在 [Firebase 控制台](https://console.firebase.google.com/) 创建项目并添加 Android 应用,下载 `google-services.json` 放到 `android-app/app/`;在根目录 `build.gradle` 的 `plugins` 中增加 `id 'com.google.gms.google-services' version '4.4.0' apply false`,在 `app/build.gradle` 的 `plugins` 中取消注释 `id 'com.google.gms.google-services'`,同步后即可。可选:后端提供 `POST /api/v1/users/me/fcm_token` 保存 token,便于服务端发定向推送。 + +### 知你客服入口 + +- 会话列表页右下角有两个 FAB:**知你客服**(上方)、**新对话**(下方)。点击「知你客服」进入与知你客服 Agent 的对话页,消息通过 SAARS 后端代理转发到低代码平台执行 API,回复展示在列表中并写入 Room 做历史。 + +### 构建 + +```bash +cd android-app +./gradlew assembleDebug +``` + +将 `build.gradle` 中 `applicationId` 与后端地址配置为实际环境后安装运行。 + +## 质量与部署 + +- **测试**: 后端 `pytest`,目标单元测试覆盖率 ≥85% +- **部署**: Docker 容器化,可配合 CI/CD 流水线 +- **扩展**: 无状态 API + Redis,支持水平扩展 + +## 许可证 + +内部项目,按公司规定使用。 diff --git a/saars/android-app/app/build.gradle b/saars/android-app/app/build.gradle new file mode 100644 index 0000000..fb8404b --- /dev/null +++ b/saars/android-app/app/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'com.android.application' + // 启用 FCM 时取消下一行注释,并把 Firebase 控制台下载的 google-services.json 放到 app/ 目录 + // id 'com.google.gms.google-services' +} + +android { + namespace 'com.saars.chatplatform' + compileSdk 34 + defaultConfig { + applicationId "com.saars.chatplatform" + minSdk 30 + targetSdk 34 + versionCode 1 + versionName "1.0" + // 服务器:101.43.95.130:8052;模拟器访问本机可改为 "http://10.0.2.2:8052/" + buildConfigField "String", "API_BASE_URL", "\"http://101.43.95.130:8052/\"" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + buildFeatures { + viewBinding true + buildConfig true + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.7.0' + implementation 'androidx.lifecycle:lifecycle-livedata:2.7.0' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + implementation 'io.socket:socket.io-client:2.1.0' + implementation 'androidx.room:room-runtime:2.6.1' + annotationProcessor 'androidx.room:room-compiler:2.6.1' + implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0' + implementation platform('com.google.firebase:firebase-bom:32.7.0') + implementation 'com.google.firebase:firebase-messaging' +} diff --git a/saars/android-app/app/proguard-rules.pro b/saars/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..19dd92f --- /dev/null +++ b/saars/android-app/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in sdk/tools/proguard/proguard-android.txt diff --git a/saars/android-app/app/src/main/AndroidManifest.xml b/saars/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5a2c6ee --- /dev/null +++ b/saars/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/ChatPlatformApp.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/ChatPlatformApp.java new file mode 100644 index 0000000..eb481e0 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/ChatPlatformApp.java @@ -0,0 +1,17 @@ +package com.saars.chatplatform; + +import android.app.Application; + +import com.saars.chatplatform.data.local.SyncHelper; +import com.saars.chatplatform.data.remote.RetrofitClient; +import com.saars.chatplatform.data.remote.SocketManager; + +public class ChatPlatformApp extends Application { + @Override + public void onCreate() { + super.onCreate(); + RetrofitClient.init(this); + SyncHelper syncHelper = new SyncHelper(this); + SocketManager.init(syncHelper); + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/AppDatabase.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/AppDatabase.java new file mode 100644 index 0000000..4964bb1 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/AppDatabase.java @@ -0,0 +1,35 @@ +package com.saars.chatplatform.data.local; + +import android.content.Context; + +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; + +import com.saars.chatplatform.data.local.dao.ConversationDao; +import com.saars.chatplatform.data.local.dao.MessageDao; +import com.saars.chatplatform.data.local.entity.ConversationEntity; +import com.saars.chatplatform.data.local.entity.MessageEntity; + +@Database(entities = {ConversationEntity.class, MessageEntity.class}, version = 1, exportSchema = false) +public abstract class AppDatabase extends RoomDatabase { + private static volatile AppDatabase instance; + + public abstract ConversationDao conversationDao(); + public abstract MessageDao messageDao(); + + public static AppDatabase getInstance(Context context) { + if (instance == null) { + synchronized (AppDatabase.class) { + if (instance == null) { + instance = Room.databaseBuilder( + context.getApplicationContext(), + AppDatabase.class, + "chat_platform_db" + ).fallbackToDestructiveMigration().build(); + } + } + } + return instance; + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/SyncHelper.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/SyncHelper.java new file mode 100644 index 0000000..58343dd --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/SyncHelper.java @@ -0,0 +1,95 @@ +package com.saars.chatplatform.data.local; + +import android.content.Context; + +import com.saars.chatplatform.data.local.entity.ConversationEntity; +import com.saars.chatplatform.data.local.entity.MessageEntity; +import com.saars.chatplatform.data.remote.ApiService; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Saves API responses to Room for offline; loads from Room first for instant UI. + */ +public class SyncHelper { + private final AppDatabase db; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + public SyncHelper(Context context) { + this.db = AppDatabase.getInstance(context); + } + + public void saveConversations(List items) { + executor.execute(() -> { + List list = new ArrayList<>(); + for (ApiService.ConversationItem c : items) { + ConversationEntity e = new ConversationEntity(); + e.id = c.id; + e.title = c.title; + e.lastMessageAt = c.last_message_at; + e.updatedAt = System.currentTimeMillis(); + list.add(e); + } + db.conversationDao().insertAll(list); + }); + } + + public void saveMessages(String conversationId, List items) { + executor.execute(() -> { + db.messageDao().deleteByConversation(conversationId); + List list = new ArrayList<>(); + for (ApiService.Message m : items) { + MessageEntity e = new MessageEntity(); + e.id = m.id; + e.conversationId = m.conversation_id; + e.senderId = m.sender_id; + e.content = m.content; + e.contentType = m.content_type != null ? m.content_type : "text"; + e.createdAt = m.created_at; + e.pending = false; + list.add(e); + } + db.messageDao().insertAll(list); + }); + } + + public void insertMessage(MessageEntity e) { + executor.execute(() -> db.messageDao().insert(e)); + } + + public List getConversationsFromDb() { + return db.conversationDao().getAll(); + } + + public List getMessagesFromDb(String conversationId) { + return db.messageDao().getByConversation(conversationId); + } + + public void saveSentMessagePending(String conversationId, String localId, String content, String senderId) { + MessageEntity e = new MessageEntity(); + e.id = localId; + e.conversationId = conversationId; + e.senderId = senderId; + e.content = content; + e.contentType = "text"; + e.createdAt = String.valueOf(System.currentTimeMillis()); + e.pending = true; + executor.execute(() -> db.messageDao().insert(e)); + } + + public void markMessageSynced(String localId, String serverId, String createdAt) { + executor.execute(() -> { + MessageEntity e = db.messageDao().getById(localId); + if (e != null) { + db.messageDao().deleteById(localId); + e.id = serverId; + e.createdAt = createdAt; + e.pending = false; + db.messageDao().insert(e); + } + }); + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/TokenStore.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/TokenStore.java new file mode 100644 index 0000000..40f4d6e --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/TokenStore.java @@ -0,0 +1,42 @@ +package com.saars.chatplatform.data.local; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * Stores JWT token after login. + */ +public class TokenStore { + private static final String PREFS_NAME = "chat_platform"; + private static final String KEY_ACCESS_TOKEN = "access_token"; + + private final SharedPreferences prefs; + + public TokenStore(Context context) { + this.prefs = context.getApplicationContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } + + public void setToken(String token) { + prefs.edit().putString(KEY_ACCESS_TOKEN, token).apply(); + } + + public String getToken() { + return prefs.getString(KEY_ACCESS_TOKEN, null); + } + + public void clear() { + prefs.edit().remove(KEY_ACCESS_TOKEN).apply(); + } + + public boolean hasToken() { + String t = getToken(); + return t != null && !t.isEmpty(); + } + + /** "Bearer <token>" for Authorization header */ + public String getBearerToken() { + String t = getToken(); + if (t == null || t.isEmpty()) return null; + return "Bearer " + t; + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/ConversationDao.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/ConversationDao.java new file mode 100644 index 0000000..5d6a7fb --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/ConversationDao.java @@ -0,0 +1,28 @@ +package com.saars.chatplatform.data.local.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; + +import com.saars.chatplatform.data.local.entity.ConversationEntity; + +import java.util.List; + +@Dao +public interface ConversationDao { + @Query("SELECT * FROM conversations ORDER BY updatedAt DESC") + List getAll(); + + @Query("SELECT * FROM conversations WHERE id = :id LIMIT 1") + ConversationEntity getById(String id); + + @Insert(onConflict = OnConflictStrategy.REPLACE) + void insertAll(List list); + + @Insert(onConflict = OnConflictStrategy.REPLACE) + void insert(ConversationEntity e); + + @Query("DELETE FROM conversations") + void deleteAll(); +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/MessageDao.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/MessageDao.java new file mode 100644 index 0000000..e9367b3 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/dao/MessageDao.java @@ -0,0 +1,34 @@ +package com.saars.chatplatform.data.local.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; + +import com.saars.chatplatform.data.local.entity.MessageEntity; + +import java.util.List; + +@Dao +public interface MessageDao { + @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY createdAt ASC") + List getByConversation(String conversationId); + + @Insert(onConflict = OnConflictStrategy.REPLACE) + void insert(MessageEntity m); + + @Insert(onConflict = OnConflictStrategy.REPLACE) + void insertAll(List list); + + @Query("DELETE FROM messages WHERE conversationId = :conversationId") + void deleteByConversation(String conversationId); + + @Query("DELETE FROM messages WHERE id = :id") + void deleteById(String id); + + @Query("SELECT * FROM messages WHERE id = :id LIMIT 1") + MessageEntity getById(String id); + + @Query("DELETE FROM messages") + void deleteAll(); +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/ConversationEntity.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/ConversationEntity.java new file mode 100644 index 0000000..c9fd462 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/ConversationEntity.java @@ -0,0 +1,15 @@ +package com.saars.chatplatform.data.local.entity; + +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +@Entity(tableName = "conversations") +public class ConversationEntity { + @PrimaryKey + public String id; + public String title; + public String lastMessageAt; + public long updatedAt; + + public ConversationEntity() {} +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/MessageEntity.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/MessageEntity.java new file mode 100644 index 0000000..3a73cae --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/local/entity/MessageEntity.java @@ -0,0 +1,20 @@ +package com.saars.chatplatform.data.local.entity; + +import androidx.room.Entity; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +@Entity(tableName = "messages", indices = {@Index("conversationId")}) +public class MessageEntity { + @PrimaryKey + public String id; + public String conversationId; + public String senderId; + public String content; + public String contentType; + public String createdAt; + /** true = saved locally, not yet confirmed by server */ + public boolean pending; + + public MessageEntity() {} +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/ApiService.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/ApiService.java new file mode 100644 index 0000000..c110e83 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/ApiService.java @@ -0,0 +1,133 @@ +package com.saars.chatplatform.data.remote; + +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.Query; + +/** + * Retrofit API interface for chat platform backend. + */ +public interface ApiService { + + @POST("api/v1/auth/login") + Call login(@Body LoginRequest request); + + @POST("api/v1/auth/register") + Call register(@Body RegisterRequest request); + + /** 知你客服 Agent 代理(方式一,Token 由拦截器统一添加) */ + @POST("api/v1/agent/chat") + Call agentChat(@Body AgentChatRequest body); + + @GET("api/v1/chat/conversations") + Call getConversations( + @Header("Authorization") String token, + @Query("skip") int skip, + @Query("limit") int limit); + + @POST("api/v1/chat/conversations") + Call createConversation( + @Header("Authorization") String token, + @Body CreateConversationRequest body); + + @GET("api/v1/chat/conversations/{id}/messages") + Call getMessages( + @Header("Authorization") String token, + @Path("id") String conversationId, + @Query("before_id") String beforeId, + @Query("limit") int limit); + + @POST("api/v1/chat/conversations/{id}/messages") + Call sendMessage( + @Header("Authorization") String token, + @Path("id") String conversationId, + @Body SendMessageRequest body); + + class LoginRequest { + public String email; + public String password; + public LoginRequest(String email, String password) { + this.email = email; + this.password = password; + } + } + + class RegisterRequest { + public String email; + public String username; + public String password; + public RegisterRequest(String email, String username, String password) { + this.email = email; + this.username = username; + this.password = password; + } + } + + class LoginResponse { + public String access_token; + public String refresh_token; + public User user; + } + + class User { + public String id; + public String email; + public String username; + } + + class ConversationListResponse { + public java.util.List items; + } + + class ConversationItem { + public String id; + public String title; + public String last_message_at; + } + + class MessageListResponse { + public java.util.List items; + } + + class Message { + public String id; + public String conversation_id; + public String sender_id; + public String content; + public String content_type; + public String created_at; + } + + class SendMessageRequest { + public String content; + public String content_type; + public SendMessageRequest(String content) { + this.content = content; + this.content_type = "text"; + } + } + + class CreateConversationRequest { + public String title; + public CreateConversationRequest(String title) { + this.title = title; + } + } + + class AgentChatRequest { + public String message; + public String user_id; + public AgentChatRequest(String message, String user_id) { + this.message = message; + this.user_id = user_id; + } + } + + class AgentChatResponse { + public String reply; + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/RetrofitClient.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/RetrofitClient.java new file mode 100644 index 0000000..2471a4d --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/RetrofitClient.java @@ -0,0 +1,67 @@ +package com.saars.chatplatform.data.remote; + +import android.content.Context; + +import com.saars.chatplatform.BuildConfig; +import com.saars.chatplatform.data.local.TokenStore; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.logging.HttpLoggingInterceptor; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +import java.util.concurrent.TimeUnit; + +/** + * Singleton Retrofit client with auth interceptor. + */ +public class RetrofitClient { + private static volatile ApiService apiService; + private static TokenStore tokenStore; + + public static void init(Context context) { + if (tokenStore == null) { + tokenStore = new TokenStore(context); + } + } + + public static TokenStore getTokenStore(Context context) { + if (tokenStore == null) tokenStore = new TokenStore(context); + return tokenStore; + } + + public static ApiService getApi() { + if (apiService == null) { + synchronized (RetrofitClient.class) { + if (apiService == null) { + HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); + logging.setLevel(HttpLoggingInterceptor.Level.BODY); + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS); + builder.addInterceptor(chain -> { + Request original = chain.request(); + String auth = tokenStore != null ? tokenStore.getBearerToken() : null; + Request.Builder req = original.newBuilder(); + if (auth != null) req.header("Authorization", auth); + req.header("Content-Type", "application/json"); + return chain.proceed(req.build()); + }); + builder.addInterceptor(logging); + + String baseUrl = BuildConfig.API_BASE_URL; + if (baseUrl != null && !baseUrl.endsWith("/")) baseUrl += "/"; + Retrofit retrofit = new Retrofit.Builder() + .baseUrl(baseUrl) + .client(builder.build()) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + apiService = retrofit.create(ApiService.class); + } + } + } + return apiService; + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/SocketManager.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/SocketManager.java new file mode 100644 index 0000000..9d6d595 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/data/remote/SocketManager.java @@ -0,0 +1,120 @@ +package com.saars.chatplatform.data.remote; + +import android.util.Log; + +import com.saars.chatplatform.BuildConfig; +import com.saars.chatplatform.data.local.SyncHelper; + +import org.json.JSONObject; + +import java.net.URISyntaxException; + +import io.socket.client.IO; +import io.socket.client.Socket; +import io.socket.emitter.Emitter; + +/** + * Socket.IO: connect with token, join conversation room, receive new_message. + */ +public class SocketManager { + private static final String TAG = "SocketManager"; + private static Socket socket; + private static String currentConversationId; + private static NewMessageListener newMessageListener; + private static SyncHelper syncHelper; + + public interface NewMessageListener { + void onNewMessage(String conversationId, String messageId, String content, String senderId, String createdAt); + } + + public static void init(SyncHelper helper) { + syncHelper = helper; + } + + public static void connect(String token) { + if (token == null || token.isEmpty()) return; + if (socket != null && socket.connected()) return; + try { + String base = BuildConfig.API_BASE_URL; + if (base != null && base.endsWith("/")) base = base.substring(0, base.length() - 1); + IO.Options opts = new IO.Options(); + opts.forceNew = true; + opts.reconnection = true; + opts.query = "token=" + token; + socket = IO.socket(base, opts); + socket.on(Socket.EVENT_CONNECT, args -> Log.d(TAG, "Socket connected")); + socket.on(Socket.EVENT_DISCONNECT, args -> Log.d(TAG, "Socket disconnected")); + socket.on(Socket.EVENT_CONNECT_ERROR, args -> Log.e(TAG, "Socket error: " + (args.length > 0 ? args[0] : ""))); + socket.on("new_message", onNewMessage); + socket.connect(); + } catch (URISyntaxException e) { + Log.e(TAG, "Socket URI error", e); + } + } + + public static void disconnect() { + if (socket != null) { + socket.disconnect(); + socket.off(); + socket = null; + } + currentConversationId = null; + } + + public static void joinConversation(String conversationId) { + currentConversationId = conversationId; + if (socket != null && socket.connected()) { + JSONObject obj = new JSONObject(); + try { + obj.put("conversation_id", conversationId); + socket.emit("join_conversation", obj); + } catch (Exception e) { + Log.e(TAG, "join_conversation emit error", e); + } + } + } + + public static void leaveConversation() { + if (socket != null && socket.connected() && currentConversationId != null) { + try { + JSONObject obj = new JSONObject(); + obj.put("conversation_id", currentConversationId); + socket.emit("leave_conversation", obj); + } catch (Exception e) { + Log.e(TAG, "leave_conversation emit error", e); + } + } + currentConversationId = null; + } + + public static void setNewMessageListener(NewMessageListener listener) { + newMessageListener = listener; + } + + private static final Emitter.Listener onNewMessage = args -> { + if (args.length == 0) return; + try { + JSONObject o = (JSONObject) args[0]; + String conversationId = o.optString("conversation_id"); + String id = o.optString("id"); + String content = o.optString("content"); + String senderId = o.optString("sender_id"); + String createdAt = o.optString("created_at"); + if (syncHelper != null) { + com.saars.chatplatform.data.local.entity.MessageEntity e = new com.saars.chatplatform.data.local.entity.MessageEntity(); + e.id = id; + e.conversationId = conversationId; + e.senderId = senderId; + e.content = content; + e.createdAt = createdAt; + e.pending = false; + syncHelper.insertMessage(e); + } + if (newMessageListener != null) { + newMessageListener.onNewMessage(conversationId, id, content, senderId, createdAt); + } + } catch (Exception e) { + Log.e(TAG, "onNewMessage parse error", e); + } + }; +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/domain/repository/ChatRepository.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/domain/repository/ChatRepository.java new file mode 100644 index 0000000..70826d3 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/domain/repository/ChatRepository.java @@ -0,0 +1,30 @@ +package com.saars.chatplatform.domain.repository; + +import java.util.List; + +/** + * Repository interface for chat: conversations and messages (domain layer). + */ +public interface ChatRepository { + + void loadConversations(LoadCallback callback); + + void loadMessages(String conversationId, String beforeId, int limit, LoadMessagesCallback callback); + + void sendMessage(String conversationId, String content, SendCallback callback); + + interface LoadCallback { + void onSuccess(List items); + void onError(Throwable t); + } + + interface LoadMessagesCallback { + void onSuccess(List items); + void onError(Throwable t); + } + + interface SendCallback { + void onSuccess(Object message); + void onError(Throwable t); + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/fcm/ChatFirebaseMessagingService.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/fcm/ChatFirebaseMessagingService.java new file mode 100644 index 0000000..d1a00fd --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/fcm/ChatFirebaseMessagingService.java @@ -0,0 +1,75 @@ +package com.saars.chatplatform.fcm; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; + +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; +import com.saars.chatplatform.presentation.MainActivity; + +import java.util.Map; + +/** + * FCM: receive data/notification messages, show notification when app in background. + */ +public class ChatFirebaseMessagingService extends FirebaseMessagingService { + + private static final String TAG = "ChatFCM"; + private static final String CHANNEL_ID = "chat_messages"; + + @Override + public void onNewToken(@NonNull String token) { + Log.d(TAG, "FCM token: " + token); + // Optional: send token to backend POST /api/v1/users/me/fcm_token + } + + @Override + public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { + Map data = remoteMessage.getData(); + if (data != null && !data.isEmpty()) { + String title = data.get("title"); + String body = data.get("body"); + String conversationId = data.get("conversation_id"); + showNotification(title != null ? title : "新消息", body != null ? body : "", conversationId); + return; + } + RemoteMessage.Notification notif = remoteMessage.getNotification(); + if (notif != null) { + showNotification(notif.getTitle(), notif.getBody(), null); + } + } + + private void showNotification(String title, String body, String conversationId) { + createChannel(); + Intent intent = new Intent(this, MainActivity.class); + if (conversationId != null) { + intent.putExtra("open_conversation_id", conversationId); + } + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pi = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_email) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pi); + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (nm != null) nm.notify((int) System.currentTimeMillis(), builder.build()); + } + + private void createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "聊天消息", NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (nm != null) nm.createNotificationChannel(ch); + } + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/ConversationListAdapter.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/ConversationListAdapter.java new file mode 100644 index 0000000..c3f949a --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/ConversationListAdapter.java @@ -0,0 +1,59 @@ +package com.saars.chatplatform.presentation; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.saars.chatplatform.R; +import com.saars.chatplatform.data.remote.ApiService; + +import java.util.List; + +public class ConversationListAdapter extends RecyclerView.Adapter { + + private final List items; + private final OnItemClickListener listener; + + public interface OnItemClickListener { + void onItemClick(ApiService.ConversationItem item); + } + + public ConversationListAdapter(List items, OnItemClickListener listener) { + this.items = items; + this.listener = listener; + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_conversation, parent, false); + return new VH(v); + } + + @Override + public void onBindViewHolder(@NonNull VH holder, int position) { + ApiService.ConversationItem item = items.get(position); + holder.tvTitle.setText(item.title != null ? item.title : "对话"); + holder.tvTime.setText(item.last_message_at != null ? item.last_message_at : ""); + holder.itemView.setOnClickListener(v -> listener.onItemClick(item)); + } + + @Override + public int getItemCount() { + return items.size(); + } + + static class VH extends RecyclerView.ViewHolder { + TextView tvTitle, tvTime; + + VH(View itemView) { + super(itemView); + tvTitle = itemView.findViewById(R.id.tvTitle); + tvTime = itemView.findViewById(R.id.tvTime); + } + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/MainActivity.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/MainActivity.java new file mode 100644 index 0000000..d9eb301 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/MainActivity.java @@ -0,0 +1,147 @@ +package com.saars.chatplatform.presentation; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.saars.chatplatform.data.local.entity.ConversationEntity; +import com.saars.chatplatform.data.local.SyncHelper; +import com.saars.chatplatform.data.local.TokenStore; +import com.saars.chatplatform.data.remote.ApiService; +import com.saars.chatplatform.data.remote.RetrofitClient; +import com.saars.chatplatform.data.remote.SocketManager; +import com.saars.chatplatform.databinding.ActivityMainBinding; +import com.saars.chatplatform.presentation.agent.AgentChatActivity; +import com.saars.chatplatform.presentation.chat.ChatActivity; +import com.saars.chatplatform.presentation.login.LoginActivity; + +import java.util.ArrayList; +import java.util.List; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class MainActivity extends AppCompatActivity { + + private ActivityMainBinding binding; + private TokenStore tokenStore; + private SyncHelper syncHelper; + private final List items = new ArrayList<>(); + private ConversationListAdapter adapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + tokenStore = RetrofitClient.getTokenStore(this); + if (!tokenStore.hasToken()) { + startActivity(new Intent(this, LoginActivity.class)); + finish(); + return; + } + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + setSupportActionBar(binding.toolbar); + + adapter = new ConversationListAdapter(items, item -> { + Intent i = new Intent(MainActivity.this, ChatActivity.class); + i.putExtra(ChatActivity.EXTRA_CONVERSATION_ID, item.id); + i.putExtra(ChatActivity.EXTRA_TITLE, item.title != null ? item.title : "对话"); + startActivity(i); + }); + binding.recycler.setLayoutManager(new LinearLayoutManager(this)); + binding.recycler.setAdapter(adapter); + + binding.fabNew.setOnClickListener(v -> createConversation()); + binding.fabAgent.setOnClickListener(v -> startActivity(new Intent(this, AgentChatActivity.class))); + syncHelper = new SyncHelper(this); + loadFromDbThenApi(); + } + + @Override + protected void onResume() { + super.onResume(); + if (tokenStore.hasToken()) { + SocketManager.connect(tokenStore.getToken()); + loadFromDbThenApi(); + } + } + + private void applyConversationsToUi(List list) { + items.clear(); + for (ConversationEntity e : list) { + ApiService.ConversationItem c = new ApiService.ConversationItem(); + c.id = e.id; + c.title = e.title; + c.last_message_at = e.lastMessageAt; + items.add(c); + } + adapter.notifyDataSetChanged(); + } + + private void loadFromDbThenApi() { + binding.progress.setVisibility(View.VISIBLE); + new Thread(() -> { + List fromDb = syncHelper.getConversationsFromDb(); + runOnUiThread(() -> { + applyConversationsToUi(fromDb); + binding.progress.setVisibility(View.GONE); + }); + }).start(); + RetrofitClient.getApi().getConversations(tokenStore.getBearerToken(), 0, 50) + .enqueue(new Callback() { + @Override + public void onResponse(Call call, + Response response) { + if (response.isSuccessful() && response.body() != null && response.body().items != null) { + syncHelper.saveConversations(response.body().items); + new Thread(() -> { + List fromDb = syncHelper.getConversationsFromDb(); + runOnUiThread(() -> applyConversationsToUi(fromDb)); + }).start(); + } + binding.progress.setVisibility(View.GONE); + } + + @Override + public void onFailure(Call call, Throwable t) { + binding.progress.setVisibility(View.GONE); + Toast.makeText(MainActivity.this, "加载失败: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + private void createConversation() { + binding.fabNew.setEnabled(false); + ApiService.CreateConversationRequest req = new ApiService.CreateConversationRequest("新对话"); + RetrofitClient.getApi().createConversation(tokenStore.getBearerToken(), req) + .enqueue(new Callback() { + @Override + public void onResponse(Call call, + Response response) { + binding.fabNew.setEnabled(true); + if (response.isSuccessful() && response.body() != null) { + items.add(0, response.body()); + adapter.notifyItemInserted(0); + Intent i = new Intent(MainActivity.this, ChatActivity.class); + i.putExtra(ChatActivity.EXTRA_CONVERSATION_ID, response.body().id); + i.putExtra(ChatActivity.EXTRA_TITLE, "新对话"); + startActivity(i); + } else { + Toast.makeText(MainActivity.this, "创建失败", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + binding.fabNew.setEnabled(true); + Toast.makeText(MainActivity.this, "创建失败: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/agent/AgentChatActivity.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/agent/AgentChatActivity.java new file mode 100644 index 0000000..d485970 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/agent/AgentChatActivity.java @@ -0,0 +1,158 @@ +package com.saars.chatplatform.presentation.agent; + +import android.os.Bundle; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.saars.chatplatform.data.local.SyncHelper; +import com.saars.chatplatform.data.local.TokenStore; +import com.saars.chatplatform.data.remote.ApiService; +import com.saars.chatplatform.data.remote.RetrofitClient; +import com.saars.chatplatform.databinding.ActivityAgentChatBinding; +import com.saars.chatplatform.presentation.chat.MessageListAdapter; + +import java.util.ArrayList; +import java.util.List; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +/** + * 知你客服 Agent 对话页(方式一:通过 SAARS 后端代理调用平台执行 API) + */ +public class AgentChatActivity extends AppCompatActivity { + + /** 固定会话 ID,用于 Room 存储历史 */ + public static final String AGENT_CONVERSATION_ID = "agent_知你客服"; + + private ActivityAgentChatBinding binding; + private TokenStore tokenStore; + private SyncHelper syncHelper; + private final List messages = new ArrayList<>(); + private MessageListAdapter adapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityAgentChatBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + tokenStore = RetrofitClient.getTokenStore(this); + syncHelper = new SyncHelper(this); + setSupportActionBar(binding.toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle("知你客服"); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + binding.toolbar.setNavigationOnClickListener(v -> finish()); + + adapter = new MessageListAdapter(messages); + binding.recycler.setLayoutManager(new LinearLayoutManager(this)); + binding.recycler.setAdapter(adapter); + + binding.btnSend.setOnClickListener(v -> sendToAgent()); + loadHistoryFromDb(); + } + + private void loadHistoryFromDb() { + new Thread(() -> { + List fromDb = + syncHelper.getMessagesFromDb(AGENT_CONVERSATION_ID); + runOnUiThread(() -> { + messages.clear(); + for (com.saars.chatplatform.data.local.entity.MessageEntity e : fromDb) { + ApiService.Message m = new ApiService.Message(); + m.id = e.id; + m.conversation_id = e.conversationId; + m.sender_id = e.senderId; + m.content = e.content; + m.created_at = e.createdAt; + messages.add(m); + } + adapter.notifyDataSetChanged(); + scrollToBottom(); + }); + }).start(); + } + + private void sendToAgent() { + String content = binding.etInput.getText().toString().trim(); + if (content.isEmpty()) return; + binding.etInput.setText(""); + binding.btnSend.setEnabled(false); + + // user_id 不传时后端使用 JWT 的 sub 作为知你客服多轮记忆的 user_id + ApiService.AgentChatRequest req = new ApiService.AgentChatRequest(content, null); + RetrofitClient.getApi().agentChat(req).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + binding.btnSend.setEnabled(true); + if (response.isSuccessful() && response.body() != null) { + String reply = response.body().reply != null ? response.body().reply : ""; + appendUserMessage(content); + appendBotMessage(reply); + scrollToBottom(); + } else { + Toast.makeText(AgentChatActivity.this, "请求失败: " + response.message(), Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + binding.btnSend.setEnabled(true); + Toast.makeText(AgentChatActivity.this, "请求失败: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + private void appendUserMessage(String content) { + ApiService.Message m = new ApiService.Message(); + m.id = "local_u_" + System.currentTimeMillis(); + m.conversation_id = AGENT_CONVERSATION_ID; + m.sender_id = "user"; + m.content = content; + m.created_at = String.valueOf(System.currentTimeMillis()); + messages.add(m); + adapter.notifyItemInserted(messages.size() - 1); + saveMessageToRoom(m); + } + + private void appendBotMessage(String content) { + ApiService.Message m = new ApiService.Message(); + m.id = "local_b_" + System.currentTimeMillis(); + m.conversation_id = AGENT_CONVERSATION_ID; + m.sender_id = "agent"; + m.content = content; + m.created_at = String.valueOf(System.currentTimeMillis()); + messages.add(m); + adapter.notifyItemInserted(messages.size() - 1); + com.saars.chatplatform.data.local.entity.MessageEntity e = new com.saars.chatplatform.data.local.entity.MessageEntity(); + e.id = m.id; + e.conversationId = m.conversation_id; + e.senderId = m.sender_id; + e.content = m.content; + e.createdAt = m.created_at; + e.pending = false; + syncHelper.insertMessage(e); + } + + private void saveMessageToRoom(ApiService.Message m) { + com.saars.chatplatform.data.local.entity.MessageEntity e = new com.saars.chatplatform.data.local.entity.MessageEntity(); + e.id = m.id; + e.conversationId = m.conversation_id; + e.senderId = m.sender_id; + e.content = m.content; + e.createdAt = m.created_at; + e.pending = false; + syncHelper.insertMessage(e); + } + + private void scrollToBottom() { + if (messages.isEmpty()) return; + binding.recycler.smoothScrollToPosition(messages.size() - 1); + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/ChatActivity.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/ChatActivity.java new file mode 100644 index 0000000..063d9f2 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/ChatActivity.java @@ -0,0 +1,178 @@ +package com.saars.chatplatform.presentation.chat; + +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.saars.chatplatform.data.local.SyncHelper; +import com.saars.chatplatform.data.local.TokenStore; +import com.saars.chatplatform.data.local.entity.MessageEntity; +import com.saars.chatplatform.data.remote.ApiService; +import com.saars.chatplatform.data.remote.RetrofitClient; +import com.saars.chatplatform.data.remote.SocketManager; +import com.saars.chatplatform.databinding.ActivityChatBinding; + +import java.util.ArrayList; +import java.util.List; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class ChatActivity extends AppCompatActivity { + + public static final String EXTRA_CONVERSATION_ID = "conversation_id"; + public static final String EXTRA_TITLE = "title"; + + private ActivityChatBinding binding; + private TokenStore tokenStore; + private SyncHelper syncHelper; + private String conversationId; + private final List messages = new ArrayList<>(); + private MessageListAdapter adapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityChatBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + conversationId = getIntent().getStringExtra(EXTRA_CONVERSATION_ID); + String title = getIntent().getStringExtra(EXTRA_TITLE); + if (conversationId == null) { + finish(); + return; + } + tokenStore = RetrofitClient.getTokenStore(this); + syncHelper = new SyncHelper(this); + setSupportActionBar(binding.toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setTitle(title != null ? title : "对话"); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + binding.toolbar.setNavigationOnClickListener(v -> finish()); + + adapter = new MessageListAdapter(messages); + binding.recycler.setLayoutManager(new LinearLayoutManager(this)); + binding.recycler.setAdapter(adapter); + + binding.btnSend.setOnClickListener(v -> sendMessage()); + loadFromDbThenApi(); + } + + @Override + protected void onResume() { + super.onResume(); + SocketManager.joinConversation(conversationId); + SocketManager.setNewMessageListener((convId, msgId, content, senderId, createdAt) -> { + if (!convId.equals(conversationId)) return; + runOnUiThread(() -> { + ApiService.Message m = new ApiService.Message(); + m.id = msgId; + m.conversation_id = convId; + m.sender_id = senderId; + m.content = content; + m.created_at = createdAt; + messages.add(m); + adapter.notifyItemInserted(messages.size() - 1); + scrollToBottom(); + }); + }); + } + + @Override + protected void onPause() { + super.onPause(); + SocketManager.leaveConversation(); + SocketManager.setNewMessageListener(null); + } + + private void applyMessagesToUi(List list) { + messages.clear(); + for (MessageEntity e : list) { + ApiService.Message m = new ApiService.Message(); + m.id = e.id; + m.conversation_id = e.conversationId; + m.sender_id = e.senderId; + m.content = e.content; + m.created_at = e.createdAt; + messages.add(m); + } + adapter.notifyDataSetChanged(); + scrollToBottom(); + } + + private void loadFromDbThenApi() { + new Thread(() -> { + List fromDb = syncHelper.getMessagesFromDb(conversationId); + runOnUiThread(() -> applyMessagesToUi(fromDb)); + }).start(); + RetrofitClient.getApi().getMessages(tokenStore.getBearerToken(), conversationId, null, 50) + .enqueue(new Callback() { + @Override + public void onResponse(Call call, + Response response) { + if (response.isSuccessful() && response.body() != null && response.body().items != null) { + syncHelper.saveMessages(conversationId, response.body().items); + new Thread(() -> { + List fromDb = syncHelper.getMessagesFromDb(conversationId); + runOnUiThread(() -> applyMessagesToUi(fromDb)); + }).start(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + Toast.makeText(ChatActivity.this, "加载消息失败: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + private void sendMessage() { + String content = binding.etInput.getText().toString().trim(); + if (content.isEmpty()) return; + binding.etInput.setText(""); + binding.btnSend.setEnabled(false); + + ApiService.SendMessageRequest req = new ApiService.SendMessageRequest(content); + RetrofitClient.getApi().sendMessage(tokenStore.getBearerToken(), conversationId, req) + .enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + binding.btnSend.setEnabled(true); + if (response.isSuccessful() && response.body() != null) { + ApiService.Message msg = response.body(); + messages.add(msg); + adapter.notifyItemInserted(messages.size() - 1); + scrollToBottom(); + MessageEntity e = new MessageEntity(); + e.id = msg.id; + e.conversationId = msg.conversation_id; + e.senderId = msg.sender_id; + e.content = msg.content; + e.contentType = msg.content_type != null ? msg.content_type : "text"; + e.createdAt = msg.created_at; + e.pending = false; + syncHelper.insertMessage(e); + } else { + Toast.makeText(ChatActivity.this, "发送失败: " + response.message(), Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + binding.btnSend.setEnabled(true); + Toast.makeText(ChatActivity.this, "发送失败: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + private void scrollToBottom() { + if (messages.isEmpty()) return; + binding.recycler.smoothScrollToPosition(messages.size() - 1); + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/MessageListAdapter.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/MessageListAdapter.java new file mode 100644 index 0000000..6e75013 --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/chat/MessageListAdapter.java @@ -0,0 +1,45 @@ +package com.saars.chatplatform.presentation.chat; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import com.saars.chatplatform.R; +import com.saars.chatplatform.data.remote.ApiService; +import java.util.List; + +public class MessageListAdapter extends RecyclerView.Adapter { + private final List items; + + public MessageListAdapter(List items) { + this.items = items; + } + + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_message, parent, false); + return new VH(v); + } + + @Override + public void onBindViewHolder(@NonNull VH holder, int position) { + ApiService.Message m = items.get(position); + holder.tvMessage.setText(m.content != null ? m.content : ""); + } + + @Override + public int getItemCount() { + return items.size(); + } + + static class VH extends RecyclerView.ViewHolder { + TextView tvMessage; + VH(View itemView) { + super(itemView); + tvMessage = itemView.findViewById(R.id.tvMessage); + } + } +} diff --git a/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/login/LoginActivity.java b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/login/LoginActivity.java new file mode 100644 index 0000000..ffe088e --- /dev/null +++ b/saars/android-app/app/src/main/java/com/saars/chatplatform/presentation/login/LoginActivity.java @@ -0,0 +1,98 @@ +package com.saars.chatplatform.presentation.login; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; + +import com.saars.chatplatform.data.local.TokenStore; +import com.saars.chatplatform.data.remote.ApiService; +import com.saars.chatplatform.data.remote.RetrofitClient; +import com.saars.chatplatform.databinding.ActivityLoginBinding; +import com.saars.chatplatform.presentation.MainActivity; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class LoginActivity extends AppCompatActivity { + + private ActivityLoginBinding binding; + private TokenStore tokenStore; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityLoginBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + tokenStore = RetrofitClient.getTokenStore(this); + + binding.btnLogin.setOnClickListener(v -> doLogin()); + binding.btnRegister.setOnClickListener(v -> doRegister()); + } + + private void doLogin() { + String email = binding.etEmail.getText().toString().trim(); + String password = binding.etPassword.getText().toString(); + if (email.isEmpty() || password.isEmpty()) { + Toast.makeText(this, "请输入邮箱和密码", Toast.LENGTH_SHORT).show(); + return; + } + binding.btnLogin.setEnabled(false); + ApiService.LoginRequest req = new ApiService.LoginRequest(email, password); + RetrofitClient.getApi().login(req).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + binding.btnLogin.setEnabled(true); + if (response.isSuccessful() && response.body() != null) { + tokenStore.setToken(response.body().access_token); + com.saars.chatplatform.data.remote.SocketManager.connect(response.body().access_token); + startActivity(new Intent(LoginActivity.this, MainActivity.class)); + finish(); + } else { + Toast.makeText(LoginActivity.this, "登录失败: " + (response.message()), Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + binding.btnLogin.setEnabled(true); + Toast.makeText(LoginActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + private void doRegister() { + String email = binding.etEmail.getText().toString().trim(); + String username = binding.etUsername.getText().toString().trim(); + String password = binding.etPassword.getText().toString(); + if (email.isEmpty() || username.isEmpty() || password.isEmpty()) { + Toast.makeText(this, "请输入邮箱、用户名和密码", Toast.LENGTH_SHORT).show(); + return; + } + binding.btnRegister.setEnabled(false); + ApiService.RegisterRequest req = new ApiService.RegisterRequest(email, username, password); + RetrofitClient.getApi().register(req).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + binding.btnRegister.setEnabled(true); + if (response.isSuccessful() && response.body() != null) { + tokenStore.setToken(response.body().access_token); + com.saars.chatplatform.data.remote.SocketManager.connect(response.body().access_token); + Toast.makeText(LoginActivity.this, "注册成功", Toast.LENGTH_SHORT).show(); + startActivity(new Intent(LoginActivity.this, MainActivity.class)); + finish(); + } else { + Toast.makeText(LoginActivity.this, "注册失败: " + response.message(), Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call call, Throwable t) { + binding.btnRegister.setEnabled(true); + Toast.makeText(LoginActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } +} diff --git a/saars/android-app/app/src/main/res/layout/activity_agent_chat.xml b/saars/android-app/app/src/main/res/layout/activity_agent_chat.xml new file mode 100644 index 0000000..e2d1484 --- /dev/null +++ b/saars/android-app/app/src/main/res/layout/activity_agent_chat.xml @@ -0,0 +1,39 @@ + + + + + + +