android app

This commit is contained in:
rjb
2026-03-07 09:01:00 +08:00
parent 9d3198f6bc
commit 717cd2a1ac
63 changed files with 3067 additions and 0 deletions

119
saars/README.md Normal file
View 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 + PyMySQLFlask-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。
- 发送成功的消息会写入 RoomSocket 收到的新消息也会写入 Room下次进入直接读本地。
### Socket.IO 实时
- 登录/注册成功后自动连接 Socketquery 带 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支持水平扩展
## 许可证
内部项目,按公司规定使用。

View 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'
}

View 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

View 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>

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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 &lt;token&gt;" for Authorization header */
public String getBearerToken() {
String t = getToken();
if (t == null || t.isEmpty()) return null;
return "Bearer " + t;
}
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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() {}
}

View File

@@ -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() {}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
};
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
});
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
});
}
}

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#6750A4</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Chat Platform</string>
</resources>

View 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>

View File

@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.2.0' apply false
}

View File

@@ -0,0 +1,3 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

View 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
View File

@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
venv/
.env
*.db
/tmp/
uploads/

17
saars/backend/Dockerfile Normal file
View 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"]

View 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

View 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"))

View 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")

View 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,
})

View 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

View 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())

View 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"})

View 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

View 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,
}

View 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"]

View 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,
}

View 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,
}

View File

@@ -0,0 +1,4 @@
from app.services.chat_engine import ChatEngineService
from app.services.file_service import FileService
__all__ = ["ChatEngineService", "FileService"]

View 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)

View 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

View 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)

View 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)

View File

@@ -0,0 +1,3 @@
from app.utils.auth import hash_password, check_password
__all__ = ["hash_password", "check_password"]

View 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"))

View File

@@ -0,0 +1 @@
# Flask-Migrate will populate this when you run: flask db init

View 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
View 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)

View File

@@ -0,0 +1 @@
# Tests package

View 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
View 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/123456Agent 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"

View 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": "appID"
}
}
```
- `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.51 秒,最多轮询约 3060 秒,超时可按「请求超时」处理。
---
## 四、方式二:前端直连
适用于「用户已在低代码平台登录」的 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 方式即可完成接入。