245 lines
9.4 KiB
Go
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, ®ions)
|
|
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)
|
|
}
|