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 @@
+
+
+
+
+
+
+
+
+
diff --git a/saars/android-app/app/src/main/res/layout/activity_chat.xml b/saars/android-app/app/src/main/res/layout/activity_chat.xml
new file mode 100644
index 0000000..00f174c
--- /dev/null
+++ b/saars/android-app/app/src/main/res/layout/activity_chat.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/saars/android-app/app/src/main/res/layout/activity_login.xml b/saars/android-app/app/src/main/res/layout/activity_login.xml
new file mode 100644
index 0000000..923e7d6
--- /dev/null
+++ b/saars/android-app/app/src/main/res/layout/activity_login.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/saars/android-app/app/src/main/res/layout/activity_main.xml b/saars/android-app/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..059aad1
--- /dev/null
+++ b/saars/android-app/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/saars/android-app/app/src/main/res/layout/item_conversation.xml b/saars/android-app/app/src/main/res/layout/item_conversation.xml
new file mode 100644
index 0000000..3683af3
--- /dev/null
+++ b/saars/android-app/app/src/main/res/layout/item_conversation.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/saars/android-app/app/src/main/res/layout/item_message.xml b/saars/android-app/app/src/main/res/layout/item_message.xml
new file mode 100644
index 0000000..23e9067
--- /dev/null
+++ b/saars/android-app/app/src/main/res/layout/item_message.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/saars/android-app/app/src/main/res/values/colors.xml b/saars/android-app/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..7bf2d73
--- /dev/null
+++ b/saars/android-app/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #6750A4
+
diff --git a/saars/android-app/app/src/main/res/values/strings.xml b/saars/android-app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..1e555c4
--- /dev/null
+++ b/saars/android-app/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Chat Platform
+
diff --git a/saars/android-app/app/src/main/res/values/themes.xml b/saars/android-app/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..bc637ee
--- /dev/null
+++ b/saars/android-app/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/saars/android-app/build.gradle b/saars/android-app/build.gradle
new file mode 100644
index 0000000..3d55f50
--- /dev/null
+++ b/saars/android-app/build.gradle
@@ -0,0 +1,3 @@
+plugins {
+ id 'com.android.application' version '8.2.0' apply false
+}
diff --git a/saars/android-app/gradle.properties b/saars/android-app/gradle.properties
new file mode 100644
index 0000000..bae9034
--- /dev/null
+++ b/saars/android-app/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/saars/android-app/settings.gradle b/saars/android-app/settings.gradle
new file mode 100644
index 0000000..70cdde9
--- /dev/null
+++ b/saars/android-app/settings.gradle
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "ChatPlatform"
+include ":app"
diff --git a/saars/backend/.gitignore b/saars/backend/.gitignore
new file mode 100644
index 0000000..063db7d
--- /dev/null
+++ b/saars/backend/.gitignore
@@ -0,0 +1,7 @@
+__pycache__/
+*.py[cod]
+venv/
+.env
+*.db
+/tmp/
+uploads/
diff --git a/saars/backend/Dockerfile b/saars/backend/Dockerfile
new file mode 100644
index 0000000..df2e985
--- /dev/null
+++ b/saars/backend/Dockerfile
@@ -0,0 +1,17 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libpq-dev gcc \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+ENV FLASK_APP=run.py
+ENV PORT=8052
+EXPOSE 8052
+
+CMD ["gunicorn", "--worker-class", "eventlet", "-w", "1", "-b", "0.0.0.0:8052", "run:app"]
diff --git a/saars/backend/app/__init__.py b/saars/backend/app/__init__.py
new file mode 100644
index 0000000..bbb4106
--- /dev/null
+++ b/saars/backend/app/__init__.py
@@ -0,0 +1,50 @@
+"""
+Flask application factory for chat platform backend.
+"""
+import os
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from flask_jwt_extended import JWTManager
+from flask_cors import CORS
+from flask_socketio import SocketIO
+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
+from redis import Redis
+
+from app.config import config_by_name
+
+db = SQLAlchemy()
+migrate = Migrate()
+jwt = JWTManager()
+socketio = SocketIO(cors_allowed_origins="*", async_mode="eventlet")
+limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "50 per hour"])
+
+
+def create_app(config_name=None):
+ config_name = config_name or os.getenv("FLASK_ENV", "development")
+ app = Flask(__name__)
+ app.config.from_object(config_by_name[config_name])
+
+ CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
+ db.init_app(app)
+ migrate.init_app(app, db)
+ jwt.init_app(app)
+ limiter.init_app(app)
+ socketio.init_app(app, message_queue=app.config.get("CELERY_BROKER_URL") or None)
+
+ app.redis = Redis.from_url(app.config["REDIS_URL"]) if app.config.get("REDIS_URL") else None
+
+ from app.api import register_blueprints
+ register_blueprints(app)
+
+ import app.socket_events # noqa: F401 - register SocketIO handlers
+
+ from app.admin import init_admin
+ init_admin(app)
+
+ @app.route("/health")
+ def health():
+ return {"status": "healthy"}, 200
+
+ return app
diff --git a/saars/backend/app/admin/__init__.py b/saars/backend/app/admin/__init__.py
new file mode 100644
index 0000000..ebf3602
--- /dev/null
+++ b/saars/backend/app/admin/__init__.py
@@ -0,0 +1,37 @@
+"""Flask-Admin: RBAC, user management, chat monitoring."""
+import jwt
+from flask_admin import Admin
+from flask_admin.contrib.sqla import ModelView
+from flask import redirect, url_for, request, current_app
+from app import db
+from app.models.user import User, Role
+from app.models.chat import Conversation, Message
+
+
+class AdminModelView(ModelView):
+ def is_accessible(self):
+ auth = request.headers.get("Authorization") or request.args.get("token")
+ if not auth or not auth.startswith("Bearer "):
+ return False
+ try:
+ token = auth[7:]
+ payload = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"])
+ uid = payload.get("sub")
+ user = User.query.get(uid)
+ if not user or not user.role_id:
+ return False
+ role = Role.query.get(user.role_id)
+ return role and role.name == "admin"
+ except Exception:
+ return False
+
+ def inaccessible_callback(self, name, **kwargs):
+ return redirect(url_for("auth.login"))
+
+
+def init_admin(app):
+ admin = Admin(app, name="Chat Platform Admin", template_mode="bootstrap4")
+ admin.add_view(AdminModelView(User, db.session, name="Users", category="Auth"))
+ admin.add_view(AdminModelView(Role, db.session, name="Roles", category="Auth"))
+ admin.add_view(AdminModelView(Conversation, db.session, name="Conversations", category="Chat"))
+ admin.add_view(AdminModelView(Message, db.session, name="Messages", category="Chat"))
diff --git a/saars/backend/app/api/__init__.py b/saars/backend/app/api/__init__.py
new file mode 100644
index 0000000..3a0556f
--- /dev/null
+++ b/saars/backend/app/api/__init__.py
@@ -0,0 +1,16 @@
+"""
+Register all API blueprints.
+"""
+from app.api.auth import auth_bp
+from app.api.chat import chat_bp
+from app.api.files import files_bp
+from app.api.admin_api import admin_api_bp
+from app.api.agent_proxy import agent_bp
+
+
+def register_blueprints(app):
+ app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
+ app.register_blueprint(chat_bp, url_prefix="/api/v1/chat")
+ app.register_blueprint(files_bp, url_prefix="/api/v1/files")
+ app.register_blueprint(admin_api_bp, url_prefix="/api/v1/admin")
+ app.register_blueprint(agent_bp, url_prefix="/api/v1/agent")
diff --git a/saars/backend/app/api/admin_api.py b/saars/backend/app/api/admin_api.py
new file mode 100644
index 0000000..f907936
--- /dev/null
+++ b/saars/backend/app/api/admin_api.py
@@ -0,0 +1,84 @@
+"""
+Admin REST API: user management, chat audit, system stats (RBAC-protected).
+"""
+from flask import Blueprint, request, jsonify
+from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
+from app import db
+from app.models.user import User, Role
+from app.models.chat import Conversation, Message
+
+admin_api_bp = Blueprint("admin_api", __name__)
+
+
+def admin_required(fn):
+ """Require admin role (role name 'admin')."""
+ from functools import wraps
+ @wraps(fn)
+ def wrapper(*args, **kwargs):
+ identity = get_jwt_identity()
+ user = User.query.get(identity)
+ if not user or not user.role_id:
+ return jsonify({"error": "Forbidden"}), 403
+ role = Role.query.get(user.role_id)
+ if not role or role.name != "admin":
+ return jsonify({"error": "Admin required"}), 403
+ return fn(*args, **kwargs)
+ return wrapper
+
+
+@admin_api_bp.route("/users", methods=["GET"])
+@jwt_required()
+@admin_required
+def list_users():
+ skip = request.args.get("skip", 0, type=int)
+ limit = min(request.args.get("limit", 20, type=int), 100)
+ users = User.query.order_by(User.created_at.desc()).offset(skip).limit(limit).all()
+ return jsonify({"items": [u.to_dict() for u in users]})
+
+
+@admin_api_bp.route("/users/", methods=["PATCH"])
+@jwt_required()
+@admin_required
+def update_user(user_id):
+ user = User.query.get(user_id)
+ if not user:
+ return jsonify({"error": "User not found"}), 404
+ data = request.get_json() or {}
+ if "is_active" in data:
+ user.is_active = bool(data["is_active"])
+ db.session.commit()
+ return jsonify(user.to_dict())
+
+
+@admin_api_bp.route("/conversations", methods=["GET"])
+@jwt_required()
+@admin_required
+def list_all_conversations():
+ skip = request.args.get("skip", 0, type=int)
+ limit = min(request.args.get("limit", 20, type=int), 100)
+ convs = Conversation.query.order_by(Conversation.last_message_at.desc()).offset(skip).limit(limit).all()
+ return jsonify({
+ "items": [
+ {
+ "id": c.id,
+ "user_id": c.user_id,
+ "title": c.title,
+ "last_message_at": c.last_message_at.isoformat() if c.last_message_at else None,
+ }
+ for c in convs
+ ]
+ })
+
+
+@admin_api_bp.route("/stats", methods=["GET"])
+@jwt_required()
+@admin_required
+def system_stats():
+ user_count = User.query.count()
+ conv_count = Conversation.query.count()
+ msg_count = Message.query.count()
+ return jsonify({
+ "users": user_count,
+ "conversations": conv_count,
+ "messages": msg_count,
+ })
diff --git a/saars/backend/app/api/agent_proxy.py b/saars/backend/app/api/agent_proxy.py
new file mode 100644
index 0000000..d970712
--- /dev/null
+++ b/saars/backend/app/api/agent_proxy.py
@@ -0,0 +1,34 @@
+"""
+知你客服 Agent 代理 API(方式一:App 后端代理)
+"""
+from flask import Blueprint, request, jsonify
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from app.services.agent_proxy import chat_with_agent
+from flask import current_app
+
+agent_bp = Blueprint("agent", __name__)
+
+
+@agent_bp.route("/chat", methods=["POST"])
+@jwt_required()
+def agent_chat():
+ """
+ 请求体: { "message": "用户输入", "user_id": "可选,App 侧用户唯一 ID,用于多轮记忆" }
+ 响应: { "reply": "知你客服回复文本" }
+ """
+ data = request.get_json() or {}
+ message = (data.get("message") or data.get("query") or "").strip()
+ if not message:
+ return jsonify({"error": "message 不能为空"}), 400
+ user_id = data.get("user_id") or get_jwt_identity()
+ base_url = current_app.config.get("PLATFORM_BASE_URL", "")
+ username = current_app.config.get("PLATFORM_USERNAME", "")
+ password = current_app.config.get("PLATFORM_PASSWORD", "")
+ agent_id = current_app.config.get("PLATFORM_AGENT_ID", "")
+ if not all([base_url, username, password, agent_id]):
+ return jsonify({"error": "未配置知你客服代理(PLATFORM_BASE_URL/USERNAME/PASSWORD/AGENT_ID)"}), 503
+ try:
+ reply = chat_with_agent(base_url, username, password, agent_id, message, user_id)
+ return jsonify({"reply": reply or ""})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 502
diff --git a/saars/backend/app/api/auth.py b/saars/backend/app/api/auth.py
new file mode 100644
index 0000000..4b45645
--- /dev/null
+++ b/saars/backend/app/api/auth.py
@@ -0,0 +1,85 @@
+"""
+User authentication API: login, register, JWT refresh, OAuth 2.0 placeholder.
+"""
+from flask import Blueprint, request, jsonify
+from flask_jwt_extended import (
+ create_access_token,
+ create_refresh_token,
+ jwt_required,
+ get_jwt_identity,
+ get_jwt,
+)
+from app import db
+from app.models.user import User, Role
+from app.utils.auth import hash_password, check_password
+
+auth_bp = Blueprint("auth", __name__)
+
+
+@auth_bp.route("/register", methods=["POST"])
+def register():
+ data = request.get_json() or {}
+ email = (data.get("email") or "").strip()
+ username = (data.get("username") or "").strip()
+ password = data.get("password")
+ if not email or not username or not password:
+ return jsonify({"error": "email, username and password are required"}), 400
+ if User.query.filter_by(email=email).first():
+ return jsonify({"error": "Email already registered"}), 409
+ if User.query.filter_by(username=username).first():
+ return jsonify({"error": "Username already taken"}), 409
+ user = User(
+ email=email,
+ username=username,
+ password_hash=hash_password(password),
+ )
+ db.session.add(user)
+ db.session.commit()
+ access = create_access_token(identity=user.id)
+ refresh = create_refresh_token(identity=user.id)
+ return jsonify({
+ "user": user.to_dict(),
+ "access_token": access,
+ "refresh_token": refresh,
+ "token_type": "bearer",
+ }), 201
+
+
+@auth_bp.route("/login", methods=["POST"])
+def login():
+ data = request.get_json() or {}
+ email = (data.get("email") or "").strip()
+ password = data.get("password")
+ if not email or not password:
+ return jsonify({"error": "email and password are required"}), 400
+ user = User.query.filter_by(email=email).first()
+ if not user or not check_password(password, user.password_hash):
+ return jsonify({"error": "Invalid credentials"}), 401
+ if not user.is_active:
+ return jsonify({"error": "Account disabled"}), 403
+ access = create_access_token(identity=user.id)
+ refresh = create_refresh_token(identity=user.id)
+ return jsonify({
+ "user": user.to_dict(),
+ "access_token": access,
+ "refresh_token": refresh,
+ "token_type": "bearer",
+ })
+
+
+@auth_bp.route("/refresh", methods=["POST"])
+@jwt_required(refresh=True)
+def refresh():
+ identity = get_jwt_identity()
+ access = create_access_token(identity=identity)
+ return jsonify({"access_token": access, "token_type": "bearer"})
+
+
+@auth_bp.route("/me", methods=["GET"])
+@jwt_required()
+def me():
+ uid = get_jwt_identity()
+ user = User.query.get(uid)
+ if not user:
+ return jsonify({"error": "User not found"}), 404
+ return jsonify(user.to_dict())
diff --git a/saars/backend/app/api/chat.py b/saars/backend/app/api/chat.py
new file mode 100644
index 0000000..dea89c9
--- /dev/null
+++ b/saars/backend/app/api/chat.py
@@ -0,0 +1,87 @@
+"""
+Chat REST API: conversations, messages, recall.
+"""
+from flask import Blueprint, request, jsonify
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from app.services.chat_engine import ChatEngineService
+from app.socket_events import broadcast_new_message
+
+chat_bp = Blueprint("chat", __name__)
+
+
+@chat_bp.route("/conversations", methods=["GET"])
+@jwt_required()
+def list_conversations():
+ skip = request.args.get("skip", 0, type=int)
+ limit = min(request.args.get("limit", 20, type=int), 100)
+ user_id = get_jwt_identity()
+ convs = ChatEngineService.list_conversations(user_id, skip=skip, limit=limit)
+ return jsonify({
+ "items": [
+ {
+ "id": c.id,
+ "title": c.title,
+ "last_message_at": c.last_message_at.isoformat() if c.last_message_at else None,
+ "created_at": c.created_at.isoformat() if c.created_at else None,
+ }
+ for c in convs
+ ]
+ })
+
+
+@chat_bp.route("/conversations", methods=["POST"])
+@jwt_required()
+def create_conversation():
+ data = request.get_json() or {}
+ title = (data.get("title") or "新对话").strip() or "新对话"
+ user_id = get_jwt_identity()
+ conv = ChatEngineService.get_or_create_conversation(user_id, title=title)
+ return jsonify({
+ "id": conv.id,
+ "title": conv.title,
+ "last_message_at": conv.last_message_at.isoformat() if conv.last_message_at else None,
+ "created_at": conv.created_at.isoformat() if conv.created_at else None,
+ }), 201
+
+
+@chat_bp.route("/conversations//messages", methods=["GET"])
+@jwt_required()
+def get_messages(conversation_id):
+ before_id = request.args.get("before_id")
+ limit = min(request.args.get("limit", 50, type=int), 100)
+ user_id = get_jwt_identity()
+ messages = ChatEngineService.get_messages(conversation_id, user_id, before_id=before_id, limit=limit)
+ return jsonify({"items": [m.to_dict() for m in reversed(messages)]})
+
+
+@chat_bp.route("/conversations//messages", methods=["POST"])
+@jwt_required()
+def send_message(conversation_id):
+ data = request.get_json() or {}
+ content = (data.get("content") or "").strip()
+ content_type = data.get("content_type") or "text"
+ attachment_url = data.get("attachment_url")
+ attachment_name = data.get("attachment_name")
+ if not content and not attachment_url:
+ return jsonify({"error": "content or attachment required"}), 400
+ user_id = get_jwt_identity()
+ msg = ChatEngineService.send_message(
+ conversation_id, user_id, content or "(附件)",
+ content_type=content_type,
+ attachment_url=attachment_url,
+ attachment_name=attachment_name,
+ )
+ if not msg:
+ return jsonify({"error": "Conversation not found or access denied"}), 404
+ broadcast_new_message(msg)
+ return jsonify(msg.to_dict()), 201
+
+
+@chat_bp.route("/messages//recall", methods=["POST"])
+@jwt_required()
+def recall_message(message_id):
+ user_id = get_jwt_identity()
+ ok = ChatEngineService.recall_message(message_id, user_id)
+ if not ok:
+ return jsonify({"error": "Message not found or recall not allowed"}), 400
+ return jsonify({"status": "recalled"})
diff --git a/saars/backend/app/api/files.py b/saars/backend/app/api/files.py
new file mode 100644
index 0000000..dd8b9ea
--- /dev/null
+++ b/saars/backend/app/api/files.py
@@ -0,0 +1,25 @@
+"""
+File upload API: images and documents, max 10MB.
+"""
+import os
+from flask import Blueprint, request, jsonify, current_app
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from app.services.file_service import FileService
+
+files_bp = Blueprint("files", __name__)
+
+
+@files_bp.route("/upload", methods=["POST"])
+@jwt_required()
+def upload():
+ if "file" not in request.files:
+ return jsonify({"error": "No file part"}), 400
+ file = request.files["file"]
+ subdir = request.form.get("subdir", "uploads")
+ result = FileService.save_upload(file, subdir=subdir)
+ if not result:
+ return jsonify({"error": "Invalid file or exceeds 10MB"}), 400
+ relative_path, _ = result
+ base_url = current_app.config.get("FILE_BASE_URL", "").rstrip("/")
+ url = f"{base_url}/{relative_path}" if base_url else f"/files/{relative_path}"
+ return jsonify({"url": url, "path": relative_path}), 201
diff --git a/saars/backend/app/config.py b/saars/backend/app/config.py
new file mode 100644
index 0000000..8677449
--- /dev/null
+++ b/saars/backend/app/config.py
@@ -0,0 +1,60 @@
+"""
+Application configuration by environment.
+数据库:腾讯云 MySQL (liaotian_db)
+"""
+import os
+from datetime import timedelta
+
+# 腾讯云 MySQL - 默认开发/生产库
+DEFAULT_DATABASE_URL = (
+ "mysql+pymysql://root:!Rjb12191@"
+ "gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/"
+ "liaotian_db?charset=utf8mb4"
+)
+
+
+class Config:
+ """Base config."""
+ SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-in-production")
+ SQLALCHEMY_TRACK_MODIFICATIONS = False
+ # 知你客服 Agent 代理(方式一):低代码平台地址与对接账号(用户名 amind,密码 123456)
+ PLATFORM_BASE_URL = os.getenv("PLATFORM_BASE_URL", "http://101.43.95.130:8037")
+ PLATFORM_USERNAME = os.getenv("PLATFORM_USERNAME", "amind")
+ PLATFORM_PASSWORD = os.getenv("PLATFORM_PASSWORD", "123456")
+ PLATFORM_AGENT_ID = os.getenv("PLATFORM_AGENT_ID", "7332bba7-f9e7-4e10-9af6-7a0509a3ef97")
+ JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
+ JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)
+ JWT_TOKEN_LOCATION = ["headers"]
+ MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB
+ UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "/tmp/chat_uploads")
+
+
+class DevelopmentConfig(Config):
+ DEBUG = True
+ SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
+ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
+ CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/1")
+
+
+class TestingConfig(Config):
+ TESTING = True
+ SQLALCHEMY_DATABASE_URI = os.getenv(
+ "TEST_DATABASE_URL",
+ "mysql+pymysql://root:postgres@localhost:3306/liaotian_db_test?charset=utf8mb4"
+ )
+ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/2")
+ WTF_CSRF_ENABLED = False
+
+
+class ProductionConfig(Config):
+ DEBUG = False
+ SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
+ REDIS_URL = os.getenv("REDIS_URL")
+ CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL") or os.getenv("REDIS_URL")
+
+
+config_by_name = {
+ "development": DevelopmentConfig,
+ "testing": TestingConfig,
+ "production": ProductionConfig,
+}
diff --git a/saars/backend/app/models/__init__.py b/saars/backend/app/models/__init__.py
new file mode 100644
index 0000000..9483a0b
--- /dev/null
+++ b/saars/backend/app/models/__init__.py
@@ -0,0 +1,4 @@
+from app.models.user import User, Role
+from app.models.chat import Conversation, Message, MessageStatus
+
+__all__ = ["User", "Role", "Conversation", "Message", "MessageStatus"]
diff --git a/saars/backend/app/models/chat.py b/saars/backend/app/models/chat.py
new file mode 100644
index 0000000..5368bf8
--- /dev/null
+++ b/saars/backend/app/models/chat.py
@@ -0,0 +1,55 @@
+"""
+Chat models: conversations and messages with status tracking.
+"""
+import uuid
+from datetime import datetime
+from app import db
+
+
+class Conversation(db.Model):
+ __tablename__ = "conversations"
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True)
+ title = db.Column(db.String(256), default="新对话")
+ last_message_at = db.Column(db.DateTime, default=datetime.utcnow)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ messages = db.relationship("Message", backref="conversation", lazy="dynamic", order_by="Message.created_at")
+
+
+class MessageStatus:
+ SENT = "sent"
+ DELIVERED = "delivered"
+ READ = "read"
+ RECALLED = "recalled"
+ FAILED = "failed"
+
+
+class Message(db.Model):
+ __tablename__ = "messages"
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ conversation_id = db.Column(db.String(36), db.ForeignKey("conversations.id"), nullable=False, index=True)
+ sender_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True)
+ content = db.Column(db.Text, nullable=False)
+ content_type = db.Column(db.String(32), default="text") # text, image, file
+ attachment_url = db.Column(db.String(512))
+ attachment_name = db.Column(db.String(256))
+ status = db.Column(db.String(32), default=MessageStatus.SENT)
+ is_recalled = db.Column(db.Boolean, default=False)
+ recalled_at = db.Column(db.DateTime)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "conversation_id": self.conversation_id,
+ "sender_id": self.sender_id,
+ "content": self.content,
+ "content_type": self.content_type,
+ "attachment_url": self.attachment_url,
+ "attachment_name": self.attachment_name,
+ "status": self.status,
+ "is_recalled": self.is_recalled,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ }
diff --git a/saars/backend/app/models/user.py b/saars/backend/app/models/user.py
new file mode 100644
index 0000000..6600935
--- /dev/null
+++ b/saars/backend/app/models/user.py
@@ -0,0 +1,46 @@
+"""
+User and Role models for authentication and RBAC.
+"""
+import uuid
+from datetime import datetime
+from app import db
+
+
+class Role(db.Model):
+ __tablename__ = "roles"
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ name = db.Column(db.String(64), unique=True, nullable=False)
+ description = db.Column(db.String(256))
+ permissions = db.Column(db.JSON, default=list) # list of permission strings
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+ users = db.relationship("User", backref="role", lazy="dynamic")
+
+
+class User(db.Model):
+ __tablename__ = "users"
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ email = db.Column(db.String(255), unique=True, nullable=False, index=True)
+ username = db.Column(db.String(64), unique=True, nullable=False, index=True)
+ password_hash = db.Column(db.String(256), nullable=False)
+ display_name = db.Column(db.String(128))
+ avatar_url = db.Column(db.String(512))
+ role_id = db.Column(db.String(36), db.ForeignKey("roles.id"), nullable=True)
+ is_active = db.Column(db.Boolean, default=True)
+ is_verified = db.Column(db.Boolean, default=False)
+ last_login_at = db.Column(db.DateTime)
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ conversations = db.relationship("Conversation", backref="user", lazy="dynamic", foreign_keys="Conversation.user_id")
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "email": self.email,
+ "username": self.username,
+ "display_name": self.display_name or self.username,
+ "avatar_url": self.avatar_url,
+ "is_active": self.is_active,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ }
diff --git a/saars/backend/app/services/__init__.py b/saars/backend/app/services/__init__.py
new file mode 100644
index 0000000..55e4cbf
--- /dev/null
+++ b/saars/backend/app/services/__init__.py
@@ -0,0 +1,4 @@
+from app.services.chat_engine import ChatEngineService
+from app.services.file_service import FileService
+
+__all__ = ["ChatEngineService", "FileService"]
diff --git a/saars/backend/app/services/agent_proxy.py b/saars/backend/app/services/agent_proxy.py
new file mode 100644
index 0000000..41e0195
--- /dev/null
+++ b/saars/backend/app/services/agent_proxy.py
@@ -0,0 +1,112 @@
+"""
+知你客服 Agent 代理(方式一:App 后端代理)
+调用低代码平台执行 API:登录拿 Token -> 创建执行 -> 轮询状态 -> 取 output_data 作为回复。
+"""
+import time
+import logging
+import requests
+
+logger = logging.getLogger(__name__)
+
+# 内存缓存:platform_token, token_expires_at (简单实现,生产可用 Redis)
+_platform_token = None
+_token_expires_at = 0
+TOKEN_BUFFER_SECONDS = 300 # 提前 5 分钟视为过期
+
+
+def _get_platform_token(base_url, username, password):
+ """登录平台获取 access_token,带简单内存缓存。"""
+ global _platform_token, _token_expires_at
+ if _platform_token and time.time() < _token_expires_at - TOKEN_BUFFER_SECONDS:
+ return _platform_token
+ url = f"{base_url.rstrip('/')}/api/v1/auth/login"
+ try:
+ r = requests.post(
+ url,
+ data={"username": username, "password": password},
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ timeout=10,
+ )
+ r.raise_for_status()
+ data = r.json()
+ token = data.get("access_token")
+ if not token:
+ raise ValueError("平台登录响应无 access_token")
+ _platform_token = token
+ _token_expires_at = time.time() + 3600 # 假设 1 小时有效
+ return token
+ except Exception as e:
+ logger.exception("平台登录失败: %s", e)
+ raise
+
+
+def _create_execution(base_url, token, agent_id, input_data):
+ """POST /api/v1/executions"""
+ url = f"{base_url.rstrip('/')}/api/v1/executions"
+ r = requests.post(
+ url,
+ json={"agent_id": agent_id, "input_data": input_data},
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
+ timeout=15,
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+def _get_execution_status(base_url, token, execution_id):
+ """GET /api/v1/executions/{id}/status"""
+ url = f"{base_url.rstrip('/')}/api/v1/executions/{execution_id}/status"
+ r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=10)
+ r.raise_for_status()
+ return r.json()
+
+
+def _get_execution(base_url, token, execution_id):
+ """GET /api/v1/executions/{id}"""
+ url = f"{base_url.rstrip('/')}/api/v1/executions/{execution_id}"
+ r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=10)
+ r.raise_for_status()
+ return r.json()
+
+
+def _extract_reply(output_data):
+ """从 output_data 中提取回复文本(知你客服 End 节点输出结构以实际为准)。"""
+ if output_data is None:
+ return ""
+ if isinstance(output_data, str):
+ return output_data
+ for key in ("reply", "content", "output", "text", "message", "result"):
+ if key in output_data and output_data[key]:
+ v = output_data[key]
+ return v if isinstance(v, str) else str(v)
+ if isinstance(output_data, dict):
+ for v in output_data.values():
+ if isinstance(v, str) and v.strip():
+ return v
+ return str(output_data)
+
+
+def chat_with_agent(base_url, username, password, agent_id, message, user_id, poll_interval=0.8, poll_timeout=60):
+ """
+ 与知你客服对话:创建执行 -> 轮询直到 completed/failed -> 返回回复文本。
+ """
+ token = _get_platform_token(base_url, username, password)
+ input_data = {"query": message, "user_id": user_id or "default"}
+ exec_body = _create_execution(base_url, token, agent_id, input_data)
+ execution_id = exec_body.get("id")
+ if not execution_id:
+ raise ValueError("创建执行未返回 id")
+ status = exec_body.get("status", "pending")
+ deadline = time.time() + poll_timeout
+ status_body = None
+ while status in ("pending", "running") and time.time() < deadline:
+ time.sleep(poll_interval)
+ status_body = _get_execution_status(base_url, token, execution_id)
+ status = status_body.get("status", status)
+ if status != "completed":
+ detail = _get_execution(base_url, token, execution_id)
+ err = detail.get("error_message") or (status_body or {}).get("error_message") or "执行未完成或失败"
+ raise ValueError(err)
+ detail = _get_execution(base_url, token, execution_id)
+ output_data = detail.get("output_data")
+ return _extract_reply(output_data)
diff --git a/saars/backend/app/services/chat_engine.py b/saars/backend/app/services/chat_engine.py
new file mode 100644
index 0000000..d813362
--- /dev/null
+++ b/saars/backend/app/services/chat_engine.py
@@ -0,0 +1,78 @@
+"""Chat engine: message persistence, status tracking, recall."""
+from datetime import datetime
+from app import db
+from app.models.chat import Conversation, Message, MessageStatus
+
+
+class ChatEngineService:
+ @staticmethod
+ def get_or_create_conversation(user_id, title="新对话"):
+ conv = Conversation.query.filter_by(user_id=user_id).order_by(
+ Conversation.last_message_at.desc()
+ ).first()
+ if conv:
+ return conv
+ conv = Conversation(user_id=user_id, title=title)
+ db.session.add(conv)
+ db.session.commit()
+ return conv
+
+ @staticmethod
+ def list_conversations(user_id, skip=0, limit=20):
+ return Conversation.query.filter_by(user_id=user_id).order_by(
+ Conversation.last_message_at.desc()
+ ).offset(skip).limit(limit).all()
+
+ @staticmethod
+ def send_message(conversation_id, sender_id, content, content_type="text",
+ attachment_url=None, attachment_name=None):
+ conv = Conversation.query.get(conversation_id)
+ if not conv or str(conv.user_id) != str(sender_id):
+ return None
+ msg = Message(
+ conversation_id=conversation_id,
+ sender_id=sender_id,
+ content=content,
+ content_type=content_type or "text",
+ attachment_url=attachment_url,
+ attachment_name=attachment_name,
+ status=MessageStatus.SENT,
+ )
+ db.session.add(msg)
+ conv.last_message_at = datetime.utcnow()
+ db.session.commit()
+ return msg
+
+ @staticmethod
+ def get_messages(conversation_id, user_id, before_id=None, limit=50):
+ conv = Conversation.query.get(conversation_id)
+ if not conv or str(conv.user_id) != str(user_id):
+ return []
+ q = Message.query.filter_by(conversation_id=conversation_id).filter(
+ Message.is_recalled == False
+ )
+ if before_id:
+ before = Message.query.get(before_id)
+ if before:
+ q = q.filter(Message.created_at < before.created_at)
+ return q.order_by(Message.created_at.desc()).limit(limit).all()
+
+ @staticmethod
+ def recall_message(message_id, user_id):
+ msg = Message.query.get(message_id)
+ if not msg or str(msg.sender_id) != str(user_id):
+ return False
+ msg.is_recalled = True
+ msg.recalled_at = datetime.utcnow()
+ msg.status = MessageStatus.RECALLED
+ db.session.commit()
+ return True
+
+ @staticmethod
+ def update_message_status(message_id, status):
+ msg = Message.query.get(message_id)
+ if not msg:
+ return False
+ msg.status = status
+ db.session.commit()
+ return True
diff --git a/saars/backend/app/services/file_service.py b/saars/backend/app/services/file_service.py
new file mode 100644
index 0000000..659a552
--- /dev/null
+++ b/saars/backend/app/services/file_service.py
@@ -0,0 +1,32 @@
+"""File upload: images and documents, max 10MB."""
+import os
+import uuid
+from flask import current_app
+from werkzeug.utils import secure_filename
+
+ALLOWED_IMAGE_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
+ALLOWED_DOC_EXT = {"pdf", "doc", "docx", "txt", "md"}
+MAX_SIZE = 10 * 1024 * 1024
+
+
+class FileService:
+ @staticmethod
+ def save_upload(file, subdir="uploads"):
+ if not file or not file.filename:
+ return None
+ filename = secure_filename(file.filename)
+ if not filename:
+ filename = str(uuid.uuid4())
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
+ if ext not in ALLOWED_IMAGE_EXT and ext not in ALLOWED_DOC_EXT:
+ return None
+ upload_root = current_app.config.get("UPLOAD_FOLDER", "/tmp/chat_uploads")
+ dest_dir = os.path.join(upload_root, subdir)
+ os.makedirs(dest_dir, exist_ok=True)
+ unique = "{}_{}".format(uuid.uuid4().hex, filename)
+ path = os.path.join(dest_dir, unique)
+ file.save(path)
+ if os.path.getsize(path) > MAX_SIZE:
+ os.remove(path)
+ return None
+ return (os.path.join(subdir, unique), path)
diff --git a/saars/backend/app/socket_events.py b/saars/backend/app/socket_events.py
new file mode 100644
index 0000000..b57cda7
--- /dev/null
+++ b/saars/backend/app/socket_events.py
@@ -0,0 +1,63 @@
+"""
+WebSocket events via Flask-SocketIO: real-time message delivery.
+"""
+from flask_socketio import emit, join_room, leave_room
+from flask import request
+from app import socketio
+from app.services.chat_engine import ChatEngineService
+from app.models.chat import Message
+import jwt
+from app.config import config_by_name
+import os
+
+
+def get_user_id_from_token():
+ token = request.args.get("token") or request.headers.get("Authorization")
+ if token and token.startswith("Bearer "):
+ token = token[7:]
+ if not token:
+ return None
+ try:
+ cfg = config_by_name.get(os.getenv("FLASK_ENV", "development"))
+ payload = jwt.decode(token, cfg.SECRET_KEY, algorithms=["HS256"])
+ return payload.get("sub")
+ except Exception:
+ return None
+
+
+@socketio.on("connect")
+def on_connect():
+ user_id = get_user_id_from_token()
+ if user_id:
+ join_room("user:{}".format(user_id))
+ emit("connected", {"user_id": user_id})
+ else:
+ emit("error", {"message": "Authentication required"}, room=request.sid)
+
+
+@socketio.on("disconnect")
+def on_disconnect():
+ pass
+
+
+@socketio.on("join_conversation")
+def on_join_conversation(data):
+ cid = (data or {}).get("conversation_id")
+ user_id = get_user_id_from_token()
+ if not user_id or not cid:
+ return
+ join_room("conv:{}".format(cid))
+
+
+@socketio.on("leave_conversation")
+def on_leave_conversation(data):
+ cid = (data or {}).get("conversation_id")
+ if cid:
+ leave_room("conv:{}".format(cid))
+
+
+def broadcast_new_message(msg):
+ if not isinstance(msg, Message):
+ return
+ room = "conv:{}".format(msg.conversation_id)
+ socketio.emit("new_message", msg.to_dict(), room=room)
diff --git a/saars/backend/app/utils/__init__.py b/saars/backend/app/utils/__init__.py
new file mode 100644
index 0000000..5ccdb02
--- /dev/null
+++ b/saars/backend/app/utils/__init__.py
@@ -0,0 +1,3 @@
+from app.utils.auth import hash_password, check_password
+
+__all__ = ["hash_password", "check_password"]
diff --git a/saars/backend/app/utils/auth.py b/saars/backend/app/utils/auth.py
new file mode 100644
index 0000000..3ff28c8
--- /dev/null
+++ b/saars/backend/app/utils/auth.py
@@ -0,0 +1,12 @@
+"""
+Password hashing and validation utilities.
+"""
+import bcrypt
+
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def check_password(password: str, password_hash: str) -> bool:
+ return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
diff --git a/saars/backend/migrations/env.py b/saars/backend/migrations/env.py
new file mode 100644
index 0000000..f7b155c
--- /dev/null
+++ b/saars/backend/migrations/env.py
@@ -0,0 +1 @@
+# Flask-Migrate will populate this when you run: flask db init
diff --git a/saars/backend/requirements.txt b/saars/backend/requirements.txt
new file mode 100644
index 0000000..595ab78
--- /dev/null
+++ b/saars/backend/requirements.txt
@@ -0,0 +1,42 @@
+# Flask core
+Flask>=2.3.0,<3.0
+Flask-SocketIO>=5.3.0
+Flask-SQLAlchemy>=3.0.0
+Flask-Migrate>=4.0.0
+Flask-JWT-Extended>=4.5.0
+Flask-Admin>=1.6.0
+Flask-CORS>=4.0.0
+Flask-Limiter>=3.5.0
+
+# Database - MySQL (腾讯云)
+SQLAlchemy>=2.0.0
+PyMySQL>=1.1.0
+cryptography>=41.0.0
+alembic>=1.12.0
+
+# Redis
+redis>=5.0.0
+celery>=5.3.0
+
+# Auth & Security
+PyJWT>=2.8.0
+python-dotenv>=1.0.0
+bcrypt>=4.1.0
+
+# WebSocket
+python-socketio>=5.10.0
+python-engineio>=4.8.0
+eventlet>=0.33.0
+
+# File upload & validation
+Pillow>=10.0.0
+python-magic>=0.4.27
+Werkzeug>=3.0.0
+
+# Utils
+gunicorn>=21.0.0
+requests>=2.31.0
+
+# Test
+pytest>=7.4.0
+pytest-cov>=4.1.0
diff --git a/saars/backend/run.py b/saars/backend/run.py
new file mode 100644
index 0000000..a28babc
--- /dev/null
+++ b/saars/backend/run.py
@@ -0,0 +1,11 @@
+"""
+Entry point: run Flask app with SocketIO.
+"""
+import os
+from app import create_app, socketio
+
+app = create_app(os.getenv("FLASK_ENV", "development"))
+
+if __name__ == "__main__":
+ port = int(os.getenv("PORT", 8052))
+ socketio.run(app, host="0.0.0.0", port=port, debug=app.debug)
diff --git a/saars/backend/tests/__init__.py b/saars/backend/tests/__init__.py
new file mode 100644
index 0000000..d4839a6
--- /dev/null
+++ b/saars/backend/tests/__init__.py
@@ -0,0 +1 @@
+# Tests package
diff --git a/saars/backend/tests/test_auth.py b/saars/backend/tests/test_auth.py
new file mode 100644
index 0000000..85f686b
--- /dev/null
+++ b/saars/backend/tests/test_auth.py
@@ -0,0 +1,38 @@
+"""Auth API tests."""
+import pytest
+from app import create_app, db
+from app.models.user import User
+
+
+@pytest.fixture
+def client():
+ app = create_app("testing")
+ with app.test_client() as c:
+ with app.app_context():
+ db.create_all()
+ yield c
+ db.drop_all()
+
+
+def test_register(client):
+ r = client.post(
+ "/api/v1/auth/register",
+ json={"email": "test@example.com", "username": "testuser", "password": "secret123"},
+ )
+ assert r.status_code == 201
+ data = r.get_json()
+ assert "access_token" in data
+ assert data["user"]["email"] == "test@example.com"
+
+
+def test_login(client):
+ client.post(
+ "/api/v1/auth/register",
+ json={"email": "login@example.com", "username": "loginuser", "password": "pass123"},
+ )
+ r = client.post(
+ "/api/v1/auth/login",
+ json={"email": "login@example.com", "password": "pass123"},
+ )
+ assert r.status_code == 200
+ assert "access_token" in r.get_json()
diff --git a/saars/docker-compose.yml b/saars/docker-compose.yml
new file mode 100644
index 0000000..b7fef03
--- /dev/null
+++ b/saars/docker-compose.yml
@@ -0,0 +1,23 @@
+# 数据库使用腾讯云 MySQL,见 DATABASE_URL
+# 本地仅启动 backend + redis
+services:
+ backend:
+ build: ./backend
+ ports:
+ - "8052:8052"
+ environment:
+ FLASK_ENV: development
+ PORT: "8052"
+ DATABASE_URL: mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/liaotian_db?charset=utf8mb4
+ REDIS_URL: redis://redis:6379/0
+ # 知你客服 Agent 代理(方式一):平台账号 amind/123456;Agent ID 需在平台 Agent 管理中点「知你客服」复制
+ PLATFORM_BASE_URL: http://101.43.95.130:8037
+ PLATFORM_USERNAME: amind
+ PLATFORM_PASSWORD: "123456"
+ PLATFORM_AGENT_ID: "7332bba7-f9e7-4e10-9af6-7a0509a3ef97"
+ depends_on:
+ - redis
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
diff --git a/知你客服Agent智能聊天App接入方案.md b/知你客服Agent智能聊天App接入方案.md
new file mode 100644
index 0000000..2958526
--- /dev/null
+++ b/知你客服Agent智能聊天App接入方案.md
@@ -0,0 +1,160 @@
+# 知你客服 Agent 智能聊天 App 接入方案
+
+## 一、结论
+
+**可以接入。**「知你客服」是平台上的已发布聊天智能体,支持记忆管理、意图识别与多轮对话,通过平台提供的 **执行 API** 即可在智能聊天 App 中调用,由 App 后端或前端携带平台用户 Token 调用即可。
+
+---
+
+## 二、接入方式概览
+
+| 方式 | 适用场景 | 说明 |
+|------|----------|------|
+| **方式一:App 后端代理** | 推荐,移动端/多端 App | App 后端用平台账号拿 Token,代用户调执行 API,再把结果返回给 App |
+| **方式二:前端直连** | Web 版聊天、用户已在平台登录 | 前端拿到平台 Token 后直接调执行 API(同域名或配置 CORS) |
+| **方式三:内嵌平台对话页** | 快速上线 | 用 iframe 或 H5 打开平台「使用」页的对话界面,无需对接 API |
+
+---
+
+## 三、方式一:App 后端代理(推荐)
+
+### 3.1 流程
+
+1. 在平台为「对接用」创建一个账号(如 `app-service@yourcompany.com`),并登录拿到 **access_token**。
+2. 在平台 **Agent 管理** 中打开「知你客服」,复制其 **Agent ID**(或通过「获取 Agent 列表」接口查到)。
+3. App 用户发消息时,App 后端:
+ - 用上述 token 调「创建执行」接口,传入 `agent_id` 和 `input_data`(见下)。
+ - 轮询「执行状态」或「执行详情」,直到 `status` 为 `completed`。
+ - 从 `output_data` 中取出回复内容返回给 App 用户。
+
+### 3.2 接口说明(与知你客服约定)
+
+- **Base URL**:`http(s)://你的平台域名:8037`(如 `http://101.43.95.130:8037`)
+
+**1)登录拿 Token**
+
+```http
+POST /api/v1/auth/login
+Content-Type: application/x-www-form-urlencoded
+
+username=app-service&password=xxx
+```
+
+响应示例:
+
+```json
+{ "access_token": "eyJ...", "token_type": "bearer" }
+```
+
+**2)创建执行(发用户消息给知你客服)**
+
+```http
+POST /api/v1/executions
+Authorization: Bearer
+Content-Type: application/json
+
+{
+ "agent_id": "知你客服的Agent ID",
+ "input_data": {
+ "query": "用户当前这句话",
+ "user_id": "app侧用户唯一ID"
+ }
+}
+```
+
+- `query`:必填,用户本轮输入。
+- `user_id`:建议传。知你客服工作流里用「缓存」做记忆时,key 一般为 `user_memory_{user_id}`,传了才能按用户隔离多轮记忆。
+
+响应示例:
+
+```json
+{
+ "id": "execution_uuid",
+ "agent_id": "...",
+ "status": "pending",
+ "task_id": "celery_task_id",
+ "created_at": "2026-01-22T..."
+}
+```
+
+**3)轮询执行结果**
+
+```http
+GET /api/v1/executions/{execution_id}/status
+Authorization: Bearer
+```
+
+返回中有 `status`:`pending` / `running` / `completed` / `failed`。为 `completed` 后再取详情。
+
+```http
+GET /api/v1/executions/{execution_id}
+Authorization: Bearer
+```
+
+响应中的 `output_data` 即为工作流输出,其中会包含客服回复内容(具体字段以知你客服工作流 End 节点输出为准,常见为 `reply`、`content`、`output` 等)。
+
+### 3.3 App 后端实现要点
+
+- Token 管理:登录一次,将 `access_token` 缓存在服务端(注意过期时间,过期前重新登录或使用 refresh 若平台支持)。
+- 每个 App 用户建议固定一个 `user_id`(如 open_id、user_uuid),保证多轮对话记忆正确。
+- 轮询间隔建议 0.5~1 秒,最多轮询约 30~60 秒,超时可按「请求超时」处理。
+
+---
+
+## 四、方式二:前端直连
+
+适用于「用户已在低代码平台登录」的 Web 聊天场景:
+
+1. 前端从平台登录接口拿到 `access_token`。
+2. 用户发消息时,前端直接:
+ - `POST /api/v1/executions`,body 同上(`agent_id` + `input_data`)。
+ - 轮询 `GET /api/v1/executions/{id}/status` 和 `GET /api/v1/executions/{id}`。
+3. 将 `output_data` 中的回复展示在聊天界面。
+
+注意:需保证前端请求能带 Cookie/Header 到 8037 端口,且平台 CORS 已放行该前端域名(当前配置见 `docker-compose.dev.yml` 中 `CORS_ORIGINS`)。
+
+---
+
+## 五、方式三:内嵌平台对话页
+
+- 在 Agent 管理中对「知你客服」点击 **「使用」**,会打开平台自带的对话页。
+- 在智能聊天 App 内用 **WebView / iframe** 打开该对话页 URL,即可在 App 内使用知你客服,无需对接执行 API。
+- 优点:零开发量;缺点:界面与账号体系受平台限制,且无法深度定制 UI 与用户标识(多端统一记忆需平台支持「使用」页传 user_id 等参数,若当前不支持可向平台侧确认是否可扩展)。
+
+---
+
+## 六、知你客服输入输出约定(建议在平台上确认)
+
+- **输入**(`input_data`):
+ - `query`(必填):用户当前轮次文本。
+ - `user_id`(建议):App 侧用户唯一 ID,用于记忆 key。
+- **输出**(`output_data`):
+ - 以实际工作流 End 节点输出为准,可在平台「执行历史」中跑一轮对话,查看该执行详情中的 `output_data` 结构,再在 App 中解析展示。
+
+---
+
+## 七、获取 Agent ID
+
+- 方式 A:在 Agent 管理列表中,用浏览器开发者工具查看「知你客服」对应行的接口或 DOM,可看到 `id`。
+- 方式 B:调用平台接口(需登录):
+
+```http
+GET /api/v1/agents?search=知你客服
+Authorization: Bearer
+```
+
+在返回列表中找到名称为「知你客服」的项,取其 `id` 作为上述 `agent_id`。
+
+---
+
+## 八、小结
+
+| 项目 | 说明 |
+|------|------|
+| 是否可接入 | 可以,通过执行 API 或内嵌「使用」页 |
+| 推荐方式 | 智能聊天 App 用 **App 后端代理**(方式一) |
+| 认证 | 使用平台账号登录得到的 Bearer Token |
+| 多轮记忆 | 在 `input_data` 中传稳定 `user_id` |
+| 文档与调试 | 可访问 `http(s)://域名:8037/docs` 查看并调试执行、状态、详情等接口 |
+
+若后续需要「无登录、仅用 API Key 调用知你客服」,需在平台侧为 Agent 增加 API Key 鉴权接口,再在 App 后端用 Key 替代 Token 调用即可;当前版本按上述 Token 方式即可完成接入。