新增用户类型缓存及会话列表过滤
- 新增`TrtcUserTypeRepository`,用于缓存用户是否为AI账号。 - 实现三级缓存策略(内存、Room数据库、网络),以优化`trtcId`对应的用户类型(是否为AI)的获取性能。 - 在`Agent`和`Friend`聊天列表中,根据缓存的用户类型对会话进行过滤,确保正确分类。 - 在消息列表加载时,增加用户类型缓存的预热机制,提升进入会话列表的加载速度。 - 为`UserService`和`RiderProAPI`添加通过`trtcUserIds`批量获取用户信息的接口。 - 为`PointService`新增积分定价规则的解析和缓存功能。 - 在项目构建配置中,添加`Room`数据库和`KSP`的相关依赖。
This commit is contained in:
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -13,37 +17,121 @@ 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
|
||||
|
||||
/**
|
||||
* 设置当前用户ID;当用户切换时,会清空旧的积分数据以避免串号
|
||||
*/
|
||||
@Volatile private var currentUserId: Int? = null
|
||||
|
||||
/** 设置当前用户ID;当用户切换时,会清空旧的积分数据以避免串号 */
|
||||
fun setCurrentUser(userId: Int?) {
|
||||
if (currentUserId != userId) {
|
||||
currentUserId = userId
|
||||
_pointsBalance.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空内存中的积分状态(用于登出或用户切换)
|
||||
*/
|
||||
|
||||
/** 清空内存中的积分状态(用于登出或用户切换) */
|
||||
fun clear() {
|
||||
_pointsBalance.value = null
|
||||
currentUserId = null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 刷新当前用户的积分余额(进入应用并完成登录态初始化后调用)
|
||||
* - 若为游客或无 token,则清空并返回
|
||||
@@ -58,14 +146,14 @@ object PointService {
|
||||
_pointsBalance.value = balance
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取当前用户积分余额
|
||||
*
|
||||
*
|
||||
* @param includeStatistics 是否包含统计信息(累计获得和累计消费),默认 true
|
||||
* @return 积分余额信息,包含当前余额和可选的统计数据
|
||||
* @throws Exception 网络请求失败或服务器返回错误
|
||||
*
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* try {
|
||||
@@ -74,7 +162,7 @@ object PointService {
|
||||
* println("当前余额: ${balance.balance}")
|
||||
* println("累计获得: ${balance.totalEarned}")
|
||||
* println("累计消费: ${balance.totalSpent}")
|
||||
*
|
||||
*
|
||||
* // 仅获取当前余额
|
||||
* val simpleBalance = PointService.getMyPointsBalance(includeStatistics = false)
|
||||
* println("当前余额: ${simpleBalance.balance}")
|
||||
@@ -93,10 +181,10 @@ object PointService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取当前用户积分变更日志列表
|
||||
*
|
||||
*
|
||||
* @param page 页码,默认 1
|
||||
* @param pageSize 每页数量,默认 20
|
||||
* @param changeType 变更类型筛选("add": 增加, "subtract": 减少, "adjust": 调整),null 表示不筛选
|
||||
@@ -104,7 +192,7 @@ object PointService {
|
||||
* @param endTime 结束时间,格式:YYYY-MM-DD,null 表示不限制
|
||||
* @return 积分变更日志列表响应,包含日志列表和分页信息
|
||||
* @throws Exception 网络请求失败或服务器返回错误
|
||||
*
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* try {
|
||||
@@ -114,10 +202,10 @@ object PointService {
|
||||
* logs.list.forEach { log ->
|
||||
* println("${log.createdAt}: ${log.changeType} ${log.amount} (${log.reason})")
|
||||
* }
|
||||
*
|
||||
*
|
||||
* // 筛选积分增加记录
|
||||
* val earnLogs = PointService.getMyPointsChangeLogs(changeType = "add")
|
||||
*
|
||||
*
|
||||
* // 查询指定时间范围的记录
|
||||
* val rangeLogs = PointService.getMyPointsChangeLogs(
|
||||
* startTime = "2024-01-01",
|
||||
@@ -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 {
|
||||
@@ -150,10 +239,8 @@ object PointService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分变更类型常量
|
||||
*/
|
||||
|
||||
/** 积分变更类型常量 */
|
||||
object ChangeType {
|
||||
/** 积分增加 */
|
||||
const val ADD = "add"
|
||||
@@ -162,10 +249,8 @@ object PointService {
|
||||
/** 积分调整 */
|
||||
const val ADJUST = "adjust"
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分变更原因常量
|
||||
*/
|
||||
|
||||
/** 积分变更原因常量 */
|
||||
object ChangeReason {
|
||||
// 获得积分类型
|
||||
/** 新用户注册奖励 */
|
||||
@@ -178,7 +263,7 @@ object PointService {
|
||||
const val EARN_INVITE = "earn_invite"
|
||||
/** 充值获得 */
|
||||
const val EARN_RECHARGE = "earn_recharge"
|
||||
|
||||
|
||||
// 消费积分类型
|
||||
/** 创建群聊 */
|
||||
const val SPEND_GROUP_CREATE = "spend_group_create"
|
||||
@@ -195,10 +280,10 @@ object PointService {
|
||||
/** 定时事件解锁 */
|
||||
const val SPEND_SCHEDULE_EVENT = "spend_schedule_event"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取变更原因的中文描述
|
||||
*
|
||||
*
|
||||
* @param reason 变更原因代码
|
||||
* @return 中文描述
|
||||
*/
|
||||
@@ -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,14 +301,13 @@ object PointService {
|
||||
ChangeReason.SPEND_ROOM_MEMORY -> "房间记忆添加"
|
||||
ChangeReason.SPEND_CHAT_BACKGROUND -> "自定义聊天背景"
|
||||
ChangeReason.SPEND_SCHEDULE_EVENT -> "定时事件解锁"
|
||||
|
||||
else -> reason // 未知原因,返回原始代码
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取变更类型的中文描述
|
||||
*
|
||||
*
|
||||
* @param changeType 变更类型代码
|
||||
* @return 中文描述
|
||||
*/
|
||||
@@ -237,4 +320,3 @@ object PointService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user