package httpapi import ( "context" "errors" "strconv" "strings" "time" "knowledge-governance/internal/governance" ) // libraryIntent 是"用户这一次明确意图"的不可变身份。 // // 为什么不能再拿 `library.sync::` 当终身幂等键:那是把**意图**和 // **目标状态**混成一体。成功同步 B 之后改成 A、再要求同步 B 时,第二次 B 拿到的是第一次 // 的历史成功作业,什么都不做——用户要的状态没实现,页面还显示成功(F01)。 // // 现在:客户端可以用 `operation_key` 重放同一次意图(双击/网络重试 ⇒ 同一个 operation ⇒ // 同一个作业);不带或换一个键 ⇒ 新意图 ⇒ 新 operation ⇒ 真的执行一遍,并留下自己的 // 事件与 current 变更。 type libraryIntent struct { Operation governance.LibraryOperation // JobKey 是作业幂等键:同一意图重放得到同一个作业行(不会重复建)。 JobKey string } // intentCacheKey 把"目标快照 + 客户端意图键"拼成 operation 键: // 快照参与是因为 operation 的身份必须绑定它发布的是什么;客户端键参与是因为 // 同一次重放要命中同一条意图。 func intentCacheKey(snapshotID, clientKey string, now time.Time) string { base := strings.TrimSpace(snapshotID) if client := strings.TrimSpace(clientKey); client != "" { return base + "#" + client } // 没有客户端键:每次请求都是一次新意图。用纳秒而不是随机数,便于从日志里回溯。 return base + "#" + strconv.FormatInt(now.UnixNano(), 10) } // ensureLibraryIntent 幂等登记意图。摘要不一致 ⇒ 冲突(同一个键不许换内容)。 func ensureLibraryIntent(ctx context.Context, store libraryJobStore, scope string, kind governance.LibraryJobKind, snapshotID, digest, actor, clientKey string, now time.Time) (libraryIntent, error) { key := intentCacheKey(snapshotID, clientKey, now) op, _, err := store.EnsureLibraryOperation(ctx, governance.LibraryOperation{ ID: governance.LibraryOperationID(scope, kind, key), Scope: scope, Kind: kind, TargetSnapshotID: snapshotID, RequestDigest: digest, OperationKey: key, Actor: actor, CreatedAt: now.UTC(), }) if err != nil { return libraryIntent{}, err } return libraryIntent{Operation: op, JobKey: "library.job:" + op.ID}, nil } // isIntentConflict 区分"同一个意图键换了内容"(409,调用方应换键)与内部错误。 func isIntentConflict(err error) bool { return errors.Is(err, governance.ErrLibraryOperationConflict) } // libraryOperationView 是响应里给前端的意图身份(可复用于重放)。 func libraryOperationView(intent libraryIntent) map[string]any { return map[string]any{"operation_id": intent.Operation.ID, "operation_key": intent.Operation.OperationKey, "target_snapshot_id": intent.Operation.TargetSnapshotID, "kind": string(intent.Operation.Kind)} }