Compare commits
6
Commits
feeb49ebeb
...
664fd8dcef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664fd8dcef | ||
|
|
b44e8d96b9 | ||
|
|
51ea890684 | ||
|
|
f413f8db55 | ||
|
|
cc2f266564 | ||
|
|
b7806951eb |
No files matched your search
@@ -11,11 +11,13 @@ import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.MetaRepository
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
import top.yeij.cyrene.data.repository.UploadRepository
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
|
||||
/** 手写 DI 容器(D:不引 Hilt)。 */
|
||||
@@ -76,6 +78,31 @@ class AppContainer(context: Context) {
|
||||
|
||||
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
|
||||
|
||||
private var cachedUploadApi: UploadApi? = null
|
||||
private var cachedUploadBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
|
||||
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
|
||||
*/
|
||||
val uploadApi: UploadApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedUploadApi
|
||||
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = UploadApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedUploadBaseUrl = baseUrl
|
||||
cachedUploadApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
|
||||
|
||||
private var cachedAdminApi: AdminApi? = null
|
||||
private var cachedAdminBaseUrl: String? = null
|
||||
|
||||
|
||||
@@ -15,17 +15,42 @@ private val Context.appDataStore: DataStore<Preferences> by preferencesDataStore
|
||||
|
||||
enum class ThemeMode { System, Light, Dark }
|
||||
|
||||
/**
|
||||
* 她正忙时按发送的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`)。
|
||||
*
|
||||
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **整键省略**
|
||||
* (服务端视同 `"all"`,与加入 `scope` 之前那条帧逐字节相同)。
|
||||
*/
|
||||
enum class InterruptMode(val wireScope: String?) {
|
||||
|
||||
/**
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断在跑回合。
|
||||
*
|
||||
* 该模式**不发** `interrupt`;一旦因别的触发点要发,就用省略 `scope` 的旧形状
|
||||
* (服务端 = `all`:取消在跑回合 + 清空待处理队列)——这是与旧版逐字节兼容的那一档。
|
||||
*/
|
||||
AppendQueue(null),
|
||||
|
||||
/**
|
||||
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
|
||||
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
|
||||
*/
|
||||
Interrupt("current"),
|
||||
}
|
||||
|
||||
/**
|
||||
* App 级偏好(非机密)。
|
||||
*
|
||||
* @param themeMode 主题深浅
|
||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val showNarration: Boolean = true,
|
||||
val thinkingExpanded: Boolean = true,
|
||||
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
|
||||
)
|
||||
|
||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
||||
@@ -40,6 +65,7 @@ class AppSettings(context: Context) {
|
||||
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
|
||||
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
|
||||
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
|
||||
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,15 +81,26 @@ class AppSettings(context: Context) {
|
||||
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setInterruptMode(mode: InterruptMode) {
|
||||
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
|
||||
}
|
||||
|
||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
||||
ThemeMode.Light.name -> ThemeMode.Light
|
||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
||||
else -> ThemeMode.System
|
||||
}
|
||||
|
||||
/** 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。 */
|
||||
private fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||
else -> InterruptMode.AppendQueue
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
||||
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
||||
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package top.yeij.cyrene.data.api
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
@@ -60,4 +62,17 @@ interface SessionsApi {
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("platform") platform: String? = null,
|
||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
||||
|
||||
/**
|
||||
* 推进已读游标(D4,契约 v0.37 `api-reference §3.6b`)。
|
||||
*
|
||||
* `before` 缺席 = 「现在」;游标**只前进**,所以重复 / 乱序 / 重试都幂等安全。
|
||||
* 错误面:`before` 非法 → `400`、未知会话(含隔离 `sess-xxx`)→ `404`、
|
||||
* 未注入 HistoryStore → `501`。成功后服务端会广播 `session_read`(§3.9)。
|
||||
*/
|
||||
@POST("api/v1/sessions/{id}/read")
|
||||
suspend fun markRead(
|
||||
@Path("id") sessionId: String,
|
||||
@Body body: ReadSessionRequest,
|
||||
): Response<ApiEnvelope<ReadSessionResponse>>
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.UploadResponse
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
/**
|
||||
* 图片上传端点(`POST /api/v1/upload`,api-client §1 行 13)。权限 `owner|admin`。
|
||||
*
|
||||
* **上传本身不是目的**:服务端把 `chat.image` 字符串**原样落库**,于是每一页历史与每一次
|
||||
* WS `history` 回放都要把整段 base64 再传一遍(协同 G3)。上传一次、消息里只留 URL,
|
||||
* 是这条链路上唯一能把「历史重量」永久压下去的手段。
|
||||
*
|
||||
* 与 [ChatApi] 分开:它的请求体是 multipart(不是 JSON)、错误面也完全不同
|
||||
* (`413 payload_too_large` / `415 unsupported_media_type` 是它的日常回执)。
|
||||
*
|
||||
* 返回原始 [Response] 而不是直接折成 `Result`:与其它端点一样由仓库层统一走
|
||||
* [ApiErrorMapper],状态码在这里**不承载额外语义**(`413`/`415` 的机器可读区分在
|
||||
* `error.code` 里,所以仓库能用 [apiCall])。
|
||||
*/
|
||||
interface UploadApi {
|
||||
|
||||
/**
|
||||
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`):字段名错了会回
|
||||
* `400 {error:{code:"bad_request",message:"file is required"}}`(已对着在线后端实测)。
|
||||
*/
|
||||
@Multipart
|
||||
@POST("api/v1/upload")
|
||||
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<UploadResponse>>
|
||||
}
|
||||
@@ -21,6 +21,16 @@ data class SessionSummaryDto(
|
||||
val pinned: Boolean = false,
|
||||
/** 会话还没有消息时**整个键缺席**。 */
|
||||
val lastMessage: SessionLastMessageDto? = null,
|
||||
/**
|
||||
* 未读条数(D4,契约 v0.37 起**恒下发**):`createdAt >` 该会话已读游标的消息条数,
|
||||
* `0` = 无未读。
|
||||
*
|
||||
* 有默认值是为了**旧快照仍能解码**:[top.yeij.cyrene.core.prefs.SessionPreferences]
|
||||
* 把整个列表原样持久化成 JSON,非默认值会让升级后第一次启动的整份快照解码失败,
|
||||
* 临时失效(列表先空、等 REST 回来)——那正是本应用最不该出现的表现。
|
||||
* 默认 `0` 的口径也是安全的:它只会让角标少显示一会儿,`Get /sessions` 一到就被纠正。
|
||||
*/
|
||||
val unreadCount: Int = 0,
|
||||
) {
|
||||
val isMain: Boolean
|
||||
get() = type == TYPE_MAIN || id == MAIN_SESSION_ID
|
||||
@@ -82,3 +92,26 @@ data class UpdateSessionResponse(
|
||||
data class DeleteSessionResponse(
|
||||
val deleted: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/v1/sessions/{id}/read`(D4)的请求体:可选地把已读游标推进到 `before`。
|
||||
*
|
||||
* - **键缺席 = 「现在」**(「我看到的都算已读」)。生产 `Json` 的 `explicitNulls = false`
|
||||
* 保证 `before = null` 时整个键缺席,序列化出来是 `{}`——服务端明确接受空对象
|
||||
* (`api-reference §3.6b`:**空 body / `{}` = 现在**)。
|
||||
* - 服务端对 `0` / 负数 / 非数字一律 `400`,所以**绝不能**把 `0` 发上去:调用方拿到
|
||||
* 非正的「没有时间戳」时必须折成缺席(null),而不是原样透传。
|
||||
* - 游标**只前进**(SQL `MAX(read_at, before)`),重复 / 乱序 / 重试都幂等安全。
|
||||
*/
|
||||
@Serializable
|
||||
data class ReadSessionRequest(
|
||||
val before: Long? = null,
|
||||
)
|
||||
|
||||
/** `POST /api/v1/sessions/{id}/read` → `200 {"data":{"sessionId":"<id>","readAt":<毫秒>}}`。 */
|
||||
@Serializable
|
||||
data class ReadSessionResponse(
|
||||
val sessionId: String = "",
|
||||
/** 推进**后**的游标(毫秒 Unix 时间戳,与该会话消息的 `createdAt` 同单位)。 */
|
||||
val readAt: Long = 0L,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `POST /api/v1/upload` 的成功载荷(api-client §1 行 13,v0.24 起的 B1 端点)。
|
||||
*
|
||||
* 线上形状(`internal/services/gateway/upload.go` 的 `uploadResponse`,字段名逐字如此):
|
||||
* ```
|
||||
* 201 {"data":{"url":"http://192.168.1.10:10942/api/v1/files/<32hex>.png","size":1234,"mime":"image/png","name":"<32hex>.png"}}
|
||||
* ```
|
||||
*
|
||||
* 语义要点:
|
||||
* - [url] 是**绝对同源地址**(`scheme://host/api/v1/files/<name>`),**可直接作 `chat.image`**:
|
||||
* 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 —— 所以这个字段必须按 URL 校验,
|
||||
* 不能原样塞回去(见 `UploadRepository`);
|
||||
* - [name] 是服务端生成的 **32 位小写 hex + 白名单扩展名**,客户端原始文件名**被丢弃**(不可信输入);
|
||||
* - [size] 字节数与 [mime] 都是**服务端嗅探/统计的结果**,不是客户端声明的——客户端传的
|
||||
* `Content-Type` 一律不被采信(`sniffImageMIME`)。
|
||||
*
|
||||
* 全部字段带默认值:解码器是生产同一份 `Json`(`ignoreUnknownKeys` / `coerceInputValues`),
|
||||
* 将来服务端加键不会让整次上传变成解析失败。**缺 `url`(或 `url` 不是绝对 http(s))仍要判失败**——
|
||||
* 那是「上传成功但拿不到可用地址」,由仓库层拦,绝不把空串/路径当 URL 发出去。
|
||||
*/
|
||||
@Serializable
|
||||
data class UploadResponse(
|
||||
val url: String = "",
|
||||
val size: Long = 0,
|
||||
val mime: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
@@ -8,6 +8,19 @@ import android.util.Base64
|
||||
import top.yeij.cyrene.domain.chat.ChatImageBudget
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* 一次重编码的产物。
|
||||
*
|
||||
* [dataUri] 与 [bytes] 是**同一张图**的两种形态:内联(base64 `data:` URI)用前者,
|
||||
* 上传(`POST /api/v1/upload`)用后者。上传时**不能**从 [dataUri] 反解一遍——那既多一次
|
||||
* 解码与分配,也让「上传的字节」和「内联的字节」有漂移的余地。
|
||||
*/
|
||||
class PreparedImage(
|
||||
val dataUri: String,
|
||||
val bytes: ByteArray,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
|
||||
*
|
||||
@@ -23,6 +36,10 @@ import java.io.ByteArrayOutputStream
|
||||
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
|
||||
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
|
||||
*
|
||||
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
|
||||
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
|
||||
* 编完之后的真实字节数才谈得上。
|
||||
*
|
||||
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
|
||||
*/
|
||||
object ChatImagePreparer {
|
||||
@@ -33,10 +50,10 @@ object ChatImagePreparer {
|
||||
private const val MAX_ATTEMPTS = 4
|
||||
|
||||
/**
|
||||
* @return 可直接作为 `image` 字段发送的 `data:` URI;失败返回 `null`。
|
||||
* @return 可内联发送的 `data:` URI 与可上传的同一份字节;失败返回 `null`。
|
||||
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
|
||||
*/
|
||||
fun prepare(context: Context, uri: Uri): String? {
|
||||
fun prepare(context: Context, uri: Uri): PreparedImage? {
|
||||
val raw = readBytes(context, uri) ?: return null
|
||||
val decoded = decodeScaled(raw) ?: return null
|
||||
return try {
|
||||
@@ -80,7 +97,7 @@ object ChatImagePreparer {
|
||||
}
|
||||
|
||||
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
|
||||
private fun encodeWithinBudget(source: Bitmap): String? {
|
||||
private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
|
||||
var width = source.width
|
||||
var height = source.height
|
||||
repeat(MAX_ATTEMPTS) { attempt ->
|
||||
@@ -92,7 +109,8 @@ object ChatImagePreparer {
|
||||
|
||||
if (ChatImageBudget.withinTarget(bytes.size)) {
|
||||
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
|
||||
return dataUri.takeIf { ChatImageBudget.withinServerLimit(it.length) }
|
||||
if (!ChatImageBudget.withinServerLimit(dataUri.length)) return null
|
||||
return PreparedImage(dataUri = dataUri, bytes = bytes, mime = mime)
|
||||
}
|
||||
width = (width / 2).coerceAtLeast(1)
|
||||
height = (height / 2).coerceAtLeast(1)
|
||||
|
||||
@@ -2,6 +2,8 @@ package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.ReadSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionRequest
|
||||
import top.yeij.cyrene.data.api.dto.UpdateSessionResponse
|
||||
@@ -49,4 +51,18 @@ class SessionRepository(
|
||||
suspend fun deleteSession(sessionId: String): Result<String> = withContext(dispatcher) {
|
||||
apiCall { api.deleteSession(sessionId) }.map { it.deleted }
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /sessions/{id}/read`(D4):推进该会话的已读游标,成功返回**推进后**的游标。
|
||||
*
|
||||
* @param before 毫秒时间戳;**null = 现在**(「我看到的都算已读」)。服务端对 `0` / 负数
|
||||
* 一律 `400`,所以调用方必须把「没有时间戳」表达成 null 而不是 0。
|
||||
*
|
||||
* 这是**幂等**的(服务端 `MAX(read_at, before)`,只前进),失败也没有副作用——
|
||||
* 唯一真源仍是服务端游标,漏标一次会在下一次 `GET /sessions` 自然暴露、再被纠正。
|
||||
*/
|
||||
suspend fun markRead(sessionId: String, before: Long? = null): Result<ReadSessionResponse> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.markRead(sessionId, ReadSessionRequest(before = before)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import top.yeij.cyrene.data.api.apiCall
|
||||
|
||||
/**
|
||||
* 一次成功上传的可用形态。
|
||||
*
|
||||
* 调用方(`ChatViewModel`)只需要 [url],其余三个字段由服务端生成、留着排查用:
|
||||
* [size] 是服务端统计的字节数、[mime] 是**服务端嗅探**出的类型(不是客户端声明的)、
|
||||
* [name] 是服务端存储名(客户端原始文件名已被丢弃)。
|
||||
*/
|
||||
data class UploadedImage(
|
||||
val url: String,
|
||||
val size: Long,
|
||||
val mime: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 图片上传取数层(`POST /api/v1/upload`)。
|
||||
*
|
||||
* ## 为什么这台机器存在
|
||||
* 服务端把 `chat.image` 字符串**原样落库**(api-client §1 行 3 + 协同 G3):内联的 base64
|
||||
* 会在**每一页历史**与**每一次 WS `history` 回放**里重传一遍。上传一次、消息里只留一个
|
||||
* URL,是唯一能让历史不再随图片长大的路。
|
||||
*
|
||||
* ## 错误语义(这里是本仓库与只读仓库的差别)
|
||||
* 用 [apiCall]:它的状态码**不承载额外语义**——`413 payload_too_large` 与
|
||||
* `415 unsupported_media_type` 的机器可读区分都在 `error.code` 里(已对着在线后端实测,
|
||||
* 见 `UploadDtoRealPayloadTest`),不像 `POST /api/v1/chat` 那样必须靠状态码分 `202` / `409`。
|
||||
* 于是网络 / 超时 / `401` / 服务端拒绝一律折进 `Result.failure`,调用方按 `code` 或
|
||||
* [CyreneError] 的类型分支。
|
||||
*
|
||||
* ## 两处**客户端**必须自己把关的地方(服务端帮不了)
|
||||
* 1. **`url` 必须是绝对 http(s) 地址**:`chat.image` 的视觉层只把 `http(s)://` 当 URL
|
||||
* (`vision.ParseImageRef` 的判据就是 `strings.HasPrefix(src, "http://")`),裸路径
|
||||
* (如 `/api/v1/files/x.png`)会被当成 **base64** 载荷。拿到这种 url 还照发,就是把这一轮
|
||||
* 丢进「图片解析失败」的黑洞——所以这里直接判解析失败,宁可当场重试。
|
||||
* 2. **`url` 不能为空**:`201` 却没有可用地址 = 上传了但拿不到东西,同样判失败,**绝不**
|
||||
* 退回内联 base64(那正是本任务要消灭的成本)。
|
||||
*/
|
||||
class UploadRepository(
|
||||
private val api: UploadApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 上传一张**已重编码**的图片,返回可作 `chat.image` 的绝对 URL。
|
||||
*
|
||||
* [bytes] 与 [mime] 必须来自同一次编码(`ChatImagePreparer` 的产物):
|
||||
* 服务端按**字节嗅探**判类型,声明的 `Content-Type` 只作参考,两者不一致时以字节为准。
|
||||
*/
|
||||
suspend fun uploadImage(bytes: ByteArray, mime: String): Result<UploadedImage> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.upload(partOf(bytes, mime)) }.fold(
|
||||
onSuccess = { data ->
|
||||
val url = absoluteHttpUrl(data.url)
|
||||
if (url == null) {
|
||||
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
} else {
|
||||
Result.success(
|
||||
UploadedImage(url = url, size = data.size, mime = data.mime, name = data.name),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { Result.failure(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart 部件。
|
||||
*
|
||||
* **部件名固定 `file`**(服务端 `FormFile("file")`)。文件名也**必须给**:Go 的
|
||||
* `mime/multipart` 只把带 `filename` 参数的部件当**文件**,不带的会落进普通表单值、
|
||||
* 于是 `FormFile` 找不到它。扩展名与服务端白名单一致(服务端丢弃原始名,这里只是让
|
||||
* 线上的部件看起来像一张真图,中间件不会因为怪名字做多余判断)。
|
||||
*/
|
||||
private fun partOf(bytes: ByteArray, mime: String): MultipartBody.Part =
|
||||
MultipartBody.Part.createFormData(
|
||||
name = UPLOAD_FIELD_NAME,
|
||||
filename = fileNameFor(mime),
|
||||
body = bytes.toRequestBody(mime.toMediaTypeOrNull()),
|
||||
)
|
||||
|
||||
private fun fileNameFor(mime: String): String = "upload." + when (mime) {
|
||||
MIME_PNG -> "png"
|
||||
MIME_GIF -> "gif"
|
||||
MIME_WEBP -> "webp"
|
||||
MIME_BMP -> "bmp"
|
||||
else -> "jpg"
|
||||
}
|
||||
|
||||
/** 只认绝对 http(s) 地址,且**大小写敏感**(与服务端 `ParseImageRef` 的判据同一口径:放宽只会放行服务端不认的东西)。 */
|
||||
private fun absoluteHttpUrl(raw: String): String? = raw.trim()
|
||||
.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
||||
}
|
||||
|
||||
private const val UPLOAD_FIELD_NAME = "file"
|
||||
|
||||
private const val MIME_PNG = "image/png"
|
||||
private const val MIME_GIF = "image/gif"
|
||||
private const val MIME_WEBP = "image/webp"
|
||||
private const val MIME_BMP = "image/bmp"
|
||||
@@ -155,8 +155,18 @@ class ChatSocket(
|
||||
),
|
||||
)
|
||||
|
||||
/** 发送 `interrupt`(取消在跑 turn + 清空 pending)。 */
|
||||
fun sendInterrupt(sessionId: String): Boolean = send(WsOutbound.Interrupt(sessionId))
|
||||
/**
|
||||
* 发送 `interrupt`(api-client §3.1;契约 v0.56/A2 起支持 `scope`)。
|
||||
*
|
||||
* @param scope 打断范围:`null` = **省略该键**(服务端视同 `"all"`:取消在跑回合 + 清空待处理队列,
|
||||
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
|
||||
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
|
||||
*
|
||||
* **今天没有任何调用方**:聊天的「打断」触发点属聊天区那一轮,本 API 先把传输面备好——
|
||||
* 调用方从 App 偏好(`InterruptMode`)取 `scope` 后传进来即可。
|
||||
*/
|
||||
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
|
||||
|
||||
private fun send(outbound: WsOutbound): Boolean {
|
||||
if (_state.value != WsState.Connected) return false
|
||||
|
||||
@@ -309,6 +309,26 @@ sealed class WsEvent {
|
||||
override val type: String get() = "chat_duplicate"
|
||||
}
|
||||
|
||||
/**
|
||||
* 已读游标推进(D4,契约 v0.37,api-client §3.9):
|
||||
* `{type:"session_read", sessionId, data:{readAt}}`——**载荷里没有会话 id**,
|
||||
* 会话归属**只**来自帧壳(服务端 `POST /sessions/{id}/read` 成功后广播)。
|
||||
*
|
||||
* 它是那 7 个「不含内容的状态机信号」之一(发给**所有**已认证连接,含 `viewer`),
|
||||
* 与 `message_persisted` 同一条全连接广播路径,故同样要按 [isForSession] 收敛。
|
||||
* [readAt] 是**推进后**的游标(毫秒,与该会话消息的 `createdAt` 同单位);
|
||||
* data 缺席时回落 `0`——游标实际不可能为 0,于是「坏帧」天然退化成**什么也不改**
|
||||
* (见 `SessionItem.withReadCursor`:只有 `lastMessageAt <= readAt` 才清零)。
|
||||
*/
|
||||
data class SessionRead(
|
||||
override val sessionId: String,
|
||||
val readAt: Long,
|
||||
override val turnId: String? = null,
|
||||
override val seq: Long? = null,
|
||||
) : WsEvent() {
|
||||
override val type: String get() = "session_read"
|
||||
}
|
||||
|
||||
/** 主会话(`web` / `web:<uuid>`)消息落库后广播。 */
|
||||
data class MessagePersisted(
|
||||
val message: HistoryMessageDto?,
|
||||
@@ -459,6 +479,9 @@ sealed class WsEvent {
|
||||
seq,
|
||||
)
|
||||
}
|
||||
// 已读游标(D4 §3.9):会话归属只在帧壳上(data 只有 readAt)。
|
||||
"session_read" -> decode<SessionReadData>(data)
|
||||
.let { SessionRead(resolveSessionId(sessionId, ""), it.readAt, turnId, seq) }
|
||||
"message_persisted" -> decode<PersistedData>(data)
|
||||
.let { MessagePersisted(it.message, turnId, seq, sessionId ?: it.message?.sessionId) }
|
||||
"channel_persisted" -> decode<PersistedData>(data)
|
||||
@@ -567,6 +590,10 @@ private data class ChatDuplicateData(
|
||||
val messageId: String? = null,
|
||||
)
|
||||
|
||||
/** `session_read` 载荷(api-client §3.9):`{readAt}`——毫秒游标,**不带**会话 id。 */
|
||||
@Serializable
|
||||
private data class SessionReadData(val readAt: Long = 0L)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedData(val message: HistoryMessageDto? = null)
|
||||
|
||||
|
||||
@@ -56,14 +56,25 @@ sealed interface WsOutbound {
|
||||
}
|
||||
}
|
||||
|
||||
/** `interrupt`:`{sessionId}`。命中空闲会话为 no-op。 */
|
||||
/** `interrupt`:`{sessionId, scope?}`。命中空闲会话为 no-op。 */
|
||||
data class Interrupt(
|
||||
val sessionId: String,
|
||||
/**
|
||||
* 打断范围(契约 v0.56/A2,api-client §3.1):取值域 `{"", "all", "current"}`。
|
||||
* `null` = **整键省略**——服务端视同 `"all"`(取消在跑回合 + 清空待处理队列),
|
||||
* 也就是加入该字段之前那条帧**逐字节相同**;`"current"` = 只取消在跑回合、**保留**待处理队列。
|
||||
* 其它取值服务端定向回 `error{bad_request}`(不打断任何会话)——故本层**原样透传**,
|
||||
* 不做取值校验、不静默回退成默认(服务端的开放取值域由服务端裁决)。
|
||||
*/
|
||||
val scope: String? = null,
|
||||
override val reqId: String? = null,
|
||||
) : WsOutbound {
|
||||
override val type: String get() = "interrupt"
|
||||
|
||||
override fun data(): JsonElement = buildJsonObject { put("sessionId", sessionId) }
|
||||
override fun data(): JsonElement = buildJsonObject {
|
||||
put("sessionId", sessionId)
|
||||
scope?.let { put("scope", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 一张**已重编码**的图片该怎么送上去。 */
|
||||
enum class ImageDelivery {
|
||||
/** 直接把 `data:` URI 塞进 `chat.image`:一次请求,但字节永久留在历史里。 */
|
||||
Inline,
|
||||
|
||||
/** 先 `POST /api/v1/upload` 换 URL,再把 URL 当 `chat.image`:多一次往返,历史里只留几十字节。 */
|
||||
Upload,
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联(base64)与上传(URL)的分界(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 这是一笔什么账
|
||||
* 服务端把 `chat.image` 字符串**原样落库**,所以两边的成本不是「发一次」而是「发多少次」:
|
||||
*
|
||||
* | | 发出时 | 之后每一次历史取数 |
|
||||
* |---|---|---|
|
||||
* | 内联 | 请求体里带 base64(4/3 × B 字符) | **再带一遍**(REST 每页、WS `history` 每次回放、每次上拉) |
|
||||
* | 上传 | 多**一次** HTTP 往返;字节同样只传一遍 | 只剩那个 URL(几十字节) |
|
||||
*
|
||||
* 也就是说:上传不是「省一次流量」,是**用一次往返买断此后所有的历史重量**。
|
||||
*
|
||||
* ## 阈值:64 KiB([INLINE_MAX_BYTES],含等于)
|
||||
* 重编码后的字节数 `B`,内联的代价是每次历史取数再多 `4/3 × B`:
|
||||
* - `B = 64 KiB` → 每次取数 +85 KB。一个会话里 20 张这种图,每次冷启动 / 重连回放就要多传
|
||||
* ≥1.7 MB(1 Mbps 下约 14 秒纯图片流量),而那次上传往返在局域网是毫秒、在移动网也就是
|
||||
* 一两百毫秒——**从第二次取数起就稳赚**。
|
||||
* - `B ≈ 32 KiB`(缩略图、小截图)→ 每次取数 +43 KB,省下的量与一次往返本身的量级相当,
|
||||
* 而多出来的一次请求**必然**多一个失败面(上传失败就得让用户重发,内联不会)。
|
||||
* 这种量级下「一次请求发完」更划算,所以留在内联。
|
||||
*
|
||||
* 阈值取整到 64 KiB 而不是更细的数,是因为它本来就是个成本量级的判断,不是服务端限制:
|
||||
* 服务端的两个 5 MiB 上限(`chat.image` 与 `/upload`)离这里都还差两个数量级,
|
||||
* **本决策与「装不装得下」无关**(重编码出口已保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES])。
|
||||
*/
|
||||
object ChatImageDelivery {
|
||||
|
||||
/**
|
||||
* 内联与上传的分界:**重编码后**的字节数(含等于 → 内联)。
|
||||
*
|
||||
* 必须严格小于 [ChatImageBudget.MAX_DECODED_BYTES],否则上传分支永远走不到(死代码);
|
||||
* 这一条由 `ChatImageDeliveryTest` 钉住。
|
||||
*/
|
||||
const val INLINE_MAX_BYTES = 64 * 1024
|
||||
|
||||
/** @param preparedBytes [top.yeij.cyrene.data.image.ChatImagePreparer] 产出的字节数。 */
|
||||
fun forPreparedBytes(preparedBytes: Int): ImageDelivery =
|
||||
if (preparedBytes <= INLINE_MAX_BYTES) ImageDelivery.Inline else ImageDelivery.Upload
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.ChatRequest
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.image.ChatImagePreparer
|
||||
import top.yeij.cyrene.data.image.PreparedImage
|
||||
import top.yeij.cyrene.data.repository.ChatAck
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.historyForSession
|
||||
@@ -23,7 +25,9 @@ import top.yeij.cyrene.data.ws.ChatSocket
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
import top.yeij.cyrene.data.ws.WsState
|
||||
import top.yeij.cyrene.data.ws.isForSession
|
||||
import top.yeij.cyrene.domain.chat.ChatImageDelivery
|
||||
import top.yeij.cyrene.domain.chat.ChatSendRouting
|
||||
import top.yeij.cyrene.domain.chat.ImageDelivery
|
||||
import top.yeij.cyrene.domain.chat.SendChannel
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
import top.yeij.cyrene.domain.parse.StreamingContentParser
|
||||
@@ -219,6 +223,9 @@ data class ChatUiState(
|
||||
*
|
||||
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
|
||||
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
|
||||
* 图片在 REST 之前还多一层选择:小图内联成 `data:` URI,大图先 `POST /api/v1/upload`
|
||||
* 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页
|
||||
* 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。
|
||||
*
|
||||
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
|
||||
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
|
||||
@@ -287,7 +294,9 @@ class ChatViewModel(
|
||||
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
|
||||
*
|
||||
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
|
||||
* 气泡同步上屏,所以等待期间用户已经能看到它。
|
||||
* 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
|
||||
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
|
||||
* 也不把几 MB base64 永久留在每一页历史里。
|
||||
*/
|
||||
fun send() {
|
||||
val snapshot = _state.value
|
||||
@@ -324,7 +333,30 @@ class ChatViewModel(
|
||||
if (!sent) rollback(localId, "发送失败,请重试")
|
||||
}
|
||||
|
||||
SendChannel.Rest -> sendViaRest(localId, text, image)
|
||||
SendChannel.Rest -> if (image == null) {
|
||||
sendViaRest(localId, text, image = null)
|
||||
} else {
|
||||
deliverImage(localId, text, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image`
|
||||
* (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。
|
||||
*
|
||||
* **上传失败不回退内联**:回退等于把「省历史重量」这件事当场作废——用户以为发的是 URL、
|
||||
* 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与
|
||||
* [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。
|
||||
*/
|
||||
private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) {
|
||||
when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) {
|
||||
ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri)
|
||||
|
||||
ImageDelivery.Upload -> container.uploadRepository
|
||||
.uploadImage(image.bytes, image.mime)
|
||||
.onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url) }
|
||||
.onFailure { e -> rollback(localId, uploadFailureMessage(e)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,5 +699,24 @@ class ChatViewModel(
|
||||
private fun Throwable.userMessage(): String =
|
||||
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试"
|
||||
|
||||
/**
|
||||
* 上传失败的文案(纯函数,可单测)。
|
||||
*
|
||||
* 前两条是服务端的**机器可读**拒绝码(对着在线后端实测过,见 `UploadDtoRealPayloadTest`):
|
||||
* 在本端预算下它们不该出现(重编码出口 ≤ 1 MiB,类型非 png 即 jpeg,都在白名单里),
|
||||
* 但真出现时正确的动作是「换一张图」而不是「重试」,文案必须把这两者分开。
|
||||
*
|
||||
* 其余(网络 / 超时 / 5xx)用既有错误口径,并**点明是上传这一步**失败——否则用户看到
|
||||
* 「无法连接到服务器」会以为整条消息没发出去,而实际上文字也是跟着一起回滚的。
|
||||
*/
|
||||
fun uploadFailureMessage(e: Throwable): String {
|
||||
val error = (e as? CyreneException)?.error
|
||||
return when ((error as? CyreneError.Server)?.code) {
|
||||
"payload_too_large" -> "图片超过服务端 5 MB 上限,请换一张更小的"
|
||||
"unsupported_media_type" -> "服务端不接受这张图片的格式,请换一张"
|
||||
else -> "图片上传失败," + (error?.userMessage ?: "请重试")
|
||||
}
|
||||
}
|
||||
|
||||
/** 思维链在流式结束后继续保留的时长。 */
|
||||
private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L
|
||||
@@ -21,10 +21,16 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.Badge
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
@@ -157,6 +163,19 @@ fun ColumnScope.SessionDrawerContent(
|
||||
* 会话行。手势只有**一个**检测器:整个行由 `combinedClickable` 负责单击/长按,
|
||||
* `BasicComponent` 因此不再传 `onClick`(传了它内部会再装一个 clickable,
|
||||
* 子级会先吃掉按下事件,长按就永远不会触发)。
|
||||
*
|
||||
* ## 为什么不用 `BasicComponent` 的 `title` / `summary` 参数
|
||||
* 那两个参数只收 `String`,塞不下未读角标;而角标**不能**放进 `endActions`——
|
||||
* 抽屉固定 300dp,卡片 + 内边距之后只剩约 244dp,而 `endActions` 里已经有三个 40dp 的
|
||||
* 图标按钮(120dp),`BasicComponent` 又把它内部可用宽度的 60% 作为 `endActions` 的硬上限:
|
||||
* 再塞一个角标会把图标挤到裁剪(Row 不换行),标题列也被压到约 95dp。
|
||||
* 所以这里改用「内容重载」自己搭标题行:`[标题][角标]` 同行,角标紧挨标题、不占额外列宽。
|
||||
*
|
||||
* 代价是标题 / 摘要的字号、字重、颜色要自己写,取值与库内实现(`Component.kt`)逐字一致
|
||||
* (`headline1.fontSize` + `Medium` + `BasicComponentDefaults.titleColor()`;
|
||||
* `body2.fontSize` + `summaryColor()`),**唯一的差异是收紧了行数**:
|
||||
* 标题与摘要都 `maxLines = 1` + 省略号——这正是「可读性要强」要的效果:长标题不再在约 116dp 的
|
||||
* 列里折成两三行把卡片撑高,而是稳定地占一行、尾部省略,列表因此等高、可扫读。
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
@@ -167,11 +186,13 @@ private fun SessionRow(
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val titleColor = BasicComponentDefaults.titleColor().color
|
||||
val summaryColor = BasicComponentDefaults.summaryColor().color
|
||||
val badge = unreadBadgeText(session.unreadCount)
|
||||
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onRename),
|
||||
title = session.title,
|
||||
summary = session.lastMessage,
|
||||
endActions = {
|
||||
if (active) {
|
||||
Text(
|
||||
@@ -202,10 +223,53 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = session.title,
|
||||
// 标题是这一行唯一的权重子项,`fill = false` 让短标题只占自己需要的宽度。
|
||||
// 真正保证角标不被挤掉的是 Row 的测量顺序:**非权重子项(角标与它前面的间距)
|
||||
// 先量**,权重只分配剩下的空间,标题再在这块空间里省略——
|
||||
// 所以 `99+` 永远进得来,标题也不会被压到完全看不见。
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
fontSize = MiuixTheme.textStyles.headline1.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = titleColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (badge != null) {
|
||||
Spacer(Modifier.width(UNREAD_BADGE_GAP))
|
||||
// Miuix 自带角标:错误色药丸 + `onError` 文字 + 16dp 下限高度,
|
||||
// 在 `surfaceContainer` 卡片上对比度足够;`99+` 会自然撑成药丸而不会溢出。
|
||||
Badge(
|
||||
modifier = Modifier.clearAndSetSemantics {
|
||||
contentDescription = "未读 $badge"
|
||||
},
|
||||
) {
|
||||
Text(badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.lastMessage?.let { preview ->
|
||||
Text(
|
||||
text = preview,
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = summaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 标题与角标之间的间距:太小会粘成一个词,太大又脱离标题。 */
|
||||
private val UNREAD_BADGE_GAP = 6.dp
|
||||
|
||||
/** 重命名对话框:服务端不接受空标题(400)也不接受超过 80 个字符,两者都在这里挡住。 */
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
|
||||
@@ -12,6 +12,8 @@ import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
data class SessionItem(
|
||||
val id: String,
|
||||
@@ -20,6 +22,13 @@ data class SessionItem(
|
||||
val pinned: Boolean,
|
||||
/** 最后一条消息的一行摘要;会话还没有消息时为 null。 */
|
||||
val lastMessage: String?,
|
||||
/**
|
||||
* 最后一条消息的时间戳(毫秒);会话还没有消息时为 null。
|
||||
* 只用于已读游标比对(见 [SessionItem.withReadCursor]),不直接显示。
|
||||
*/
|
||||
val lastMessageAt: Long? = null,
|
||||
/** 未读条数(服务端 `unreadCount`,D4);`0` = 无未读、不显示角标。 */
|
||||
val unreadCount: Int = 0,
|
||||
)
|
||||
|
||||
data class SessionsUiState(
|
||||
@@ -37,6 +46,21 @@ data class SessionsUiState(
|
||||
* 启动先用本地快照渲染(指纹不匹配就不用),再拉服务端;成功则整体替换并回写快照,
|
||||
* 失败则保留快照并提示。顺序完全信任服务端(`pinned DESC, main DESC, created_at ASC`),
|
||||
* 客户端不重排。
|
||||
*
|
||||
* ## 未读(D4)
|
||||
* 未读条数来自服务端(`GET /sessions` 的 `unreadCount`,恒下发),本类只在三处改它:
|
||||
* 列表整表替换(以服务端为准)、**进入会话**、以及**正在看的会话有新消息落库**——后两处
|
||||
* 同时调 `POST /sessions/{id}/read` 把游标推上去(策略详见 [pushReadCursor])。
|
||||
* 反向同步(别的端读了)靠下行 `session_read` 帧。
|
||||
*
|
||||
* ## 为什么本类自己开一条 WS 连接
|
||||
* `session_read` / `message_persisted` 都在 WS 上,而 `AppContainer` 只提供
|
||||
* `createChatSocket()`(**每次调用新建**,没有进程级共享连接),`ChatSocket` / `ChatViewModel`
|
||||
* 又都在别的改动范围里。要在这里收帧,唯一不改别人的办法就是自持一条连接。
|
||||
* 代价如实说:这是本应用**已知遗留问题**(「每 VM 一条 socket、旧 socket 不关」,见
|
||||
* `devdocs/05`)之上再加一条——服务端对同一账号的多连接是支持的(多端场景本来如此),
|
||||
* 而且本类只关心两个帧类型,收不到也不会让功能失效(`GET /sessions` 会收敛)。
|
||||
* 生命周期:登录后连接、登出断开、VM 销毁断开([onCleared])。
|
||||
*/
|
||||
class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
@@ -45,6 +69,21 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val prefs = container.sessionPreferences
|
||||
|
||||
/**
|
||||
* 本端**已知的已读游标**(会话 id → 毫秒),写入值一律取自**服务端口径**:
|
||||
* - `POST /sessions/{id}/read` 响应里的 `readAt`(权威);
|
||||
* - 下行 `session_read` 帧的 `readAt`(可能是别的端推的);
|
||||
* - 本端对某条落库消息发起的推进,先用该消息的 `createdAt` 占位——它也是服务端时间,
|
||||
* 所以不存在设备时钟偏差。
|
||||
*
|
||||
* 唯一用途是 [withReadCursors]:整表替换(REST 快照)时,把「本地已知没有比游标更新的
|
||||
* 消息」的那些行的未读压回 0。不做这件事的话,刚在别处读完、或本端刚标记过的会话,
|
||||
* 会因为一次竞态中的 GET 把角标又闪回来。
|
||||
*/
|
||||
private val readCursors = mutableMapOf<String, Long>()
|
||||
|
||||
private val socket: ChatSocket = container.createChatSocket()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val fingerprint = fingerprint()
|
||||
@@ -52,7 +91,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
val snapshot = cached.snapshotFor(fingerprint)
|
||||
_state.update {
|
||||
it.copy(
|
||||
sessions = snapshot.map { dto -> dto.toItem() },
|
||||
sessions = snapshot.map { dto -> dto.toItem() }.withReadCursors(readCursors),
|
||||
activeSessionId = if (snapshot.isEmpty()) {
|
||||
SessionSummaryDto.MAIN_SESSION_ID
|
||||
} else {
|
||||
@@ -65,15 +104,34 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
refresh()
|
||||
}
|
||||
// 登出时把快照一起清掉(先例:EnumCatalogViewModel 在登出时清枚举快照)——
|
||||
// 否则换账号后第一帧会闪一份上个账号的会话列表。
|
||||
// 否则换账号后第一帧会闪一份上个账号的会话列表。已读游标同理:它是**账号级**的
|
||||
// (服务端单一游标),留着会让新账号的会话被旧账号的游标错误地清成已读。
|
||||
viewModelScope.launch {
|
||||
container.authManager.state.collect { auth ->
|
||||
if (auth == SessionState.LoggedOut) {
|
||||
prefs.clear()
|
||||
_state.value = SessionsUiState()
|
||||
when (auth) {
|
||||
SessionState.LoggedOut -> {
|
||||
socket.close()
|
||||
readCursors.clear()
|
||||
prefs.clear()
|
||||
_state.value = SessionsUiState()
|
||||
}
|
||||
|
||||
// 连接只在登录后建立:Socket 的令牌 / 地址都是每次重连现读的,
|
||||
// connect() 幂等(已连接时直接返回)。本 VM 存活期跨越登出 → 重登,
|
||||
// 所以「登出断开、重登接上」必须在这里显式表达。
|
||||
is SessionState.LoggedIn -> socket.connect()
|
||||
|
||||
SessionState.Unknown -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未读的两个下行来源都在这里收敛;见 [onEvent]。
|
||||
viewModelScope.launch { socket.events.collect(::onEvent) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
socket.close()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +153,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
if (active != _state.value.activeSessionId) prefs.setActive(active)
|
||||
_state.update {
|
||||
it.copy(
|
||||
sessions = list.map { dto -> dto.toItem() },
|
||||
// 未读以服务端为准,但先过一遍本地已知游标:一次竞态中的 GET
|
||||
// (快照早于本端刚发起的 read)不该把角标闪回来。
|
||||
sessions = list.map { dto -> dto.toItem() }.withReadCursors(readCursors),
|
||||
activeSessionId = active,
|
||||
loading = false,
|
||||
fromCache = false,
|
||||
@@ -116,9 +176,23 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 / 进入会话。
|
||||
*
|
||||
* **进入即已读**(D4):用户点开这个会话,聊天页紧接着会加载并显示它的最新一页,
|
||||
* 所以立刻把游标推到「现在」(`before` 缺席 = 服务端取现在)——这比推一个本地
|
||||
* `lastMessage.createdAt` 更诚实:本地的列表可能已经陈旧,而聊天页看到的一定是最新的。
|
||||
* 先就地清零(不等网络往返,角标立刻消失),再用响应里的权威游标收敛。
|
||||
*
|
||||
* `createSession` / `deleteSession` 也走这里,语义同样成立:那两个动作之后,
|
||||
* 屏幕上(即将)显示的就是这个会话。
|
||||
*/
|
||||
fun select(sessionId: String) {
|
||||
_state.update { it.copy(activeSessionId = sessionId) }
|
||||
_state.update {
|
||||
it.copy(activeSessionId = sessionId, sessions = it.sessions.markRead(sessionId))
|
||||
}
|
||||
viewModelScope.launch { prefs.setActive(sessionId) }
|
||||
pushReadCursor(sessionId)
|
||||
}
|
||||
|
||||
fun createSession() {
|
||||
@@ -184,6 +258,76 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
fun dismissError() = _state.update { it.copy(error = null) }
|
||||
|
||||
/**
|
||||
* 下行帧收敛(未读相关只有两类)。
|
||||
*
|
||||
* **这里刻意不用 [top.yeij.cyrene.data.ws.isForSession] 过滤**:那道闸是给「一个 VM 只关心
|
||||
* 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId
|
||||
* 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id
|
||||
* **逐行定位**([applyReadCursor],影响面严格限制在那一行)。
|
||||
*/
|
||||
private fun onEvent(event: WsEvent) {
|
||||
when (event) {
|
||||
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
|
||||
is WsEvent.SessionRead -> onSessionRead(event)
|
||||
// 正在看的会话落了新消息:消息已经显示在屏幕上了,立刻推进游标,
|
||||
// 否则「自己正看着的会话」会攒出一个未读角标(契约 §3.9 的客户端处理建议)。
|
||||
is WsEvent.MessagePersisted -> onMessagePersisted(event)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSessionRead(event: WsEvent.SessionRead) {
|
||||
if (event.sessionId.isBlank()) return
|
||||
recordReadCursor(event.sessionId, event.readAt)
|
||||
_state.update { it.copy(sessions = it.sessions.applyReadCursor(event.sessionId, event.readAt)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* `message_persisted`:只有**当前打开的那个会话**才推进游标。
|
||||
*
|
||||
* 别的会话攒未读是正确行为(红点本来就该亮),所以这里不碰它们的计数——
|
||||
* 本端只做「看到即已读」,不做「替服务端数未读」(增量计数会与编辑 / 删除 / 别端操作漂移,
|
||||
* 而抽屉每次打开都会静默重拉,以服务端为准)。
|
||||
*/
|
||||
private fun onMessagePersisted(event: WsEvent.MessagePersisted) {
|
||||
val sessionId = event.sessionId?.takeIf { it.isNotBlank() } ?: return
|
||||
if (sessionId != _state.value.activeSessionId) return
|
||||
val message = event.message ?: return
|
||||
// `createdAt` 是服务端时间:用它当 before 比「现在」更精确——只把**这条**及其之前标成
|
||||
// 已读,同时落库的其它消息(时间更晚)仍算未读。缺时间戳时退化成「现在」,语义见 select()。
|
||||
val before = message.createdAt.takeIf { it > 0 }
|
||||
before?.let { recordReadCursor(sessionId, it) }
|
||||
_state.update { it.copy(sessions = it.sessions.markRead(sessionId)) }
|
||||
pushReadCursor(sessionId, before)
|
||||
}
|
||||
|
||||
/**
|
||||
* 推进已读游标(D4)。**失败静默**:这是一次后台的「顺手记一下」,不是用户发起的操作——
|
||||
* 弹错误横幅会为一件用户没要求、也不影响使用的失败打扰他;而且服务端游标才是唯一真源,
|
||||
* 漏标一次会在下一次 `GET /sessions` 暴露出来、再被 [select] / [onMessagePersisted] 纠正。
|
||||
* 请求本身幂等(服务端 `MAX(read_at, before)`),重复发不会把已读退回未读。
|
||||
*
|
||||
* @param before null = 现在。**绝不能传 0**:服务端把 `0` / 负数当非法参数回 400。
|
||||
*/
|
||||
private fun pushReadCursor(sessionId: String, before: Long? = null) {
|
||||
viewModelScope.launch {
|
||||
container.sessionRepository.markRead(sessionId, before)
|
||||
.onSuccess { response ->
|
||||
if (response.readAt > 0) recordReadCursor(sessionId, response.readAt)
|
||||
_state.update {
|
||||
it.copy(sessions = it.sessions.applyReadCursor(sessionId, response.readAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 记下已知游标:只接受正数(坏帧的 `readAt = 0` 不能污染成「读到纪元」)。 */
|
||||
private fun recordReadCursor(sessionId: String, readAt: Long) {
|
||||
if (readAt <= 0) return
|
||||
readCursors[sessionId] = maxOf(readCursors[sessionId] ?: 0L, readAt)
|
||||
}
|
||||
|
||||
private fun fingerprint(): String =
|
||||
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
|
||||
}
|
||||
@@ -194,8 +338,57 @@ internal fun SessionSummaryDto.toItem(): SessionItem = SessionItem(
|
||||
isMain = isMain,
|
||||
pinned = pinned,
|
||||
lastMessage = lastMessage?.content?.toPreviewText()?.takeIf { it.isNotBlank() },
|
||||
lastMessageAt = lastMessage?.createdAt?.takeIf { it > 0 },
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
|
||||
/**
|
||||
* 未读角标的文本(纯函数)。`null` = **不显示**角标。
|
||||
*
|
||||
* `0` 与负数都不显示(服务端只会给非负整数,负数当数据错误处理,不画一个「-1」)。
|
||||
* 超过 [UNREAD_BADGE_MAX] 收敛为 `99+`:三位数的角标在 300dp 的抽屉里会把标题挤没,
|
||||
* 而三位数以上的精确值对「有多少条没看」几乎没有信息增量。
|
||||
*/
|
||||
internal fun unreadBadgeText(count: Int): String? = when {
|
||||
count <= 0 -> null
|
||||
count > UNREAD_BADGE_MAX -> "$UNREAD_BADGE_MAX+"
|
||||
else -> count.toString()
|
||||
}
|
||||
|
||||
/** 角标显示的未读上限;超过就显示 `99+`。 */
|
||||
internal const val UNREAD_BADGE_MAX = 99
|
||||
|
||||
/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */
|
||||
internal fun List<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
|
||||
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
|
||||
|
||||
/** 把某一行的未读清零(`session_read` 帧、或本端刚标记过)。 */
|
||||
internal fun List<SessionItem>.applyReadCursor(sessionId: String, readAt: Long): List<SessionItem> =
|
||||
map { if (it.id == sessionId) it.withReadCursor(readAt) else it }
|
||||
|
||||
/**
|
||||
* 用户**正在看**这个会话(或本端刚为它推进过游标)时,无条件清掉它的未读。
|
||||
* 与 [withReadCursor] 的区别:这里不需要游标依据——调用方已经知道「这一行就是屏幕上的那一行」
|
||||
* (进入会话、正在看的会话落了新消息)。
|
||||
*/
|
||||
internal fun List<SessionItem>.markRead(sessionId: String): List<SessionItem> =
|
||||
map { if (it.id == sessionId) it.copy(unreadCount = 0) else it }
|
||||
|
||||
/**
|
||||
* 按已读游标清未读(纯函数):**只在「本地已知的最新一条消息不比游标新」时清零**。
|
||||
*
|
||||
* 为什么不能一律清零(api-client §3.9 说「直接清零也不会错」):那边的假设是「本地没有更完整的
|
||||
* 数据」。本端有——`lastMessage.createdAt` 就是本地已知的最新消息时间。若它**晚于**游标,
|
||||
* 说明确实还有未读(游标只前进,这只能是读完以后新到的消息),此时清零就是**把真实未读藏起来**。
|
||||
* 保留旧计数更接近真相:精确条数本地算不出来(没有逐条时间线),等下次 `GET /sessions` 收敛。
|
||||
*
|
||||
* 会话没有消息([SessionItem.lastMessageAt] 为 null)时一定没有未读,清零。
|
||||
*/
|
||||
internal fun SessionItem.withReadCursor(readAt: Long): SessionItem {
|
||||
val newest = lastMessageAt
|
||||
return if (newest != null && newest > readAt) this else copy(unreadCount = 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新成功后校验「当前会话」是否还在列表里;不在就回落到主会话。
|
||||
*
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.prefs.AppPrefs
|
||||
import top.yeij.cyrene.core.prefs.InterruptMode
|
||||
import top.yeij.cyrene.core.prefs.ThemeMode
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
@@ -102,6 +103,27 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SectionTitle("她忙时发消息")
|
||||
InterruptMode.entries.forEach { mode ->
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
title = mode.label(),
|
||||
summary = mode.summary(),
|
||||
onClick = { scope.launch { settings.setInterruptMode(mode) } },
|
||||
endActions = {
|
||||
if (prefs.interruptMode == mode) {
|
||||
Text(
|
||||
text = "✓",
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
SectionTitle("关于")
|
||||
PreferenceCard {
|
||||
BasicComponent(
|
||||
@@ -155,3 +177,19 @@ private fun ThemeMode.label(): String = when (this) {
|
||||
ThemeMode.Light -> "浅色"
|
||||
ThemeMode.Dark -> "深色"
|
||||
}
|
||||
|
||||
private fun InterruptMode.label(): String = when (this) {
|
||||
InterruptMode.AppendQueue -> "追加队列"
|
||||
InterruptMode.Interrupt -> "打断"
|
||||
}
|
||||
|
||||
/**
|
||||
* 两档的差别只有一处:**忙时那条排队消息还在不在**。文案必须说清这一点——
|
||||
* 一个用户看不懂效果的偏好,比没有这个偏好更糟。
|
||||
*/
|
||||
private fun InterruptMode.summary(): String = when (this) {
|
||||
InterruptMode.AppendQueue ->
|
||||
"她正忙时只把消息排进队列,等她做完当前回复再依次回;不打断(默认)"
|
||||
InterruptMode.Interrupt ->
|
||||
"她正忙时先打断当前这一句;已排队的消息保留,继续依次回答"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.ws.WsOutbound
|
||||
|
||||
/**
|
||||
* A2 偏好 → 线上取值 的映射(契约 v0.56 §3.1)。
|
||||
*
|
||||
* 这一层只负责一件事:把用户选的档位翻成 `interrupt` 帧的 `scope`。默认档必须翻成
|
||||
* **整键省略**——服务端视同 `"all"`,帧因此与加入 `scope` 之前**逐字节相同**(兼容保证)。
|
||||
*/
|
||||
class InterruptModeTest {
|
||||
|
||||
private fun frameOf(mode: InterruptMode): String =
|
||||
WsOutbound.Interrupt(sessionId = "web", scope = mode.wireScope).toJson()
|
||||
|
||||
@Test
|
||||
fun `the default mode serialises to today's interrupt frame byte for byte`() {
|
||||
val mode = AppPrefs().interruptMode
|
||||
|
||||
assertEquals(InterruptMode.AppendQueue, mode)
|
||||
assertNull("默认档必须是「省略 scope」(服务端 = all)", mode.wireScope)
|
||||
assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(mode))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the interrupt mode maps to scope current so the queue survives`() {
|
||||
assertEquals("current", InterruptMode.Interrupt.wireScope)
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
|
||||
frameOf(InterruptMode.Interrupt),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the preference only ever produces the two documented wire values`() {
|
||||
// 服务端取值域是 {"", "all", "current"};客户端只发「省略」与 "current" 两种,
|
||||
// ""/"all" 已由省略表达——不自己补一串等价别名,少一种线上形状就少一类歧义。
|
||||
assertEquals(listOf(null, "current"), InterruptMode.entries.map { it.wireScope })
|
||||
assertTrue(InterruptMode.entries.none { it.wireScope == "all" })
|
||||
assertTrue(InterruptMode.entries.none { it.wireScope == "" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both modes stay distinguishable on the wire`() {
|
||||
// 打成同一个形状就等于「两种模式被当成一种」——正是 A2 要修掉的那类缺陷。
|
||||
assertTrue(frameOf(InterruptMode.AppendQueue) != frameOf(InterruptMode.Interrupt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the persisted enum names are stable`() {
|
||||
// AppSettings 按 enum 名落盘:改名 = 把所有已存偏好静默重置回默认档。
|
||||
assertEquals(listOf("AppendQueue", "Interrupt"), InterruptMode.entries.map { it.name })
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
* **置顶的次级会话合法地排在主会话前面**(不能假设下标 0 是主会话);
|
||||
* - 还没有用户消息的会话 `title` 键**整个缺席**(自动命名只在首条用户消息时写入);
|
||||
* - 没有任何消息的会话 `lastMessage` 键整个缺席;
|
||||
* - `pinned` 恒下发(主会话上是显式 `false`),`unreadCount` 无关键照收。
|
||||
* - `pinned` 恒下发(主会话上是显式 `false`)、`unreadCount`(D4)恒下发——两者都必须读进模型。
|
||||
*/
|
||||
class SessionDtoRealPayloadTest {
|
||||
|
||||
@@ -164,4 +164,69 @@ class SessionDtoRealPayloadTest {
|
||||
// 服务端对 `{}` 回 400 `title or pinned is required`;调用方必须保证至少带一个字段。
|
||||
assertEquals("{}", body)
|
||||
}
|
||||
|
||||
// —— D4 未读 / 已读游标(契约 v0.37) ——
|
||||
|
||||
@Test
|
||||
fun `the unread count of the live payload is read off the wire`() {
|
||||
val sessions = decode(realSessionList)
|
||||
|
||||
// 这份线上载荷里两个值都有:已读过的次级会话是显式 0,主会话有 6 条没看。
|
||||
assertEquals(0, sessions[0].unreadCount)
|
||||
assertEquals(6, sessions[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session row without the unread key decodes to zero`() {
|
||||
// 旧快照:本版之前写下的一份(那时 DTO 里还没有这个字段),升级后必须照样解得开——
|
||||
// 否则第一次启动整份快照解码失败、列表先空一下,正是最不该出现的表现。
|
||||
val legacy = """{"id":"web","type":"main","createdAt":1788724031196,"pinned":false}"""
|
||||
|
||||
assertEquals(0, json.decodeFromString(SessionSummaryDto.serializer(), legacy).unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snapshot round trip keeps the unread count`() {
|
||||
// `SessionPreferences` 存快照用的就是这份 Json + 这个 ListSerializer。
|
||||
val before = decode(realSessionList)
|
||||
|
||||
val restored = json.decodeFromString(sessionList, json.encodeToString(sessionList, before))
|
||||
|
||||
assertEquals(before, restored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read request without a timestamp omits the key entirely`() {
|
||||
val body = json.encodeToString(ReadSessionRequest.serializer(), ReadSessionRequest())
|
||||
|
||||
// 「缺省 = 现在」:服务端接受的正是空对象 `{}`;而 `before` 为 0 / 负数 / 非数字一律 400,
|
||||
// 所以「没有时间戳」只能表达成**整键缺席**,绝不能是 `{"before":null}` 或 `{"before":0}`。
|
||||
assertEquals("{}", body)
|
||||
assertFalse(body, body.contains("before"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read request with a timestamp sends it as millis`() {
|
||||
val body = json.encodeToString(
|
||||
ReadSessionRequest.serializer(),
|
||||
ReadSessionRequest(before = 1_753_000_000_000L),
|
||||
)
|
||||
|
||||
assertEquals("""{"before":1753000000000}""", body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the documented read response decodes to the advanced cursor`() {
|
||||
// 形状取自 api-reference §3.6b 的 curl 示例,与后端 `handleMarkSessionRead` 的
|
||||
// `writeJSON`(`{"data": …}`)一致。**这是文档示例,不是线上抓取**(不调线上端点)。
|
||||
val response = json.decodeFromString(
|
||||
ApiEnvelope.serializer(ReadSessionResponse.serializer()),
|
||||
"""{"data":{"sessionId":"web","readAt":1753000000000}}""",
|
||||
)
|
||||
|
||||
val data = requireNotNull(response.data)
|
||||
|
||||
assertEquals("web", data.sessionId)
|
||||
assertEquals(1_753_000_000_000L, data.readAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
import top.yeij.cyrene.data.api.ApiErrorMapper
|
||||
|
||||
/**
|
||||
* 契约钉死测试:`POST /api/v1/upload` 的**成功载荷**用的是服务端
|
||||
* `internal/services/gateway/upload.go` 的 `uploadResponse` 结构体形状(字段名逐字:
|
||||
* `url` / `size` / `mime` / `name`),**错误载荷**用的是 2026-10-04 对着在线后端
|
||||
* (`http://127.0.0.1:10942`)**实测抓下来**的原文——只发错误请求(缺部件 / 非图片 / 超限),
|
||||
* 服务端一律在落盘之前拒绝,所以这些抓包**没有在服务端留下任何文件**。
|
||||
*
|
||||
* 解码器用 [ApiClientFactory.json]——与生产同一份 `Json`,这里过不了的形状线上也过不了。
|
||||
*/
|
||||
class UploadDtoRealPayloadTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
/** 成功载荷:32 位小写 hex 名 + 白名单扩展名 + 同源绝对 URL,全是服务端生成的。 */
|
||||
private val realSuccess = """
|
||||
{"data":{"url":"http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg","size":184320,"mime":"image/jpeg","name":"9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg"}}
|
||||
""".trimIndent()
|
||||
|
||||
private fun decode(body: String): UploadResponse =
|
||||
json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), body).data!!
|
||||
|
||||
@Test
|
||||
fun `the real success payload decodes into a directly usable url`() {
|
||||
val data = decode(realSuccess)
|
||||
|
||||
assertEquals(
|
||||
"http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg",
|
||||
data.url,
|
||||
)
|
||||
assertEquals(184320L, data.size)
|
||||
assertEquals("image/jpeg", data.mime)
|
||||
assertEquals("9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg", data.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the url is absolute same-origin because chat image only accepts http s urls`() {
|
||||
val url = decode(realSuccess).url
|
||||
|
||||
// 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 载荷 —— 所以这个形状是硬要求。
|
||||
assertTrue(url, url.startsWith("http://") || url.startsWith("https://"))
|
||||
assertTrue(url, url.contains("/api/v1/files/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the name is 32 lowercase hex plus a whitelisted raster extension`() {
|
||||
val name = decode(realSuccess).name
|
||||
val stem = name.substringBeforeLast('.')
|
||||
|
||||
assertEquals(32, stem.length)
|
||||
assertEquals(stem, stem.lowercase())
|
||||
assertTrue(stem, stem.all { it in "0123456789abcdef" })
|
||||
// 白名单只有光栅:SVG / HTML 会被拒(`uploadExtForMIME`)。
|
||||
assertTrue(name, name.endsWith(".jpg"))
|
||||
}
|
||||
|
||||
// —— 以下四条是**在线抓包原文**(逐字照抄) ——
|
||||
|
||||
@Test
|
||||
fun `the captured 415 body maps to unsupported_media_type`() {
|
||||
val body = """{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}"""
|
||||
|
||||
val error = ApiErrorMapper.fromHttp(415, body)
|
||||
|
||||
assertEquals(CyreneError.Server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415), error)
|
||||
// 客户端按 code 分支(改文案不改判定),所以这两个字符串必须钉住。
|
||||
assertEquals("unsupported_media_type", (error as CyreneError.Server).code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the captured 413 body maps to payload_too_large`() {
|
||||
val body = """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}"""
|
||||
|
||||
val error = ApiErrorMapper.fromHttp(413, body) as CyreneError.Server
|
||||
|
||||
assertEquals("payload_too_large", error.code)
|
||||
assertEquals(413, error.httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong part name is a 400 that names the field`() {
|
||||
// 实测:部件名不是 `file`(例如 `image`)→ 这条。它是「字段名写错」的唯一征兆。
|
||||
val body = """{"error":{"code":"bad_request","message":"file is required"}}"""
|
||||
|
||||
val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server
|
||||
|
||||
assertEquals("bad_request", error.code)
|
||||
assertEquals("file is required", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a body that is not multipart at all is a 400 with a different message`() {
|
||||
val body = """{"error":{"code":"bad_request","message":"invalid multipart body"}}"""
|
||||
|
||||
val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server
|
||||
|
||||
assertEquals("bad_request", error.code)
|
||||
// 与「部件名错了」同码不同文案:排障时靠 message 区分,靠 code 分支。
|
||||
assertEquals("invalid multipart body", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the captured 401 is unauthorized and not a server rejection`() {
|
||||
val error = ApiErrorMapper.fromHttp(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
|
||||
|
||||
assertEquals(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"), error)
|
||||
}
|
||||
|
||||
// —— 形状的松紧 ——
|
||||
|
||||
@Test
|
||||
fun `a success envelope without data is not an upload`() {
|
||||
assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), "{}").data)
|
||||
assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), """{"data":null}""").data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown extra keys are ignored so a future field cannot break decoding`() {
|
||||
val data = decode("""{"data":{"url":"http://h/api/v1/files/aa.jpg","size":1,"mime":"image/jpeg","name":"aa.jpg","sha256":"deadbeef"}}""")
|
||||
|
||||
assertEquals("http://h/api/v1/files/aa.jpg", data.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit nulls fall back to the defaults instead of failing the decode`() {
|
||||
val data = decode("""{"data":{"url":null,"size":null,"mime":null,"name":null}}""")
|
||||
|
||||
assertEquals("", data.url)
|
||||
assertEquals(0L, data.size)
|
||||
assertEquals("", data.mime)
|
||||
assertEquals("", data.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 上传端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端(不引新依赖,
|
||||
* 与 [ChatRepositoryTest] 同一手法)。
|
||||
*
|
||||
* 这里要证明的不是「能解析一个 JSON」,而是三件线上真会出事的事:
|
||||
* 1. 请求**真的是 multipart**、部件名真的是 `file`、载荷字节**一个不改**地发出去
|
||||
* (服务端 `r.FormFile("file")` 认不到就回 `400 file is required`);
|
||||
* 2. `413 payload_too_large` / `415 unsupported_media_type` 按 `code` 分成可辨的两类;
|
||||
* 3. 服务端给的 url **不是绝对 http(s) 就判失败**——`chat.image` 会把裸路径当成 base64,
|
||||
* 照发等于把这一轮扔进解析错误里。
|
||||
*/
|
||||
class UploadRepositoryTest {
|
||||
|
||||
private val backend = FakeUploadBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = UploadRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = UploadApi::class.java),
|
||||
)
|
||||
|
||||
/** 一段带 JPEG 魔数的假图:足够让「字节原样发出」这件事可断言。 */
|
||||
private val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) +
|
||||
"B1-UPLOAD-PAYLOAD".toByteArray()
|
||||
|
||||
@Test
|
||||
fun `a successful upload returns the server built url`() = runBlocking {
|
||||
backend.reply(201, backend.successBody())
|
||||
|
||||
val uploaded = repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
|
||||
|
||||
assertEquals(backend.url, uploaded.url)
|
||||
// size / mime / name 是**服务端**的数,客户端只传递、不自己算一遍。
|
||||
assertEquals(jpeg.size.toLong(), uploaded.size)
|
||||
assertEquals("image/jpeg", uploaded.mime)
|
||||
assertEquals(backend.name, uploaded.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the request really is multipart with the file part name the server looks for`() = runBlocking {
|
||||
backend.reply(201, backend.successBody())
|
||||
|
||||
repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
|
||||
|
||||
val request = backend.requests.single()
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("/api/v1/upload", request.path)
|
||||
assertTrue(request.contentType, request.contentType.startsWith("multipart/form-data"))
|
||||
// 部件名 / 文件名都要在:Go 只把带 filename 的部件当文件,字段名不对则 FormFile 找不到。
|
||||
assertTrue(request.body, request.body.contains("""name="file""""))
|
||||
assertTrue(request.body, request.body.contains("""filename="upload.jpg""""))
|
||||
assertTrue(request.body, request.body.contains("Content-Type: image/jpeg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bytes leave untouched, jpeg magic included`() = runBlocking {
|
||||
backend.reply(201, backend.successBody())
|
||||
|
||||
repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
|
||||
|
||||
// ISO-8859-1 一一对应字节:这里的断言就是「发出去的就是编出来的那些字节」。
|
||||
val body = backend.requests.single().body
|
||||
assertTrue(body, body.contains("\u00FF\u00D8\u00FF\u00E0"))
|
||||
assertTrue(body, body.contains("B1-UPLOAD-PAYLOAD"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a png is sent as a png part`() = runBlocking {
|
||||
backend.reply(201, backend.successBody(mime = "image/png"))
|
||||
|
||||
repository().uploadImage(byteArrayOf(0x89.toByte(), 0x50), "image/png").getOrThrow()
|
||||
|
||||
val body = backend.requests.single().body
|
||||
assertTrue(body, body.contains("Content-Type: image/png"))
|
||||
assertTrue(body, body.contains("""filename="upload.png""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `413 payload_too_large is surfaced with its code`() = runBlocking {
|
||||
backend.reply(413, """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}""")
|
||||
|
||||
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(
|
||||
CyreneError.Server("payload_too_large", "file exceeds the 5MB limit", 413),
|
||||
error.error,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `415 unsupported_media_type is surfaced with its code`() = runBlocking {
|
||||
backend.reply(
|
||||
415,
|
||||
"""{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}""",
|
||||
)
|
||||
|
||||
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals("unsupported_media_type", (error.error as CyreneError.Server).code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `401 stays a failure because the session itself is no longer valid`() = runBlocking {
|
||||
backend.reply(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
|
||||
|
||||
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
|
||||
|
||||
assertTrue("实际 ${error.error}", error.error is CyreneError.Unauthorized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a relative url is a parse failure not something we would send as image`() = runBlocking {
|
||||
// `chat.image` 只把 http(s):// 当 URL:裸路径会被当成 base64 载荷。
|
||||
backend.reply(201, """{"data":{"url":"/api/v1/files/aa.jpg","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""")
|
||||
|
||||
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank url is a parse failure instead of a silent empty image`() = runBlocking {
|
||||
backend.reply(201, """{"data":{"url":"","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""")
|
||||
|
||||
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 201 without data is a parse failure`() = runBlocking {
|
||||
backend.reply(201, "{}")
|
||||
|
||||
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable server is a local failure`() = runBlocking {
|
||||
backend.close()
|
||||
|
||||
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
|
||||
|
||||
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
|
||||
}
|
||||
|
||||
/** 记录每次请求的原始字节与头,按 `reply(...)` 回话的假后端。 */
|
||||
private inner class FakeUploadBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/upload", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
/** 服务端生成的存储名:32 位小写 hex + 白名单扩展名。 */
|
||||
val name = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg"
|
||||
|
||||
val url: String get() = baseUrl + "api/v1/files/" + name
|
||||
|
||||
val requests = CopyOnWriteArrayList<RecordedRequest>()
|
||||
|
||||
@Volatile
|
||||
private var status: Int = 201
|
||||
|
||||
@Volatile
|
||||
private var body: String = ""
|
||||
|
||||
fun reply(status: Int, body: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
/** 线上的成功形状(`uploadResponse`):`size` 报的就是本次真正发出去的那段字节数。 */
|
||||
fun successBody(mime: String = "image/jpeg"): String =
|
||||
"""{"data":{"url":"$url","size":${jpeg.size},"mime":"$mime","name":"$name"}}"""
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
// ISO-8859-1:字节 ↔ 字符一一对应,断言里比对的就是线上真实字节。
|
||||
val raw = exchange.requestBody.readBytes()
|
||||
requests += RecordedRequest(
|
||||
method = exchange.requestMethod,
|
||||
path = exchange.requestURI.path,
|
||||
contentType = exchange.requestHeaders.getFirst("Content-Type").orEmpty(),
|
||||
body = raw.toString(Charsets.ISO_8859_1),
|
||||
)
|
||||
val bytes = body.toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { server.stop(0) }
|
||||
}
|
||||
}
|
||||
|
||||
private data class RecordedRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val contentType: String,
|
||||
val body: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* D4 下行帧 `session_read` 的解码(契约 v0.37,api-client §3.9)。
|
||||
*
|
||||
* 帧形状不是手写猜的,取自**后端生产者逐字**:
|
||||
* `internal/services/gateway/session_read.go:broadcastSessionRead` ——
|
||||
*
|
||||
* ```go
|
||||
* h.broadcast(wsEvent{Type: "session_read", SessionID: sessionID, Data: map[string]any{"readAt": readAt}})
|
||||
* ```
|
||||
*
|
||||
* 三个要点都在这里钉死:
|
||||
* 1. 会话归属**只在帧壳**上(`data` 里没有 sessionId),游标是 `data.readAt`(毫秒整数);
|
||||
* 2. 它是那 7 个「状态机信号」之一,发给所有已认证连接,因此同样要按 [isForSession] 收敛;
|
||||
* 3. 坏帧(缺 data)要安全降级:`readAt` 解成 0,而上层把 0 当「无从判断」——正好什么都不改。
|
||||
*/
|
||||
class WsEventSessionReadTest {
|
||||
|
||||
private fun parse(raw: String): WsEvent =
|
||||
requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" }
|
||||
|
||||
@Test
|
||||
fun `the produced frame decodes with its session and cursor`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1753000000000}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("session_read", ev.type)
|
||||
assertEquals("web", ev.sessionId)
|
||||
assertEquals(1_753_000_000_000L, ev.readAt)
|
||||
// 状态机信号不带轮次 / 序号:hub 直接广播,不经过回合。
|
||||
assertNull(ev.turnId)
|
||||
assertNull(ev.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the frame carries the session of a sub session too`() {
|
||||
// 读的是哪个会话就广播哪个——不是「总是主会话」。
|
||||
val ev = parse(
|
||||
"""{"type":"session_read","sessionId":"web:299bbf0b","data":{"readAt":1791090628688}}""",
|
||||
) as WsEvent.SessionRead
|
||||
|
||||
assertEquals("web:299bbf0b", ev.sessionId)
|
||||
assertEquals(1_791_090_628_688L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without data degrades to a zero cursor instead of failing`() {
|
||||
// 上层把 readAt = 0 当「无从判断」:有消息的行原样保留计数,绝不会误清成已读。
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web"}""") as WsEvent.SessionRead
|
||||
|
||||
assertEquals(0L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame whose shell omits the session gets a blank session, never a null`() {
|
||||
// 没有会话 id 的这一帧无法归属任何一行;上层据此直接跳过(而不是当成「属于当前会话」)。
|
||||
val ev = parse("""{"type":"session_read","data":{"readAt":1753000000000}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null session on the shell also decodes to a blank session`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":null,"data":{"readAt":1}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown keys inside data are ignored`() {
|
||||
// 后端将来在 data 里加字段不能被这一层挡掉(`ignoreUnknownKeys`)。
|
||||
val ev = parse(
|
||||
"""{"type":"session_read","sessionId":"web","data":{"readAt":42,"unread":0}}""",
|
||||
) as WsEvent.SessionRead
|
||||
|
||||
assertEquals(42L, ev.readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read frame follows the same per session rule as the other frames`() {
|
||||
val ev = parse("""{"type":"session_read","sessionId":"web","data":{"readAt":1}}""")
|
||||
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertFalse(ev.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank session on a read frame is unknown, so every session accepts it`() {
|
||||
val ev = parse("""{"type":"session_read","data":{"readAt":1}}""")
|
||||
|
||||
// 空 = 「无法判定」而不是「不属于本会话」——与其它帧同一口径。
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertTrue(ev.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sub session id on the shell is kept verbatim`() {
|
||||
// 帧壳上的 sessionId 只是字符串,本层不做格式裁决(`web:<uuid>` / `sess-xxx` 都合法)。
|
||||
val ev = parse("""{"type":"session_read","sessionId":"sess-9c3","data":{"readAt":7}}""")
|
||||
as WsEvent.SessionRead
|
||||
|
||||
assertEquals("sess-9c3", ev.sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A2:`interrupt` 的可选 `scope`(契约 v0.56 §3.1,取值域 `{"", "all", "current"}`)。
|
||||
*
|
||||
* 两条要点:
|
||||
* - **省略 `scope` 的帧必须与今天逐字节相同**——这是与老服务端 / 老行为的兼容保证;
|
||||
* - 本层**不校验**取值(服务端开放取值域由服务端裁决):除了省略,其余一律原样透传,
|
||||
* 既不自作主张补默认、也不静默改写成别名——服务端对非法值是**定向回 `bad_request`**。
|
||||
*/
|
||||
class WsOutboundInterruptTest {
|
||||
|
||||
/** 加入 `scope` 之前那条帧的逐字形状。 */
|
||||
private val legacyFrame = """{"type":"interrupt","data":{"sessionId":"web"}}"""
|
||||
|
||||
@Test
|
||||
fun `an interrupt without a scope is today's frame byte for byte`() {
|
||||
assertEquals(legacyFrame, WsOutbound.Interrupt(sessionId = "web").toJson())
|
||||
assertEquals(legacyFrame, WsOutbound.Interrupt(sessionId = "web", scope = null).toJson())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omitting the scope omits the key itself rather than sending null`() {
|
||||
val json = WsOutbound.Interrupt(sessionId = "web").toJson()
|
||||
|
||||
// `"scope":null` 与「整键省略」在服务端不是一回事:后者才是「未声明」。
|
||||
assertFalse("省略时不该出现 scope 键:$json", json.contains("scope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scope current is carried verbatim next to the session`() {
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
|
||||
WsOutbound.Interrupt(sessionId = "web", scope = "current").toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scope all and an empty scope are transmitted as-is`() {
|
||||
// 三者(省略 / "" / "all")在服务端同义,但**同义不等于客户端该改写**:
|
||||
// 调用方给了什么就发什么,服务端才是解释者。
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":"all"}}""",
|
||||
WsOutbound.Interrupt(sessionId = "web", scope = "all").toJson(),
|
||||
)
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":""}}""",
|
||||
WsOutbound.Interrupt(sessionId = "web", scope = "").toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown scope is sent verbatim and never silently downgraded to the default`() {
|
||||
// 服务端大小写敏感:`"Current"` 是非法值,会定向回 bad_request 且**不打断任何会话**。
|
||||
// 客户端若在此处「帮它纠正」成 "current",用户看到的就是一次没解释的打断。
|
||||
val json = WsOutbound.Interrupt(sessionId = "web", scope = "Current").toJson()
|
||||
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":"Current"}}""",
|
||||
json,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session id stays first so older servers see the same key order`() {
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}""",
|
||||
WsOutbound.Interrupt("web", "current").toJson(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 内联 vs 上传的阈值(纯函数)。
|
||||
*
|
||||
* 判据是**重编码后的字节数**:它同时决定「这次多走一次往返值不值」与「历史会背多重的 base64」。
|
||||
* 阈值本身不是服务端限制——服务端的两个 5 MiB 上限离这里还有两个数量级,所以下面的不变量
|
||||
* 才是重点:**上传分支必须够得着(不是死代码)、内联分支必须永远送得出去**。
|
||||
*/
|
||||
class ChatImageDeliveryTest {
|
||||
|
||||
@Test
|
||||
fun `small images stay inline`() {
|
||||
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(0))
|
||||
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(1))
|
||||
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(32 * 1024))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the threshold is inclusive on the inline side`() {
|
||||
assertEquals(
|
||||
ImageDelivery.Inline,
|
||||
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES),
|
||||
)
|
||||
assertEquals(
|
||||
ImageDelivery.Upload,
|
||||
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES + 1),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the threshold is 64 KiB`() {
|
||||
// 一个成本量级上的取整值:每次历史取数多 4/3 × B,64 KiB 时约 +85 KB/次(见下一条)。
|
||||
assertEquals(64 * 1024, ChatImageDelivery.INLINE_MAX_BYTES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at the threshold one history fetch pays at least the number the docs claim`() {
|
||||
// 类注释里「+85 KB」这个数的出处:base64 膨胀 4/3 后是 87384 字符。
|
||||
assertEquals(87384, ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES))
|
||||
assertTrue(ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES) > 85 * 1000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the upload branch is reachable but not mandatory for everything`() {
|
||||
// 阈值得低于重编码出口的上限,否则「上传」是死代码;但也不能低到把一切都推去上传。
|
||||
assertTrue(
|
||||
"阈值得小于本端预算 ${ChatImageBudget.MAX_DECODED_BYTES},否则上传分支永远走不到",
|
||||
ChatImageDelivery.INLINE_MAX_BYTES < ChatImageBudget.MAX_DECODED_BYTES,
|
||||
)
|
||||
assertTrue(
|
||||
"阈值也不能小到把缩略图也推去上传",
|
||||
ChatImageDelivery.INLINE_MAX_BYTES * 4 <= ChatImageBudget.MAX_DECODED_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the inline branch always fits the server cap by construction`() {
|
||||
// 内联的字节上限就是本端预算:它的 base64 + 前缀余量必须仍在服务端字符上限内。
|
||||
val worstChars = ChatImageBudget.base64Chars(ChatImageBudget.MAX_DECODED_BYTES) +
|
||||
ChatImageBudget.PREFIX_ALLOWANCE_CHARS
|
||||
|
||||
assertTrue(ChatImageBudget.withinServerLimit(worstChars))
|
||||
assertTrue(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the choice is about history weight, not about fitting under a cap`() {
|
||||
// 重编码出口比服务端硬顶低 5 倍:所以「走上传」从来不是为了装得下,
|
||||
// 而是为了让每一页历史 / 每一次 WS history 回放不再重传这段 base64。
|
||||
assertTrue(ChatImageBudget.MAX_DECODED_BYTES * 5 <= ChatImageBudget.SERVER_MAX_DECODED_BYTES)
|
||||
assertEquals(
|
||||
ImageDelivery.Upload,
|
||||
ChatImageDelivery.forPreparedBytes(ChatImageBudget.MAX_DECODED_BYTES),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 上传失败的文案(纯函数)。
|
||||
*
|
||||
* 这里锚的是**行为承诺**,不是措辞本身:上传失败时这条消息**没有发出去**(文本与图一起
|
||||
* 回到输入框),而且**绝不**退回内联 base64——退回就等于把「省下历史重量」当场作废,
|
||||
* 还是在用户看不见的情况下换了一条语义不同的路。
|
||||
*
|
||||
* 两个服务端拒绝码对应的是「换一张图」而不是「重试」,必须与网络失败分开说。
|
||||
*/
|
||||
class ImageUploadFailureTest {
|
||||
|
||||
private fun server(code: String, message: String, status: Int = 400) =
|
||||
CyreneException(CyreneError.Server(code, message, status))
|
||||
|
||||
@Test
|
||||
fun `payload_too_large asks for a smaller image, not a retry`() {
|
||||
val message = uploadFailureMessage(server("payload_too_large", "file exceeds the 5MB limit", 413))
|
||||
|
||||
assertEquals("图片超过服务端 5 MB 上限,请换一张更小的", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported_media_type says the format is refused`() {
|
||||
val message = uploadFailureMessage(
|
||||
server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415),
|
||||
)
|
||||
|
||||
assertEquals("服务端不接受这张图片的格式,请换一张", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network failure names the step that failed and keeps the reason`() {
|
||||
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
|
||||
|
||||
assertEquals("图片上传失败,网络不可用,请检查连接", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timeout keeps its own wording`() {
|
||||
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))
|
||||
|
||||
assertEquals("图片上传失败,连接超时,服务器没有响应", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server error keeps the server message instead of inventing one`() {
|
||||
val message = uploadFailureMessage(server("upload_failed", "failed to store upload", 500))
|
||||
|
||||
assertEquals("图片上传失败,failed to store upload", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired session is still an upload failure, never a silent success`() {
|
||||
val message = uploadFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")))
|
||||
|
||||
assertTrue(message, message.startsWith("图片上传失败"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non Cyrene throwable still produces an actionable message`() {
|
||||
assertEquals("图片上传失败,请重试", uploadFailureMessage(IllegalStateException("boom")))
|
||||
assertEquals("图片上传失败,请重试", uploadFailureMessage(RuntimeException()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no failure path ever falls back to inlining the image`() {
|
||||
// 文案里绝不能出现「改用内联 / 直接发送」这类暗示:回退内联正是要消灭的成本。
|
||||
val messages = listOf(
|
||||
uploadFailureMessage(server("payload_too_large", "x", 413)),
|
||||
uploadFailureMessage(server("unsupported_media_type", "x", 415)),
|
||||
uploadFailureMessage(server("upload_failed", "x", 500)),
|
||||
uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.ConnectFailed))),
|
||||
uploadFailureMessage(IllegalStateException()),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
messages.toString(),
|
||||
messages.all { it.contains("换一张") || it.contains("重试") || it.contains("上传失败") },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package top.yeij.cyrene.ui.sessions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.SessionLastMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
/**
|
||||
* D4 未读的两块纯逻辑:角标文本,以及两条**方向相反**的本地调和规则。
|
||||
*
|
||||
* 调和规则为什么要分两条:
|
||||
* - [withReadCursor](`session_read` 帧 / 本地已知游标)是**有依据**的清零——游标只前进,
|
||||
* 所以「本地最新一条消息不比游标新」就等于没有未读;反过来若本地明明有更新的消息
|
||||
* (读完以后新到的),清成 0 就是把真实未读藏起来,必须保留旧计数。
|
||||
* - [markRead] 是**无条件**清零——调用方(`select()` / 正在看的会话落库)已经知道
|
||||
* 这一行就是屏幕上的那一行,不需要游标依据。
|
||||
*/
|
||||
class SessionUnreadTest {
|
||||
|
||||
private fun item(
|
||||
id: String = "web",
|
||||
lastMessageAt: Long? = null,
|
||||
unreadCount: Int = 0,
|
||||
) = SessionItem(
|
||||
id = id,
|
||||
title = "标题",
|
||||
isMain = id == "web",
|
||||
pinned = false,
|
||||
lastMessage = if (lastMessageAt == null) null else "预览",
|
||||
lastMessageAt = lastMessageAt,
|
||||
unreadCount = unreadCount,
|
||||
)
|
||||
|
||||
// —— 角标文本 ——
|
||||
|
||||
@Test
|
||||
fun `no unread means no badge at all`() {
|
||||
assertNull(unreadBadgeText(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative count is a data error, not a badge`() {
|
||||
// 服务端恒下发非负整数;真出现负数说明数据坏了,绝不画一个「-1」出来。
|
||||
assertNull(unreadBadgeText(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small counts are shown verbatim`() {
|
||||
assertEquals("1", unreadBadgeText(1))
|
||||
assertEquals("9", unreadBadgeText(9))
|
||||
assertEquals("10", unreadBadgeText(10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the boundary at 99 is still exact and 100 overflows`() {
|
||||
assertEquals("99", unreadBadgeText(99))
|
||||
// 三位数会把 300dp 抽屉里的标题挤没,而精确值几乎没有信息增量。
|
||||
assertEquals("99+", unreadBadgeText(100))
|
||||
assertEquals("99+", unreadBadgeText(1234))
|
||||
}
|
||||
|
||||
// —— 按游标清零(有依据) ——
|
||||
|
||||
@Test
|
||||
fun `a cursor newer than the last message clears the badge`() {
|
||||
val cleared = item(lastMessageAt = 1_000L, unreadCount = 3).withReadCursor(2_000L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor exactly on the last message clears the badge`() {
|
||||
// 服务端口径是 `createdAt <= readAt` 算已读、只数 `createdAt > readAt` 的,所以相等即已读。
|
||||
val cleared = item(lastMessageAt = 1_000L, unreadCount = 1).withReadCursor(1_000L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message newer than the cursor keeps the badge`() {
|
||||
// 本地已知有比游标更新的消息 ⇒ 真的还有未读(游标只前进,这只能是读完以后新到的)。
|
||||
// 精确条数本地算不出来(没有逐条时间线),保留旧计数比清零更接近真相。
|
||||
val kept = item(lastMessageAt = 3_000L, unreadCount = 2).withReadCursor(1_000L)
|
||||
|
||||
assertEquals(2, kept.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session without any message has nothing to be unread`() {
|
||||
val cleared = item(lastMessageAt = null, unreadCount = 1).withReadCursor(0L)
|
||||
|
||||
assertEquals(0, cleared.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad frame with readAt zero changes nothing it cannot judge`() {
|
||||
// 帧里没有 readAt 时解出来是 0(真实游标不可能是 0):有消息的行**原样保留**。
|
||||
assertEquals(5, item(lastMessageAt = 10L, unreadCount = 5).withReadCursor(0L).unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero count stays zero`() {
|
||||
assertEquals(0, item(lastMessageAt = 10L, unreadCount = 0).withReadCursor(1L).unreadCount)
|
||||
}
|
||||
|
||||
// —— 整表替换时的游标遮罩 ——
|
||||
|
||||
@Test
|
||||
fun `only the rows with a known cursor are masked`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 1_000L, unreadCount = 4),
|
||||
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 7),
|
||||
)
|
||||
|
||||
val masked = sessions.withReadCursors(mapOf("web" to 2_000L))
|
||||
|
||||
assertEquals(0, masked[0].unreadCount)
|
||||
// 没记过游标的会话保持服务端的值——遮罩只压「本地已知已读」的行。
|
||||
assertEquals(7, masked[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cursor older than the row keeps the server count`() {
|
||||
// 服务端说 4 条未读,而本地游标只推到「上一条消息」:这不是矛盾,是真的还有未读。
|
||||
val masked = listOf(item(id = "web", lastMessageAt = 5_000L, unreadCount = 4))
|
||||
.withReadCursors(mapOf("web" to 1_000L))
|
||||
|
||||
assertEquals(4, masked[0].unreadCount)
|
||||
}
|
||||
|
||||
// —— 按会话定位(帧里带的是**那个**会话的 id,不是当前会话) ——
|
||||
|
||||
@Test
|
||||
fun `a read frame only touches its own row`() {
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 1_000L, unreadCount = 2),
|
||||
item(id = "web:other", lastMessageAt = 1_000L, unreadCount = 5),
|
||||
)
|
||||
|
||||
val updated = sessions.applyReadCursor("web:other", 2_000L)
|
||||
|
||||
// 别的端读的是 web:other——这正是本端要处理的一半,不能按 activeSessionId 过滤掉。
|
||||
assertEquals(2, updated[0].unreadCount)
|
||||
assertEquals(0, updated[1].unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read frame for an unknown session is a no-op`() {
|
||||
val sessions = listOf(item(id = "web", lastMessageAt = 1_000L, unreadCount = 2))
|
||||
|
||||
assertEquals(sessions, sessions.applyReadCursor("web:gone", 9_999L))
|
||||
}
|
||||
|
||||
// —— 无条件清零(本端刚看到) ——
|
||||
|
||||
@Test
|
||||
fun `marking the active session read clears it regardless of the cursor`() {
|
||||
// 用户就在这个会话里:即使本地已知有更新的消息,也必须清(那正是他屏幕上那条)。
|
||||
val sessions = listOf(
|
||||
item(id = "web", lastMessageAt = 9_999L, unreadCount = 3),
|
||||
item(id = "web:other", lastMessageAt = 9_999L, unreadCount = 3),
|
||||
)
|
||||
|
||||
val updated = sessions.markRead("web")
|
||||
|
||||
assertEquals(0, updated[0].unreadCount)
|
||||
assertEquals(3, updated[1].unreadCount)
|
||||
}
|
||||
|
||||
// —— DTO → 行模型的映射 ——
|
||||
|
||||
@Test
|
||||
fun `toItem carries the unread count and the last message time`() {
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
title = "临时小事",
|
||||
createdAt = 1L,
|
||||
unreadCount = 6,
|
||||
lastMessage = SessionLastMessageDto(role = "assistant", content = "在呢", createdAt = 1_791_090_628_688L),
|
||||
)
|
||||
|
||||
val item = dto.toItem()
|
||||
|
||||
assertEquals(6, item.unreadCount)
|
||||
assertEquals(1_791_090_628_688L, item.lastMessageAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toItem without a last message has no time and no unread`() {
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
createdAt = 1L,
|
||||
)
|
||||
|
||||
val item = dto.toItem()
|
||||
|
||||
assertNull(item.lastMessageAt)
|
||||
assertEquals(0, item.unreadCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unread count without a last message still reaches the row`() {
|
||||
// 理论上不会发生(有未读就一定有消息),但服务端字段是独立的:不能因为没预览就丢掉计数,
|
||||
// 否则渲染层拿不到它的唯一依据。
|
||||
val dto = SessionSummaryDto(
|
||||
id = "web:1",
|
||||
type = SessionSummaryDto.TYPE_SUB,
|
||||
createdAt = 1L,
|
||||
unreadCount = 3,
|
||||
)
|
||||
|
||||
assertEquals(3, dto.toItem().unreadCount)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user