新增迁移50与每5分钟/手动持久同步,API只读本地;同步失败保留、权限阻断、租约与质量失效原子激活。 保留全量稳定分页/固定列,已存质量批量读取,元数据不反复解压长答案;详情安全Markdown与原文。 已运行整仓 make verify、源与副本隔离PG -race、真实只读源及隔离浏览器。 实际同步2525条;固定窗口640条本地中位1.46秒,对照直接源33.10秒。 停worker、缺源凭据、重启API后仍可查询;未更新8088,未修改生产原问答。
This commit is contained in:
@@ -76,6 +76,23 @@
|
||||
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
|
||||
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
|
||||
|
||||
## 开发快照 snapshot/v0.1.28 - 2026-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- 问答运营新增持久本地副本,每 5 分钟后台同步并支持立即同步;列表、详情、检索、质量范围和使用统计改为读取本地数据库。
|
||||
- AI 答案详情提供安全 Markdown 预览与原文查看,支持表格、代码块和链接;不执行原始 HTML,外部图片默认不请求。
|
||||
|
||||
### Changed
|
||||
|
||||
- 增加治理侧问答副本表与同步状态;同步由普通 worker 执行,使用独立只读源连接,源失败保留最后成功副本,权限校验失败阻断读取。
|
||||
- 副本激活与质量输入变化的复核失效同事务提交;同步持久去重、租约校验及重启恢复不改变源问答或现有生产写入。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 本地读取不再逐条重复写质量留档或解压完整范围长答案来统计字段,保留完整列、输入版本一致性和真实缺失值。
|
||||
- 初次观察即已同步完成时自动重查列表,避免较早的首次未同步错误一直停留。
|
||||
|
||||
## 开发快照 snapshot/v0.1.27 - 2026-10-18
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -105,6 +105,93 @@
|
||||
"/api/v1/qa-ops/records/{id}/reviews": {"post": {"operationId": "saveQAQualityReview", "summary": "治理库追加人工复核,登录身份审计,源端只读,输入/复核乐观锁和nonce幂等;正确时根因为空,不计入须说明原因", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "X-Governance-Action", "in": "header", "required": true, "schema": {"type": "string", "enum": ["qa-quality"]}}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "properties": {"input_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "input_epoch": {"type": "integer", "minimum": 1}, "base_revision": {"type": "integer", "minimum": 0}, "nonce": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, "fields": {"type": "object", "additionalProperties": false, "properties": {"coverage": {"type": "string", "enum": ["covered", "uncovered", "partial", "pending", "excluded"]}, "correctness": {"type": "string", "enum": ["correct", "incorrect", "pending"]}, "product_category": {"type": "string", "maxLength": 128}, "problem_type": {"type": "string", "maxLength": 128}, "root_cause": {"type": "string", "maxLength": 2000}, "exclusion_reason": {"type": "string", "maxLength": 2000}}, "required": ["coverage", "correctness"]}, "reason": {"type": "string", "minLength": 1, "maxLength": 4000}}, "required": ["input_hash", "input_epoch", "base_revision", "nonce", "fields", "reason"]}}}}, "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}},
|
||||
"/api/v1/qa-ops/records/{id}/quality/history": {"get": {"operationId": "listQAQualityHistory", "summary": "按当前固定租户查询不可变复检/人工历史,分别倒序分页,各20条", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "review_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}, {"name": "check_before", "in": "query", "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "输入或复核版本变化/重复操作载荷不一致,未覆盖原结论"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}},
|
||||
"/api/v1/qa-ops/records/{id}/quality/inputs/{epoch}": {"get": {"operationId": "getQAQualityInputSnapshot", "summary": "管理员读取已采集的历史质量输入快照,不另存人员关联/反馈字段;元数据留档但未采集原文返回409,不编造空快照", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}, {"name": "epoch", "in": "path", "required": true, "schema": {"type": "integer", "minimum": 1}}], "tags": ["QA operations"], "responses": {"200": {"$ref": "#/components/responses/Envelope"}, "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"description": "需要登录"}, "403": {"description": "仅管理员访问"}, "503": {"description": "独立只读源或治理组织存储不可用,不返回伪造的零值"}, "500": {"$ref": "#/components/responses/InternalError"}, "409": {"description": "该输入版本仅有指纹/规则元数据、原文未采集"}, "404": {"description": "当前授权范围内未找到记录/历史输入"}}}},
|
||||
"/api/v1/qa-ops/sync": {
|
||||
"get": {
|
||||
"operationId": "getQALocalMirrorSync",
|
||||
"summary": "读取本地问答副本与后台同步状态(只读本地,不探测生产)",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "本地副本状态、最近成功时间、5分钟计划与安全作业进度;无问答原文/人员标识"
|
||||
},
|
||||
"400": {
|
||||
"description": "不接受查询参数"
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录"
|
||||
},
|
||||
"403": {
|
||||
"description": "仅管理员"
|
||||
},
|
||||
"500": {
|
||||
"description": "治理库不可用"
|
||||
},
|
||||
"503": {
|
||||
"description": "本地同步未接线"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "enqueueQALocalMirrorSync",
|
||||
"summary": "同源管理员请求后台问答同步,持久去重,不同步阻塞查询",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "X-Governance-Action",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"qa-records-sync"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "Idempotency-Key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "已入队/重放/合并;须轮询状态,不代表同步成功"
|
||||
},
|
||||
"400": {
|
||||
"description": "非法参数、null、未知字段或缺nonce"
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录"
|
||||
},
|
||||
"403": {
|
||||
"description": "非管理员/跨站/缺操作意图"
|
||||
},
|
||||
"409": {
|
||||
"description": "相同nonce的操作人/载荷冲突"
|
||||
},
|
||||
"500": {
|
||||
"description": "治理库不可用"
|
||||
},
|
||||
"503": {
|
||||
"description": "本地模式或执行器未接线;未创建任务"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/qa-ops/status": {
|
||||
"get": {
|
||||
"tags": ["QA operations"],
|
||||
|
||||
+1
-1
@@ -137,7 +137,7 @@ func run(ctx context.Context, getenv func(string) string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
qaReader, qaSettings := openQAOperations(ctx, getenv, config.DatabaseURL)
|
||||
qaReader, qaSettings := openQAOperations(ctx, getenv, config.DatabaseURL, pool)
|
||||
if closer, ok := qaReader.(interface{ Close() }); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
|
||||
+12
-7
@@ -2,20 +2,25 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/httpapi"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A failed optional source must not replace or take down the main database.
|
||||
func openQAOperations(ctx context.Context, getenv func(string) string, governanceDSN string) (httpapi.QAOpsReader, dobotaiops.Settings) {
|
||||
// API serving never constructs or probes a production-source client. The worker
|
||||
// alone synchronizes the explicitly authorized local copy; this is not a fallback.
|
||||
func openQAOperations(_ context.Context, getenv func(string) string, governanceDSN string, pool *pgxpool.Pool) (httpapi.QAOpsReader, dobotaiops.Settings) {
|
||||
settings := dobotaiops.LoadSettings(getenv, governanceDSN)
|
||||
if !settings.Enabled || settings.Problem != "" {
|
||||
if !settings.Enabled {
|
||||
return nil, settings
|
||||
}
|
||||
client, err := dobotaiops.Build(ctx, settings.Connection)
|
||||
if err != nil {
|
||||
settings.Problem = "source_connection_or_permissions_failed"
|
||||
if settings.TenantKey == "" || len(settings.TenantKey) > 128 || strings.ContainsRune(settings.TenantKey, 0) {
|
||||
settings.Problem = "local_mirror_tenant_invalid"
|
||||
return nil, settings
|
||||
}
|
||||
return client, settings
|
||||
// Missing/offline source credentials stop synchronization, not last-good local reads.
|
||||
settings.Problem = ""
|
||||
return qaopsmirror.NewStore(pool, settings.TenantKey), settings
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
)
|
||||
|
||||
func TestQAOpsConnectionFailureIsContainedOutsideMainDatabase(t *testing.T) {
|
||||
reader, settings := openQAOperations(context.Background(), func(string) string { return "" }, "postgres://main@host/governance")
|
||||
reader, settings := openQAOperations(context.Background(), func(string) string { return "" }, "postgres://main@host/governance", nil)
|
||||
if reader != nil || settings.Enabled || settings.Problem != "" {
|
||||
t.Fatal("QA operations must be disabled by default")
|
||||
}
|
||||
env := map[string]string{"DOBOTAI_OPS_READ_ENABLED": "true", "DOBOTAI_OPS_TENANT_KEY": "approved-tenant", "DOBOTAI_OPS_READ_DSN_FILE": "/nonexistent-qaops-fixture-secret"}
|
||||
reader, settings = openQAOperations(context.Background(), func(k string) string { return env[k] }, "postgres://main@host/governance")
|
||||
if reader != nil || !settings.Enabled || settings.Problem == "" {
|
||||
t.Fatal("failed external connection must be a contained module error")
|
||||
reader, settings = openQAOperations(context.Background(), func(k string) string { return env[k] }, "postgres://main@host/governance", nil)
|
||||
if reader == nil || !settings.Enabled || settings.Problem != "" {
|
||||
t.Fatal("API serving still constructs a source connection instead of a local reader")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ func run(ctx context.Context, getenv func(string) string) error {
|
||||
if syncErr := configureDirectorySync(pool, getenv, handlers); syncErr != nil {
|
||||
log.Print("QA directory synchronization unavailable; handler was not registered")
|
||||
}
|
||||
mirrorStore, mirrorErr := configureRecordsSync(pool, getenv, config.DatabaseURL, handlers)
|
||||
if mirrorErr != nil {
|
||||
log.Print("QA local mirror configuration invalid; handler was not registered")
|
||||
}
|
||||
worker, err := jobs.NewWorker(jobs.WorkerConfig{
|
||||
Queue: store,
|
||||
WorkerID: config.WorkerID,
|
||||
@@ -67,6 +71,9 @@ func run(ctx context.Context, getenv func(string) string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mirrorStore != nil {
|
||||
go runRecordsSyncScheduler(ctx, mirrorStore, func(error) { log.Print("QA local mirror scheduler unavailable; last committed data retained") })
|
||||
}
|
||||
go runSourceRefreshScheduler(ctx, store, config.SourceRefreshSchedulerInterval, func(err error) {
|
||||
log.Printf("worker %s source refresh scheduler error: %v", config.WorkerID, err)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/jobs"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func configureRecordsSync(pool *pgxpool.Pool, getenv func(string) string, governanceDSN string, handlers map[string]jobs.Handler) (*qaopsmirror.Store, error) {
|
||||
settings := dobotaiops.LoadSettings(getenv, governanceDSN)
|
||||
if !settings.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if settings.TenantKey == "" || len(settings.TenantKey) > 128 || strings.ContainsRune(settings.TenantKey, 0) {
|
||||
return nil, qaopsmirror.ErrInvalid
|
||||
}
|
||||
store := qaopsmirror.NewStore(pool, settings.TenantKey)
|
||||
handlers[store.SyncJobType()] = func(ctx context.Context, job jobs.Job) error {
|
||||
return store.HandleSync(ctx, job, func(ctx context.Context) (qaopsmirror.Source, error) {
|
||||
current := dobotaiops.LoadSettings(getenv, governanceDSN)
|
||||
if !current.Enabled || current.Problem != "" || current.TenantKey != settings.TenantKey {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
// One source connection, shared by every manifest and bounded payload read in this job.
|
||||
current.Connection.MaxConns = 1
|
||||
current.Connection.MinConns = 0
|
||||
return dobotaiops.Build(ctx, current.Connection)
|
||||
})
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// The next due time and automatic operation key live in PostgreSQL, so a worker
|
||||
// restart does not reset the five-minute cadence or duplicate an active import.
|
||||
func runRecordsSyncScheduler(ctx context.Context, store *qaopsmirror.Store, onError func(error)) {
|
||||
tick := time.NewTicker(15 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
if _, e := store.EnqueueDue(ctx); e != nil && ctx.Err() == nil && onError != nil {
|
||||
onError(e)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"knowledge-governance/internal/jobs"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalMirrorWorkerRegistrationNeverConnectsSource(t *testing.T) {
|
||||
values := map[string]string{}
|
||||
get := func(k string) string { return values[k] }
|
||||
handlers := map[string]jobs.Handler{}
|
||||
if store, e := configureRecordsSync(nil, get, "postgres://fixture@localhost/local", handlers); e != nil || store != nil || len(handlers) != 0 {
|
||||
t.Fatal("disabled mirror registered")
|
||||
}
|
||||
values["DOBOTAI_OPS_READ_ENABLED"] = "true"
|
||||
values["DOBOTAI_OPS_TENANT_KEY"] = "fixture-worker-tenant"
|
||||
values["DOBOTAI_OPS_READ_DSN_FILE"] = "/nonexistent-fixture-source-secret"
|
||||
store, e := configureRecordsSync(nil, get, "postgres://fixture@localhost/local", handlers)
|
||||
if e != nil || store == nil || handlers[qaopsmirror.NewStore(nil, "fixture-worker-tenant").SyncJobType()] == nil {
|
||||
t.Fatal("local mirror handler missing; registration must not connect to source", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package dobotaiops
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxMirrorRecords = 100000
|
||||
const mirrorFingerprintSQL = `encode(sha256(convert_to((payload-'_quality_observed_at')::text,'UTF8')),'hex')`
|
||||
|
||||
type MirrorKey struct {
|
||||
ID string
|
||||
Fingerprint string
|
||||
}
|
||||
type MirrorManifest struct {
|
||||
Keys []MirrorKey
|
||||
AsOf time.Time
|
||||
Safety SourceSafety
|
||||
}
|
||||
type MirrorRecord struct {
|
||||
Key MirrorKey
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
func validMirrorTenant(tenant string) bool {
|
||||
return strings.TrimSpace(tenant) == tenant && len(tenant) > 0 && len(tenant) <= 128 && !strings.ContainsRune(tenant, 0)
|
||||
}
|
||||
|
||||
// Only a manifest (IDs and stable hashes), not all long answers, crosses the
|
||||
// network on unchanged polls. Each read retains the source ACL and read-only gates.
|
||||
func (c *Client) ReadMirrorManifest(ctx context.Context, tenant string) (MirrorManifest, error) {
|
||||
out := MirrorManifest{Keys: []MirrorKey{}}
|
||||
if !validMirrorTenant(tenant) {
|
||||
return out, ErrInvalidQuery
|
||||
}
|
||||
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
|
||||
if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&out.AsOf) != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
rows, e := q.Query(ctx, recordsBaseSQL+` SELECT id,`+mirrorFingerprintSQL+` FROM records ORDER BY id LIMIT 100001`, tenant)
|
||||
if e != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k MirrorKey
|
||||
if rows.Scan(&k.ID, &k.Fingerprint) != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if len(out.Keys) >= MaxMirrorRecords {
|
||||
return ErrInvalidQuery
|
||||
}
|
||||
out.Keys = append(out.Keys, k)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
out.Safety = c.Safety()
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) ReadMirrorRecords(ctx context.Context, tenant string, ids []string) ([]MirrorRecord, error) {
|
||||
if !validMirrorTenant(tenant) || len(ids) == 0 || len(ids) > 100 {
|
||||
return nil, ErrInvalidQuery
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if id == "" || len(id) > 256 || strings.ContainsRune(id, 0) || seen[id] {
|
||||
return nil, ErrInvalidQuery
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
out := make([]MirrorRecord, 0, len(ids))
|
||||
size := 0
|
||||
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
|
||||
rows, e := q.Query(ctx, recordsBaseSQL+` SELECT id,`+mirrorFingerprintSQL+`,payload FROM records WHERE id=ANY($2::text[]) ORDER BY id`, tenant, ids)
|
||||
if e != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r MirrorRecord
|
||||
if rows.Scan(&r.Key.ID, &r.Key.Fingerprint, &r.Payload) != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
size += len(r.Payload)
|
||||
if len(r.Payload) > 8<<20 || size > 32<<20 {
|
||||
return ErrInvalidQuery
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if len(out) != len(ids) {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dobotaiops
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMirrorSourceManifestDetectsUpdatesWithoutVolatileTimes(t *testing.T) {
|
||||
p := fixturePool(t)
|
||||
c := buildReadOnlyClient(t, qaRecordsFixture(t, p))
|
||||
ctx := context.Background()
|
||||
a, e := c.ReadMirrorManifest(ctx, "default")
|
||||
if e != nil || len(a.Keys) != 4 || a.AsOf.IsZero() {
|
||||
t.Fatal("full fixed-channel manifest is missing", e, len(a.Keys))
|
||||
}
|
||||
b, e := c.ReadMirrorManifest(ctx, "default")
|
||||
if e != nil || len(b.Keys) != len(a.Keys) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
old := map[string]string{}
|
||||
for i, k := range a.Keys {
|
||||
old[k.ID] = k.Fingerprint
|
||||
if k != b.Keys[i] {
|
||||
t.Fatal("source clock alone changed the content fingerprint")
|
||||
}
|
||||
}
|
||||
rows, e := c.ReadMirrorRecords(ctx, "default", []string{"turn-3"})
|
||||
if e != nil || len(rows) != 1 || rows[0].Key.Fingerprint != old["turn-3"] {
|
||||
t.Fatal("bounded safe payload fetch failed", e)
|
||||
}
|
||||
before, e := DecodeMirrorPayload(string(rows[0].Payload))
|
||||
if e != nil || before.Feedback.Up != 1 || before.Identity.StaffID == "" {
|
||||
t.Fatal("safe payload lost required fields", e)
|
||||
}
|
||||
execFixture(t, p, `UPDATE answer_feedback SET value='down' WHERE id='fb1'`)
|
||||
after, e := c.ReadMirrorRecords(ctx, "default", []string{"turn-3"})
|
||||
if e != nil || after[0].Key.Fingerprint == old["turn-3"] {
|
||||
t.Fatal("feedback update on old question was missed", e)
|
||||
}
|
||||
rec, _ := DecodeMirrorPayload(string(after[0].Payload))
|
||||
if rec.QualityInputHash != before.QualityInputHash {
|
||||
t.Fatal("feedback invalidated unrelated human quality input")
|
||||
}
|
||||
execFixture(t, p, `UPDATE turns SET answer='changed answer' WHERE id='turn-3'`)
|
||||
after, e = c.ReadMirrorRecords(ctx, "default", []string{"turn-3"})
|
||||
rec, _ = DecodeMirrorPayload(string(after[0].Payload))
|
||||
if e != nil || rec.QualityInputHash == before.QualityInputHash {
|
||||
t.Fatal("answer change was missed")
|
||||
}
|
||||
if _, e = c.ReadMirrorRecords(ctx, "default", make([]string, 101)); e == nil {
|
||||
t.Fatal("unbounded payload fetch allowed")
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func decodeRecord(raw string) (Record, error) {
|
||||
wire.Record.Coverage = CheckAnswerCoverage(wire.Record.Answer)
|
||||
return wire.Record, nil
|
||||
}
|
||||
func (c *Client) ReadUsageGroups(ctx context.Context, query RecordQuery) (UsageGroups, error) {
|
||||
func (c *RecordsRepository) ReadUsageGroups(ctx context.Context, query RecordQuery) (UsageGroups, error) {
|
||||
if err := c.validateQueryWindow(query.Window); err != nil {
|
||||
return UsageGroups{}, err
|
||||
}
|
||||
@@ -69,11 +69,11 @@ func (c *Client) ReadUsageGroups(ctx context.Context, query RecordQuery) (UsageG
|
||||
return UsageGroups{}, patternErr
|
||||
}
|
||||
result := UsageGroups{Groups: []UsageGroup{}}
|
||||
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
|
||||
if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&result.AsOf) != nil {
|
||||
err := c.transact(ctx, func(ctx context.Context, q querier) error {
|
||||
if c.readAsOf(ctx, q, &result.AsOf) != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
rows, err := q.Query(ctx, recordsBaseSQL+` SELECT identity::text,count(*),count(*) FILTER(WHERE status='succeeded'),count(*) FILTER(WHERE status='failed') FROM records WHERE `+recordFilterSQL+` GROUP BY identity`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern)
|
||||
rows, err := q.Query(ctx, c.baseSQL+` SELECT identity::text,count(*),count(*) FILTER(WHERE status='succeeded'),count(*) FILTER(WHERE status='failed') FROM records WHERE `+recordFilterSQL+` GROUP BY identity`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern)
|
||||
if err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type QualityScope struct {
|
||||
AsOf time.Time
|
||||
}
|
||||
|
||||
func (c *Client) ReadQualityScope(ctx context.Context, query RecordQuery) (QualityScope, error) {
|
||||
func (c *RecordsRepository) ReadQualityScope(ctx context.Context, query RecordQuery) (QualityScope, error) {
|
||||
if err := c.validateQueryWindow(query.Window); err != nil {
|
||||
return QualityScope{}, err
|
||||
}
|
||||
@@ -23,11 +23,15 @@ func (c *Client) ReadQualityScope(ctx context.Context, query RecordQuery) (Quali
|
||||
return QualityScope{}, err
|
||||
}
|
||||
result := QualityScope{Keys: []QualityKey{}}
|
||||
err = c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
|
||||
if q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&result.AsOf) != nil {
|
||||
err = c.transact(ctx, func(ctx context.Context, q querier) error {
|
||||
if c.readAsOf(ctx, q, &result.AsOf) != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
rows, err := q.Query(ctx, recordsBaseSQL+` SELECT id,quality_hash,strpos(payload->>'answer',$5)>0,(payload->'source_coverage')::text FROM records WHERE `+recordFilterSQL+` ORDER BY id LIMIT 5001`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern, noPublishedMarker)
|
||||
scopeSQL := c.baseSQL + ` SELECT id,quality_hash,strpos(payload->>'answer',$5)>0,(payload->'source_coverage')::text FROM records WHERE `
|
||||
if c.qualityScopeSQL != "" {
|
||||
scopeSQL = c.qualityScopeSQL
|
||||
}
|
||||
rows, err := q.Query(ctx, scopeSQL+recordFilterSQL+` ORDER BY id LIMIT 5001`, query.Window.TenantKey, query.Window.Start, query.Window.End, pattern, noPublishedMarker)
|
||||
if err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package dobotaiops
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RecordQuerier and RecordTransaction keep the projection identical while an
|
||||
// explicitly selected reader supplies either source-ACL or local-snapshot transactions.
|
||||
type RecordRow = rowScanner
|
||||
type RecordQuerier = querier
|
||||
type RecordTransaction func(context.Context, func(context.Context, RecordQuerier) error) error
|
||||
type RecordsRepositoryOptions struct {
|
||||
BaseSQL string
|
||||
SummarySQL string
|
||||
QualityScopeSQL string
|
||||
Transaction RecordTransaction
|
||||
ValidateWindow func(Window) error
|
||||
ReadAsOf func(context.Context, RecordQuerier, *time.Time) error
|
||||
TenantKey string
|
||||
}
|
||||
type RecordsRepository struct {
|
||||
baseSQL string
|
||||
summarySQL string
|
||||
qualityScopeSQL string
|
||||
transact RecordTransaction
|
||||
validateQueryWindow func(Window) error
|
||||
readAsOf func(context.Context, RecordQuerier, *time.Time) error
|
||||
tenant string
|
||||
}
|
||||
|
||||
func NewRecordsRepository(o RecordsRepositoryOptions) *RecordsRepository {
|
||||
asOf := o.ReadAsOf
|
||||
if asOf == nil {
|
||||
asOf = func(ctx context.Context, q RecordQuerier, t *time.Time) error {
|
||||
return q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(t)
|
||||
}
|
||||
}
|
||||
return &RecordsRepository{summarySQL: o.SummarySQL, qualityScopeSQL: o.QualityScopeSQL, baseSQL: o.BaseSQL, transact: o.Transaction, validateQueryWindow: o.ValidateWindow, readAsOf: asOf, tenant: o.TenantKey}
|
||||
}
|
||||
func (c *Client) recordRepository() *RecordsRepository {
|
||||
return NewRecordsRepository(RecordsRepositoryOptions{BaseSQL: recordsBaseSQL, Transaction: c.withReadOnlyTx, ValidateWindow: c.validateQueryWindow})
|
||||
}
|
||||
func (c *Client) ListRecords(ctx context.Context, q RecordQuery) (RecordPage, error) {
|
||||
return c.recordRepository().ListRecords(ctx, q)
|
||||
}
|
||||
func (c *Client) GetRecord(ctx context.Context, tenant, id string) (Record, error) {
|
||||
return c.recordRepository().GetRecord(ctx, tenant, id)
|
||||
}
|
||||
func (c *Client) ReadUsageGroups(ctx context.Context, q RecordQuery) (UsageGroups, error) {
|
||||
return c.recordRepository().ReadUsageGroups(ctx, q)
|
||||
}
|
||||
func (c *Client) ReadQualityScope(ctx context.Context, q RecordQuery) (QualityScope, error) {
|
||||
return c.recordRepository().ReadQualityScope(ctx, q)
|
||||
}
|
||||
|
||||
// DecodeMirrorPayload accepts only the source adapter's fixed safe projection;
|
||||
// credentials and arbitrary source tables are never part of this protocol.
|
||||
func DecodeMirrorPayload(raw string) (Record, error) { return decodeRecord(raw) }
|
||||
@@ -91,15 +91,16 @@ type QualityKey struct {
|
||||
InputHash string `json:"input_hash"`
|
||||
}
|
||||
type RecordPage struct {
|
||||
ScopeQuality []QualityKey `json:"-"`
|
||||
ScopeActors []ActorIdentity `json:"-"`
|
||||
Organization *OrganizationVersion `json:"organization,omitempty"`
|
||||
Items []Record `json:"items"`
|
||||
Columns []string `json:"columns"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
AsOf time.Time `json:"as_of"`
|
||||
LocalRevision int64 `json:"local_revision,omitempty"`
|
||||
ScopeQuality []QualityKey `json:"-"`
|
||||
ScopeActors []ActorIdentity `json:"-"`
|
||||
Organization *OrganizationVersion `json:"organization,omitempty"`
|
||||
Items []Record `json:"items"`
|
||||
Columns []string `json:"columns"`
|
||||
Total int64 `json:"total"`
|
||||
HasMore bool `json:"has_more"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
AsOf time.Time `json:"as_of"`
|
||||
}
|
||||
|
||||
type recordCursor struct {
|
||||
@@ -172,7 +173,7 @@ func recordKeywordPattern(keyword string) (string, error) {
|
||||
return "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(keyword) + "%", nil
|
||||
}
|
||||
|
||||
func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage, error) {
|
||||
func (c *RecordsRepository) ListRecords(ctx context.Context, query RecordQuery) (RecordPage, error) {
|
||||
if err := c.validateQueryWindow(query.Window); err != nil {
|
||||
return RecordPage{}, err
|
||||
}
|
||||
@@ -197,12 +198,16 @@ func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage
|
||||
}
|
||||
args := []any{query.Window.TenantKey, query.Window.Start, query.Window.End, pattern}
|
||||
page := RecordPage{Items: make([]Record, 0), Columns: make([]string, 0)}
|
||||
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
|
||||
if err := q.QueryRow(ctx, "SELECT transaction_timestamp()").Scan(&page.AsOf); err != nil {
|
||||
err := c.transact(ctx, func(ctx context.Context, q querier) error {
|
||||
if err := c.readAsOf(ctx, q, &page.AsOf); err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
var rawColumns, rawActors, rawQuality string
|
||||
if err := q.QueryRow(ctx, recordsBaseSQL+recordColumnsSQL+recordFilterSQL, append(args, noPublishedMarker)...).Scan(&page.Total, &rawColumns, &rawActors, &rawQuality); err != nil {
|
||||
summarySQL := c.baseSQL + recordColumnsSQL
|
||||
if c.summarySQL != "" {
|
||||
summarySQL = c.summarySQL
|
||||
}
|
||||
if err := q.QueryRow(ctx, summarySQL+recordFilterSQL, append(args, noPublishedMarker)...).Scan(&page.Total, &rawColumns, &rawActors, &rawQuality); err != nil {
|
||||
return fmt.Errorf("%w: record count failed", ErrUnavailable)
|
||||
}
|
||||
if json.Unmarshal([]byte(rawQuality), &page.ScopeQuality) != nil {
|
||||
@@ -223,7 +228,7 @@ func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage
|
||||
page.Columns = append(page.Columns, column)
|
||||
}
|
||||
}
|
||||
rows, err := q.Query(ctx, recordsBaseSQL+` SELECT payload::text FROM records WHERE `+recordFilterSQL+`
|
||||
rows, err := q.Query(ctx, c.baseSQL+` SELECT payload::text FROM records WHERE `+recordFilterSQL+`
|
||||
AND ($5::timestamptz IS NULL OR (asked_at,id)<($5,$6)) ORDER BY asked_at DESC,id DESC LIMIT $7`, append(args, before, cursor.ID, query.Limit+1)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: record list failed", ErrUnavailable)
|
||||
@@ -259,14 +264,14 @@ func (c *Client) ListRecords(ctx context.Context, query RecordQuery) (RecordPage
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetRecord(ctx context.Context, tenantKey, id string) (Record, error) {
|
||||
if strings.TrimSpace(tenantKey) == "" || id == "" || len(id) > 256 || strings.ContainsRune(id, 0) {
|
||||
func (c *RecordsRepository) GetRecord(ctx context.Context, tenantKey, id string) (Record, error) {
|
||||
if strings.TrimSpace(tenantKey) == "" || (c.tenant != "" && tenantKey != c.tenant) || id == "" || len(id) > 256 || strings.ContainsRune(id, 0) {
|
||||
return Record{}, ErrInvalidQuery
|
||||
}
|
||||
var item Record
|
||||
err := c.withReadOnlyTx(ctx, func(ctx context.Context, q querier) error {
|
||||
err := c.transact(ctx, func(ctx context.Context, q querier) error {
|
||||
var raw string
|
||||
err := q.QueryRow(ctx, recordsBaseSQL+` SELECT payload::text FROM records WHERE id=$2`, tenantKey, id).Scan(&raw)
|
||||
err := q.QueryRow(ctx, c.baseSQL+` SELECT payload::text FROM records WHERE id=$2`, tenantKey, id).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrRecordNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mirroredQualitySource struct {
|
||||
qualitySource
|
||||
reads int
|
||||
}
|
||||
|
||||
func (s *mirroredQualitySource) ReadStoredQuality(_ context.Context, keys []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) {
|
||||
s.reads++
|
||||
out := map[string]qaopsquality.View{}
|
||||
for _, key := range keys {
|
||||
out[key.ID] = qaopsquality.Project(key.InputHash, 1, 0, qaopsquality.Check{Result: dobotaiops.CheckMarkerCoverage(true)}, nil)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func TestMirroredQualityQueriesDoNotRepeatObservationWrites(t *testing.T) {
|
||||
source := &mirroredQualitySource{qualitySource: qualitySource{record: dobotaiops.Record{ID: "fixture-turn", Question: "fixture", Answer: "暂无已发布"}}}
|
||||
e, _ := qaopsquality.EvidenceFor(source.record)
|
||||
source.record.QualityInputHash = e.Hash
|
||||
legacy := &qualityFake{fail: true}
|
||||
auth := newFakeAuthStore()
|
||||
auth.sessions["quality-admin"] = "usr-boss"
|
||||
h, e2 := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}, QAOps: source, QAOpsTenantKey: "fixed", QAQuality: legacy, Now: func() time.Time { return time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) }})
|
||||
if e2 != nil {
|
||||
t.Fatal(e2)
|
||||
}
|
||||
for _, q := range []struct{ method, path, body string }{{"GET", "/api/v1/qa-ops/records/fixture-turn", ""}, {"GET", "/api/v1/qa-ops/records?start=2026-10-14T00:00:00Z&end=2026-10-15T00:00:00Z", ""}, {"POST", "/api/v1/qa-ops/quality/query", `{"start":"2026-10-14T00:00:00Z","end":"2026-10-15T00:00:00Z","keyword":""}`}} {
|
||||
w := qualityRequest(h, q.method, q.path, "quality-admin", q.body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatal("local mirror read went back through observation writes", q.path, w.Code)
|
||||
}
|
||||
}
|
||||
if source.reads != 3 || legacy.calls != 0 {
|
||||
t.Fatal("batched stored projection not used", source.reads, legacy.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type qaMirrorSyncStore interface {
|
||||
GetSyncStatus(context.Context) (qaopsmirror.Status, error)
|
||||
EnqueueSync(context.Context, string, string) (qaopsmirror.SyncReceipt, bool, error)
|
||||
}
|
||||
|
||||
func qaRecordsSync(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
if !authorizeQAOps(w, r) {
|
||||
return
|
||||
}
|
||||
if r.URL.RawQuery != "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "同步入口不接受查询参数"})
|
||||
return
|
||||
}
|
||||
store, ok := c.QAOps.(qaMirrorSyncStore)
|
||||
if !ok || !c.QAOpsEnabled {
|
||||
writeJSON(w, 503, map[string]string{"error": "本地问答同步未接线,未创建任务"})
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
state, e := store.GetSyncStatus(r.Context())
|
||||
if e != nil {
|
||||
qaOpsError(w, e)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"data": state})
|
||||
return
|
||||
}
|
||||
if !authorizeQAIntent(w, r, "qa-records-sync") {
|
||||
return
|
||||
}
|
||||
key := r.Header.Get("Idempotency-Key")
|
||||
if key == "" || len(key) > 128 || strings.TrimSpace(key) != key {
|
||||
writeJSON(w, 400, map[string]string{"error": "同步请求需要有效 Idempotency-Key,重试须复用原标识"})
|
||||
return
|
||||
}
|
||||
var body *struct{}
|
||||
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&body) != nil || body == nil || d.Decode(&struct{}{}) != io.EOF {
|
||||
writeJSON(w, 400, map[string]string{"error": "同步请求体必须是空对象"})
|
||||
return
|
||||
}
|
||||
identity, _ := sessionFrom(r.Context())
|
||||
receipt, replayed, e := store.EnqueueSync(r.Context(), key, identity.Username)
|
||||
if e != nil {
|
||||
status := 503
|
||||
message := "同步未成功入队,已有本地数据保持不变"
|
||||
if errors.Is(e, qaopsmirror.ErrInvalid) {
|
||||
status = 400
|
||||
message = "同步请求标识无效"
|
||||
}
|
||||
if errors.Is(e, qaopsmirror.ErrConflict) {
|
||||
status = 409
|
||||
message = "同步标识已用于另一操作,请重新发起"
|
||||
}
|
||||
if errors.Is(e, qaopsmirror.ErrWorkerUnavailable) {
|
||||
message = "问答同步执行器未在线,未创建任务"
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
return
|
||||
}
|
||||
operation := "created"
|
||||
if replayed {
|
||||
operation = "replayed"
|
||||
}
|
||||
writeJSON(w, 202, map[string]any{"data": receipt, "operation": operation})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mirrorSyncSpy struct {
|
||||
qaOpsSpy
|
||||
syncCalls int
|
||||
key, actor string
|
||||
}
|
||||
|
||||
func (s *mirrorSyncSpy) GetSyncStatus(context.Context) (qaopsmirror.Status, error) {
|
||||
return qaopsmirror.Status{Mode: "local", IntervalSeconds: 300, WorkerReady: true}, nil
|
||||
}
|
||||
func (s *mirrorSyncSpy) EnqueueSync(_ context.Context, key, actor string) (qaopsmirror.SyncReceipt, bool, error) {
|
||||
s.syncCalls++
|
||||
s.key = key
|
||||
s.actor = actor
|
||||
return qaopsmirror.SyncReceipt{JobID: "fixture-mirror-job"}, false, nil
|
||||
}
|
||||
func TestQAMirrorSyncRequiresSessionIntentAndIdempotency(t *testing.T) {
|
||||
spy := &mirrorSyncSpy{}
|
||||
auth := newFakeAuthStore()
|
||||
auth.sessions["mirror-admin"] = "usr-boss"
|
||||
auth.sessions["mirror-viewer"] = "usr-eye"
|
||||
h, e := NewHandler(Config{Store: &fakeJobStore{}, QAOps: spy, QAOpsEnabled: true, QAOpsTenantKey: "fixture-tenant", Auth: &AuthConfig{Store: auth}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
method, token, key, intent, origin, body string
|
||||
status int
|
||||
}{
|
||||
{"GET", "", "", "", "", "", 401}, {"GET", "mirror-viewer", "", "", "", "", 403}, {"GET", "mirror-admin", "", "", "", "", 200},
|
||||
{"POST", "mirror-admin", "op-one", "", "", "{}", 403}, {"POST", "mirror-admin", "op-one", "qa-records-sync", "https://foreign.invalid", "{}", 403},
|
||||
{"POST", "mirror-admin", "", "qa-records-sync", "", "{}", 400}, {"POST", "mirror-admin", "op-one", "qa-records-sync", "", "null", 400},
|
||||
{"POST", "mirror-admin", "op-one", "qa-records-sync", "", `{"tenant":"forged"}`, 400}, {"POST", "mirror-admin", "op-one", "qa-records-sync", "", "{}", 202},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, "/api/v1/qa-ops/sync", strings.NewReader(tc.body))
|
||||
if tc.token != "" {
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tc.token})
|
||||
}
|
||||
req.Header.Set("Idempotency-Key", tc.key)
|
||||
req.Header.Set("X-Governance-Action", tc.intent)
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != tc.status {
|
||||
t.Fatalf("%s %s got %d want %d: %s", tc.method, tc.body, w.Code, tc.status, w.Body.String())
|
||||
}
|
||||
}
|
||||
if spy.syncCalls != 1 || spy.key != "op-one" || spy.actor == "" {
|
||||
t.Fatal("invalid sync reached store or operator was not session-stamped")
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/v1/qa-ops/status", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "mirror-admin"})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Cache *qaopsmirror.Status `json:"cache"`
|
||||
}
|
||||
}
|
||||
if json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Data.Cache == nil || body.Data.Cache.Mode != "local" {
|
||||
t.Fatal("local serving mode missing from status")
|
||||
}
|
||||
}
|
||||
func TestQAMirrorSyncNamespaceCannotBypassEntry(t *testing.T) {
|
||||
auth := newFakeAuthStore()
|
||||
auth.sessions["mirror-admin"] = "usr-boss"
|
||||
h, _ := NewHandler(Config{Store: &fakeJobStore{}, Auth: &AuthConfig{Store: auth}})
|
||||
req := httptest.NewRequest("POST", "/api/v1/jobs", strings.NewReader(`{"type":"qaops.records.sync.fixture","payload":{},"idempotency_key":"bypass"}`))
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "mirror-admin"})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Fatal("generic jobs endpoint bypassed mirror sync admission", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,16 @@ func qaOpsStatus(w http.ResponseWriter, r *http.Request, config Config) {
|
||||
warnings = append(warnings, "database_transport_unencrypted")
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"state": state, "code": code, "warnings": warnings, "read_only": true, "source_version": "2.0", "channel": "dingtalk"}})
|
||||
data := map[string]any{"state": state, "code": code, "warnings": warnings, "read_only": true, "source_version": "2.0", "channel": "dingtalk"}
|
||||
if mirror, ok := config.QAOps.(qaMirrorSyncStore); ok {
|
||||
cache, err := mirror.GetSyncStatus(r.Context())
|
||||
if err != nil {
|
||||
qaOpsError(w, err)
|
||||
return
|
||||
}
|
||||
data["cache"] = cache
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": data})
|
||||
}
|
||||
|
||||
func listQAOpsRecords(w http.ResponseWriter, r *http.Request, config Config) {
|
||||
@@ -144,7 +153,7 @@ func getQAOpsRecord(w http.ResponseWriter, r *http.Request, config Config) {
|
||||
func qaOpsError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusServiceUnavailable
|
||||
code := "source_unavailable"
|
||||
message := "问答数据源不可用,请检查独立只读连接;原有知识治理功能不受影响"
|
||||
message := "问答记录当前不可用;本地模式请检查同步状态,原有知识治理功能不受影响"
|
||||
if errors.Is(err, errQAOrganization) {
|
||||
code = "organization_unavailable"
|
||||
message = "通讯录或区域配置读取失败,未将不可用数据伪装成空列,请重试"
|
||||
|
||||
@@ -22,6 +22,21 @@ type QAQualityStore interface {
|
||||
Snapshot(context.Context, string, int64) (qaopsquality.Snapshot, error)
|
||||
}
|
||||
|
||||
type storedQAQualityReader interface {
|
||||
ReadStoredQuality(context.Context, []dobotaiops.QualityKey) (map[string]qaopsquality.View, error)
|
||||
}
|
||||
|
||||
func qaQualityViews(ctx context.Context, c Config, records []dobotaiops.Record) (map[string]qaopsquality.View, error) {
|
||||
if reader, ok := c.QAOps.(storedQAQualityReader); ok {
|
||||
keys := make([]dobotaiops.QualityKey, 0, len(records))
|
||||
for _, r := range records {
|
||||
keys = append(keys, dobotaiops.QualityKey{ID: r.ID, InputHash: r.QualityInputHash})
|
||||
}
|
||||
return reader.ReadStoredQuality(ctx, keys)
|
||||
}
|
||||
return c.QAQuality.Observe(ctx, records)
|
||||
}
|
||||
|
||||
type qaQualityReader interface {
|
||||
ReadQualityScope(context.Context, dobotaiops.RecordQuery) (dobotaiops.QualityScope, error)
|
||||
}
|
||||
@@ -44,7 +59,7 @@ func enrichQAQualityRecord(ctx context.Context, c Config, item *dobotaiops.Recor
|
||||
if c.QAQuality == nil {
|
||||
return nil
|
||||
}
|
||||
views, err := c.QAQuality.Observe(ctx, []dobotaiops.Record{*item})
|
||||
views, err := qaQualityViews(ctx, c, []dobotaiops.Record{*item})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -59,7 +74,7 @@ func enrichQAQualityPage(ctx context.Context, c Config, page *dobotaiops.RecordP
|
||||
if c.QAQuality == nil {
|
||||
return nil
|
||||
}
|
||||
views, err := c.QAQuality.Observe(ctx, page.Items)
|
||||
views, err := qaQualityViews(ctx, c, page.Items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,7 +212,12 @@ func qaQualityQuery(w http.ResponseWriter, r *http.Request, c Config) {
|
||||
qaOpsError(w, err)
|
||||
return
|
||||
}
|
||||
views, err := c.QAQuality.ObserveKeys(r.Context(), scope.Keys)
|
||||
var views map[string]qaopsquality.View
|
||||
if stored, ok := c.QAOps.(storedQAQualityReader); ok {
|
||||
views, err = stored.ReadStoredQuality(r.Context(), scope.Keys)
|
||||
} else {
|
||||
views, err = c.QAQuality.ObserveKeys(r.Context(), scope.Keys)
|
||||
}
|
||||
if err != nil || len(views) != len(scope.Keys) {
|
||||
if err == nil {
|
||||
err = qaopsquality.ErrUnavailable
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"knowledge-governance/internal/jobs"
|
||||
"knowledge-governance/internal/monitoring"
|
||||
postgresstore "knowledge-governance/internal/platform/postgres"
|
||||
"knowledge-governance/internal/qaopsmirror"
|
||||
"knowledge-governance/internal/qaopsorg"
|
||||
)
|
||||
|
||||
@@ -188,6 +189,8 @@ func NewHandler(config Config) (http.Handler, error) {
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/organization", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/organization/preview", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
|
||||
mux.HandleFunc("PUT /api/v1/qa-ops/organization/regions", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/sync", func(w http.ResponseWriter, r *http.Request) { qaRecordsSync(w, r, config) })
|
||||
mux.HandleFunc("POST /api/v1/qa-ops/sync", func(w http.ResponseWriter, r *http.Request) { qaRecordsSync(w, r, config) })
|
||||
mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
qaOpsStatus(w, r, config)
|
||||
})
|
||||
@@ -569,7 +572,7 @@ func createJob(w http.ResponseWriter, r *http.Request, config Config) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON request"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(input.Type, qaopsorg.SyncJobPrefix) {
|
||||
if strings.HasPrefix(input.Type, qaopsorg.SyncJobPrefix) || strings.HasPrefix(input.Type, qaopsmirror.SyncJobPrefix) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "通讯录同步任务只能从问答运营的管理员入口创建"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE qa_ops_mirror_records;
|
||||
DROP TABLE qa_ops_mirror_state;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE qa_ops_mirror_state (
|
||||
tenant_key TEXT PRIMARY KEY CHECK(length(tenant_key) BETWEEN 1 AND 128),
|
||||
ready BOOLEAN NOT NULL DEFAULT false, blocked BOOLEAN NOT NULL DEFAULT false,
|
||||
revision BIGINT NOT NULL DEFAULT 0 CHECK(revision>=0), record_count BIGINT NOT NULL DEFAULT 0 CHECK(record_count>=0),
|
||||
source_observed_at TIMESTAMPTZ, synced_at TIMESTAMPTZ, next_sync_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error TEXT NOT NULL DEFAULT '', source_safety JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE TABLE qa_ops_mirror_records (
|
||||
tenant_key TEXT NOT NULL REFERENCES qa_ops_mirror_state(tenant_key), id TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL CHECK(fingerprint ~ '^[a-f0-9]{64}$'), asked_at TIMESTAMPTZ NOT NULL,
|
||||
question TEXT NOT NULL, status TEXT NOT NULL, identity JSONB NOT NULL, quality_hash TEXT NOT NULL CHECK(quality_hash ~ '^[a-f0-9]{64}$'),
|
||||
payload JSONB NOT NULL CHECK(jsonb_typeof(payload)='object'), PRIMARY KEY(tenant_key,id)
|
||||
);
|
||||
CREATE INDEX idx_qa_mirror_records_window ON qa_ops_mirror_records(tenant_key,asked_at DESC,id DESC);
|
||||
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("loadMigrations() error = %v", err)
|
||||
}
|
||||
if len(migrations) != 49 {
|
||||
t.Fatalf("migration count = %d, want 49", len(migrations))
|
||||
if len(migrations) != 50 {
|
||||
t.Fatalf("migration count = %d, want 50", len(migrations))
|
||||
}
|
||||
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
|
||||
t.Fatalf("migration 1 = %#v", migrations[0])
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/jobs"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalMirrorFailuresUpdatesAndReviewPreservation(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
s := NewStore(p, "fixture-failures")
|
||||
q := pg.NewStore(p)
|
||||
ctx := context.Background()
|
||||
first := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("one", "old answer")}}
|
||||
run := func(source *fixtureSource, key string) error {
|
||||
j := claimMirrorJob(t, s, q, key)
|
||||
e := s.HandleSync(ctx, *j, func(context.Context) (Source, error) { return source, nil })
|
||||
if e == nil {
|
||||
if e = q.Complete(ctx, j.ID, j.LeaseOwner, time.Now()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
} else {
|
||||
if _, cleanupErr := p.Exec(ctx, `UPDATE jobs SET state='failed',lease_owner=NULL,lease_until=NULL WHERE id=$1`, j.ID); cleanupErr != nil {
|
||||
t.Fatal(cleanupErr)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
if e := run(first, "initial-good"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
quality := qaopsquality.NewStore(p, s.tenant)
|
||||
record, _ := s.GetRecord(ctx, s.tenant, "one")
|
||||
views, e := quality.Observe(ctx, []dobotaiops.Record{record})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
v := views["one"]
|
||||
saved, e := quality.Save(ctx, record, qaopsquality.SaveRequest{InputHash: v.InputHash, InputEpoch: v.InputEpoch, BaseRevision: v.Revision, Nonce: strings.Repeat("a", 32), Fields: qaopsquality.Fields{Coverage: "covered", Correctness: "correct"}, Reason: "explicit synthetic review"}, "fixture-admin")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
unchanged := &fixtureSource{rows: first.rows}
|
||||
if e = run(unchanged, "unchanged-check"); e != nil || unchanged.payloadCalls != 0 {
|
||||
t.Fatal("unchanged poll transferred long answers", e)
|
||||
}
|
||||
state, _ := s.GetSyncStatus(ctx)
|
||||
if state.Revision != 1 {
|
||||
t.Fatal("unchanged poll invalidated query generations")
|
||||
}
|
||||
current, e := quality.Observe(ctx, []dobotaiops.Record{record})
|
||||
if e != nil || current["one"].State != "reviewed" || current["one"].Revision != saved.SavedRevision {
|
||||
t.Fatal("unchanged synchronization damaged review", e)
|
||||
}
|
||||
partial := &fixtureSource{payloadError: errors.New("fixture interrupted payload"), failAfter: 2}
|
||||
partial.rows = append(partial.rows, fixtureEntry("one", "new answer"))
|
||||
for n := 0; n < 101; n++ {
|
||||
partial.rows = append(partial.rows, fixtureEntry(fmt.Sprintf("extra-%03d", n), "additional answer"))
|
||||
}
|
||||
if e = run(partial, "interrupted-full-copy"); e == nil {
|
||||
t.Fatal("partial fetch was called complete")
|
||||
}
|
||||
old, e := s.GetRecord(ctx, s.tenant, "one")
|
||||
state, _ = s.GetSyncStatus(ctx)
|
||||
if e != nil || old.Answer != "old answer" || state.Records != 1 || state.Revision != 1 || state.ErrorCode == "" || !state.Ready {
|
||||
t.Fatal("partial failure mutated last-good local copy", e)
|
||||
}
|
||||
current, e = quality.Observe(ctx, []dobotaiops.Record{old})
|
||||
if e != nil || current["one"].State != "reviewed" {
|
||||
t.Fatal("partial source fetch advanced review input", e)
|
||||
}
|
||||
changed := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("one", "changed answer")}}
|
||||
if e = run(changed, "changed-old-record"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
fresh, _ := s.GetRecord(ctx, s.tenant, "one")
|
||||
current, e = quality.Observe(ctx, []dobotaiops.Record{fresh})
|
||||
if e != nil || current["one"].State != "stale" || current["one"].LatestReview == nil || current["one"].LatestReview.Revision != saved.SavedRevision {
|
||||
t.Fatal("answer change lost history or retained an obsolete judgment", e)
|
||||
}
|
||||
denied := &fixtureSource{err: dobotaiops.ErrNotReadOnly}
|
||||
if e = run(denied, "authorization-denied"); e == nil {
|
||||
t.Fatal("source authorization failure ignored")
|
||||
}
|
||||
if e = s.Available(ctx); !errors.Is(e, dobotaiops.ErrNotReadOnly) {
|
||||
t.Fatal("revoked source left local reads enabled", e)
|
||||
}
|
||||
recovered := &fixtureSource{rows: changed.rows}
|
||||
if e = run(recovered, "authorization-recovered"); e != nil || s.Available(ctx) != nil {
|
||||
t.Fatal("verified recovery failed", e)
|
||||
}
|
||||
}
|
||||
func TestLocalMirrorRejectsUnadmittedJobsBeforeSource(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
s := NewStore(p, "fixture-unadmitted")
|
||||
q := pg.NewStore(p)
|
||||
ctx := context.Background()
|
||||
_, e := q.Enqueue(ctx, jobs.EnqueueRequest{ID: "fixture-bypass", Type: s.SyncJobType(), Payload: []byte(`{}`), AvailableAt: time.Now(), MaxAttempts: 1, IdempotencyKey: "fixture-bypass"}, time.Now())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
j, e := q.Claim(ctx, "fixture-bypass-worker", []string{s.SyncJobType()}, time.Now(), time.Minute)
|
||||
if e != nil || j == nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
calls := 0
|
||||
e = s.HandleSync(ctx, *j, func(context.Context) (Source, error) { calls++; return &fixtureSource{}, nil })
|
||||
if !errors.Is(e, ErrLease) || calls != 0 {
|
||||
t.Fatal("unadmitted job reached source", e, calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package qaopsmirror
|
||||
|
||||
import "knowledge-governance/internal/dobotaiops"
|
||||
|
||||
// The complete fixed-column UI does not need to repeatedly decompress every
|
||||
// large answer on every page just to rediscover which headings exist. Exact
|
||||
// marker/source evidence was committed atomically with each mirror payload.
|
||||
const localMetadataBase = `WITH records AS (SELECT m.id,m.asked_at,m.question,m.identity,m.quality_hash,COALESCE(c.result->>'verdict'='uncovered',false) AS marker,COALESCE(c.source,'{}'::jsonb) AS source_coverage FROM qa_ops_mirror_records m LEFT JOIN qa_ops_quality_records h ON h.tenant_key=m.tenant_key AND h.record_id=m.id AND h.input_hash=m.quality_hash LEFT JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version='` + dobotaiops.CoverageRuleVersion + `' WHERE m.tenant_key=$1)`
|
||||
const localSummarySQL = localMetadataBase + ` SELECT count(*),'{}'::text,COALESCE(jsonb_agg(DISTINCT identity),'[]'::jsonb)::text,COALESCE(jsonb_agg(jsonb_build_object('id',id,'input_hash',quality_hash,'marker',marker,'source',source_coverage)),'[]'::jsonb)::text FROM records WHERE $5::text IS NOT NULL AND `
|
||||
const localQualityScopeSQL = localMetadataBase + ` SELECT id,quality_hash,marker,source_coverage::text FROM records WHERE $5::text IS NOT NULL AND `
|
||||
@@ -0,0 +1,39 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalMirrorSchedulerRecoversExhaustedCrash(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
s := NewStore(p, "fixture-crashed-scheduler")
|
||||
q := pg.NewStore(p)
|
||||
ctx := context.Background()
|
||||
if _, e := q.Claim(ctx, "fixture-crashed-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key,next_sync_at) VALUES($1,now()-interval '1 hour')`, s.tenant); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if yes, e := s.EnqueueDue(ctx); e != nil || !yes {
|
||||
t.Fatal(e)
|
||||
}
|
||||
job, e := q.Claim(ctx, "fixture-crashed-worker", []string{s.SyncJobType()}, time.Now(), time.Minute)
|
||||
if e != nil || job == nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
// Model death after claiming the final attempt, before any failure checkpoint.
|
||||
if _, e = p.Exec(ctx, `UPDATE jobs SET attempt=max_attempts,lease_until=now()-interval '1 second' WHERE id=$1`, job.ID); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = s.EnqueueDue(ctx); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
state, e := s.GetSyncStatus(ctx)
|
||||
if e != nil || state.NextSyncAt == nil || !state.NextSyncAt.After(time.Now()) || state.ErrorCode != "mirror_sync_interrupted" {
|
||||
t.Fatal("scheduler replayed an exhausted operation forever instead of persisting a new retry due time", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package qaopsmirror serves an explicitly synchronized local copy, never an implicit source fallback.
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Interval = 5 * time.Minute
|
||||
const localBaseSQL = `WITH records AS (SELECT id,asked_at,question,status,identity,quality_hash,payload FROM qa_ops_mirror_records WHERE tenant_key=$1)`
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
tenant string
|
||||
mu sync.RWMutex
|
||||
safety dobotaiops.SourceSafety
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} }
|
||||
|
||||
type Status struct {
|
||||
Mode string `json:"mode"`
|
||||
Ready bool `json:"ready"`
|
||||
Blocked bool `json:"blocked"`
|
||||
Revision int64 `json:"revision"`
|
||||
Records int64 `json:"records"`
|
||||
SourceObservedAt *time.Time `json:"source_observed_at"`
|
||||
LastSuccessAt *time.Time `json:"last_success_at"`
|
||||
NextSyncAt *time.Time `json:"next_sync_at"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
IntervalSeconds int `json:"interval_seconds"`
|
||||
WorkerReady bool `json:"worker_ready"`
|
||||
Job *SyncJob `json:"job"`
|
||||
Safety dobotaiops.SourceSafety `json:"-"`
|
||||
}
|
||||
type SyncJob struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Loaded int `json:"loaded"`
|
||||
Expected int `json:"expected"`
|
||||
Changed int `json:"changed"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
|
||||
func (s *Store) valid() bool {
|
||||
return s != nil && s.pool != nil && strings.TrimSpace(s.tenant) == s.tenant && len(s.tenant) > 0 && len(s.tenant) <= 128 && !strings.ContainsRune(s.tenant, 0)
|
||||
}
|
||||
func (s *Store) readState(ctx context.Context, q dobotaiops.RecordQuerier) (Status, error) {
|
||||
out := Status{Mode: "local", IntervalSeconds: int(Interval / time.Second)}
|
||||
var safety []byte
|
||||
err := q.QueryRow(ctx, `SELECT ready,blocked,revision,record_count,source_observed_at,synced_at,next_sync_at,last_error,source_safety FROM qa_ops_mirror_state WHERE tenant_key=$1`, s.tenant).Scan(&out.Ready, &out.Blocked, &out.Revision, &out.Records, &out.SourceObservedAt, &out.LastSuccessAt, &out.NextSyncAt, &out.ErrorCode, &safety)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
out.ErrorCode = "initial_sync_pending"
|
||||
return out, nil
|
||||
}
|
||||
if err != nil || json.Unmarshal(safety, &out.Safety) != nil {
|
||||
return Status{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.safety = out.Safety
|
||||
s.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type txQuery struct{ pgx.Tx }
|
||||
|
||||
func (q txQuery) QueryRow(ctx context.Context, sql string, args ...any) dobotaiops.RecordRow {
|
||||
return q.Tx.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
type poolQuery struct{ *pgxpool.Pool }
|
||||
|
||||
func (q poolQuery) QueryRow(ctx context.Context, sql string, args ...any) dobotaiops.RecordRow {
|
||||
return q.Pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
func (s *Store) Available(ctx context.Context) error {
|
||||
if !s.valid() {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
state, e := s.readState(ctx, poolQuery{s.pool})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if state.Blocked {
|
||||
return dobotaiops.ErrNotReadOnly
|
||||
}
|
||||
if !state.Ready {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) Safety() dobotaiops.SourceSafety { s.mu.RLock(); defer s.mu.RUnlock(); return s.safety }
|
||||
func (s *Store) repository(revision *int64) *dobotaiops.RecordsRepository {
|
||||
return dobotaiops.NewRecordsRepository(dobotaiops.RecordsRepositoryOptions{
|
||||
BaseSQL: localBaseSQL, SummarySQL: localSummarySQL, QualityScopeSQL: localQualityScopeSQL, TenantKey: s.tenant,
|
||||
ValidateWindow: func(w dobotaiops.Window) error {
|
||||
if w.TenantKey != s.tenant {
|
||||
return dobotaiops.ErrInvalidQuery
|
||||
}
|
||||
_, e := dobotaiops.NewWindow(w.TenantKey, w.Start.Format(time.RFC3339Nano), w.End.Format(time.RFC3339Nano), 90*24*time.Hour, time.Now())
|
||||
return e
|
||||
},
|
||||
ReadAsOf: func(ctx context.Context, q dobotaiops.RecordQuerier, t *time.Time) error {
|
||||
return q.QueryRow(ctx, `SELECT source_observed_at FROM qa_ops_mirror_state WHERE tenant_key=$1`, s.tenant).Scan(t)
|
||||
},
|
||||
Transaction: func(ctx context.Context, fn func(context.Context, dobotaiops.RecordQuerier) error) error {
|
||||
if !s.valid() {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||
if e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
q := txQuery{tx}
|
||||
state, e := s.readState(ctx, q)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if state.Blocked {
|
||||
return dobotaiops.ErrNotReadOnly
|
||||
}
|
||||
if !state.Ready {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
if revision != nil {
|
||||
*revision = state.Revision
|
||||
}
|
||||
if e = fn(ctx, q); e != nil {
|
||||
return e
|
||||
}
|
||||
if tx.Commit(ctx) != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
func (s *Store) ListRecords(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.RecordPage, error) {
|
||||
var rev int64
|
||||
p, e := s.repository(&rev).ListRecords(ctx, q)
|
||||
p.LocalRevision = rev
|
||||
p.Columns = []string{"question_chinese", "answer", "coverage", "is_correct", "product_category", "problem_type", "root_cause", "asked_at", "asker", "question", "language", "trigger_person", "department", "region", "feedback"}
|
||||
return p, e
|
||||
}
|
||||
func (s *Store) GetRecord(ctx context.Context, tenant, id string) (dobotaiops.Record, error) {
|
||||
return s.repository(nil).GetRecord(ctx, tenant, id)
|
||||
}
|
||||
func (s *Store) ReadUsageGroups(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.UsageGroups, error) {
|
||||
return s.repository(nil).ReadUsageGroups(ctx, q)
|
||||
}
|
||||
func (s *Store) ReadQualityScope(ctx context.Context, q dobotaiops.RecordQuery) (dobotaiops.QualityScope, error) {
|
||||
return s.repository(nil).ReadQualityScope(ctx, q)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mirrorFixture(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("KG_QAOPS_MIRROR_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("explicit disposable mirror database required")
|
||||
}
|
||||
u, e := url.Parse(dsn)
|
||||
if e != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_mirror_test_") || os.Getenv("KG_QAOPS_MIRROR_TEST_ISOLATED") != "1" {
|
||||
t.Fatal("refusing shared mirror database")
|
||||
}
|
||||
p, e := pgxpool.New(context.Background(), dsn)
|
||||
if e != nil {
|
||||
t.Fatal("fixture connection failed")
|
||||
}
|
||||
t.Cleanup(p.Close)
|
||||
if e = pg.ApplyMigrations(context.Background(), p); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func TestLocalMirrorQueriesNeverRequireSource(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
ctx := context.Background()
|
||||
s := NewStore(p, "fixture-mirror")
|
||||
if s.Available(ctx) == nil {
|
||||
t.Fatal("an unsynchronized mirror pretended to be empty/ready")
|
||||
}
|
||||
asked := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
|
||||
wire := map[string]any{"id": "fixture-one", "status": "succeeded", "question": "literal 100%_ question", "answer": "# local answer\n暂无已发布", "asked_at": asked, "time_source": "turn.created_at", "asker": "fixture name", "trigger_person": "", "department": "", "region": "", "references": []any{}, "media": []any{}, "source_coverage": map[string]string{"verdict": "covered", "reason_code": "", "evaluator_version": "v5"}, "feedback": map[string]any{"asker_value": "up", "up": 1, "down": 0}, "_identity": map[string]string{"actor_id": "actor1", "staff_id": "staff1", "union_id": ""}, "_quality_hash": strings.Repeat("a", 64), "_quality_observed_at": time.Now().UTC()}
|
||||
raw, _ := json.Marshal(wire)
|
||||
if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key,ready,revision,record_count,source_observed_at,synced_at) VALUES($1,true,1,1,now(),now())`, s.tenant); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := p.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,'fixture-one',$2,$3,$4,'succeeded',$5,$2,$6)`, s.tenant, strings.Repeat("a", 64), asked, wire["question"], `{"actor_id":"actor1","staff_id":"staff1","union_id":""}`, raw); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
// No source client or source credentials have been constructed anywhere in this test.
|
||||
if e := s.Available(ctx); e != nil {
|
||||
t.Fatal("local serving still requires a remote source", e)
|
||||
}
|
||||
q := dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: s.tenant, Start: asked.Add(-time.Minute), End: asked.Add(time.Minute)}, Keyword: "100%_", Limit: 1}
|
||||
page, e := s.ListRecords(ctx, q)
|
||||
if e != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].Answer != wire["answer"] {
|
||||
t.Fatal("local fixed-tenant literal query failed", e, page.Total)
|
||||
}
|
||||
if page.Items[0].Identity.StaffID != "staff1" || page.Items[0].Coverage.Verdict != "uncovered" {
|
||||
t.Fatal("local copy lost source identity or deterministic rule")
|
||||
}
|
||||
out, _ := json.Marshal(page)
|
||||
if strings.Contains(string(out), "staff1") || strings.Contains(string(out), "_identity") {
|
||||
t.Fatal("private identity leaked to browser")
|
||||
}
|
||||
got, e := s.GetRecord(ctx, s.tenant, "fixture-one")
|
||||
if e != nil || got.Answer != wire["answer"] {
|
||||
t.Fatal("detail is not served from local data", e)
|
||||
}
|
||||
if _, e = s.GetRecord(ctx, "other-tenant", "fixture-one"); !errors.Is(e, dobotaiops.ErrInvalidQuery) {
|
||||
t.Fatal("cross-tenant detail was not rejected", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
)
|
||||
|
||||
// Payload activation observes quality atomically. Browser reads must not repeat
|
||||
// per-record write transactions; mismatched inputs fail closed in ReadViews.
|
||||
func (s *Store) ReadStoredQuality(ctx context.Context, keys []dobotaiops.QualityKey) (map[string]qaopsquality.View, error) {
|
||||
return qaopsquality.NewStore(s.pool, s.tenant).ReadViews(ctx, keys)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalMirrorMetadataDoesNotReadLargeAnswerPayload(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
ctx := context.Background()
|
||||
tenant := "fixture-metadata-acl"
|
||||
record := dobotaiops.Record{ID: "metadata-one", Question: "fixture", Answer: "暂无已发布", QualityObservedAt: time.Now()}
|
||||
views, e := qaopsquality.NewStore(p, tenant).Observe(ctx, []dobotaiops.Record{record})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
hash := views[record.ID].InputHash
|
||||
if _, e = p.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key,ready,revision,record_count,source_observed_at,synced_at) VALUES($1,true,1,1,now(),now());`, tenant); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = p.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,$2,$3,now()-interval '1 hour','fixture','succeeded','{}',$3,'{}')`, tenant, record.ID, hash); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
role := fmt.Sprintf("kg_mirror_metadata_%d", time.Now().UnixNano())
|
||||
if _, e = p.Exec(ctx, "CREATE ROLE "+role+" NOLOGIN"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer func() { p.Exec(ctx, "DROP OWNED BY "+role); p.Exec(ctx, "DROP ROLE "+role) }()
|
||||
if _, e = p.Exec(ctx, "GRANT SELECT(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash) ON qa_ops_mirror_records TO "+role+";GRANT SELECT ON qa_ops_mirror_state,qa_ops_quality_records,qa_ops_quality_inputs,qa_ops_quality_checks TO "+role); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
config := p.Config().Copy()
|
||||
config.AfterConnect = func(ctx context.Context, c *pgx.Conn) error { _, e := c.Exec(ctx, "SET ROLE "+role); return e }
|
||||
limited, e := pgxpool.NewWithConfig(ctx, config)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer limited.Close()
|
||||
scope, e := NewStore(limited, tenant).ReadQualityScope(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: tenant, Start: time.Now().Add(-2 * time.Hour), End: time.Now()}, Limit: 100})
|
||||
if e != nil || len(scope.Keys) != 1 || !scope.Keys[0].Marker {
|
||||
t.Fatal("local metadata still requires SELECT on large payload instead of committed quality evidence", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalMirrorStoredQualityUsesReadOnlyBatchedProjection(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
ctx := context.Background()
|
||||
tenant := "fixture-read-stored"
|
||||
record := dobotaiops.Record{ID: "stored-one", Question: "fixture question", Answer: "暂无已发布", QualityObservedAt: time.Now()}
|
||||
views, e := qaopsquality.NewStore(p, tenant).Observe(ctx, []dobotaiops.Record{record})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
hash := views[record.ID].InputHash
|
||||
config := p.Config().Copy()
|
||||
config.ConnConfig.RuntimeParams["default_transaction_read_only"] = "on"
|
||||
readonly, e := pgxpool.NewWithConfig(ctx, config)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer readonly.Close()
|
||||
store := NewStore(readonly, tenant)
|
||||
keys := []dobotaiops.QualityKey{{ID: record.ID, InputHash: hash}}
|
||||
got, e := store.ReadStoredQuality(ctx, keys)
|
||||
if e != nil || len(got) != 1 || got[record.ID].InputHash != hash {
|
||||
t.Fatal("synchronized quality must be readable without any write transaction", e)
|
||||
}
|
||||
keys[0].InputHash = strings.Repeat("f", 64)
|
||||
if _, e = store.ReadStoredQuality(ctx, keys); !errors.Is(e, qaopsquality.ErrConflict) {
|
||||
t.Fatal("stale mirror input paired with newer quality", e)
|
||||
}
|
||||
if _, e = NewStore(readonly, "other-tenant").ReadStoredQuality(ctx, []dobotaiops.QualityKey{{ID: record.ID, InputHash: hash}}); e == nil {
|
||||
t.Fatal("stored quality crossed tenant scope")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/jobs"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const SyncJobPrefix = "qaops.records.sync."
|
||||
|
||||
func (s *Store) EnqueueDue(ctx context.Context) (bool, error) {
|
||||
if !s.valid() {
|
||||
return false, dobotaiops.ErrUnavailable
|
||||
}
|
||||
// A final-attempt process can die before recordFailure. Rotate the persisted
|
||||
// due time once, otherwise ExecuteOperation would replay its dead receipt forever.
|
||||
if _, e := s.pool.Exec(ctx, `WITH latest AS (SELECT state,attempt,max_attempts,lease_until,updated_at FROM jobs WHERE type=$2 ORDER BY created_at DESC,id DESC LIMIT 1) UPDATE qa_ops_mirror_state s SET next_sync_at=clock_timestamp()+interval '5 minutes',last_error='mirror_sync_interrupted' FROM latest j WHERE s.tenant_key=$1 AND s.next_sync_at<=j.updated_at AND(j.state='failed' OR(j.state='running' AND j.attempt>=j.max_attempts AND j.lease_until<clock_timestamp()))`, s.tenant, s.SyncJobType()); e != nil {
|
||||
return false, dobotaiops.ErrUnavailable
|
||||
}
|
||||
var next time.Time
|
||||
var due bool
|
||||
e := s.pool.QueryRow(ctx, `SELECT next_sync_at,next_sync_at<=clock_timestamp() FROM qa_ops_mirror_state WHERE tenant_key=$1`, s.tenant).Scan(&next, &due)
|
||||
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
||||
return false, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if e == nil && !due {
|
||||
return false, nil
|
||||
}
|
||||
key := "automatic:initial"
|
||||
if !next.IsZero() {
|
||||
key = "automatic:" + next.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
receipt, replayed, e := s.EnqueueSync(ctx, key, "system:qa-record-sync")
|
||||
return e == nil && !replayed && !receipt.Deduplicated, e
|
||||
}
|
||||
|
||||
var ErrWorkerUnavailable = errors.New("local mirror worker is unavailable")
|
||||
var ErrInvalid = errors.New("invalid local mirror request")
|
||||
var ErrConflict = errors.New("local mirror request conflict")
|
||||
var ErrLease = errors.New("local mirror lease lost")
|
||||
var ErrSync = errors.New("local mirror source synchronization failed; last committed data retained")
|
||||
|
||||
type SyncReceipt struct {
|
||||
JobID string `json:"job_id"`
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
|
||||
func (s *Store) SyncJobType() string {
|
||||
h := sha256.Sum256([]byte(s.tenant))
|
||||
return SyncJobPrefix + hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
const workerReadySQL = `SELECT EXISTS(SELECT 1 FROM worker_heartbeats WHERE $1=ANY(string_to_array(job_types,',')) AND last_seen_at>clock_timestamp()-interval '2 minutes') OR EXISTS(SELECT 1 FROM jobs WHERE type=$1 AND state='running' AND lease_until>clock_timestamp())`
|
||||
|
||||
func validToken(v string, n int) bool {
|
||||
return strings.TrimSpace(v) == v && len(v) > 0 && len(v) <= n && utf8.ValidString(v) && !strings.ContainsRune(v, 0)
|
||||
}
|
||||
func (s *Store) EnqueueSync(ctx context.Context, key, actor string) (SyncReceipt, bool, error) {
|
||||
if !s.valid() {
|
||||
return SyncReceipt{}, false, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if !validToken(key, 128) || !validToken(actor, 256) {
|
||||
return SyncReceipt{}, false, ErrInvalid
|
||||
}
|
||||
fp := sha256.Sum256([]byte(actor))
|
||||
now := time.Now()
|
||||
raw, replay, err := pg.NewStore(s.pool).ExecuteOperation(ctx, s.SyncJobType(), key, hex.EncodeToString(fp[:]), now, func(tx pgx.Tx) (json.RawMessage, error) {
|
||||
if _, e := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, s.SyncJobType()); e != nil {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if _, e := tx.Exec(ctx, `UPDATE jobs SET state='failed',last_error='local mirror worker interrupted',last_error_code='mirror_sync_interrupted',lease_owner=NULL,lease_until=NULL,updated_at=now() WHERE type=$1 AND state='running' AND lease_until<clock_timestamp() AND attempt>=max_attempts`, s.SyncJobType()); e != nil {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
var active string
|
||||
e := tx.QueryRow(ctx, `SELECT id FROM jobs WHERE type=$1 AND state IN ('pending','running','retry') ORDER BY created_at DESC,id DESC LIMIT 1`, s.SyncJobType()).Scan(&active)
|
||||
if e == nil {
|
||||
return json.Marshal(SyncReceipt{JobID: active, Deduplicated: true})
|
||||
}
|
||||
if !errors.Is(e, pgx.ErrNoRows) {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
var ready bool
|
||||
if tx.QueryRow(ctx, workerReadySQL, s.SyncJobType()).Scan(&ready) != nil {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
if !ready {
|
||||
return nil, ErrWorkerUnavailable
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_mirror_state(tenant_key) VALUES($1) ON CONFLICT DO NOTHING`, s.tenant); e != nil {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
h := sha256.Sum256([]byte(s.SyncJobType() + ":" + key))
|
||||
payload, _ := json.Marshal(map[string]string{"triggered_by": actor})
|
||||
job, e := pg.NewStore(tx).Enqueue(ctx, jobs.EnqueueRequest{ID: "job-" + hex.EncodeToString(h[:16]), Type: s.SyncJobType(), Payload: payload, AvailableAt: now, MaxAttempts: 3, IdempotencyKey: s.SyncJobType() + ":" + key}, now)
|
||||
if e != nil || job == nil {
|
||||
return nil, dobotaiops.ErrUnavailable
|
||||
}
|
||||
return json.Marshal(SyncReceipt{JobID: job.ID})
|
||||
})
|
||||
if errors.Is(err, pg.ErrOperationRequestConflict) {
|
||||
return SyncReceipt{}, false, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return SyncReceipt{}, false, err
|
||||
}
|
||||
var receipt SyncReceipt
|
||||
if json.Unmarshal(raw, &receipt) != nil || receipt.JobID == "" {
|
||||
return receipt, false, dobotaiops.ErrUnavailable
|
||||
}
|
||||
return receipt, replay, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalMirrorDurableQueueCoalescesAndAudits(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
s := NewStore(p, "fixture-queue")
|
||||
q := pg.NewStore(p)
|
||||
ctx := context.Background()
|
||||
if _, _, e := s.EnqueueSync(ctx, "fixture-no-worker", "fixture-admin"); !errors.Is(e, ErrWorkerUnavailable) {
|
||||
t.Fatal("offline worker accepted work", e)
|
||||
}
|
||||
if _, e := q.Claim(ctx, "fixture-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
ids := make(chan string, 8)
|
||||
errs := make(chan error, 8)
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, _, e := s.EnqueueSync(ctx, "fixture-same-action", "fixture-admin")
|
||||
if e != nil {
|
||||
errs <- e
|
||||
return
|
||||
}
|
||||
ids <- r.JobID
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(ids)
|
||||
for e := range errs {
|
||||
t.Fatal("durable enqueue failed", e)
|
||||
}
|
||||
id := ""
|
||||
n := 0
|
||||
for got := range ids {
|
||||
if got == "" || id != "" && got != id {
|
||||
t.Fatal("same action created different effects")
|
||||
}
|
||||
id = got
|
||||
n++
|
||||
}
|
||||
if n != 8 {
|
||||
t.Fatal("lost enqueue results")
|
||||
}
|
||||
again, _, e := s.EnqueueSync(ctx, "fixture-another-click", "fixture-admin")
|
||||
if e != nil || !again.Deduplicated || again.JobID != id {
|
||||
t.Fatal("in-flight synchronization did not coalesce", e)
|
||||
}
|
||||
if _, _, e = s.EnqueueSync(ctx, "fixture-same-action", "different-admin"); !errors.Is(e, ErrConflict) {
|
||||
t.Fatal("replay changed actor", e)
|
||||
}
|
||||
var count int
|
||||
if p.QueryRow(ctx, `SELECT count(*) FROM jobs WHERE type=$1`, s.SyncJobType()).Scan(&count) != nil || count != 1 {
|
||||
t.Fatal("duplicate jobs")
|
||||
}
|
||||
job, e := q.Claim(ctx, "fixture-worker", []string{s.SyncJobType()}, time.Now(), time.Minute)
|
||||
if e != nil || job == nil || job.ID != id {
|
||||
t.Fatal("job not durable", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/jobs"
|
||||
"knowledge-governance/internal/qaopsquality"
|
||||
"regexp"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Source interface {
|
||||
ReadMirrorManifest(context.Context, string) (dobotaiops.MirrorManifest, error)
|
||||
ReadMirrorRecords(context.Context, string, []string) ([]dobotaiops.MirrorRecord, error)
|
||||
}
|
||||
type SourceFactory func(context.Context) (Source, error)
|
||||
|
||||
var hashPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
|
||||
func (s *Store) lease(ctx context.Context, q dobotaiops.RecordQuerier, j jobs.Job, lock bool) (bool, error) {
|
||||
if j.Type != s.SyncJobType() || j.LeaseOwner == "" {
|
||||
return false, ErrLease
|
||||
}
|
||||
sql := `SELECT payload ? 'mirror_result' FROM jobs WHERE id=$1 AND type=$2 AND state='running' AND lease_owner=$3 AND attempt=$4 AND lease_until>clock_timestamp() AND EXISTS(SELECT 1 FROM operation_requests op WHERE op.operation_type=jobs.type AND op.result->>'job_id'=jobs.id)`
|
||||
if lock {
|
||||
sql += " FOR UPDATE"
|
||||
}
|
||||
var done bool
|
||||
if q.QueryRow(ctx, sql, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt).Scan(&done) != nil {
|
||||
return false, ErrLease
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
func (s *Store) progress(ctx context.Context, j jobs.Job, loaded, expected, changed int) error {
|
||||
p, _ := json.Marshal(map[string]int{"loaded": loaded, "expected": expected, "changed": changed})
|
||||
tag, e := s.pool.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{mirror_progress}',$5::jsonb),updated_at=now() WHERE id=$1 AND type=$2 AND lease_owner=$3 AND attempt=$4 AND state='running' AND lease_until>clock_timestamp()`, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt, p)
|
||||
if e != nil || tag.RowsAffected() != 1 {
|
||||
return ErrLease
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) recordFailure(j jobs.Job, cause error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
code := "mirror_source_unavailable"
|
||||
blocked := errors.Is(cause, dobotaiops.ErrNotReadOnly)
|
||||
if blocked {
|
||||
code = "mirror_source_authorization_failed"
|
||||
}
|
||||
_, _ = s.pool.Exec(ctx, `UPDATE qa_ops_mirror_state SET last_error=$2,blocked=blocked OR $3,next_sync_at=clock_timestamp()+interval '5 minutes' WHERE tenant_key=$1 AND EXISTS(SELECT 1 FROM jobs WHERE id=$4 AND type=$5 AND state='running' AND lease_owner=$6 AND attempt=$7 AND lease_until>clock_timestamp())`, s.tenant, code, blocked, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt)
|
||||
}
|
||||
func (s *Store) HandleSync(ctx context.Context, j jobs.Job, factory SourceFactory) (err error) {
|
||||
if !s.valid() || factory == nil {
|
||||
return ErrSync
|
||||
}
|
||||
done, e := s.lease(ctx, poolQuery{s.pool}, j, false)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if err != nil && !errors.Is(err, ErrLease) {
|
||||
s.recordFailure(j, err)
|
||||
if errors.Is(err, dobotaiops.ErrNotReadOnly) {
|
||||
err = jobs.Permanent("mirror_source_authorization_failed", ErrSync)
|
||||
} else {
|
||||
err = ErrSync
|
||||
}
|
||||
}
|
||||
}()
|
||||
source, e := factory(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if closer, ok := source.(interface{ Close() }); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
known := map[string]string{}
|
||||
rows, e := s.pool.Query(ctx, `SELECT id,fingerprint FROM qa_ops_mirror_records WHERE tenant_key=$1`, s.tenant)
|
||||
if e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, fp string
|
||||
if rows.Scan(&id, &fp) != nil {
|
||||
rows.Close()
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
known[id] = fp
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
manifest, e := source.ReadMirrorManifest(ctx, s.tenant)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if manifest.AsOf.IsZero() || manifest.AsOf.After(time.Now().Add(5*time.Minute)) || len(manifest.Keys) > dobotaiops.MaxMirrorRecords {
|
||||
return ErrSync
|
||||
}
|
||||
// A sudden completely empty scope is not accepted as authority to erase a populated local history.
|
||||
if len(manifest.Keys) == 0 && len(known) > 0 {
|
||||
return ErrSync
|
||||
}
|
||||
keys := map[string]string{}
|
||||
changed := []string{}
|
||||
ids := make([]string, 0, len(manifest.Keys))
|
||||
for _, k := range manifest.Keys {
|
||||
if !validToken(k.ID, 256) || !hashPattern.MatchString(k.Fingerprint) || keys[k.ID] != "" {
|
||||
return ErrSync
|
||||
}
|
||||
keys[k.ID] = k.Fingerprint
|
||||
ids = append(ids, k.ID)
|
||||
if known[k.ID] != k.Fingerprint {
|
||||
changed = append(changed, k.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(changed)
|
||||
loaded := len(ids) - len(changed)
|
||||
if e = s.progress(ctx, j, loaded, len(ids), 0); e != nil {
|
||||
return e
|
||||
}
|
||||
collected := make([]dobotaiops.MirrorRecord, 0, len(changed))
|
||||
totalBytes := 0
|
||||
for start := 0; start < len(changed); start += 100 {
|
||||
if _, e = s.lease(ctx, poolQuery{s.pool}, j, false); e != nil {
|
||||
return e
|
||||
}
|
||||
batch := changed[start:min(start+100, len(changed))]
|
||||
got, e := source.ReadMirrorRecords(ctx, s.tenant, batch)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(got) != len(batch) {
|
||||
return ErrSync
|
||||
}
|
||||
expected := map[string]bool{}
|
||||
for _, id := range batch {
|
||||
expected[id] = true
|
||||
}
|
||||
for _, row := range got {
|
||||
if !expected[row.Key.ID] || !hashPattern.MatchString(row.Key.Fingerprint) {
|
||||
return ErrSync
|
||||
}
|
||||
delete(expected, row.Key.ID)
|
||||
record, e := dobotaiops.DecodeMirrorPayload(string(row.Payload))
|
||||
if e != nil || record.ID != row.Key.ID || !hashPattern.MatchString(record.QualityInputHash) || record.QualityObservedAt.IsZero() || record.AskedAt.IsZero() || len(row.Payload) > 8<<20 {
|
||||
return ErrSync
|
||||
}
|
||||
totalBytes += len(row.Payload)
|
||||
if totalBytes > 256<<20 {
|
||||
return ErrSync
|
||||
}
|
||||
// A row can complete between manifest and payload reads. Keep that newer full
|
||||
// row, never store an old manifest hash against a different current payload.
|
||||
keys[row.Key.ID] = row.Key.Fingerprint
|
||||
if record.QualityObservedAt.After(manifest.AsOf) {
|
||||
manifest.AsOf = record.QualityObservedAt
|
||||
}
|
||||
collected = append(collected, row)
|
||||
}
|
||||
loaded += len(got)
|
||||
if e = s.progress(ctx, j, loaded, len(ids), len(collected)); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return s.activate(ctx, j, manifest, ids, known, collected)
|
||||
}
|
||||
func (s *Store) activate(ctx context.Context, j jobs.Job, m dobotaiops.MirrorManifest, ids []string, known map[string]string, rows []dobotaiops.MirrorRecord) error {
|
||||
tx, e := s.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, s.SyncJobType()); e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
done, e := s.lease(ctx, txQuery{tx}, j, true)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
quality := qaopsquality.NewStore(s.pool, s.tenant)
|
||||
for start := 0; start < len(rows); start += 100 {
|
||||
batch := rows[start:min(start+100, len(rows))]
|
||||
records := make([]dobotaiops.Record, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
rec, e := dobotaiops.DecodeMirrorPayload(string(row.Payload))
|
||||
if e != nil {
|
||||
return ErrSync
|
||||
}
|
||||
records = append(records, rec)
|
||||
identity, _ := json.Marshal(rec.Identity)
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(tenant_key,id) DO UPDATE SET fingerprint=EXCLUDED.fingerprint,asked_at=EXCLUDED.asked_at,question=EXCLUDED.question,status=EXCLUDED.status,identity=EXCLUDED.identity,quality_hash=EXCLUDED.quality_hash,payload=EXCLUDED.payload`, s.tenant, rec.ID, row.Key.Fingerprint, rec.AskedAt, rec.Question, rec.Status, identity, rec.QualityInputHash, row.Payload); e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
}
|
||||
if e = quality.ObserveRecordsInTx(ctx, tx, records); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
deleted, e := tx.Exec(ctx, `DELETE FROM qa_ops_mirror_records WHERE tenant_key=$1 AND NOT(id=ANY($2::text[]))`, s.tenant, ids)
|
||||
if e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
changed := len(rows) > 0 || deleted.RowsAffected() > 0
|
||||
var revision int64
|
||||
var wasReady bool
|
||||
if tx.QueryRow(ctx, `SELECT revision,ready FROM qa_ops_mirror_state WHERE tenant_key=$1 FOR UPDATE`, s.tenant).Scan(&revision, &wasReady) != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
if changed || !wasReady {
|
||||
revision++
|
||||
}
|
||||
safety, _ := json.Marshal(m.Safety)
|
||||
if _, e = tx.Exec(ctx, `UPDATE qa_ops_mirror_state SET ready=true,blocked=false,revision=$2,record_count=$3,source_observed_at=$4,synced_at=clock_timestamp(),next_sync_at=clock_timestamp()+interval '5 minutes',last_error='',source_safety=$5 WHERE tenant_key=$1`, s.tenant, revision, len(ids), m.AsOf, safety); e != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
result, _ := json.Marshal(map[string]any{"records": len(ids), "changed": len(rows), "deleted": deleted.RowsAffected(), "revision": revision})
|
||||
tag, e := tx.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{mirror_result}',$5::jsonb),updated_at=now() WHERE id=$1 AND type=$2 AND lease_owner=$3 AND attempt=$4 AND state='running' AND lease_until>clock_timestamp()`, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt, result)
|
||||
if e != nil || tag.RowsAffected() != 1 {
|
||||
return ErrLease
|
||||
}
|
||||
if tx.Commit(ctx) != nil {
|
||||
return dobotaiops.ErrUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) GetSyncStatus(ctx context.Context) (Status, error) {
|
||||
if !s.valid() {
|
||||
return Status{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
state, e := s.readState(ctx, poolQuery{s.pool})
|
||||
if e != nil {
|
||||
return state, e
|
||||
}
|
||||
if s.pool.QueryRow(ctx, workerReadySQL, s.SyncJobType()).Scan(&state.WorkerReady) != nil {
|
||||
return Status{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
var j SyncJob
|
||||
var payload []byte
|
||||
var expired bool
|
||||
var attempt, maxAttempts int
|
||||
e = s.pool.QueryRow(ctx, `SELECT id,state,payload,COALESCE(last_error_code,''),COALESCE(state='running' AND lease_until<clock_timestamp(),false),attempt,max_attempts FROM jobs WHERE type=$1 ORDER BY created_at DESC,id DESC LIMIT 1`, s.SyncJobType()).Scan(&j.ID, &j.State, &payload, &j.ErrorCode, &expired, &attempt, &maxAttempts)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return state, nil
|
||||
}
|
||||
if e != nil {
|
||||
return Status{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
var data struct {
|
||||
Progress struct {
|
||||
Loaded int `json:"loaded"`
|
||||
Expected int `json:"expected"`
|
||||
Changed int `json:"changed"`
|
||||
} `json:"mirror_progress"`
|
||||
}
|
||||
if json.Unmarshal(payload, &data) != nil {
|
||||
return Status{}, dobotaiops.ErrUnavailable
|
||||
}
|
||||
j.Loaded = data.Progress.Loaded
|
||||
j.Expected = data.Progress.Expected
|
||||
j.Changed = data.Progress.Changed
|
||||
if expired {
|
||||
j.State = "retry"
|
||||
if attempt >= maxAttempts {
|
||||
j.State = "failed"
|
||||
}
|
||||
j.ErrorCode = "mirror_sync_interrupted"
|
||||
}
|
||||
state.Job = &j
|
||||
return state, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package qaopsmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
"knowledge-governance/internal/jobs"
|
||||
pg "knowledge-governance/internal/platform/postgres"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fixtureSource struct {
|
||||
rows []dobotaiops.MirrorRecord
|
||||
calls int
|
||||
payloadCalls int
|
||||
err error
|
||||
payloadError error
|
||||
failAfter int
|
||||
hook func()
|
||||
}
|
||||
|
||||
func (f *fixtureSource) ReadMirrorManifest(context.Context, string) (dobotaiops.MirrorManifest, error) {
|
||||
f.calls++
|
||||
m := dobotaiops.MirrorManifest{AsOf: time.Now().UTC(), Safety: dobotaiops.SourceSafety{SSL: true, TablesChecked: 5, ConnectionLimit: 1}}
|
||||
for _, r := range f.rows {
|
||||
m.Keys = append(m.Keys, r.Key)
|
||||
}
|
||||
return m, f.err
|
||||
}
|
||||
func (f *fixtureSource) ReadMirrorRecords(_ context.Context, _ string, ids []string) ([]dobotaiops.MirrorRecord, error) {
|
||||
f.payloadCalls++
|
||||
if f.payloadError != nil && f.payloadCalls >= f.failAfter {
|
||||
return nil, f.payloadError
|
||||
}
|
||||
out := []dobotaiops.MirrorRecord{}
|
||||
for _, id := range ids {
|
||||
for _, r := range f.rows {
|
||||
if r.Key.ID == id {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.hook != nil {
|
||||
f.hook()
|
||||
}
|
||||
return out, f.err
|
||||
}
|
||||
func fixtureEntry(id, answer string) dobotaiops.MirrorRecord {
|
||||
qh := sha256.Sum256([]byte(id + answer))
|
||||
fp := sha256.Sum256([]byte("source:" + id + answer))
|
||||
now := time.Now().UTC()
|
||||
p, _ := json.Marshal(map[string]any{"id": id, "status": "succeeded", "question": "fixture question", "answer": answer, "asked_at": now.Add(-time.Hour), "time_source": "turn.created_at", "asker": "", "trigger_person": "", "department": "", "region": "", "references": []any{}, "media": []any{}, "source_coverage": map[string]string{"verdict": "covered", "reason_code": "", "evaluator_version": "v5"}, "feedback": map[string]any{"asker_value": "", "up": 0, "down": 0}, "_identity": map[string]string{"actor_id": "a", "staff_id": "s", "union_id": ""}, "_quality_hash": hex.EncodeToString(qh[:]), "_quality_observed_at": now})
|
||||
return dobotaiops.MirrorRecord{Key: dobotaiops.MirrorKey{ID: id, Fingerprint: hex.EncodeToString(fp[:])}, Payload: p}
|
||||
}
|
||||
func claimMirrorJob(t *testing.T, s *Store, q *pg.Store, key string) *jobs.Job {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, e := q.Claim(ctx, "fixture-sync-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, _, e := s.EnqueueSync(ctx, key, "fixture-admin")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
j, e := q.Claim(ctx, "fixture-sync-worker", []string{s.SyncJobType()}, time.Now(), time.Minute)
|
||||
if e != nil || j == nil {
|
||||
t.Fatal("missing durable sync job", e)
|
||||
}
|
||||
return j
|
||||
}
|
||||
func TestLocalMirrorPeriodicSyncIsDurableAndDueOnly(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
s := NewStore(p, "fixture-periodic")
|
||||
q := pg.NewStore(p)
|
||||
ctx := context.Background()
|
||||
if _, e := q.Claim(ctx, "fixture-periodic-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if enqueued, e := s.EnqueueDue(ctx); e != nil || !enqueued {
|
||||
t.Fatal("first automatic sync was not scheduled", e)
|
||||
}
|
||||
if _, e := s.EnqueueDue(ctx); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var count int
|
||||
if p.QueryRow(ctx, `SELECT count(*) FROM jobs WHERE type=$1`, s.SyncJobType()).Scan(&count) != nil || count != 1 {
|
||||
t.Fatal("scheduler duplicated active job")
|
||||
}
|
||||
job, e := q.Claim(ctx, "fixture-periodic-worker", []string{s.SyncJobType()}, time.Now(), time.Minute)
|
||||
if e != nil || job == nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("auto-one", "automatic")}}
|
||||
if e = s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = q.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if enqueued, e := s.EnqueueDue(ctx); e != nil || enqueued {
|
||||
t.Fatal("automatic sync ignored the persisted five-minute interval", e)
|
||||
}
|
||||
if _, e = p.Exec(ctx, `UPDATE qa_ops_mirror_state SET next_sync_at=now()-interval '1 second' WHERE tenant_key=$1`, s.tenant); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if enqueued, e := s.EnqueueDue(ctx); e != nil || !enqueued {
|
||||
t.Fatal("due sync did not resume", e)
|
||||
}
|
||||
}
|
||||
func TestLocalMirrorSyncActivatesAndCheckpoints(t *testing.T) {
|
||||
p := mirrorFixture(t)
|
||||
s := NewStore(p, "fixture-sync")
|
||||
q := pg.NewStore(p)
|
||||
ctx := context.Background()
|
||||
job := claimMirrorJob(t, s, q, "fixture-first-sync")
|
||||
source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("one", "# first answer")}}
|
||||
factory := func(context.Context) (Source, error) { return source, nil }
|
||||
if e := s.HandleSync(ctx, *job, factory); e != nil {
|
||||
t.Fatal("first full sync did not activate", e)
|
||||
}
|
||||
state, e := s.GetSyncStatus(ctx)
|
||||
if e != nil || !state.Ready || state.Records != 1 || state.Revision != 1 || state.LastSuccessAt == nil || state.NextSyncAt == nil || state.NextSyncAt.Sub(*state.LastSuccessAt) < Interval-time.Second {
|
||||
t.Fatal("incomplete local sync state", e, state)
|
||||
}
|
||||
record, e := s.GetRecord(ctx, s.tenant, "one")
|
||||
if e != nil || record.Answer != "# first answer" {
|
||||
t.Fatal("committed local answer missing", e)
|
||||
}
|
||||
var inputs int
|
||||
if p.QueryRow(ctx, `SELECT count(*) FROM qa_ops_quality_inputs WHERE tenant_key=$1 AND payload IS NOT NULL`, s.tenant).Scan(&inputs) != nil || inputs != 1 {
|
||||
t.Fatal("quality evidence did not commit with mirror")
|
||||
}
|
||||
calls := source.calls
|
||||
if e = s.HandleSync(ctx, *job, factory); e != nil || source.calls != calls {
|
||||
t.Fatal("restart after commit fetched source twice", e)
|
||||
}
|
||||
if e = q.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package qaopsquality
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"knowledge-governance/internal/dobotaiops"
|
||||
)
|
||||
|
||||
// ReadViews projects quality already observed by mirror activation. It performs
|
||||
// one SELECT, never inserts inputs/checks or advances an observation epoch.
|
||||
func (s *Store) ReadViews(ctx context.Context, keys []dobotaiops.QualityKey) (map[string]View, error) {
|
||||
if !s.available() {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if len(keys) > 5000 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
expected := map[string]string{}
|
||||
ids := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if !validID(k.ID) || !hashPattern.MatchString(k.InputHash) {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
if old, exists := expected[k.ID]; exists {
|
||||
if old != k.InputHash {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
continue
|
||||
}
|
||||
expected[k.ID] = k.InputHash
|
||||
ids = append(ids, k.ID)
|
||||
}
|
||||
out := map[string]View{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, e := s.pool.Query(ctx, `SELECT h.record_id,h.input_hash,h.input_epoch,h.review_revision,`+checkObject+`::text,COALESCE((SELECT `+reviewObject+` FROM qa_ops_quality_reviews r WHERE r.tenant_key=h.tenant_key AND r.record_id=h.record_id AND r.revision=h.review_revision),'null'::jsonb)::text FROM qa_ops_quality_records h JOIN qa_ops_quality_inputs i ON i.tenant_key=h.tenant_key AND i.record_id=h.record_id AND i.input_epoch=h.input_epoch JOIN qa_ops_quality_checks c ON c.tenant_key=h.tenant_key AND c.record_id=h.record_id AND c.input_epoch=h.input_epoch AND c.rule_version=$3 WHERE h.tenant_key=$1 AND h.record_id=ANY($2::text[])`, s.tenant, ids, dobotaiops.CoverageRuleVersion)
|
||||
if e != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, hash, checkRaw, reviewRaw string
|
||||
var epoch, revision int64
|
||||
if rows.Scan(&id, &hash, &epoch, &revision, &checkRaw, &reviewRaw) != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if expected[id] != hash {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
var automatic Check
|
||||
var review *Review
|
||||
if json.Unmarshal([]byte(checkRaw), &automatic) != nil || json.Unmarshal([]byte(reviewRaw), &review) != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
out[id] = Project(hash, epoch, revision, automatic, review)
|
||||
}
|
||||
if rows.Err() != nil || len(out) != len(ids) {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -131,6 +131,34 @@ func (s *Store) view(ctx context.Context, tx pgx.Tx, id string) (View, error) {
|
||||
}
|
||||
return Project(hash, epoch, revision, automatic, review), nil
|
||||
}
|
||||
|
||||
// ObserveRecordsInTx makes mirror activation and review invalidation one commit.
|
||||
// Only the caller owns commit/rollback; no remote I/O occurs while these locks are held.
|
||||
func (s *Store) ObserveRecordsInTx(ctx context.Context, tx pgx.Tx, records []dobotaiops.Record) error {
|
||||
if !s.available() || tx == nil || len(records) > 100 {
|
||||
return ErrInvalid
|
||||
}
|
||||
items := make([]Evidence, 0, len(records))
|
||||
size := 0
|
||||
for _, r := range records {
|
||||
e, err := EvidenceFor(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size += len(e.Payload)
|
||||
if size > 32<<20 {
|
||||
return ErrInvalid
|
||||
}
|
||||
items = append(items, e)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Record.ID < items[j].Record.ID })
|
||||
for _, e := range items {
|
||||
if err := s.observeLocked(ctx, tx, e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) Observe(ctx context.Context, records []dobotaiops.Record) (map[string]View, error) {
|
||||
if len(records) > 5000 {
|
||||
return nil, ErrInvalid
|
||||
|
||||
+41
-3
@@ -9,9 +9,22 @@
|
||||
"⚠️ 文件缺失或解析不了 ⇒ 门禁红:范围声明不出来时,不许默认放行。"
|
||||
],
|
||||
"optional": [
|
||||
{"package":"knowledge-governance/internal/qaopsquality","test":"TestQualityStoreImmutableHistoryReplayConcurrencyAndStaleness","reason":"仅在显式一次性 loopback kg_qaops_quality_test_ 库执行;默认 CI 不连接共享/生产库,隔离 PG 另行实跑"},
|
||||
{"package": "knowledge-governance/internal/dobotaiops", "test": "TestQARecordInternalIdentityAndWholeFilterKeys", "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"},
|
||||
{"package": "knowledge-governance/internal/dobotaiops", "test": "TestQAUsageGroupsCountEventsOnceOverTheFullFilter", "reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"},
|
||||
{"package":"knowledge-governance/internal/qaopsmirror","reason":"本包所有集成用例只在显式一次性 loopback kg_qaops_mirror_test_ 库运行;默认 CI 不连共享或生产库;隔离 PG -race 另行实跑"},
|
||||
{
|
||||
"package": "knowledge-governance/internal/qaopsquality",
|
||||
"test": "TestQualityStoreImmutableHistoryReplayConcurrencyAndStaleness",
|
||||
"reason": "仅在显式一次性 loopback kg_qaops_quality_test_ 库执行;默认 CI 不连接共享/生产库,隔离 PG 另行实跑"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/dobotaiops",
|
||||
"test": "TestQARecordInternalIdentityAndWholeFilterKeys",
|
||||
"reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/dobotaiops",
|
||||
"test": "TestQAUsageGroupsCountEventsOnceOverTheFullFilter",
|
||||
"reason": "必须显式一次性隔离PG;验证真实只读SQL身份关联/计数及隐私,不连接生产;默认无库时明确跳过,另行本地补验"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/qaopsorg",
|
||||
"test": "TestDirectorySyncQueueDurableReplayAndLeaseFencing",
|
||||
@@ -111,6 +124,31 @@
|
||||
"package": "knowledge-governance/internal/versionhistory",
|
||||
"test": "TestLiveSyncAgainstRealRepository",
|
||||
"reason": "真实仓库的只读同步用例需要内部可达的 Gitea(**匿名**,不读凭据);无环境即跳过(补验路径:设 VERSION_HISTORY_LIVE_BASE_URL 后单跑这条)"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/qaopsmirror",
|
||||
"test": "TestLocalMirrorQueriesNeverRequireSource",
|
||||
"reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/qaopsmirror",
|
||||
"test": "TestLocalMirrorDurableQueueCoalescesAndAudits",
|
||||
"reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/qaopsmirror",
|
||||
"test": "TestLocalMirrorSyncActivatesAndCheckpoints",
|
||||
"reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/qaopsmirror",
|
||||
"test": "TestLocalMirrorPeriodicSyncIsDurableAndDueOnly",
|
||||
"reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收"
|
||||
},
|
||||
{
|
||||
"package": "knowledge-governance/internal/dobotaiops",
|
||||
"test": "TestMirrorSourceManifestDetectsUpdatesWithoutVolatileTimes",
|
||||
"reason": "仅显式一次性隔离PG验证本地副本/持久同步或专门只读源夹具;默认CI不连接共享与生产库,另行实际执行隔离验收"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+1493
-6
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -13,7 +13,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1"
|
||||
"react-dom": "19.1.1",
|
||||
"react-markdown": "10.1.0",
|
||||
"remark-gfm": "4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.10",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { QAAnswerMarkdown } from './QAAnswerMarkdown'
|
||||
describe('AI answer Markdown preview', () => {
|
||||
it('renders headings, tables, code, links and keeps original source', () => {
|
||||
const answer =
|
||||
'# 安装步骤\n\n- **先断电**\n- 检查接线\n\n| 参数 | 值 |\n| --- | --- |\n| 速度 | 50 |\n\n```python\nprint("ready")\n```\n\n[说明](https://example.invalid/guide)'
|
||||
const html = renderToStaticMarkup(<QAAnswerMarkdown answer={answer} />)
|
||||
expect(html).toContain('<h1>安装步骤</h1>')
|
||||
expect(html).toContain('<strong>先断电</strong>')
|
||||
expect(html).toContain('<table>')
|
||||
expect(html).toContain('language-python')
|
||||
expect(html).toContain('href="https://example.invalid/guide"')
|
||||
expect(html).toContain('noopener noreferrer')
|
||||
expect(html).toContain('查看 Markdown 原文')
|
||||
})
|
||||
it('never executes raw HTML, unsafe URL schemes or automatically loads external images', () => {
|
||||
const answer =
|
||||
'<script>globalThis.PWNED=true</script>\n\n<img src="x" onerror="alert(1)">\n\n[unsafe](javascript:alert%281%29)\n\n'
|
||||
const html = renderToStaticMarkup(<QAAnswerMarkdown answer={answer} />)
|
||||
expect(html).not.toContain('<script>')
|
||||
expect(html).not.toContain('<img ')
|
||||
expect(html).not.toContain('href="javascript:')
|
||||
expect(html.split('<details')[0]).not.toContain('onerror=')
|
||||
expect(html).toContain('查看图片')
|
||||
})
|
||||
it('does not confuse an empty answer with a positive assessment', () => {
|
||||
expect(renderToStaticMarkup(<QAAnswerMarkdown answer="" />)).toContain('尚无答案;不默认判覆盖或正确')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import './qaAnswer.css'
|
||||
|
||||
export function safeAnswerURL(value: string): string {
|
||||
if (!value || /[\u0000-\u0020\u007f\\]/.test(value) || value.startsWith('//')) return ''
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) return value
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? value : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function QAAnswerMarkdown({ answer }: { answer: string }) {
|
||||
const [showImages, setShowImages] = useState(false)
|
||||
if (!answer) return <p>尚无答案;不默认判覆盖或正确</p>
|
||||
return (
|
||||
<div className="qa-answer">
|
||||
<div className="qa-answer-tools">
|
||||
<span>Markdown 预览</span>
|
||||
{/!\[/.test(answer) ? (
|
||||
<button type="button" onClick={() => setShowImages(!showImages)} aria-pressed={showImages}>
|
||||
{showImages ? '隐藏图片' : '显示答案图片'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="qa-answer-markdown">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
skipHtml
|
||||
urlTransform={safeAnswerURL}
|
||||
components={{
|
||||
a: ({ href, children }) =>
|
||||
href ? (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<span>{children}</span>
|
||||
),
|
||||
img: ({ src, alt }) =>
|
||||
src ? (
|
||||
showImages ? (
|
||||
<img src={src} alt={alt ?? ''} loading="lazy" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<a href={src} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">
|
||||
查看图片{alt ? `:${alt}` : ''}
|
||||
</a>
|
||||
)
|
||||
) : (
|
||||
<span>{alt || '图片地址不可用'}</span>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{answer}
|
||||
</Markdown>
|
||||
</div>
|
||||
<details className="qa-answer-source">
|
||||
<summary>查看 Markdown 原文</summary>
|
||||
<pre>{answer}</pre>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { QAMirrorSyncView } from './QAMirrorSync'
|
||||
import type { QAMirrorStatus } from './qaMirror'
|
||||
const base: QAMirrorStatus = {
|
||||
mode: 'local',
|
||||
ready: false,
|
||||
blocked: false,
|
||||
revision: 0,
|
||||
records: 0,
|
||||
source_observed_at: null,
|
||||
last_success_at: null,
|
||||
next_sync_at: null,
|
||||
error_code: 'initial_sync_pending',
|
||||
interval_seconds: 300,
|
||||
worker_ready: true,
|
||||
job: null,
|
||||
}
|
||||
describe('local Q&A synchronization strip', () => {
|
||||
it('shows pending first import rather than a successfully empty dataset', () => {
|
||||
const html = renderToStaticMarkup(<QAMirrorSyncView status={base} error="" submitting={false} onSync={vi.fn()} />)
|
||||
expect(html).toContain('本地数据库')
|
||||
expect(html).toContain('首次同步尚未完成')
|
||||
expect(html).toContain('立即同步')
|
||||
expect(html).toContain('每 5 分钟')
|
||||
})
|
||||
it('keeps the last-good dataset visibly stale after a failed sync', () => {
|
||||
const status = {
|
||||
...base,
|
||||
ready: true,
|
||||
revision: 1,
|
||||
records: 639,
|
||||
last_success_at: '2026-10-18T00:00:00Z',
|
||||
source_observed_at: '2026-10-18T00:00:00Z',
|
||||
next_sync_at: '2026-10-18T00:05:00Z',
|
||||
error_code: 'mirror_source_unavailable',
|
||||
}
|
||||
const html = renderToStaticMarkup(<QAMirrorSyncView status={status} error="" submitting={false} onSync={vi.fn()} />)
|
||||
expect(html).toContain('639')
|
||||
expect(html).toContain('保留上次成功数据')
|
||||
expect(html).toContain('最近成功同步')
|
||||
})
|
||||
it('distinguishes authorization blocking from an ordinary network failure', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QAMirrorSyncView
|
||||
status={{ ...base, blocked: true, error_code: 'mirror_source_authorization_failed' }}
|
||||
error=""
|
||||
submitting={false}
|
||||
onSync={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(html).toContain('权限校验未通过')
|
||||
expect(html).toContain('本地读取已阻断')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { fetchQAMirrorStatus, mirrorSyncActive, mirrorStateChanged, startQAMirrorSync } from './qaMirror'
|
||||
import type { QAMirrorStatus } from './qaMirror'
|
||||
import { newOperationID } from './operationKey'
|
||||
import './qaMirror.css'
|
||||
interface ViewProps {
|
||||
status: QAMirrorStatus | null
|
||||
error: string
|
||||
submitting: boolean
|
||||
onSync: () => void
|
||||
}
|
||||
const stamp = (v: string) => new Date(v).toLocaleString('zh-CN', { hour12: false })
|
||||
export function QAMirrorSyncView(p: ViewProps) {
|
||||
const s = p.status
|
||||
const active = mirrorSyncActive(s)
|
||||
return (
|
||||
<section className="qa-mirror-sync" aria-label="问答本地数据库与同步状态">
|
||||
<div className="qa-mirror-sync-line">
|
||||
<strong>本地数据库</strong>
|
||||
<span>每 5 分钟后台同步 · 查询不请求远端</span>
|
||||
{s?.ready ? <span>已同步 {s.records} 条</span> : null}
|
||||
<button type="button" onClick={p.onSync} disabled={p.submitting || !s?.worker_ready || active}>
|
||||
{p.submitting ? '正在入队…' : active ? '后台同步中…' : '立即同步'}
|
||||
</button>
|
||||
</div>
|
||||
<div role="status" className="qa-ops-muted">
|
||||
{!s
|
||||
? '正在读取同步状态…'
|
||||
: s.blocked
|
||||
? '源端权限校验未通过,本地读取已阻断;修复授权后可重新同步。'
|
||||
: !s.ready
|
||||
? '首次同步尚未完成,暂不把未同步数据视为空结果。'
|
||||
: `最近成功同步:${stamp(s.last_success_at!)};页面基于该本地副本,不承诺实时源快照。`}
|
||||
{s?.job && active ? (
|
||||
<span>
|
||||
{' '}
|
||||
{s.job.expected > 0
|
||||
? `已检查 ${s.job.loaded} / ${s.job.expected} 条,取回变化 ${s.job.changed} 条。`
|
||||
: '正在检查源端变化。'}
|
||||
</span>
|
||||
) : null}
|
||||
{s?.error_code && s.error_code !== 'initial_sync_pending' && !s.blocked ? (
|
||||
<span className="qa-ops-warning"> 最近同步失败,保留上次成功数据;请检查源端连接后重试。</span>
|
||||
) : null}
|
||||
{s && !s.worker_ready ? <span className="qa-ops-warning"> 同步执行器未在线。</span> : null}
|
||||
</div>
|
||||
{p.error ? (
|
||||
<p className="error-banner" role="alert">
|
||||
{p.error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
export function QAMirrorSync({ onChanged }: { onChanged: () => void }) {
|
||||
const [status, setStatus] = useState<QAMirrorStatus | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
[submitting, setSubmitting] = useState(false)
|
||||
const key = useRef('')
|
||||
const acceptedJob = useRef('')
|
||||
const changed = useRef(onChanged)
|
||||
changed.current = onChanged
|
||||
const observed = useRef<{ revision: number; blocked: boolean; ready: boolean } | undefined>(undefined)
|
||||
const kick = useRef<() => void>(() => {})
|
||||
useEffect(() => {
|
||||
let disposed = false,
|
||||
timer: ReturnType<typeof setTimeout> | undefined
|
||||
const controller = new AbortController()
|
||||
let inFlight = false
|
||||
const read = async () => {
|
||||
if (disposed || inFlight) return
|
||||
inFlight = true
|
||||
let next = 15000
|
||||
try {
|
||||
const fresh = await fetchQAMirrorStatus(controller.signal)
|
||||
if (disposed) return
|
||||
const previous = observed.current
|
||||
observed.current = { revision: fresh.revision, blocked: fresh.blocked, ready: fresh.ready }
|
||||
setStatus(fresh)
|
||||
setError('')
|
||||
if (mirrorStateChanged(previous, fresh)) changed.current()
|
||||
if (!mirrorSyncActive(fresh) && fresh.job?.id === acceptedJob.current) {
|
||||
key.current = ''
|
||||
acceptedJob.current = ''
|
||||
}
|
||||
if (mirrorSyncActive(fresh) || !fresh.ready) next = 2500
|
||||
} catch (e) {
|
||||
if (!disposed) setError(e instanceof Error ? e.message : '本地同步状态读取失败')
|
||||
} finally {
|
||||
inFlight = false
|
||||
if (!disposed) {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => void read(), next)
|
||||
}
|
||||
}
|
||||
}
|
||||
kick.current = () => {
|
||||
clearTimeout(timer)
|
||||
void read()
|
||||
}
|
||||
void read()
|
||||
return () => {
|
||||
disposed = true
|
||||
controller.abort()
|
||||
clearTimeout(timer)
|
||||
kick.current = () => {}
|
||||
}
|
||||
}, [])
|
||||
const synchronize = async () => {
|
||||
if (submitting || mirrorSyncActive(status)) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
if (!key.current) key.current = newOperationID()
|
||||
try {
|
||||
acceptedJob.current = await startQAMirrorSync(key.current)
|
||||
kick.current()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '同步请求未确认,请重试,已有本地数据不变')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
return <QAMirrorSyncView status={status} error={error} submitting={submitting} onSync={() => void synchronize()} />
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { QAQualityEditor, QAQualityCounts } from './QAQuality'
|
||||
import { qualityLabels } from './qaQuality'
|
||||
import { usageLabels } from './qaUsage'
|
||||
import { QAWeekPicker } from './QAWeekPicker'
|
||||
import { QAAnswerMarkdown } from './QAAnswerMarkdown'
|
||||
import { QAMirrorSync } from './QAMirrorSync'
|
||||
import { currentQAWeek, qaDateTimeToISO, qaWeekQueryRange } from './qaWeeks'
|
||||
|
||||
interface ViewProps {
|
||||
@@ -58,7 +60,7 @@ export function QAOperationsView(props: ViewProps) {
|
||||
: '尚未读取问答'}
|
||||
</span>
|
||||
<span className="qa-ops-source">
|
||||
DobotAI 2.0 / 钉钉 · 独立只读连接{props.status ? ` · ${stateLabels[props.status.state]}` : ''}
|
||||
DobotAI 2.0 / 钉钉 · 本地问答副本{props.status ? ` · ${stateLabels[props.status.state]}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{page?.organization ? (
|
||||
@@ -240,7 +242,7 @@ export function QAOperationsView(props: ViewProps) {
|
||||
<h4>原始问题</h4>
|
||||
<div className="qa-ops-full-text">{detail.question || '无文本问题'}</div>
|
||||
<h4>AI答案</h4>
|
||||
<div className="qa-ops-full-text">{detail.answer || '尚无答案;不默认判覆盖或正确'}</div>
|
||||
<QAAnswerMarkdown key={detail.id} answer={detail.answer} />
|
||||
<h4>治理侧覆盖复检</h4>
|
||||
<p>
|
||||
{detail.coverage.verdict === 'uncovered'
|
||||
@@ -309,6 +311,7 @@ export function QAOperations() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [qualityRevision, setQualityRevision] = useState(0)
|
||||
const [mirrorChanged, setMirrorChanged] = useState(false)
|
||||
const [detail, setDetail] = useState<QARecord | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detailError, setDetailError] = useState<string | null>(null)
|
||||
@@ -400,6 +403,11 @@ export function QAOperations() {
|
||||
}
|
||||
}
|
||||
const close = () => {
|
||||
if (mirrorChanged) {
|
||||
setMirrorChanged(false)
|
||||
setRevision((v) => v + 1)
|
||||
setQualityRevision((v) => v + 1)
|
||||
}
|
||||
detailRequest.current?.abort()
|
||||
setDetail(null)
|
||||
setDetailError(null)
|
||||
@@ -412,7 +420,7 @@ export function QAOperations() {
|
||||
<h2>
|
||||
问答运营 <span>/ {view === 'regions' ? '区域归属' : view === 'usage' ? '使用统计' : '问答记录'}</span>
|
||||
</h2>
|
||||
<p>真实问答只读查询;原始记录、源端判断与治理复检分开保留。</p>
|
||||
<p>本地问答查询;远端只读后台同步,原始记录与人工复核分开保留。</p>
|
||||
</header>
|
||||
{!canRead ? (
|
||||
<p className="error-banner" role="alert">
|
||||
@@ -420,6 +428,20 @@ export function QAOperations() {
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<QAMirrorSync
|
||||
onChanged={() => {
|
||||
if (detail || detailLoading) {
|
||||
setMirrorChanged(true)
|
||||
return
|
||||
}
|
||||
setQuery((q) => (q ? { ...q } : q))
|
||||
setRevision((v) => v + 1)
|
||||
setQualityRevision((v) => v + 1)
|
||||
}}
|
||||
/>
|
||||
{mirrorChanged ? (
|
||||
<p className="qa-ops-warning">本地副本已更新;当前详情和未保存复核不会被覆盖,关闭详情后刷新列表。</p>
|
||||
) : null}
|
||||
<nav className="qa-region-tabs" aria-label="问答运营视图">
|
||||
<button type="button" aria-pressed={view === 'records'} onClick={() => setView('records')}>
|
||||
问答记录
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
.qa-answer {
|
||||
min-width: 0;
|
||||
}
|
||||
.qa-answer-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--muted-foreground, #65758b);
|
||||
font-size: 12px;
|
||||
}
|
||||
.qa-answer-markdown {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.qa-answer-markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.qa-answer-markdown h1 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
.qa-answer-markdown h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.qa-answer-markdown h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.qa-answer-markdown h1,
|
||||
.qa-answer-markdown h2,
|
||||
.qa-answer-markdown h3 {
|
||||
line-height: 1.4;
|
||||
margin: 1.1em 0 0.55em;
|
||||
}
|
||||
.qa-answer-markdown ul,
|
||||
.qa-answer-markdown ol {
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
.qa-answer-markdown p {
|
||||
margin: 0.7em 0;
|
||||
}
|
||||
.qa-answer-markdown table {
|
||||
width: auto;
|
||||
min-width: min(100%, 360px);
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.qa-answer-markdown th,
|
||||
.qa-answer-markdown td {
|
||||
white-space: normal;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border, #dce4ef);
|
||||
text-align: left;
|
||||
}
|
||||
.qa-answer-markdown blockquote {
|
||||
border-left: 3px solid var(--accent, #2794dc);
|
||||
padding-left: 14px;
|
||||
margin-left: 0;
|
||||
color: var(--muted-foreground, #65758b);
|
||||
}
|
||||
.qa-answer-markdown pre,
|
||||
.qa-answer-source pre {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border, #dce4ef);
|
||||
border-radius: 6px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.qa-answer-markdown code {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.qa-answer-markdown img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.qa-answer-source {
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid var(--border, #dce4ef);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.qa-answer-source summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground, #65758b);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.qa-mirror-sync {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border, #dce4ef);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.qa-mirror-sync-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.qa-mirror-sync-line strong {
|
||||
font-size: 13px;
|
||||
color: #176940;
|
||||
}
|
||||
.qa-mirror-sync-line > button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.qa-mirror-sync .qa-ops-muted {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.qa-mirror-sync-line > button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||
import { parseQAMirrorStatus, mirrorStateChanged } from './qaMirror'
|
||||
import { mergeQAPages, parseQARecordPage } from './qaOps'
|
||||
const initial = {
|
||||
mode: 'local',
|
||||
ready: false,
|
||||
blocked: false,
|
||||
revision: 0,
|
||||
records: 0,
|
||||
source_observed_at: null,
|
||||
last_success_at: null,
|
||||
next_sync_at: null,
|
||||
error_code: 'initial_sync_pending',
|
||||
interval_seconds: 300,
|
||||
worker_ready: true,
|
||||
job: null,
|
||||
}
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe('local Q&A mirror contract', () => {
|
||||
it('refreshes records on the first already-ready snapshot so an earlier initial 503 is not stranded', () => {
|
||||
expect(mirrorStateChanged(undefined, { revision: 1, blocked: false, ready: true })).toBe(true)
|
||||
expect(mirrorStateChanged(undefined, { revision: 0, blocked: false, ready: false })).toBe(false)
|
||||
expect(
|
||||
mirrorStateChanged({ revision: 1, blocked: false, ready: true }, { revision: 1, blocked: false, ready: true })
|
||||
).toBe(false)
|
||||
})
|
||||
it('keeps unsynchronized and successfully empty data distinct', () => {
|
||||
expect(parseQAMirrorStatus(initial).ready).toBe(false)
|
||||
expect(
|
||||
parseQAMirrorStatus({
|
||||
...initial,
|
||||
ready: true,
|
||||
revision: 1,
|
||||
error_code: '',
|
||||
source_observed_at: '2026-10-18T00:00:00Z',
|
||||
last_success_at: '2026-10-18T00:01:00Z',
|
||||
next_sync_at: '2026-10-18T00:06:00Z',
|
||||
}).records
|
||||
).toBe(0)
|
||||
})
|
||||
it('rejects fake completeness, wrong cadence and malformed progress', () => {
|
||||
expect(() => parseQAMirrorStatus({ ...initial, ready: true })).toThrow('响应无效')
|
||||
expect(() => parseQAMirrorStatus({ ...initial, interval_seconds: 60 })).toThrow('响应无效')
|
||||
expect(() =>
|
||||
parseQAMirrorStatus({
|
||||
...initial,
|
||||
job: { id: 'j', state: 'running', loaded: 3, expected: 2, changed: 1, error_code: '' },
|
||||
})
|
||||
).toThrow('响应无效')
|
||||
})
|
||||
it('rejects equal-count pages from different local generations', () => {
|
||||
const p = parseQARecordPage({
|
||||
items: [],
|
||||
columns: [],
|
||||
total: 0,
|
||||
has_more: false,
|
||||
next_cursor: '',
|
||||
as_of: '2026-10-18T00:00:00Z',
|
||||
local_revision: 1,
|
||||
})
|
||||
expect(() => mergeQAPages(p, { ...p, local_revision: 2 })).toThrow('本地问答已更新')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getPayload } from './qaOps'
|
||||
export interface QAMirrorStatus {
|
||||
mode: 'local'
|
||||
ready: boolean
|
||||
blocked: boolean
|
||||
revision: number
|
||||
records: number
|
||||
source_observed_at: string | null
|
||||
last_success_at: string | null
|
||||
next_sync_at: string | null
|
||||
error_code: string
|
||||
interval_seconds: 300
|
||||
worker_ready: boolean
|
||||
job: { id: string; state: string; loaded: number; expected: number; changed: number; error_code: string } | null
|
||||
}
|
||||
const invalid = (): never => {
|
||||
throw new Error('本地问答同步响应无效,请检查接口契约')
|
||||
}
|
||||
const object = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : invalid()
|
||||
const text = (v: unknown): string => (typeof v === 'string' ? v : invalid())
|
||||
const count = (v: unknown): number => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : invalid())
|
||||
const bool = (v: unknown): boolean => (typeof v === 'boolean' ? v : invalid())
|
||||
const stamp = (v: unknown): string | null =>
|
||||
v === null ? null : typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v : invalid()
|
||||
export function parseQAMirrorStatus(value: unknown): QAMirrorStatus {
|
||||
const v = object(value)
|
||||
if (v.mode !== 'local' || v.interval_seconds !== 300) return invalid()
|
||||
let job: QAMirrorStatus['job'] = null
|
||||
if (v.job !== null) {
|
||||
const j = object(v.job)
|
||||
const state = text(j.state)
|
||||
if (!['pending', 'running', 'retry', 'completed', 'failed', 'cancelled'].includes(state)) return invalid()
|
||||
job = {
|
||||
id: text(j.id),
|
||||
state,
|
||||
loaded: count(j.loaded),
|
||||
expected: count(j.expected),
|
||||
changed: count(j.changed),
|
||||
error_code: text(j.error_code),
|
||||
}
|
||||
if (!job.id || job.loaded > job.expected || job.changed > job.loaded) return invalid()
|
||||
}
|
||||
const out: QAMirrorStatus = {
|
||||
mode: 'local',
|
||||
ready: bool(v.ready),
|
||||
blocked: bool(v.blocked),
|
||||
revision: count(v.revision),
|
||||
records: count(v.records),
|
||||
source_observed_at: stamp(v.source_observed_at),
|
||||
last_success_at: stamp(v.last_success_at),
|
||||
next_sync_at: stamp(v.next_sync_at),
|
||||
error_code: text(v.error_code),
|
||||
interval_seconds: 300,
|
||||
worker_ready: bool(v.worker_ready),
|
||||
job,
|
||||
}
|
||||
if (out.ready && (!out.last_success_at || !out.source_observed_at || out.revision < 1)) return invalid()
|
||||
if (!out.ready && (out.records !== 0 || out.revision !== 0 || out.last_success_at !== null)) return invalid()
|
||||
return out
|
||||
}
|
||||
export async function fetchQAMirrorStatus(signal?: AbortSignal): Promise<QAMirrorStatus> {
|
||||
return parseQAMirrorStatus(await getPayload('/api/v1/qa-ops/sync', signal))
|
||||
}
|
||||
export async function startQAMirrorSync(key: string, signal?: AbortSignal): Promise<string> {
|
||||
const data = object(
|
||||
await getPayload('/api/v1/qa-ops/sync', signal, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-records-sync', 'Idempotency-Key': key },
|
||||
body: '{}',
|
||||
})
|
||||
)
|
||||
const id = text(data.job_id)
|
||||
if (!id || typeof data.deduplicated !== 'boolean') return invalid()
|
||||
return id
|
||||
}
|
||||
export function mirrorStateChanged(
|
||||
previous: { revision: number; blocked: boolean; ready: boolean } | undefined,
|
||||
current: { revision: number; blocked: boolean; ready: boolean }
|
||||
): boolean {
|
||||
return previous
|
||||
? previous.revision !== current.revision || previous.blocked !== current.blocked || previous.ready !== current.ready
|
||||
: current.ready
|
||||
}
|
||||
export const mirrorSyncActive = (s: QAMirrorStatus | null) =>
|
||||
Boolean(s?.job && ['pending', 'running', 'retry'].includes(s.job.state))
|
||||
@@ -60,6 +60,8 @@ export function parseQAOrganizationVersion(value: unknown): QAOrganizationVersio
|
||||
return result
|
||||
}
|
||||
export function mergeQAPages(current: QARecordPage, next: QARecordPage): QARecordPage {
|
||||
if (current.local_revision !== next.local_revision)
|
||||
throw new Error('本地问答已更新,请重新查询,不能混合不同同步版本的数据')
|
||||
if (
|
||||
current.organization?.directory_id !== next.organization?.directory_id ||
|
||||
current.organization?.revision_id !== next.organization?.revision_id
|
||||
@@ -96,6 +98,7 @@ export interface QARecord {
|
||||
language?: string
|
||||
}
|
||||
export interface QARecordPage {
|
||||
local_revision?: number
|
||||
organization?: QAOrganizationVersion
|
||||
items: QARecord[]
|
||||
columns: QAColumn[]
|
||||
@@ -232,6 +235,7 @@ export function parseQARecordPage(value: unknown): QARecordPage {
|
||||
next_cursor: nextCursor,
|
||||
as_of: instant(page.as_of),
|
||||
...(page.organization === undefined ? {} : { organization: parseQAOrganizationVersion(page.organization) }),
|
||||
...(page.local_revision === undefined ? {} : { local_revision: count(page.local_revision) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user