跳转至

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
}