android app
This commit is contained in:
119
saars/README.md
Normal file
119
saars/README.md
Normal file
@@ -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,支持水平扩展
|
||||
|
||||
## 许可证
|
||||
|
||||
内部项目,按公司规定使用。
|
||||
52
saars/android-app/app/build.gradle
Normal file
52
saars/android-app/app/build.gradle
Normal file
@@ -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'
|
||||
}
|
||||
3
saars/android-app/app/proguard-rules.pro
vendored
Normal file
3
saars/android-app/app/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||
27
saars/android-app/app/src/main/AndroidManifest.xml
Normal file
27
saars/android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".ChatPlatformApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/ic_menu_send"
|
||||
android:roundIcon="@android:drawable/ic_menu_send"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.ChatPlatform">
|
||||
<activity android:name=".presentation.login.LoginActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".presentation.MainActivity" android:exported="false" />
|
||||
<activity android:name=".presentation.chat.ChatActivity" android:exported="false" />
|
||||
<activity android:name=".presentation.agent.AgentChatActivity" android:exported="false" />
|
||||
<service android:name=".fcm.ChatFirebaseMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ApiService.ConversationItem> items) {
|
||||
executor.execute(() -> {
|
||||
List<ConversationEntity> 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<ApiService.Message> items) {
|
||||
executor.execute(() -> {
|
||||
db.messageDao().deleteByConversation(conversationId);
|
||||
List<MessageEntity> 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<ConversationEntity> getConversationsFromDb() {
|
||||
return db.conversationDao().getAll();
|
||||
}
|
||||
|
||||
public List<MessageEntity> 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationEntity> getAll();
|
||||
|
||||
@Query("SELECT * FROM conversations WHERE id = :id LIMIT 1")
|
||||
ConversationEntity getById(String id);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insertAll(List<ConversationEntity> list);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(ConversationEntity e);
|
||||
|
||||
@Query("DELETE FROM conversations")
|
||||
void deleteAll();
|
||||
}
|
||||
@@ -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<MessageEntity> getByConversation(String conversationId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(MessageEntity m);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insertAll(List<MessageEntity> 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();
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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<LoginResponse> login(@Body LoginRequest request);
|
||||
|
||||
@POST("api/v1/auth/register")
|
||||
Call<LoginResponse> register(@Body RegisterRequest request);
|
||||
|
||||
/** 知你客服 Agent 代理(方式一,Token 由拦截器统一添加) */
|
||||
@POST("api/v1/agent/chat")
|
||||
Call<AgentChatResponse> agentChat(@Body AgentChatRequest body);
|
||||
|
||||
@GET("api/v1/chat/conversations")
|
||||
Call<ConversationListResponse> getConversations(
|
||||
@Header("Authorization") String token,
|
||||
@Query("skip") int skip,
|
||||
@Query("limit") int limit);
|
||||
|
||||
@POST("api/v1/chat/conversations")
|
||||
Call<ConversationItem> createConversation(
|
||||
@Header("Authorization") String token,
|
||||
@Body CreateConversationRequest body);
|
||||
|
||||
@GET("api/v1/chat/conversations/{id}/messages")
|
||||
Call<MessageListResponse> 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<Message> 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<ConversationItem> items;
|
||||
}
|
||||
|
||||
class ConversationItem {
|
||||
public String id;
|
||||
public String title;
|
||||
public String last_message_at;
|
||||
}
|
||||
|
||||
class MessageListResponse {
|
||||
public java.util.List<Message> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ConversationListAdapter.VH> {
|
||||
|
||||
private final List<ApiService.ConversationItem> items;
|
||||
private final OnItemClickListener listener;
|
||||
|
||||
public interface OnItemClickListener {
|
||||
void onItemClick(ApiService.ConversationItem item);
|
||||
}
|
||||
|
||||
public ConversationListAdapter(List<ApiService.ConversationItem> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApiService.ConversationItem> 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<ConversationEntity> 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<ConversationEntity> fromDb = syncHelper.getConversationsFromDb();
|
||||
runOnUiThread(() -> {
|
||||
applyConversationsToUi(fromDb);
|
||||
binding.progress.setVisibility(View.GONE);
|
||||
});
|
||||
}).start();
|
||||
RetrofitClient.getApi().getConversations(tokenStore.getBearerToken(), 0, 50)
|
||||
.enqueue(new Callback<ApiService.ConversationListResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.ConversationListResponse> call,
|
||||
Response<ApiService.ConversationListResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().items != null) {
|
||||
syncHelper.saveConversations(response.body().items);
|
||||
new Thread(() -> {
|
||||
List<ConversationEntity> fromDb = syncHelper.getConversationsFromDb();
|
||||
runOnUiThread(() -> applyConversationsToUi(fromDb));
|
||||
}).start();
|
||||
}
|
||||
binding.progress.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.ConversationListResponse> 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<ApiService.ConversationItem>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.ConversationItem> call,
|
||||
Response<ApiService.ConversationItem> 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<ApiService.ConversationItem> call, Throwable t) {
|
||||
binding.fabNew.setEnabled(true);
|
||||
Toast.makeText(MainActivity.this, "创建失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<ApiService.Message> 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<com.saars.chatplatform.data.local.entity.MessageEntity> 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<ApiService.AgentChatResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.AgentChatResponse> call, Response<ApiService.AgentChatResponse> 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<ApiService.AgentChatResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ApiService.Message> 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<MessageEntity> 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<MessageEntity> fromDb = syncHelper.getMessagesFromDb(conversationId);
|
||||
runOnUiThread(() -> applyMessagesToUi(fromDb));
|
||||
}).start();
|
||||
RetrofitClient.getApi().getMessages(tokenStore.getBearerToken(), conversationId, null, 50)
|
||||
.enqueue(new Callback<ApiService.MessageListResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.MessageListResponse> call,
|
||||
Response<ApiService.MessageListResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().items != null) {
|
||||
syncHelper.saveMessages(conversationId, response.body().items);
|
||||
new Thread(() -> {
|
||||
List<MessageEntity> fromDb = syncHelper.getMessagesFromDb(conversationId);
|
||||
runOnUiThread(() -> applyMessagesToUi(fromDb));
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiService.MessageListResponse> 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<ApiService.Message>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.Message> call, Response<ApiService.Message> 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<ApiService.Message> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<MessageListAdapter.VH> {
|
||||
private final List<ApiService.Message> items;
|
||||
|
||||
public MessageListAdapter(List<ApiService.Message> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApiService.LoginResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.LoginResponse> call, Response<ApiService.LoginResponse> 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<ApiService.LoginResponse> 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<ApiService.LoginResponse>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiService.LoginResponse> call, Response<ApiService.LoginResponse> 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<ApiService.LoginResponse> call, Throwable t) {
|
||||
binding.btnRegister.setEnabled(true);
|
||||
Toast.makeText(LoginActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationIcon="@android:drawable/ic_menu_revert"
|
||||
app:title="知你客服" />
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
<EditText
|
||||
android:id="@+id/etInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="输入消息,与知你客服对话"
|
||||
android:inputType="text"
|
||||
android:minHeight="48dp" />
|
||||
<Button
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="发送"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
39
saars/android-app/app/src/main/res/layout/activity_chat.xml
Normal file
39
saars/android-app/app/src/main/res/layout/activity_chat.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:navigationIcon="@android:drawable/ic_menu_revert"
|
||||
app:title="对话" />
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
<EditText
|
||||
android:id="@+id/etInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="输入消息"
|
||||
android:inputType="text"
|
||||
android:minHeight="48dp" />
|
||||
<Button
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="发送"
|
||||
android:layout_marginStart="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
77
saars/android-app/app/src/main/res/layout/activity_login.xml
Normal file
77
saars/android-app/app/src/main/res/layout/activity_login.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="聊天平台"
|
||||
android:textSize="24sp"
|
||||
android:layout_marginTop="48dp"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="邮箱"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etEmail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textEmailAddress" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="用户名(注册时填)"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etUsername"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="密码"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLogin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="登录"
|
||||
android:layout_marginTop="24dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnRegister"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="注册"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
54
saars/android-app/app/src/main/res/layout/activity_main.xml
Normal file
54
saars/android-app/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:title="会话列表" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false" />
|
||||
</FrameLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabAgent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="88dp"
|
||||
android:contentDescription="知你客服"
|
||||
app:srcCompat="@android:drawable/ic_menu_send" />
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabNew"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="新对话"
|
||||
app:srcCompat="@android:drawable/ic_input_add" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTime"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
13
saars/android-app/app/src/main/res/layout/item_message.xml
Normal file
13
saars/android-app/app/src/main/res/layout/item_message.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp">
|
||||
<TextView
|
||||
android:id="@+id/tvMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="280dp"
|
||||
android:padding="12dp"
|
||||
android:textSize="15sp" />
|
||||
</FrameLayout>
|
||||
4
saars/android-app/app/src/main/res/values/colors.xml
Normal file
4
saars/android-app/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#6750A4</color>
|
||||
</resources>
|
||||
4
saars/android-app/app/src/main/res/values/strings.xml
Normal file
4
saars/android-app/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Chat Platform</string>
|
||||
</resources>
|
||||
6
saars/android-app/app/src/main/res/values/themes.xml
Normal file
6
saars/android-app/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.ChatPlatform" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
</style>
|
||||
</resources>
|
||||
3
saars/android-app/build.gradle
Normal file
3
saars/android-app/build.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.0' apply false
|
||||
}
|
||||
3
saars/android-app/gradle.properties
Normal file
3
saars/android-app/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
16
saars/android-app/settings.gradle
Normal file
16
saars/android-app/settings.gradle
Normal file
@@ -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"
|
||||
7
saars/backend/.gitignore
vendored
Normal file
7
saars/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
venv/
|
||||
.env
|
||||
*.db
|
||||
/tmp/
|
||||
uploads/
|
||||
17
saars/backend/Dockerfile
Normal file
17
saars/backend/Dockerfile
Normal file
@@ -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"]
|
||||
50
saars/backend/app/__init__.py
Normal file
50
saars/backend/app/__init__.py
Normal file
@@ -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
|
||||
37
saars/backend/app/admin/__init__.py
Normal file
37
saars/backend/app/admin/__init__.py
Normal file
@@ -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"))
|
||||
16
saars/backend/app/api/__init__.py
Normal file
16
saars/backend/app/api/__init__.py
Normal file
@@ -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")
|
||||
84
saars/backend/app/api/admin_api.py
Normal file
84
saars/backend/app/api/admin_api.py
Normal file
@@ -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/<user_id>", 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,
|
||||
})
|
||||
34
saars/backend/app/api/agent_proxy.py
Normal file
34
saars/backend/app/api/agent_proxy.py
Normal file
@@ -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
|
||||
85
saars/backend/app/api/auth.py
Normal file
85
saars/backend/app/api/auth.py
Normal file
@@ -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())
|
||||
87
saars/backend/app/api/chat.py
Normal file
87
saars/backend/app/api/chat.py
Normal file
@@ -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/<conversation_id>/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/<conversation_id>/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/<message_id>/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"})
|
||||
25
saars/backend/app/api/files.py
Normal file
25
saars/backend/app/api/files.py
Normal file
@@ -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
|
||||
60
saars/backend/app/config.py
Normal file
60
saars/backend/app/config.py
Normal file
@@ -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,
|
||||
}
|
||||
4
saars/backend/app/models/__init__.py
Normal file
4
saars/backend/app/models/__init__.py
Normal file
@@ -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"]
|
||||
55
saars/backend/app/models/chat.py
Normal file
55
saars/backend/app/models/chat.py
Normal file
@@ -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,
|
||||
}
|
||||
46
saars/backend/app/models/user.py
Normal file
46
saars/backend/app/models/user.py
Normal file
@@ -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,
|
||||
}
|
||||
4
saars/backend/app/services/__init__.py
Normal file
4
saars/backend/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.services.chat_engine import ChatEngineService
|
||||
from app.services.file_service import FileService
|
||||
|
||||
__all__ = ["ChatEngineService", "FileService"]
|
||||
112
saars/backend/app/services/agent_proxy.py
Normal file
112
saars/backend/app/services/agent_proxy.py
Normal file
@@ -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)
|
||||
78
saars/backend/app/services/chat_engine.py
Normal file
78
saars/backend/app/services/chat_engine.py
Normal file
@@ -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
|
||||
32
saars/backend/app/services/file_service.py
Normal file
32
saars/backend/app/services/file_service.py
Normal file
@@ -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)
|
||||
63
saars/backend/app/socket_events.py
Normal file
63
saars/backend/app/socket_events.py
Normal file
@@ -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)
|
||||
3
saars/backend/app/utils/__init__.py
Normal file
3
saars/backend/app/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.utils.auth import hash_password, check_password
|
||||
|
||||
__all__ = ["hash_password", "check_password"]
|
||||
12
saars/backend/app/utils/auth.py
Normal file
12
saars/backend/app/utils/auth.py
Normal file
@@ -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"))
|
||||
1
saars/backend/migrations/env.py
Normal file
1
saars/backend/migrations/env.py
Normal file
@@ -0,0 +1 @@
|
||||
# Flask-Migrate will populate this when you run: flask db init
|
||||
42
saars/backend/requirements.txt
Normal file
42
saars/backend/requirements.txt
Normal file
@@ -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
|
||||
11
saars/backend/run.py
Normal file
11
saars/backend/run.py
Normal file
@@ -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)
|
||||
1
saars/backend/tests/__init__.py
Normal file
1
saars/backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
38
saars/backend/tests/test_auth.py
Normal file
38
saars/backend/tests/test_auth.py
Normal file
@@ -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()
|
||||
23
saars/docker-compose.yml
Normal file
23
saars/docker-compose.yml
Normal file
@@ -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"
|
||||
160
知你客服Agent智能聊天App接入方案.md
Normal file
160
知你客服Agent智能聊天App接入方案.md
Normal file
@@ -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 <access_token>
|
||||
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 <access_token>
|
||||
```
|
||||
|
||||
返回中有 `status`:`pending` / `running` / `completed` / `failed`。为 `completed` 后再取详情。
|
||||
|
||||
```http
|
||||
GET /api/v1/executions/{execution_id}
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
响应中的 `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 <access_token>
|
||||
```
|
||||
|
||||
在返回列表中找到名称为「知你客服」的项,取其 `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 方式即可完成接入。
|
||||
Reference in New Issue
Block a user