feat(handler): 暴露健康档案 upsert 接口(T-204)

This commit is contained in:
ila
2026-07-16 02:00:56 +08:00
parent 3a30ebdc45
commit 48263d8b9a
16 changed files with 852 additions and 44 deletions
+19 -8
View File
@@ -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) {
+8 -5
View File
@@ -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")
}
+73
View File
@@ -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
}
+47
View File
@@ -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")
}
}