98294b9583
build / build (push) Failing after 24s
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 集成、故障注入、部署。
64 lines
2.9 KiB
Go
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)}
|
|
}
|