Files
go-sip/internal/store/current_result.go
T

174 lines
9.0 KiB
Go

package store
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"git.ipao.vip/rogee/go-sip/internal/tenant"
)
var ErrCurrentUploadUnverified = errors.New("recording outcome is not verified against its Dispatcher-approved upload")
var ErrCurrentResultConflict = errors.New("call already has a different final result")
var ErrCurrentEndUnconfirmed = errors.New("final result requires confirmed call end and its frozen snapshot")
var ErrCurrentResultInvalid = errors.New("final result input violates the approved contract")
func currentResultEventID(dispatcherID, sourceEventID string) string {
identity := sha256.Sum256([]byte("call.execute.result\x00" + dispatcherID + "\x00" + sourceEventID))
return fmt.Sprintf("result-%x", identity[:])
}
// RecordCallResult stores one no-recording final result only after confirmed
// call end. A durable upload grant cannot be bypassed with an empty recording.
func (s *CurrentStore) RecordCallResult(dispatcherID, sourceEventID string, payload []byte) (CurrentOutboxEvent, bool, error) {
return s.recordCallResult(dispatcherID, sourceEventID, payload, nil)
}
// RecordUploadedCallResult accepts the authenticated Agent's successful PUT
// observation only for the precise, previously bound OSS asset. Both the
// upload fact and sole SaaS result outbox entry commit in the same transaction.
func (s *CurrentStore) RecordUploadedCallResult(dispatcherID, sourceEventID string, payload []byte, proof CurrentUploadProof) (CurrentOutboxEvent, bool, error) {
return s.recordCallResult(dispatcherID, sourceEventID, payload, &proof)
}
func (s *CurrentStore) recordCallResult(dispatcherID, sourceEventID string, payload []byte, proof *CurrentUploadProof) (CurrentOutboxEvent, bool, error) {
if dispatcherID == "" || sourceEventID == "" || len(sourceEventID) > 255 || len(payload) == 0 {
return CurrentOutboxEvent{}, false, fmt.Errorf("%w: durable call identity and payload are required", ErrCurrentResultInvalid)
}
var result struct {
TaskID string `json:"task_id"`
CallerProfileID string `json:"caller_profile_id"`
Callee string `json:"callee"`
TrunkID string `json:"trunk_id"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
Recording json.RawMessage `json:"recording"`
}
if err := json.Unmarshal(payload, &result); err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("%w: decode JSON: %v", ErrCurrentResultInvalid, err)
}
start, err := time.Parse(time.RFC3339Nano, result.StartedAt)
if err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("%w: invalid start time", ErrCurrentResultInvalid)
}
end, err := time.Parse(time.RFC3339Nano, result.EndedAt)
if err != nil || end.Before(start) {
return CurrentOutboxEvent{}, false, fmt.Errorf("%w: end precedes start or is invalid", ErrCurrentResultInvalid)
}
tx, err := s.db.Begin()
if err != nil {
return CurrentOutboxEvent{}, false, err
}
defer tx.Rollback()
var tenantID int64
var taskID, callee, trunkID, status string
var snapshotJSON []byte
err = tx.QueryRow(`SELECT tenant_id,task_id,callee,COALESCE(selected_trunk_id,''),status,snapshot_json FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, sourceEventID).Scan(&tenantID, &taskID, &callee, &trunkID, &status, &snapshotJSON)
if errors.Is(err, sql.ErrNoRows) {
return CurrentOutboxEvent{}, false, errors.New("no durable approved call matches final result")
}
if err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("load completed call identity: %w", err)
}
if status != "finished" || trunkID == "" || len(snapshotJSON) == 0 {
return CurrentOutboxEvent{}, false, ErrCurrentEndUnconfirmed
}
var snapshot struct {
Task configread.CurrentTask `json:"task"`
}
if err := json.Unmarshal(snapshotJSON, &snapshot); err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("decode frozen call task: %w", err)
}
if snapshot.Task.DispatcherID != dispatcherID || snapshot.Task.TenantID != tenantID || snapshot.Task.TaskID != taskID || result.TaskID != taskID || result.Callee != callee || result.TrunkID != trunkID || result.CallerProfileID != snapshot.Task.CallerProfileID {
return CurrentOutboxEvent{}, false, errors.New("final result identity differs from the approved call")
}
var recording struct {
Status string `json:"status"`
Bucket string `json:"bucket"`
ObjectKey string `json:"object_key"`
Format string `json:"format"`
Channels int `json:"channels"`
SampleRateHz int `json:"sample_rate_hz"`
DurationMS int64 `json:"duration_ms"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSHA256 string `json:"checksum_sha256"`
}
if err := json.Unmarshal(result.Recording, &recording); err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("%w: recording fact is invalid", ErrCurrentResultInvalid)
}
grant, grantErr := loadRecordingUpload(tx.QueryRow(`SELECT dispatcher_id,source_event_id,upload_id,recording_id,bucket,object_key,checksum_sha256,size_bytes,format,channels,sample_rate_hz,duration_ms,COALESCE(confirmed_at,'') FROM dispatcher_recordings WHERE dispatcher_id=? AND source_event_id=?`, dispatcherID, sourceEventID))
if grantErr != nil && !errors.Is(grantErr, ErrCurrentUploadNotFound) {
return CurrentOutboxEvent{}, false, fmt.Errorf("inspect original recording target: %w", grantErr)
}
if recording.Status == "uploaded" {
if proof == nil || grantErr != nil || proof.StatusCode < 200 || proof.StatusCode >= 300 ||
proof.UploadID != grant.UploadID || proof.RecordingID != grant.RecordingID || proof.SizeBytes != grant.SizeBytes || proof.SHA256 != grant.ChecksumSHA256 ||
recording.Bucket != grant.Bucket || recording.ObjectKey != grant.ObjectKey || recording.Format != grant.Format || recording.Channels != grant.Channels || recording.SampleRateHz != grant.SampleRateHz || recording.DurationMS != grant.DurationMS || recording.SizeBytes != grant.SizeBytes || recording.ChecksumSHA256 != grant.ChecksumSHA256 {
return CurrentOutboxEvent{}, false, ErrCurrentUploadUnverified
}
} else if proof != nil || grantErr == nil {
return CurrentOutboxEvent{}, false, ErrCurrentUploadUnverified
}
route, err := tenant.CurrentResultRoute(dispatcherID)
if err != nil {
return CurrentOutboxEvent{}, false, err
}
eventID := currentResultEventID(dispatcherID, sourceEventID)
body, err := json.Marshal(struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
IssuedAt string `json:"issued_at"`
Payload json.RawMessage `json:"payload"`
}{eventID, "call.execute.result", dispatcherID, tenantID, result.EndedAt, payload})
if err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("encode final result: %w", err)
}
if err := contract.ValidateCurrent("mq", body); err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("%w: MQ contract: %v", ErrCurrentResultInvalid, err)
}
inserted, err := tx.Exec(`INSERT INTO dispatcher_outbox(dispatcher_id,event_id,event_type,routing_key,body) VALUES(?,?,?,?,?) ON CONFLICT(dispatcher_id,event_id) DO NOTHING`, dispatcherID, eventID, "call.execute.result", route.BindingKey, body)
if err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("persist final result outbox: %w", err)
}
count, err := inserted.RowsAffected()
if err != nil {
return CurrentOutboxEvent{}, false, err
}
var stored CurrentOutboxEvent
stored.EventID = eventID
if err := tx.QueryRow(`SELECT event_type,routing_key,body FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=?`, dispatcherID, eventID).Scan(&stored.EventType, &stored.RoutingKey, &stored.Body); err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("inspect existing final result: %w", err)
}
if stored.EventType != "call.execute.result" || stored.RoutingKey != route.BindingKey || !bytes.Equal(stored.Body, body) {
return CurrentOutboxEvent{}, false, ErrCurrentResultConflict
}
if proof != nil {
if grant.ConfirmedAt != "" && count == 1 {
return CurrentOutboxEvent{}, false, ErrCurrentResultConflict
}
confirmed, err := tx.Exec(`UPDATE dispatcher_recordings SET confirmed_at=COALESCE(confirmed_at,?) WHERE dispatcher_id=? AND source_event_id=?`, time.Now().UTC().Format(time.RFC3339Nano), dispatcherID, sourceEventID)
if err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("persist confirmed recording with result: %w", err)
}
affected, err := confirmed.RowsAffected()
if err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("inspect recording confirmation: %w", err)
}
if affected != 1 {
return CurrentOutboxEvent{}, false, errors.New("recording confirmation lost its original target")
}
}
if err := tx.Commit(); err != nil {
return CurrentOutboxEvent{}, false, fmt.Errorf("commit final result outbox: %w", err)
}
return stored, count == 1, nil
}