00c4c13283
build / build (push) Successful in 1m19s
列表正文改为按需读取;保留完整导出、版本隔离和有效标签筛选。 验证:前端全量、Go全量及隔离PG全mirror竞态通过;覆盖首批回调、续载失败与筛选分页。
184 lines
6.7 KiB
Go
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)
|
|
}
|