Files
knowledge-governance/internal/httpapi/library_intent.go
T
姚章浩 98294b9583
build / build (push) Failing after 24s
fix(library): 统一执行身份、终态收尾、准入与数据身份
why:审计 19 项(12 P1 / 7 P2)来自同一批根因,逐点试错会把协议撕成十几份:
  · 意图与执行混为一谈(F01/F15):`library.sync:<scope>:<快照>` 是终身幂等键,
    A→B→A 的第三次发布拿到第一次的历史成功作业,什么都不做;
  · 版本行既是实体又是事件(F02/F09):主键只由 (kind,scope,快照) 决定、写入是
    upsert,第二个作业覆盖 `source_job_id`/`evidence`/`created_at`;
  · 接管没有代次(F13):两个 worker 都能认领同一 pending 作业,失租的旧执行还能
    写业务状态、释放别人的维护锁;
  · 早退与终态没有统一政策(F03/F04/F05/F06):半写窗口的前置检查失败会重新开门、
    服务起不来也照样开入口、journal 终态后收尾不可重建、恢复成功不回写 current;
  · 数据身份与准入各写一套(F07/F08/F14/F17):接管丢了复用语义、全库搬运不改
    location_hash、通用入口可造 library.* 作业、目标模型从没被问过;
  · 存储/容量/UI/CI 判据各自为政(F10/F11/F12/F16/F18/F19)。

what:按批准的一套协议集中实施(详见 handoffs/release-consolidated-implementation/
current-contract.md):
  · operation(一次明确意图)/ attempt(epoch 代次)/ 不可变版本事件 / current 指针;
    治理库迁移 76,只动治理库,不碰 WeKnora Schema/源码/镜像;
  · 所有业务状态、阶段、版本事件、维护锁写入带 epoch 守卫(失租代次写不进去);
    心跳失租立刻取消 handler;
  · 半写窗口早退保持关闭;服务未全部健康绝不开入口;journal 终态可从持久事实重建
    收尾;恢复成功登记 recovery 事件并推进 current;
  · worker 在动维护前核对业务行/kind/scope/队列关联;通用入口拒绝 library.*;
  · 全库搬运重算 location_hash 并与文档级导入共用实现;接管消费写集合的复用决定;
    发布前核目标实时模型契约、容量门禁前置;
  · 存储保护闭包改为全量依赖、类型只认显式登记、保护未知则不出可删候选;
  · 页面核 HTTP 状态与结构、恢复纳入轮询、Wiki 差异覆盖标题/摘要/父级。

how:`make verify` rc=0(16 项必需检查全绿,含 go test 全量与前端 485 例);
`go vet ./...` rc=0;`npx tsc -p tsconfig.json --noEmit` rc=0;逐项负例见各包测试
(F03/F04/F08/F12/F13/F14/F16/F18/F19 都有先红后绿记录)。
未执行:治理库迁移到任何已有库、真实 PG/ES/S3/WeKnora 集成、故障注入、部署。
2026-10-30 16:14:54 +08:00

64 lines
2.9 KiB
Go

package httpapi
import (
"context"
"errors"
"strconv"
"strings"
"time"
"knowledge-governance/internal/governance"
)
// libraryIntent 是"用户这一次明确意图"的不可变身份。
//
// 为什么不能再拿 `library.sync:<scope>:<snapshot>` 当终身幂等键:那是把**意图**和
// **目标状态**混成一体。成功同步 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)}
}