feat(handler): 暴露健康档案 upsert 接口(T-204)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"chis_osi/pipeline"
|
||||
"chis_osi/source"
|
||||
)
|
||||
|
||||
const maxHealthRecordUpsertBody = 1 << 20
|
||||
|
||||
type HealthRecordUpserter interface {
|
||||
Upsert(context.Context, source.HealthRecordTask) (pipeline.Outcome, error)
|
||||
}
|
||||
|
||||
type HealthRecordUpsertHandler struct {
|
||||
upserter HealthRecordUpserter
|
||||
}
|
||||
|
||||
func NewHealthRecordUpsertHandler(upserter HealthRecordUpserter) *HealthRecordUpsertHandler {
|
||||
return &HealthRecordUpsertHandler{upserter: upserter}
|
||||
}
|
||||
|
||||
func (h *HealthRecordUpsertHandler) Upsert(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "only POST is supported")
|
||||
return
|
||||
}
|
||||
if h.upserter == nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "health record upsert is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxHealthRecordUpsertBody)
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeJSONError(w, http.StatusRequestEntityTooLarge, "request body exceeds 1 MiB")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusBadRequest, "read request body failed")
|
||||
return
|
||||
}
|
||||
task, err := source.DecodeHealthRecordTask(raw)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid PHIS health record envelope")
|
||||
return
|
||||
}
|
||||
|
||||
outcome, err := h.upserter.Upsert(r.Context(), task)
|
||||
if err != nil {
|
||||
response := healthRecordUpsertErrorResponse{Error: "health record upsert failed", RetrySafe: outcome.Status == ""}
|
||||
if outcome.Status != "" {
|
||||
response.Outcome = &outcome
|
||||
}
|
||||
writeJSON(w, http.StatusBadGateway, response)
|
||||
return
|
||||
}
|
||||
writeJSON(w, outcomeHTTPStatus(outcome), outcome)
|
||||
}
|
||||
|
||||
type healthRecordUpsertErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
RetrySafe bool `json:"retrySafe"`
|
||||
Outcome *pipeline.Outcome `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
func outcomeHTTPStatus(outcome pipeline.Outcome) int {
|
||||
switch outcome.Status {
|
||||
case pipeline.StatusDone:
|
||||
return http.StatusOK
|
||||
case pipeline.StatusManualReview:
|
||||
return http.StatusConflict
|
||||
case pipeline.StatusRetry:
|
||||
return http.StatusServiceUnavailable
|
||||
case pipeline.StatusFailed:
|
||||
if outcome.ServiceID != "" {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return http.StatusUnprocessableEntity
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chis_osi/pipeline"
|
||||
"chis_osi/source"
|
||||
)
|
||||
|
||||
func TestHealthRecordUpsertReturnsStructuredOutcome(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outcome pipeline.Outcome
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "create", outcome: pipeline.Outcome{Status: pipeline.StatusDone, Action: pipeline.ActionCreate, ServiceID: "JKDA00001", ResponseCode: "01", PHRIDHint: "****0001"}, wantStatus: http.StatusOK},
|
||||
{name: "update", outcome: pipeline.Outcome{Status: pipeline.StatusDone, Action: pipeline.ActionUpdate, ServiceID: "JKDA00003", ResponseCode: "01", PHRIDHint: "****0001"}, wantStatus: http.StatusOK},
|
||||
{name: "manual review", outcome: pipeline.Outcome{Status: pipeline.StatusManualReview, Action: pipeline.ActionNone, Reason: "multiple CHIS health records found"}, wantStatus: http.StatusConflict},
|
||||
{name: "retry", outcome: pipeline.Outcome{Status: pipeline.StatusRetry, Action: pipeline.ActionNone, Reason: "CHIS query failed"}, wantStatus: http.StatusServiceUnavailable},
|
||||
{name: "validation failed", outcome: pipeline.Outcome{Status: pipeline.StatusFailed, Action: pipeline.ActionNone, Reason: "mapping validation failed: idCard"}, wantStatus: http.StatusUnprocessableEntity},
|
||||
{name: "upstream failed", outcome: pipeline.Outcome{Status: pipeline.StatusFailed, Action: pipeline.ActionCreate, Reason: "CHIS create failed", ServiceID: "JKDA00001"}, wantStatus: http.StatusBadGateway},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upserter := &fakeHealthRecordUpserter{outcome: tt.outcome}
|
||||
h := NewHealthRecordUpsertHandler(upserter)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(testPHISEnvelope()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.Upsert(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
var got pipeline.Outcome
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode outcome: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if got.Status != tt.outcome.Status || got.Action != tt.outcome.Action || upserter.task.ArchID != "ARCH-HTTP-1" {
|
||||
t.Fatalf("outcome=%#v task=%#v", got, upserter.task)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertHidesInternalError(t *testing.T) {
|
||||
h := NewHealthRecordUpsertHandler(&fakeHealthRecordUpserter{err: errors.New("proxy secret detail")})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(testPHISEnvelope()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.Upsert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadGateway || strings.Contains(rec.Body.String(), "secret") {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertPreservesDoneOutcomeOnFinalizationError(t *testing.T) {
|
||||
h := NewHealthRecordUpsertHandler(&fakeHealthRecordUpserter{
|
||||
outcome: pipeline.Outcome{Status: pipeline.StatusDone, Action: pipeline.ActionCreate, ServiceID: "JKDA00001", ResponseCode: "01"},
|
||||
err: errors.New("idempotency complete failed"),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(testPHISEnvelope()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.Upsert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadGateway || !strings.Contains(rec.Body.String(), `"retrySafe":false`) || !strings.Contains(rec.Body.String(), `"status":"done"`) {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertRejectsInvalidEnvelopeAndMethod(t *testing.T) {
|
||||
t.Run("invalid envelope", func(t *testing.T) {
|
||||
h := NewHealthRecordUpsertHandler(&fakeHealthRecordUpserter{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/health-record/upsert", strings.NewReader(`{"code":500,"msg":"failed"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Upsert(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("method", func(t *testing.T) {
|
||||
h := NewHealthRecordUpsertHandler(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health-record/upsert", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Upsert(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost {
|
||||
t.Fatalf("status=%d allow=%q", rec.Code, rec.Header().Get("Allow"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type fakeHealthRecordUpserter struct {
|
||||
outcome pipeline.Outcome
|
||||
err error
|
||||
task source.HealthRecordTask
|
||||
}
|
||||
|
||||
func (f *fakeHealthRecordUpserter) Upsert(_ context.Context, task source.HealthRecordTask) (pipeline.Outcome, error) {
|
||||
f.task = task
|
||||
return f.outcome, f.err
|
||||
}
|
||||
|
||||
func testPHISEnvelope() string {
|
||||
return `{"code":200,"msg":"ok","compress":false,"data":{"archId":"ARCH-HTTP-1","businessId":"BUS-HTTP-1","record":{"idCard":"440000********1234"}}}`
|
||||
}
|
||||
Reference in New Issue
Block a user