@@ -4,6 +4,8 @@ plugins {
|
|||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
id("com.google.firebase.crashlytics")
|
id("com.google.firebase.crashlytics")
|
||||||
id("com.google.firebase.firebase-perf")
|
id("com.google.firebase.firebase-perf")
|
||||||
|
id("org.jetbrains.kotlin.kapt")
|
||||||
|
id("com.google.devtools.ksp") version "1.9.10-1.0.13"
|
||||||
|
|
||||||
}
|
}
|
||||||
android {
|
android {
|
||||||
@@ -133,5 +135,10 @@ dependencies {
|
|||||||
implementation(libs.androidx.camera.view)
|
implementation(libs.androidx.camera.view)
|
||||||
implementation(libs.mlkit.barcode.scanning)
|
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.AppStore
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
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.PointsBalance
|
||||||
import com.aiosman.ravenow.data.api.PointsChangeLog
|
|
||||||
import com.aiosman.ravenow.data.api.PointsChangeLogsResponse
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -18,17 +22,103 @@ import kotlinx.coroutines.withContext
|
|||||||
*/
|
*/
|
||||||
object PointService {
|
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)
|
private val _pointsBalance = MutableStateFlow<PointsBalance?>(null)
|
||||||
val pointsBalance: StateFlow<PointsBalance?> = _pointsBalance.asStateFlow()
|
val pointsBalance: StateFlow<PointsBalance?> = _pointsBalance.asStateFlow()
|
||||||
|
|
||||||
// 当前已加载的用户ID,用于处理用户切换
|
// 当前已加载的用户ID,用于处理用户切换
|
||||||
@Volatile
|
@Volatile private var currentUserId: Int? = null
|
||||||
private var currentUserId: Int? = null
|
|
||||||
|
|
||||||
/**
|
/** 设置当前用户ID;当用户切换时,会清空旧的积分数据以避免串号 */
|
||||||
* 设置当前用户ID;当用户切换时,会清空旧的积分数据以避免串号
|
|
||||||
*/
|
|
||||||
fun setCurrentUser(userId: Int?) {
|
fun setCurrentUser(userId: Int?) {
|
||||||
if (currentUserId != userId) {
|
if (currentUserId != userId) {
|
||||||
currentUserId = userId
|
currentUserId = userId
|
||||||
@@ -36,9 +126,7 @@ object PointService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 清空内存中的积分状态(用于登出或用户切换) */
|
||||||
* 清空内存中的积分状态(用于登出或用户切换)
|
|
||||||
*/
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_pointsBalance.value = null
|
_pointsBalance.value = null
|
||||||
currentUserId = null
|
currentUserId = null
|
||||||
@@ -129,20 +217,21 @@ object PointService {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
suspend fun getMyPointsChangeLogs(
|
suspend fun getMyPointsChangeLogs(
|
||||||
page: Int = 1,
|
page: Int = 1,
|
||||||
pageSize: Int = 20,
|
pageSize: Int = 20,
|
||||||
changeType: String? = null,
|
changeType: String? = null,
|
||||||
startTime: String? = null,
|
startTime: String? = null,
|
||||||
endTime: String? = null
|
endTime: String? = null
|
||||||
): PointsChangeLogsResponse {
|
): PointsChangeLogsResponse {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val response = ApiClient.api.getMyPointsChangeLogs(
|
val response =
|
||||||
page = page,
|
ApiClient.api.getMyPointsChangeLogs(
|
||||||
pageSize = pageSize,
|
page = page,
|
||||||
changeType = changeType,
|
pageSize = pageSize,
|
||||||
startTime = startTime,
|
changeType = changeType,
|
||||||
endTime = endTime
|
startTime = startTime,
|
||||||
)
|
endTime = endTime
|
||||||
|
)
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
response.body() ?: throw Exception("响应数据为空")
|
response.body() ?: throw Exception("响应数据为空")
|
||||||
} else {
|
} else {
|
||||||
@@ -151,9 +240,7 @@ object PointService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 积分变更类型常量 */
|
||||||
* 积分变更类型常量
|
|
||||||
*/
|
|
||||||
object ChangeType {
|
object ChangeType {
|
||||||
/** 积分增加 */
|
/** 积分增加 */
|
||||||
const val ADD = "add"
|
const val ADD = "add"
|
||||||
@@ -163,9 +250,7 @@ object PointService {
|
|||||||
const val ADJUST = "adjust"
|
const val ADJUST = "adjust"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 积分变更原因常量 */
|
||||||
* 积分变更原因常量
|
|
||||||
*/
|
|
||||||
object ChangeReason {
|
object ChangeReason {
|
||||||
// 获得积分类型
|
// 获得积分类型
|
||||||
/** 新用户注册奖励 */
|
/** 新用户注册奖励 */
|
||||||
@@ -209,7 +294,6 @@ object PointService {
|
|||||||
ChangeReason.EARN_TASK -> "任务完成奖励"
|
ChangeReason.EARN_TASK -> "任务完成奖励"
|
||||||
ChangeReason.EARN_INVITE -> "邀请好友奖励"
|
ChangeReason.EARN_INVITE -> "邀请好友奖励"
|
||||||
ChangeReason.EARN_RECHARGE -> "充值获得"
|
ChangeReason.EARN_RECHARGE -> "充值获得"
|
||||||
|
|
||||||
ChangeReason.SPEND_GROUP_CREATE -> "创建群聊"
|
ChangeReason.SPEND_GROUP_CREATE -> "创建群聊"
|
||||||
ChangeReason.SPEND_GROUP_EXPAND -> "扩容群聊"
|
ChangeReason.SPEND_GROUP_EXPAND -> "扩容群聊"
|
||||||
ChangeReason.SPEND_AGENT_PRIVATE -> "Agent 私密模式"
|
ChangeReason.SPEND_AGENT_PRIVATE -> "Agent 私密模式"
|
||||||
@@ -217,7 +301,6 @@ object PointService {
|
|||||||
ChangeReason.SPEND_ROOM_MEMORY -> "房间记忆添加"
|
ChangeReason.SPEND_ROOM_MEMORY -> "房间记忆添加"
|
||||||
ChangeReason.SPEND_CHAT_BACKGROUND -> "自定义聊天背景"
|
ChangeReason.SPEND_CHAT_BACKGROUND -> "自定义聊天背景"
|
||||||
ChangeReason.SPEND_SCHEDULE_EVENT -> "定时事件解锁"
|
ChangeReason.SPEND_SCHEDULE_EVENT -> "定时事件解锁"
|
||||||
|
|
||||||
else -> reason // 未知原因,返回原始代码
|
else -> reason // 未知原因,返回原始代码
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,4 +320,3 @@ object PointService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.aiosman.ravenow.data
|
package com.aiosman.ravenow.data
|
||||||
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
import com.aiosman.ravenow.data.api.ApiClient
|
||||||
|
import com.aiosman.ravenow.data.api.BatchTrtcUserIdRequestBody
|
||||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||||
|
|
||||||
data class UserAuth(
|
data class UserAuth(
|
||||||
@@ -67,6 +68,16 @@ interface UserService {
|
|||||||
|
|
||||||
suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity
|
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 {
|
class UserServiceImpl : UserService {
|
||||||
@@ -122,4 +133,18 @@ class UserServiceImpl : UserService {
|
|||||||
val body = resp.body() ?: throw ServiceException("Failed to get account")
|
val body = resp.body() ?: throw ServiceException("Failed to get account")
|
||||||
return body.data.toAccountProfileEntity()
|
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,
|
val roomId: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BatchTrtcUserIdRequestBody(
|
||||||
|
@SerializedName("trtcUserIds")
|
||||||
|
val trtcUserIds: List<String>,
|
||||||
|
@SerializedName("includeAI")
|
||||||
|
val includeAI: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class LoginUserRequestBody(
|
data class LoginUserRequestBody(
|
||||||
@SerializedName("username")
|
@SerializedName("username")
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
@@ -1258,6 +1265,11 @@ interface RaveNowAPI {
|
|||||||
@Path("id") id: String
|
@Path("id") id: String
|
||||||
): Response<DataContainer<AccountProfile>>
|
): Response<DataContainer<AccountProfile>>
|
||||||
|
|
||||||
|
@POST("profile/trtc/batch")
|
||||||
|
suspend fun getAccountProfilesByTrtcBatch(
|
||||||
|
@Body body: BatchTrtcUserIdRequestBody
|
||||||
|
): Response<DataContainer<List<AccountProfile>>>
|
||||||
|
|
||||||
@POST("user/{id}/follow")
|
@POST("user/{id}/follow")
|
||||||
suspend fun followUser(
|
suspend fun followUser(
|
||||||
@Path("id") id: Int
|
@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.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
|
||||||
|
|
||||||
data class Conversation(
|
data class Conversation(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -76,6 +77,13 @@ object MessageListViewModel : ViewModel() {
|
|||||||
// noticeInfo = info
|
// noticeInfo = info
|
||||||
//
|
//
|
||||||
// isLoading = false
|
// 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.content.Context
|
||||||
import android.icu.util.Calendar
|
import android.icu.util.Calendar
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -28,6 +29,7 @@ import io.openim.android.sdk.models.Message
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
import com.aiosman.ravenow.utils.MessageParser
|
import com.aiosman.ravenow.utils.MessageParser
|
||||||
|
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
|
||||||
|
|
||||||
data class AgentConversation(
|
data class AgentConversation(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -53,7 +55,7 @@ data class AgentConversation(
|
|||||||
nickname = conversation.showName ?: "",
|
nickname = conversation.showName ?: "",
|
||||||
lastMessage = displayText, // 使用解析后的显示文本
|
lastMessage = displayText, // 使用解析后的显示文本
|
||||||
lastMessageTime = lastMessage.time.formatChatTime(context),
|
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,
|
unreadCount = conversation.unreadCount,
|
||||||
trtcUserId = conversation.userID ?: "",
|
trtcUserId = conversation.userID ?: "",
|
||||||
displayText = displayText,
|
displayText = displayText,
|
||||||
@@ -127,15 +129,7 @@ object AgentChatListViewModel : ViewModel() {
|
|||||||
OpenIMClient.getInstance().conversationManager.getAllConversationList(
|
OpenIMClient.getInstance().conversationManager.getAllConversationList(
|
||||||
object : OnBase<List<ConversationInfo>> {
|
object : OnBase<List<ConversationInfo>> {
|
||||||
override fun onSuccess(data: List<ConversationInfo>?) {
|
override fun onSuccess(data: List<ConversationInfo>?) {
|
||||||
// 过滤出智能体会话(单聊类型,且可能有特定标识)
|
continuation.resumeWith(Result.success(data ?: emptyList()))
|
||||||
val agentConversations = data?.filter { conversation ->
|
|
||||||
// 这里需要根据实际业务逻辑来过滤智能体会话
|
|
||||||
// 可能通过会话类型、用户ID前缀、或其他标识来判断
|
|
||||||
conversation.conversationType == 1 // 1 表示单聊
|
|
||||||
// 可以添加更多过滤条件,比如:
|
|
||||||
// && conversation.userID?.startsWith("ai_") == true
|
|
||||||
} ?: emptyList()
|
|
||||||
continuation.resumeWith(Result.success(agentConversations))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(code: Int, error: String?) {
|
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)
|
AgentConversation.convertToAgentConversation(conversation, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.index.tabs.message.tab
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.icu.util.Calendar
|
import android.icu.util.Calendar
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -25,6 +26,7 @@ import io.openim.android.sdk.models.Message
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
import com.aiosman.ravenow.utils.MessageParser
|
import com.aiosman.ravenow.utils.MessageParser
|
||||||
|
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
|
||||||
|
|
||||||
data class FriendConversation(
|
data class FriendConversation(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -135,13 +137,19 @@ object FriendChatListViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤出朋友会话(单聊类型,且排除 AI 智能体)
|
// 仅单聊
|
||||||
val filteredConversations = result.filter { conversation ->
|
val singleChats = result.filter { it.conversationType == 1 }
|
||||||
// 1 表示单聊,排除 AI 智能体会话
|
val trtcIds = singleChats.mapNotNull { it.userID }.distinct()
|
||||||
conversation.conversationType == 1 &&
|
// 预热缓存(包含AI)
|
||||||
// 可以根据实际业务逻辑添加更多过滤条件
|
try {
|
||||||
// 比如排除 AI 智能体的 userID 前缀或标识
|
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
|
||||||
!(conversation.userID?.startsWith("ai_") == true)
|
} 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 ->
|
friendChatList = filteredConversations.map { conversation ->
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ object GroupChatListViewModel : ViewModel() {
|
|||||||
|
|
||||||
private suspend fun loadGroupChatList(context: Context) {
|
private suspend fun loadGroupChatList(context: Context) {
|
||||||
// 检查 OpenIM 是否已登录
|
// 检查 OpenIM 是否已登录
|
||||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
if (!AppState.enableChat) {
|
||||||
android.util.Log.w("GroupChatListViewModel", "OpenIM 未登录,跳过加载群聊列表")
|
Log.w("GroupChatListViewModel", "OpenIM 未登录,跳过加载群聊列表")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +158,8 @@ object GroupChatListViewModel : ViewModel() {
|
|||||||
|
|
||||||
// 过滤出群聊会话(群聊类型)
|
// 过滤出群聊会话(群聊类型)
|
||||||
val filteredConversations = result.filter { conversation ->
|
val filteredConversations = result.filter { conversation ->
|
||||||
// 2 表示群聊类型
|
// 3 表示群聊类型
|
||||||
conversation.conversationType == 2
|
conversation.conversationType == 3
|
||||||
}
|
}
|
||||||
|
|
||||||
groupChatList = filteredConversations.map { conversation ->
|
groupChatList = filteredConversations.map { conversation ->
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ object PointsViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
loading = true
|
loading = true
|
||||||
|
// 并行预加载积分定价表(不影响UI)
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
PointService.refreshPointsRules()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("PointsViewModel", "refresh rules error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!AppStore.isGuest) {
|
if (!AppStore.isGuest) {
|
||||||
PointService.refreshMyPointsBalance()
|
PointService.refreshMyPointsBalance()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ rendering = "1.17.1"
|
|||||||
zoomable = "1.6.1"
|
zoomable = "1.6.1"
|
||||||
camerax = "1.3.4"
|
camerax = "1.3.4"
|
||||||
mlkitBarcode = "17.3.0"
|
mlkitBarcode = "17.3.0"
|
||||||
|
room = "2.6.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" }
|
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" }
|
zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" }
|
||||||
lottie = { module="com.airbnb.android:lottie-compose", version="6.6.10"}
|
lottie = { module="com.airbnb.android:lottie-compose", version="6.6.10"}
|
||||||
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkitBarcode" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user