feat(handler): 暴露健康档案 upsert 接口(T-204)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chis_osi/contract"
|
||||
"chis_osi/mapping"
|
||||
@@ -32,12 +33,12 @@ const (
|
||||
)
|
||||
|
||||
type Outcome struct {
|
||||
Status Status
|
||||
Action Action
|
||||
Reason string
|
||||
ServiceID string
|
||||
ResponseCode string
|
||||
PHRIDHint string
|
||||
Status Status `json:"status"`
|
||||
Action Action `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ServiceID string `json:"serviceId,omitempty"`
|
||||
ResponseCode string `json:"responseCode,omitempty"`
|
||||
PHRIDHint string `json:"phrIdHint,omitempty"`
|
||||
}
|
||||
|
||||
type UpsertEvent struct {
|
||||
@@ -135,6 +136,8 @@ type HealthRecordUpsertService struct {
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
const idempotencyCleanupTimeout = 5 * time.Second
|
||||
|
||||
func NewHealthRecordUpsertService(deps Dependencies) *HealthRecordUpsertService {
|
||||
return &HealthRecordUpsertService{deps: deps}
|
||||
}
|
||||
@@ -216,14 +219,22 @@ func (s *HealthRecordUpsertService) update(ctx context.Context, task source.Heal
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) complete(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) {
|
||||
if err := s.deps.Idempotency.Complete(ctx, key, token); err != nil {
|
||||
cleanupCtx, cancel := idempotencyCleanupContext(ctx)
|
||||
defer cancel()
|
||||
if err := s.deps.Idempotency.Complete(cleanupCtx, key, token); err != nil {
|
||||
return outcome, fmt.Errorf("persist successful CHIS write before notification: %w", err)
|
||||
}
|
||||
return s.finish(ctx, task, outcome)
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) finishAcquired(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) {
|
||||
return s.finish(ctx, task, outcome, s.deps.Idempotency.Release(ctx, key, token))
|
||||
cleanupCtx, cancel := idempotencyCleanupContext(ctx)
|
||||
defer cancel()
|
||||
return s.finish(ctx, task, outcome, s.deps.Idempotency.Release(cleanupCtx, key, token))
|
||||
}
|
||||
|
||||
func idempotencyCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), idempotencyCleanupTimeout)
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) finish(ctx context.Context, task source.HealthRecordTask, outcome Outcome, prior ...error) (Outcome, error) {
|
||||
|
||||
@@ -22,8 +22,8 @@ func TestHealthRecordUpsertCreatesWhenQueryIsEmpty(t *testing.T) {
|
||||
if err != nil || outcome.Status != StatusDone || outcome.Action != ActionCreate {
|
||||
t.Fatalf("outcome=%#v err=%v", outcome, err)
|
||||
}
|
||||
if client.findCalls != 1 || client.createCalls != 1 || client.updateCalls != 0 || !store.marked {
|
||||
t.Fatalf("calls find=%d create=%d update=%d marked=%v", client.findCalls, client.createCalls, client.updateCalls, store.marked)
|
||||
if client.findCalls != 1 || client.createCalls != 1 || client.updateCalls != 0 || !store.marked || !store.completeHasDeadline {
|
||||
t.Fatalf("calls find=%d create=%d update=%d marked=%v deadline=%v", client.findCalls, client.createCalls, client.updateCalls, store.marked, store.completeHasDeadline)
|
||||
}
|
||||
assertNotifications(t, reports, statuses, StatusDone, ActionCreate)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestHealthRecordUpsertNeverCreatesAfterQueryFailure(t *testing.T) {
|
||||
if retryable {
|
||||
want = StatusRetry
|
||||
}
|
||||
if err != nil || outcome.Status != want || client.createCalls != 0 || client.updateCalls != 0 || !store.released {
|
||||
if err != nil || outcome.Status != want || client.createCalls != 0 || client.updateCalls != 0 || !store.released || !store.releaseHasDeadline {
|
||||
t.Fatalf("retryable=%v outcome=%#v err=%v", retryable, outcome, err)
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,7 @@ func (f *fakeHealthRecordClient) UpdateHealthRecord(_ context.Context, req contr
|
||||
|
||||
type fakeIdempotencyStore struct {
|
||||
completed, inProgress, marked, released bool
|
||||
completeHasDeadline, releaseHasDeadline bool
|
||||
completeErr error
|
||||
}
|
||||
|
||||
@@ -259,14 +260,16 @@ func (f *fakeIdempotencyStore) Acquire(context.Context, string) (IdempotencyLeas
|
||||
}
|
||||
return IdempotencyLease{State: IdempotencyAcquired, Token: "lease-1"}, nil
|
||||
}
|
||||
func (f *fakeIdempotencyStore) Complete(_ context.Context, _, token string) error {
|
||||
func (f *fakeIdempotencyStore) Complete(ctx context.Context, _, token string) error {
|
||||
_, f.completeHasDeadline = ctx.Deadline()
|
||||
if token != "lease-1" {
|
||||
return errors.New("stale lease")
|
||||
}
|
||||
f.marked = true
|
||||
return f.completeErr
|
||||
}
|
||||
func (f *fakeIdempotencyStore) Release(_ context.Context, _, token string) error {
|
||||
func (f *fakeIdempotencyStore) Release(ctx context.Context, _, token string) error {
|
||||
_, f.releaseHasDeadline = ctx.Deadline()
|
||||
if token != "lease-1" {
|
||||
return errors.New("stale lease")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type memoryIdempotencyEntry struct {
|
||||
state IdempotencyState
|
||||
token string
|
||||
}
|
||||
|
||||
// MemoryIdempotencyStore only protects concurrent deliveries in one process.
|
||||
// Complete releases the lease so a later version with the same stable checkId can update.
|
||||
type MemoryIdempotencyStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]memoryIdempotencyEntry
|
||||
next atomic.Uint64
|
||||
}
|
||||
|
||||
func NewMemoryIdempotencyStore() *MemoryIdempotencyStore {
|
||||
return &MemoryIdempotencyStore{entries: make(map[string]memoryIdempotencyEntry)}
|
||||
}
|
||||
|
||||
func (s *MemoryIdempotencyStore) Acquire(ctx context.Context, key string) (IdempotencyLease, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return IdempotencyLease{}, err
|
||||
}
|
||||
if key == "" {
|
||||
return IdempotencyLease{}, fmt.Errorf("idempotency key is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if entry, ok := s.entries[key]; ok {
|
||||
if entry.state == IdempotencyAcquired {
|
||||
return IdempotencyLease{State: IdempotencyInProgress}, nil
|
||||
}
|
||||
return IdempotencyLease{State: entry.state}, nil
|
||||
}
|
||||
token := fmt.Sprintf("lease-%d", s.next.Add(1))
|
||||
s.entries[key] = memoryIdempotencyEntry{state: IdempotencyAcquired, token: token}
|
||||
return IdempotencyLease{State: IdempotencyAcquired, Token: token}, nil
|
||||
}
|
||||
|
||||
func (s *MemoryIdempotencyStore) Complete(ctx context.Context, key, token string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.entries[key]
|
||||
if !ok || entry.state != IdempotencyAcquired || entry.token != token {
|
||||
return fmt.Errorf("idempotency lease is missing or stale")
|
||||
}
|
||||
delete(s.entries, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryIdempotencyStore) Release(ctx context.Context, key, token string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.entries[key]
|
||||
if !ok || entry.state != IdempotencyAcquired || entry.token != token {
|
||||
return fmt.Errorf("idempotency lease is missing or stale")
|
||||
}
|
||||
delete(s.entries, key)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemoryIdempotencyStoreLeaseLifecycle(t *testing.T) {
|
||||
store := NewMemoryIdempotencyStore()
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := store.Acquire(ctx, "key-1")
|
||||
if err != nil || first.State != IdempotencyAcquired || first.Token == "" {
|
||||
t.Fatalf("first lease=%#v err=%v", first, err)
|
||||
}
|
||||
second, err := store.Acquire(ctx, "key-1")
|
||||
if err != nil || second.State != IdempotencyInProgress {
|
||||
t.Fatalf("second lease=%#v err=%v", second, err)
|
||||
}
|
||||
if err := store.Release(ctx, "key-1", "stale-token"); err == nil {
|
||||
t.Fatal("stale release succeeded")
|
||||
}
|
||||
if err := store.Release(ctx, "key-1", first.Token); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
|
||||
reacquired, err := store.Acquire(ctx, "key-1")
|
||||
if err != nil || reacquired.State != IdempotencyAcquired || reacquired.Token == first.Token {
|
||||
t.Fatalf("reacquired lease=%#v err=%v", reacquired, err)
|
||||
}
|
||||
if err := store.Complete(ctx, "key-1", reacquired.Token); err != nil {
|
||||
t.Fatalf("complete: %v", err)
|
||||
}
|
||||
afterComplete, err := store.Acquire(ctx, "key-1")
|
||||
if err != nil || afterComplete.State != IdempotencyAcquired || afterComplete.Token == reacquired.Token {
|
||||
t.Fatalf("lease after complete=%#v err=%v", afterComplete, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryIdempotencyStoreHonorsCanceledContext(t *testing.T) {
|
||||
store := NewMemoryIdempotencyStore()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := store.Acquire(ctx, "key-1"); err == nil {
|
||||
t.Fatal("Acquire accepted canceled context")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user