apps\online\src\main\java\cn\nfarms\online\data\Models.kt¶
源码路径:
apps\online\src\main\java\cn\nfarms\online\data\Models.kt· 121 行
package cn.nfarms.online.data
import kotlinx.serialization.Serializable
/**
* 工单。
*
* # 冲突合并为什么要「基线版本」
*
* 三路合并(three-way merge)需要三份数据:`本地`、`远端`、以及**共同祖先**。
* 只有两份(local vs remote)时,无法区分「我改了」和「他改了」——
* 任何一边的差异都可能被误判成"对方改的",于是覆盖掉别人刚写的东西。
*
* 所以每个工单都带 [base]:上一次和服务端**同步成功**时的快照。
* 冲突检测的判据是:
* * 本地 == base → 只有远端改了 → 直接用远端(无冲突);
* * 远端 == base → 只有本地改了 → 用本地(无冲突);
* * 都 != base 且 本地 == 远端 → 同一个结论,无冲突;
* * 其余 → **真冲突**,交给用户选。
*/
@Serializable
data class Ticket(
val id: String,
val title: String,
val body: String,
val status: String,
val updatedAt: Long,
/** 每次服务端改动都会 +1;客户端提交时必须带上自己看到的那个版本 */
val rev: Int,
val attachments: List<Attachment> = emptyList(),
) {
/** 只比较「用户能改的字段」——`updatedAt`/`rev` 是同步元数据,不参与冲突判定。 */
fun contentEquals(o: Ticket): Boolean =
title == o.title && body == o.body && status == o.status &&
attachments.map { it.id } == o.attachments.map { it.id }
}
@Serializable
data class Attachment(
val id: String,
val name: String,
val sizeBytes: Long,
val sha256: String,
/** 上传状态 */
val uploaded: Boolean = false,
) {
/** 单个附件上限。真实后端一般 10~25 MB,这里取 10 MB。 */
val sizeOk: Boolean get() = sizeBytes in 1..(10L * 1024 * 1024)
}
@Serializable
enum class SyncState { Local, Synced, Conflicted, Pushing }
@Serializable
data class LocalTicket(
val ticket: Ticket,
/** 共同祖先:上次同步成功时的快照 */
val base: Ticket? = null,
val state: SyncState = SyncState.Local,
/** 离线草稿:还没提交过的新工单 */
val draft: Boolean = false,
) {
fun isDirty(): Boolean = base == null || !ticket.contentEquals(base)
}
@Serializable
data class OnlineState(
val tickets: List<LocalTicket> = emptyList(),
val lastSyncAt: Long = 0,
val audit: List<AuditEntry> = emptyList(),
)
@Serializable
data class AuditEntry(
val at: Long = System.currentTimeMillis(),
val action: String,
val detail: String = "",
)
/** 合并结果。 */
sealed interface MergeResult {
data class TookRemote(val ticket: Ticket) : MergeResult
data class KeptLocal(val ticket: Ticket) : MergeResult
data object Identical : MergeResult
data class Conflict(val local: Ticket, val remote: Ticket, val base: Ticket?) : MergeResult
}
object Merge {
/**
* 三路合并。
*
* 判定顺序不能换:先比「与基线是否一致」,再比「两边是否一致」。
* 反过来会有一个坏结果:两边都改了、而且改成了**一样**的内容时,
* 先比 local==remote 会得到"无冲突,用远端"——结论恰好也对,
* 但漏掉了「这次改动是谁的」这个信息,写审计时会误导。
*/
fun merge(local: Ticket, remote: Ticket, base: Ticket?): MergeResult {
if (base == null) {
// 没有基线:只能保守 —— 内容一样就合并,不一样就报冲突(不能瞎猜)
return if (local.contentEquals(remote)) MergeResult.Identical
else MergeResult.Conflict(local, remote, null)
}
val localChanged = !local.contentEquals(base)
val remoteChanged = !remote.contentEquals(base)
return when {
!localChanged && !remoteChanged -> MergeResult.Identical
!localChanged -> MergeResult.TookRemote(remote)
!remoteChanged -> MergeResult.KeptLocal(local)
local.contentEquals(remote) -> MergeResult.KeptLocal(local)
else -> MergeResult.Conflict(local, remote, base)
}
}
/** 用户选了「用我的」:以远端的最新 rev 为基,内容用自己的(避免下次又被判冲突)。 */
fun takeMine(local: Ticket, remote: Ticket): Ticket =
local.copy(rev = remote.rev, updatedAt = remote.updatedAt)
/** 用户选了「用他的」:直接采用远端。 */
fun takeTheirs(remote: Ticket): Ticket = remote
}