Files
knowledge-governance/internal/qaopsorg/store.go
T
2026-10-18 21:50:29 +08:00

245 lines
9.4 KiB
Go

package qaopsorg
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"knowledge-governance/internal/jobs"
"sort"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrConflict = errors.New("organization snapshot or region revision changed")
var ErrUnavailable = errors.New("organization store unavailable")
var ErrNoDirectory = errors.New("organization directory has not been imported")
type Import struct {
Source string `json:"source"`
SyncedAt time.Time `json:"synced_at"`
Complete bool `json:"complete"`
Directory Directory `json:"directory"`
}
type SaveRequest struct {
Regions []RegionDefinition `json:"regions,omitempty"`
DirectoryID string `json:"directory_id"`
BaseRevision string `json:"base_revision"`
Rules []Rule `json:"rules"`
}
type State struct {
Regions []RegionDefinition `json:"regions"`
State string `json:"state"`
DirectoryID string `json:"directory_id"`
DirectorySource string `json:"directory_source"`
DirectorySyncedAt *time.Time `json:"directory_synced_at"`
RevisionID string `json:"revision_id"`
UpdatedBy string `json:"updated_by"`
UpdatedAt *time.Time `json:"updated_at"`
Rules []Rule `json:"rules"`
View PreviewResult `json:"view"`
}
type Store struct {
pool *pgxpool.Pool
tenant string
}
func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} }
func (s *Store) available() bool { return s != nil && s.pool != nil && validID(s.tenant) }
type orgQuerier interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
const defaultRegionsJSON = `[{"id":"国内工业","name":"国内工业"},{"id":"APAC","name":"APAC"},{"id":"EMEA","name":"EMEA"},{"id":"America","name":"America"}]`
const readStateSQL = `SELECT d.id,d.source,d.synced_at,d.payload,COALESCE(r.id,''),COALESCE(r.rules,'[]'::jsonb),COALESCE(r.updated_by,''),r.created_at,COALESCE(r.region_definitions,'` + defaultRegionsJSON + `'::jsonb)
FROM qa_ops_organization_state st JOIN qa_ops_directory_snapshots d ON d.tenant_key=st.tenant_key AND d.id=st.directory_id
LEFT JOIN qa_ops_region_revisions r ON r.tenant_key=st.tenant_key AND r.id=st.revision_id WHERE st.tenant_key=$1`
func (s *Store) read(ctx context.Context, q orgQuerier, lock bool) (State, Directory, error) {
state := State{Regions: DefaultRegions(), State: "empty", Rules: []Rule{}, View: PreviewResult{Departments: []DepartmentView{}, Regions: map[string]int{}, MissingDepartments: []string{}}}
for _, r := range RegionNames {
state.View.Regions[r] = 0
}
var directory Directory
query := readStateSQL
if lock {
query += " FOR UPDATE OF st"
}
var payload, rules, regions []byte
err := q.QueryRow(ctx, query, s.tenant).Scan(&state.DirectoryID, &state.DirectorySource, &state.DirectorySyncedAt, &payload, &state.RevisionID, &rules, &state.UpdatedBy, &state.UpdatedAt, &regions)
if errors.Is(err, pgx.ErrNoRows) {
return state, directory, nil
}
if err != nil || json.Unmarshal(payload, &directory) != nil || json.Unmarshal(rules, &state.Rules) != nil || json.Unmarshal(regions, &state.Regions) != nil || state.Regions == nil {
return State{}, directory, ErrUnavailable
}
state.View, err = PreviewRegions(directory, state.Rules, state.RegionDefinitions())
if err != nil {
return State{}, directory, ErrUnavailable
}
state.State = "ready"
return state, directory, nil
}
func (s *Store) GetView(ctx context.Context) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
state, _, err := s.read(ctx, s.pool, false)
return state, err
}
func (s *Store) PreviewRules(ctx context.Context, request SaveRequest) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
state, directory, err := s.read(ctx, s.pool, false)
if err != nil {
return State{}, err
}
return applyRequest(state, directory, request)
}
func applyRequest(state State, directory Directory, request SaveRequest) (State, error) {
if state.State != "ready" {
return State{}, ErrNoDirectory
}
if request.DirectoryID != state.DirectoryID || request.BaseRevision != state.RevisionID {
return State{}, ErrConflict
}
regions := request.Regions
if regions == nil {
regions = state.RegionDefinitions()
}
view, err := PreviewRegions(directory, request.Rules, regions)
if err != nil {
return State{}, err
}
// Missing saved paths are retained for operator review, never silently removed.
old := map[string]string{}
for _, rule := range state.Rules {
old[rule.DepartmentID] = rule.Region
}
for _, missing := range view.MissingDepartments {
for _, rule := range request.Rules {
if rule.DepartmentID == missing && old[missing] != rule.Region {
return State{}, ErrInvalid
}
}
}
state.View = view
state.Regions = append([]RegionDefinition{}, regions...)
state.Rules = append([]Rule{}, request.Rules...)
sort.Slice(state.Rules, func(i, j int) bool { return state.Rules[i].DepartmentID < state.Rules[j].DepartmentID })
return state, nil
}
func (s *Store) SaveRules(ctx context.Context, request SaveRequest, actor string) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
if !validID(actor) {
return State{}, ErrInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return State{}, ErrUnavailable
}
defer func() { _ = tx.Rollback(context.Background()) }()
current, directory, err := s.read(ctx, tx, true)
if err != nil {
return State{}, err
}
next, err := applyRequest(current, directory, request)
if err != nil {
return State{}, err
}
before := append([]Rule{}, current.Rules...)
sort.Slice(before, func(i, j int) bool { return before[i].DepartmentID < before[j].DepartmentID })
oldJSON, _ := json.Marshal(before)
newJSON, _ := json.Marshal(next.Rules)
oldRegions, _ := json.Marshal(current.RegionDefinitions())
newRegions, _ := json.Marshal(next.RegionDefinitions())
if current.RevisionID != "" && string(oldJSON) == string(newJSON) && string(oldRegions) == string(newRegions) {
if tx.Commit(ctx) != nil {
return State{}, ErrUnavailable
}
return current, nil
}
var nonce [16]byte
if _, err = rand.Read(nonce[:]); err != nil {
return State{}, ErrUnavailable
}
next.RevisionID = "reg-" + hex.EncodeToString(nonce[:])
next.UpdatedBy = actor
var at time.Time
if err = tx.QueryRow(ctx, `INSERT INTO qa_ops_region_revisions(tenant_key,id,directory_id,rules,updated_by,region_definitions) VALUES($1,$2,$3,$4,$5,$6) RETURNING created_at`, s.tenant, next.RevisionID, next.DirectoryID, newJSON, actor, newRegions).Scan(&at); err != nil {
return State{}, ErrUnavailable
}
next.UpdatedAt = &at
if _, err = tx.Exec(ctx, `UPDATE qa_ops_organization_state SET revision_id=$2 WHERE tenant_key=$1`, s.tenant, next.RevisionID); err != nil {
return State{}, ErrUnavailable
}
if err = tx.Commit(ctx); err != nil {
return State{}, ErrUnavailable
}
return next, nil
}
func (s *Store) ReplaceDirectory(ctx context.Context, input Import) (State, error) {
return s.replaceDirectory(ctx, input, nil)
}
func (s *Store) replaceDirectory(ctx context.Context, input Import, lease *jobs.Job) (State, error) {
if !s.available() {
return State{}, ErrUnavailable
}
if !input.Complete || input.SyncedAt.IsZero() || input.SyncedAt.After(time.Now().Add(time.Minute)) || (input.Source != "weekly_report_snapshot" && input.Source != "weekly_report_live") {
return State{}, ErrInvalid
}
if _, err := Preview(input.Directory, nil); err != nil {
return State{}, err
}
raw, err := json.Marshal(input)
if err != nil || len(raw) > 16<<20 {
return State{}, ErrInvalid
}
sum := sha256.Sum256(raw)
id := "dir-" + hex.EncodeToString(sum[:])
payload, _ := json.Marshal(input.Directory)
tx, err := s.pool.Begin(ctx)
if err != nil {
return State{}, ErrUnavailable
}
defer func() { _ = tx.Rollback(context.Background()) }()
if lease != nil {
if _, err = s.syncLease(ctx, tx, *lease, true); err != nil {
return State{}, err
}
}
if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_directory_snapshots(tenant_key,id,source,synced_at,payload) VALUES($1,$2,$3,$4,$5) ON CONFLICT(tenant_key,id) DO NOTHING`, s.tenant, id, input.Source, input.SyncedAt, payload); err != nil {
return State{}, ErrUnavailable
}
tag, err := tx.Exec(ctx, `INSERT INTO qa_ops_organization_state(tenant_key,directory_id) VALUES($1,$2) ON CONFLICT(tenant_key) DO UPDATE SET directory_id=EXCLUDED.directory_id WHERE qa_ops_organization_state.directory_id=EXCLUDED.directory_id OR (SELECT synced_at FROM qa_ops_directory_snapshots WHERE tenant_key=$1 AND id=qa_ops_organization_state.directory_id)<$3`, s.tenant, id, input.SyncedAt)
if err != nil {
return State{}, ErrUnavailable
}
if tag.RowsAffected() != 1 {
return State{}, ErrConflict
}
if lease != nil {
result, _ := json.Marshal(SyncResult{DirectoryID: id, Departments: len(input.Directory.Departments), People: len(input.Directory.People), SyncedAt: input.SyncedAt})
tag, err = tx.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{result}',$3::jsonb),updated_at=now() WHERE id=$1 AND lease_owner=$2 AND state='running' AND lease_until>clock_timestamp() AND attempt=$4`, lease.ID, lease.LeaseOwner, result, lease.Attempt)
if err != nil {
return State{}, ErrUnavailable
}
if tag.RowsAffected() != 1 {
return State{}, ErrSyncLease
}
}
if err = tx.Commit(ctx); err != nil {
return State{}, ErrUnavailable
}
return s.GetView(ctx)
}