Files
knowledge-governance/internal/qaopsmirror/store.go
T
姚章浩 00c4c13283
build / build (push) Successful in 1m19s
fix(qa): 首批即显并统一五字段后端筛选
列表正文改为按需读取;保留完整导出、版本隔离和有效标签筛选。
验证:前端全量、Go全量及隔离PG全mirror竞态通过;覆盖首批回调、续载失败与筛选分页。
2026-10-25 12:45:31 +08:00

184 lines
6.7 KiB
Go

// 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
departmentStats departmentStatisticsCache
masterFilters masterFilterCache
}
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 s.repositoryFor(revision, false)
}
func (s *Store) repositoryFor(revision *int64, master bool) *dobotaiops.RecordsRepository {
base, summary, scope := localBaseSQL, localSummarySQL, localQualityScopeSQL
if master {
base, summary, scope = masterBaseSQL, masterSummarySQL, masterQualityScopeSQL
}
return dobotaiops.NewRecordsRepository(dobotaiops.RecordsRepositoryOptions{
BaseSQL: base, SummarySQL: summary, QualityScopeSQL: scope, 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 {
if master {
return q.QueryRow(ctx, `SELECT updated_at FROM qa_ops_master_state WHERE tenant_key=$1`, s.tenant).Scan(t)
}
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}
if master {
if revision != nil {
if e = q.QueryRow(ctx, `SELECT COALESCE((SELECT revision FROM qa_ops_master_state WHERE tenant_key=$1),0)`, s.tenant).Scan(revision); e != nil {
return dobotaiops.ErrUnavailable
}
}
} else {
state, err := s.readState(ctx, q)
if err != nil {
return err
}
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)
}