Merge pull request #64 from Kevinlinpr/atm2

Atm2
This commit is contained in:
2025-11-11 11:43:20 +08:00
committed by GitHub
12 changed files with 446 additions and 74 deletions

View File

@@ -4,6 +4,8 @@ plugins {
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("com.google.firebase.firebase-perf")
id("org.jetbrains.kotlin.kapt")
id("com.google.devtools.ksp") version "1.9.10-1.0.13"
}
android {
@@ -133,5 +135,10 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)
// Room 持久化
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
}

View File

@@ -2,9 +2,13 @@ package com.aiosman.ravenow.data
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.DictItem
import com.aiosman.ravenow.data.api.PointsBalance
import com.aiosman.ravenow.data.api.PointsChangeLog
import com.aiosman.ravenow.data.api.PointsChangeLogsResponse
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -18,17 +22,103 @@ import kotlinx.coroutines.withContext
*/
object PointService {
// ========== 定价表(字典 points_rules相关 ==========
private val dictService: DictService = DictServiceImpl()
private val gson = Gson()
sealed class RuleAmount {
data class Fixed(val value: Int) : RuleAmount()
data class Range(val min: Int, val max: Int) : RuleAmount()
}
data class PointsRules(
val add: Map<String, RuleAmount>,
val sub: Map<String, RuleAmount>
)
private val _pointsRules = MutableStateFlow<PointsRules?>(null)
val pointsRules: StateFlow<PointsRules?> = _pointsRules.asStateFlow()
suspend fun refreshPointsRules(key: String = "points_rules") {
withContext(Dispatchers.IO) {
try {
val dict = dictService.getDictByKey(key)
val rules = parsePointsRules(dict)
_pointsRules.value = rules
} catch (_: Exception) {
_pointsRules.value = null
}
}
}
private fun parsePointsRules(dict: DictItem): PointsRules? {
val raw = dict.value
val jsonStr = when (raw) {
is String -> raw
else -> gson.toJson(raw)
}
return try {
val root = JsonParser.parseString(jsonStr).asJsonObject
fun parseBlock(block: JsonObject?): Map<String, RuleAmount> {
if (block == null) return emptyMap()
return block.entrySet().associate { entry ->
val key = entry.key
val v: JsonElement = entry.value
val amount: RuleAmount = when {
v.isJsonPrimitive && v.asJsonPrimitive.isNumber -> {
RuleAmount.Fixed(v.asInt)
}
v.isJsonPrimitive && v.asJsonPrimitive.isString -> {
val s = v.asString.trim()
if (s.contains("-")) {
val parts = s.split("-")
.map { it.trim() }
.filter { it.isNotEmpty() }
val min = parts.getOrNull(0)?.toIntOrNull()
val max = parts.getOrNull(1)?.toIntOrNull()
when {
min != null && max != null -> RuleAmount.Range(min, max)
min != null -> RuleAmount.Fixed(min)
else -> RuleAmount.Fixed(0)
}
} else {
RuleAmount.Fixed(s.toIntOrNull() ?: 0)
}
}
v.isJsonObject -> {
val obj = v.asJsonObject
val min = obj.get("min")?.takeIf { it.isJsonPrimitive }?.asInt
val max = obj.get("max")?.takeIf { it.isJsonPrimitive }?.asInt
val value = obj.get("value")?.takeIf { it.isJsonPrimitive }?.asInt
when {
min != null && max != null -> RuleAmount.Range(min, max)
value != null -> RuleAmount.Fixed(value)
else -> RuleAmount.Fixed(0)
}
}
else -> RuleAmount.Fixed(0)
}
key to amount
}
}
val addObj = root.getAsJsonObject("add")
val subObj = root.getAsJsonObject("sub")
PointsRules(
add = parseBlock(addObj),
sub = parseBlock(subObj)
)
} catch (_: Exception) {
null
}
}
// 全局可观察的积分余额(仅内存,不落盘)
private val _pointsBalance = MutableStateFlow<PointsBalance?>(null)
val pointsBalance: StateFlow<PointsBalance?> = _pointsBalance.asStateFlow()
// 当前已加载的用户ID用于处理用户切换
@Volatile
private var currentUserId: Int? = null
@Volatile private var currentUserId: Int? = null
/**
* 设置当前用户ID当用户切换时会清空旧的积分数据以避免串号
*/
/** 设置当前用户ID当用户切换时会清空旧的积分数据以避免串号 */
fun setCurrentUser(userId: Int?) {
if (currentUserId != userId) {
currentUserId = userId
@@ -36,9 +126,7 @@ object PointService {
}
}
/**
* 清空内存中的积分状态(用于登出或用户切换)
*/
/** 清空内存中的积分状态(用于登出或用户切换) */
fun clear() {
_pointsBalance.value = null
currentUserId = null
@@ -129,20 +217,21 @@ object PointService {
* ```
*/
suspend fun getMyPointsChangeLogs(
page: Int = 1,
pageSize: Int = 20,
changeType: String? = null,
startTime: String? = null,
endTime: String? = null
page: Int = 1,
pageSize: Int = 20,
changeType: String? = null,
startTime: String? = null,
endTime: String? = null
): PointsChangeLogsResponse {
return withContext(Dispatchers.IO) {
val response = ApiClient.api.getMyPointsChangeLogs(
page = page,
pageSize = pageSize,
changeType = changeType,
startTime = startTime,
endTime = endTime
)
val response =
ApiClient.api.getMyPointsChangeLogs(
page = page,
pageSize = pageSize,
changeType = changeType,
startTime = startTime,
endTime = endTime
)
if (response.isSuccessful) {
response.body() ?: throw Exception("响应数据为空")
} else {
@@ -151,9 +240,7 @@ object PointService {
}
}
/**
* 积分变更类型常量
*/
/** 积分变更类型常量 */
object ChangeType {
/** 积分增加 */
const val ADD = "add"
@@ -163,9 +250,7 @@ object PointService {
const val ADJUST = "adjust"
}
/**
* 积分变更原因常量
*/
/** 积分变更原因常量 */
object ChangeReason {
// 获得积分类型
/** 新用户注册奖励 */
@@ -209,7 +294,6 @@ object PointService {
ChangeReason.EARN_TASK -> "任务完成奖励"
ChangeReason.EARN_INVITE -> "邀请好友奖励"
ChangeReason.EARN_RECHARGE -> "充值获得"
ChangeReason.SPEND_GROUP_CREATE -> "创建群聊"
ChangeReason.SPEND_GROUP_EXPAND -> "扩容群聊"
ChangeReason.SPEND_AGENT_PRIVATE -> "Agent 私密模式"
@@ -217,7 +301,6 @@ object PointService {
ChangeReason.SPEND_ROOM_MEMORY -> "房间记忆添加"
ChangeReason.SPEND_CHAT_BACKGROUND -> "自定义聊天背景"
ChangeReason.SPEND_SCHEDULE_EVENT -> "定时事件解锁"
else -> reason // 未知原因,返回原始代码
}
}
@@ -237,4 +320,3 @@ object PointService {
}
}
}

View File

@@ -1,6 +1,7 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.BatchTrtcUserIdRequestBody
import com.aiosman.ravenow.entity.AccountProfileEntity
data class UserAuth(
@@ -67,6 +68,16 @@ interface UserService {
suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity
/**
* 批量通过 TRTC 用户ID 获取用户信息列表
* @param ids TRTC 用户ID列表最多100个
* @param includeAI 是否包含AI账号默认 false
* @return 用户信息实体列表
*/
suspend fun getUserProfilesByTrtcUserIds(
ids: List<String>,
includeAI: Boolean = false
): List<AccountProfileEntity>
}
class UserServiceImpl : UserService {
@@ -122,4 +133,18 @@ class UserServiceImpl : UserService {
val body = resp.body() ?: throw ServiceException("Failed to get account")
return body.data.toAccountProfileEntity()
}
override suspend fun getUserProfilesByTrtcUserIds(
ids: List<String>,
includeAI: Boolean
): List<AccountProfileEntity> {
val resp = ApiClient.api.getAccountProfilesByTrtcBatch(
BatchTrtcUserIdRequestBody(
trtcUserIds = ids,
includeAI = includeAI
)
)
val body = resp.body() ?: throw ServiceException("Failed to get accounts")
return body.data.map { it.toAccountProfileEntity() }
}
}

View File

@@ -87,6 +87,13 @@ data class JoinGroupChatRequestBody(
val roomId: Int? = null,
)
data class BatchTrtcUserIdRequestBody(
@SerializedName("trtcUserIds")
val trtcUserIds: List<String>,
@SerializedName("includeAI")
val includeAI: Boolean? = null,
)
data class LoginUserRequestBody(
@SerializedName("username")
val username: String? = null,
@@ -1258,6 +1265,11 @@ interface RaveNowAPI {
@Path("id") id: String
): Response<DataContainer<AccountProfile>>
@POST("profile/trtc/batch")
suspend fun getAccountProfilesByTrtcBatch(
@Body body: BatchTrtcUserIdRequestBody
): Response<DataContainer<List<AccountProfile>>>
@POST("user/{id}/follow")
suspend fun followUser(
@Path("id") id: Int

View File

@@ -0,0 +1,58 @@
package com.aiosman.ravenow.data.db
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Upsert
@Entity(tableName = "trtc_participant_cache")
data class TrtcParticipantCache(
@PrimaryKey val trtcId: String,
val isAI: Boolean,
val updatedAt: Long
)
@Dao
interface TrtcParticipantCacheDao {
@Query("SELECT * FROM trtc_participant_cache WHERE trtcId = :trtcId LIMIT 1")
suspend fun get(trtcId: String): TrtcParticipantCache?
@Query("SELECT * FROM trtc_participant_cache WHERE trtcId IN (:ids)")
suspend fun getMany(ids: List<String>): List<TrtcParticipantCache>
@Upsert
suspend fun upsertAll(items: List<TrtcParticipantCache>)
}
@Database(
entities = [TrtcParticipantCache::class],
version = 1,
exportSchema = false
)
abstract class MessageCacheDatabase : RoomDatabase() {
abstract fun trtcParticipantCacheDao(): TrtcParticipantCacheDao
companion object {
@Volatile
private var INSTANCE: MessageCacheDatabase? = null
fun getInstance(context: Context): MessageCacheDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
MessageCacheDatabase::class.java,
"message_cache.db"
).fallbackToDestructiveMigration()
.build()
.also { INSTANCE = it }
}
}
}
}

View File

@@ -0,0 +1,133 @@
package com.aiosman.ravenow.data.repo
import android.content.Context
import android.util.Log
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.data.db.MessageCacheDatabase
import com.aiosman.ravenow.data.db.TrtcParticipantCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
object TrtcUserTypeRepository {
private const val TAG = "TrtcUserTypeRepo"
private const val MAX_BATCH = 100
private const val TTL_MS: Long = 5L * 24 * 60 * 60 * 1000 // 5 天
private val memoryCache = ConcurrentHashMap<String, CacheEntry>()
private var initialized = false
private lateinit var userService: UserService
private lateinit var db: com.aiosman.ravenow.data.db.MessageCacheDatabase
data class CacheEntry(
val isAI: Boolean,
val updatedAt: Long
) {
fun isExpired(now: Long): Boolean = now - updatedAt > TTL_MS
}
private fun ensureInit(context: Context) {
if (!initialized) {
db = MessageCacheDatabase.getInstance(context)
userService = UserServiceImpl()
initialized = true
}
}
fun getCachedType(trtcId: String): Boolean? {
val entry = memoryCache[trtcId] ?: return null
return if (!entry.isExpired(System.currentTimeMillis())) entry.isAI else null
}
suspend fun getType(context: Context, trtcId: String): Boolean? {
ensureInit(context)
val now = System.currentTimeMillis()
val mem = memoryCache[trtcId]
if (mem != null && !mem.isExpired(now)) return mem.isAI
return withContext(Dispatchers.IO) {
val dao = db.trtcParticipantCacheDao()
val entity = dao.get(trtcId)
if (entity != null && now - entity.updatedAt <= TTL_MS) {
memoryCache[trtcId] = CacheEntry(entity.isAI, entity.updatedAt)
entity.isAI
} else {
null
}
}
}
suspend fun ensureTypes(context: Context, trtcIds: List<String>) {
ensureInit(context)
if (trtcIds.isEmpty()) return
val now = System.currentTimeMillis()
// 从内存/本地命中
val toFetch = withContext(Dispatchers.IO) {
val dao = db.trtcParticipantCacheDao()
val result = mutableSetOf<String>()
val needFromDb = mutableListOf<String>()
trtcIds.forEach { id ->
val mem = memoryCache[id]
if (mem == null || mem.isExpired(now)) {
needFromDb.add(id)
}
}
if (needFromDb.isNotEmpty()) {
val entities = dao.getMany(needFromDb)
val fromDbSet = entities.toMutableList()
// 写回内存并决定是否需要网络
val dbValid = mutableSetOf<String>()
fromDbSet.forEach { e ->
if (now - e.updatedAt <= TTL_MS) {
memoryCache[e.trtcId] = CacheEntry(e.isAI, e.updatedAt)
dbValid.add(e.trtcId)
}
}
needFromDb.forEach { id ->
if (!dbValid.contains(id)) {
result.add(id)
}
}
}
result.toList()
}
if (toFetch.isEmpty()) return
// 批量分片请求
val chunks = toFetch.chunked(MAX_BATCH)
for (chunk in chunks) {
try {
val profiles = withContext(Dispatchers.IO) {
userService.getUserProfilesByTrtcUserIds(chunk, includeAI = true)
}
// 将返回的 profile 按 trtcUserId -> isAI 映射
val upserts = profiles.mapNotNull { profile ->
val id = profile.trtcUserId
if (id.isNullOrEmpty()) null
else TrtcParticipantCache(
trtcId = id,
isAI = profile.aiAccount,
updatedAt = System.currentTimeMillis()
)
}
// 落库 + 内存
withContext(Dispatchers.IO) {
db.trtcParticipantCacheDao().upsertAll(upserts)
}
upserts.forEach { e ->
memoryCache[e.trtcId] = CacheEntry(e.isAI, e.updatedAt)
}
Log.d(TAG, "Fetched types: size=${upserts.size}, batch=${chunk.size}")
} catch (e: Exception) {
Log.w(TAG, "ensureTypes fetch failed: ${e.message}")
}
}
}
}

View File

@@ -30,6 +30,7 @@ import io.openim.android.sdk.models.ConversationInfo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class Conversation(
val id: String,
@@ -76,6 +77,13 @@ object MessageListViewModel : ViewModel() {
// noticeInfo = info
//
// isLoading = false
if (loadChat && com.aiosman.ravenow.AppState.enableChat) {
// 预热 trtcId -> isAI 缓存,避免首次进入 Agent/Friends 列表时阻塞
try {
prewarmChatTypes(context)
} catch (_: Exception) {
}
}
}
@@ -163,5 +171,23 @@ object MessageListViewModel : ViewModel() {
}
}
private suspend fun prewarmChatTypes(context: Context) {
val result = suspendCoroutine { continuation ->
OpenIMClient.getInstance().conversationManager.getAllConversationList(
object : OnBase<List<ConversationInfo>> {
override fun onSuccess(data: List<ConversationInfo>?) {
continuation.resumeWith(Result.success(data ?: emptyList()))
}
override fun onError(code: Int, error: String?) {
continuation.resumeWith(Result.failure(Exception("Error $code: $error")))
}
}
)
}
val trtcIds = result.filter { it.conversationType == 1 }
.mapNotNull { it.userID }
.distinct()
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
}
}

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.index.tabs.message.tab
import android.content.Context
import android.icu.util.Calendar
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -28,6 +29,7 @@ import io.openim.android.sdk.models.Message
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.utils.MessageParser
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class AgentConversation(
val id: String,
@@ -53,7 +55,7 @@ data class AgentConversation(
nickname = conversation.showName ?: "",
lastMessage = displayText, // 使用解析后的显示文本
lastMessageTime = lastMessage.time.formatChatTime(context),
avatar = "${ApiClient.BASE_API_URL+"/"}${conversation.faceURL}"+"?token="+"${AppStore.token}".replace("storage/avatars/", "/avatar/"),
avatar = "${ApiClient.BASE_SERVER}${conversation.faceURL}",
unreadCount = conversation.unreadCount,
trtcUserId = conversation.userID ?: "",
displayText = displayText,
@@ -127,15 +129,7 @@ object AgentChatListViewModel : ViewModel() {
OpenIMClient.getInstance().conversationManager.getAllConversationList(
object : OnBase<List<ConversationInfo>> {
override fun onSuccess(data: List<ConversationInfo>?) {
// 过滤出智能体会话(单聊类型,且可能有特定标识)
val agentConversations = data?.filter { conversation ->
// 这里需要根据实际业务逻辑来过滤智能体会话
// 可能通过会话类型、用户ID前缀、或其他标识来判断
conversation.conversationType == 1 // 1 表示单聊
// 可以添加更多过滤条件,比如:
// && conversation.userID?.startsWith("ai_") == true
} ?: emptyList()
continuation.resumeWith(Result.success(agentConversations))
continuation.resumeWith(Result.success(data ?: emptyList()))
}
override fun onError(code: Int, error: String?) {
@@ -145,7 +139,22 @@ object AgentChatListViewModel : ViewModel() {
)
}
agentChatList = result.map { conversation ->
// 仅单聊
val singleChats = result.filter { it.conversationType == 1 }
val trtcIds = singleChats.mapNotNull { it.userID }.distinct()
// 预热缓存包含AI
try {
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
} catch (e: Exception) {
Log.w("AgentChatListViewModel", "ensureTypes failed: ${e.message}")
}
// 过滤出 AI 会话
val filtered = singleChats.filter { conv ->
val id = conv.userID ?: return@filter false
TrtcUserTypeRepository.getCachedType(id) == true
}
agentChatList = filtered.map { conversation ->
AgentConversation.convertToAgentConversation(conversation, context)
}
}

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.index.tabs.message.tab
import android.content.Context
import android.icu.util.Calendar
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -25,6 +26,7 @@ import io.openim.android.sdk.models.Message
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.utils.MessageParser
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class FriendConversation(
val id: String,
@@ -135,13 +137,19 @@ object FriendChatListViewModel : ViewModel() {
)
}
// 过滤出朋友会话(单聊类型,且排除 AI 智能体)
val filteredConversations = result.filter { conversation ->
// 1 表示单聊,排除 AI 智能体会话
conversation.conversationType == 1 &&
// 可以根据实际业务逻辑添加更多过滤条件
// 比如排除 AI 智能体的 userID 前缀或标识
!(conversation.userID?.startsWith("ai_") == true)
// 仅单聊
val singleChats = result.filter { it.conversationType == 1 }
val trtcIds = singleChats.mapNotNull { it.userID }.distinct()
// 预热缓存包含AI
try {
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
} catch (e: Exception) {
Log.w("FriendChatListViewModel", "ensureTypes failed: ${e.message}")
}
// 过滤出普通人会话(未知也归入普通人以回退)
val filteredConversations = singleChats.filter { conversation ->
val id = conversation.userID ?: return@filter true
TrtcUserTypeRepository.getCachedType(id) != true
}
friendChatList = filteredConversations.map { conversation ->

View File

@@ -136,8 +136,8 @@ object GroupChatListViewModel : ViewModel() {
private suspend fun loadGroupChatList(context: Context) {
// 检查 OpenIM 是否已登录
if (!com.aiosman.ravenow.AppState.enableChat) {
android.util.Log.w("GroupChatListViewModel", "OpenIM 未登录,跳过加载群聊列表")
if (!AppState.enableChat) {
Log.w("GroupChatListViewModel", "OpenIM 未登录,跳过加载群聊列表")
return
}
@@ -158,8 +158,8 @@ object GroupChatListViewModel : ViewModel() {
// 过滤出群聊会话(群聊类型)
val filteredConversations = result.filter { conversation ->
// 2 表示群聊类型
conversation.conversationType == 2
// 3 表示群聊类型
conversation.conversationType == 3
}
groupChatList = filteredConversations.map { conversation ->

View File

@@ -23,6 +23,14 @@ object PointsViewModel : ViewModel() {
viewModelScope.launch {
try {
loading = true
// 并行预加载积分定价表不影响UI
launch {
try {
PointService.refreshPointsRules()
} catch (e: Exception) {
Log.e("PointsViewModel", "refresh rules error", e)
}
}
if (!AppStore.isGuest) {
PointService.refreshMyPointsBalance()
}

View File

@@ -42,6 +42,7 @@ rendering = "1.17.1"
zoomable = "1.6.1"
camerax = "1.3.4"
mlkitBarcode = "17.3.0"
room = "2.6.1"
[libraries]
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" }
@@ -105,6 +106,9 @@ retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "converte
zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" }
lottie = { module="com.airbnb.android:lottie-compose", version="6.6.10"}
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkitBarcode" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }