feat(osi): 实现通用 Call 与响应判码(T-004)

This commit is contained in:
ila
2026-07-06 22:31:02 +08:00
parent 56acc01d92
commit 2a77a4dbc1
10 changed files with 413 additions and 11 deletions
+136
View File
@@ -0,0 +1,136 @@
package osi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"chis_osi/contract"
)
type ClientConfig struct {
BaseURL string
OrgCode string
DeviceSN string
UserName string
Ask string
OperateUser string
OperateUnit string
Transport *Transport
NowMillis func() string
}
type Client struct {
config ClientConfig
transport *Transport
}
type Result struct {
HTTPStatus int
Code string
Message string
Raw []byte
Success bool
Retryable bool
}
type responseEnvelope struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func NewClient(config ClientConfig) *Client {
return &Client{config: config, transport: config.Transport}
}
func (c *Client) Call(ctx context.Context, serviceID string, baseInfo any, out any) (Result, error) {
if c.transport == nil {
transport, err := NewTransport(TransportConfig{})
if err != nil {
return Result{}, err
}
c.transport = transport
}
path, err := PathOf(serviceID)
if err != nil {
return Result{}, err
}
payload := contract.Envelope{
ServiceID: serviceID,
UploadInfo: contract.UploadInfo{
BaseInfo: baseInfo,
ManageInfo: contract.ManageInfo{
DSFMC: c.config.UserName,
OperateUser: c.config.OperateUser,
OperateUnit: c.operateUnit(),
},
},
}
targetURL := c.urlFor(path)
status, raw, err := c.transport.PostJSON(ctx, targetURL, payload, c.headers())
result := Result{HTTPStatus: status, Raw: raw, Retryable: isRetryableHTTPStatus(status)}
if err != nil {
result.Retryable = true
return result, err
}
var resp responseEnvelope
if err := json.Unmarshal(raw, &resp); err != nil {
return result, fmt.Errorf("decode osi response: %w", err)
}
result.Code = resp.Code
result.Message = resp.Message
result.Success = IsSuccessCode(resp.Code)
result.Retryable = result.Retryable || IsRetryableCode(resp.Code)
if !result.Success {
return result, fmt.Errorf("osi call failed code=%s message=%s", resp.Code, resp.Message)
}
if out != nil && len(resp.Data) > 0 && string(resp.Data) != "null" {
if err := json.Unmarshal(resp.Data, out); err != nil {
return result, fmt.Errorf("decode osi data: %w", err)
}
}
return result, nil
}
func (c *Client) headers() map[string]string {
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
if c.config.NowMillis != nil {
ts = c.config.NowMillis()
}
return BuildHeaders(HeaderInput{
OrgCode: c.config.OrgCode,
DeviceSN: c.config.DeviceSN,
UserName: c.config.UserName,
Ask: c.config.Ask,
TS: ts,
})
}
func (c *Client) operateUnit() string {
if c.config.OperateUnit != "" {
return c.config.OperateUnit
}
return c.config.OrgCode
}
func (c *Client) urlFor(path string) string {
baseURL := strings.TrimRight(c.config.BaseURL, "/")
if strings.HasSuffix(baseURL, "/osi/api") {
return baseURL + path
}
return baseURL + "/osi/api" + path
}
func isRetryableHTTPStatus(status int) bool {
return status == http.StatusTooManyRequests || status >= 500
}
+137
View File
@@ -0,0 +1,137 @@
package osi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"chis_osi/contract"
)
func TestClientCallPostsSignedEnvelopeAndDecodesSuccess(t *testing.T) {
var seenPath string
var seenPayload map[string]any
var seenHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenPath = r.URL.Path
seenHeaders = r.Header.Clone()
if err := json.NewDecoder(r.Body).Decode(&seenPayload); err != nil {
t.Fatalf("decode request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"01","message":"操作成功","data":{"phrId":"phr-001"}}`))
}))
defer server.Close()
transport, err := NewTransport(TransportConfig{Timeout: time.Second})
if err != nil {
t.Fatalf("NewTransport: %v", err)
}
client := NewClient(ClientConfig{
BaseURL: server.URL,
OrgCode: "12441625456962881G",
DeviceSN: "device-001",
UserName: "dyytgw",
Ask: "secret-key",
OperateUser: "712041",
OperateUnit: "12441625456962881G",
Transport: transport,
NowMillis: func() string {
return "1700000000123"
},
})
var out struct {
PHRID string `json:"phrId"`
}
result, err := client.Call(context.Background(), ServiceIDJKDAFind, map[string]string{"idCard": "440100199001011234"}, &out)
if err != nil {
t.Fatalf("Call: %v", err)
}
if seenPath != "/osi/api/auto/jkda/find" {
t.Fatalf("path = %q", seenPath)
}
if seenHeaders.Get("orgCode") != "12441625456962881G" {
t.Fatalf("orgCode header = %q", seenHeaders.Get("orgCode"))
}
if seenHeaders.Get("password") != "008aceff8247cb42d2a99b2c48d0ac88" {
t.Fatalf("password header = %q", seenHeaders.Get("password"))
}
if _, ok := seenPayload["uploadinfo"]; !ok {
t.Fatalf("payload missing uploadinfo: %#v", seenPayload)
}
if seenPayload["serviceId"] != ServiceIDJKDAFind {
t.Fatalf("serviceId = %v", seenPayload["serviceId"])
}
uploadInfo := seenPayload["uploadinfo"].(map[string]any)
baseInfo := uploadInfo["baseInfo"].(map[string]any)
if baseInfo["idCard"] != "440100199001011234" {
t.Fatalf("baseInfo.idCard = %v", baseInfo["idCard"])
}
manageInfo := uploadInfo["manageInfo"].(map[string]any)
if manageInfo["DSFMC"] != "dyytgw" || manageInfo["operateUser"] != "712041" || manageInfo["operateUnit"] != "12441625456962881G" {
t.Fatalf("manageInfo = %#v", manageInfo)
}
if !result.Success || result.Retryable {
t.Fatalf("result = %#v", result)
}
if out.PHRID != "phr-001" {
t.Fatalf("decoded phrId = %q", out.PHRID)
}
}
func TestClientCallMarks405Retryable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":"405","message":"服务调用超时"}`))
}))
defer server.Close()
transport, err := NewTransport(TransportConfig{Timeout: time.Second})
if err != nil {
t.Fatalf("NewTransport: %v", err)
}
client := NewClient(ClientConfig{BaseURL: server.URL, UserName: "dyytgw", Ask: "secret-key", Transport: transport})
result, err := client.Call(context.Background(), ServiceIDJKDAFind, contract.UploadInfo{}, nil)
if err == nil {
t.Fatal("Call returned nil error for 405")
}
if !result.Retryable || result.Success {
t.Fatalf("result = %#v", result)
}
}
func TestClientCallGeneratesTimestampWhenClockNotInjected(t *testing.T) {
var seenTS string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenTS = r.Header.Get("ts")
_, _ = w.Write([]byte(`{"code":"01","message":"操作成功"}`))
}))
defer server.Close()
transport, err := NewTransport(TransportConfig{Timeout: time.Second})
if err != nil {
t.Fatalf("NewTransport: %v", err)
}
client := NewClient(ClientConfig{BaseURL: server.URL, UserName: "dyytgw", Ask: "secret-key", Transport: transport})
if _, err := client.Call(context.Background(), ServiceIDJKDAFind, map[string]string{"idCard": "440100199001011234"}, nil); err != nil {
t.Fatalf("Call: %v", err)
}
if len(seenTS) != 13 {
t.Fatalf("ts header length = %d, want 13; ts=%q", len(seenTS), seenTS)
}
}
func TestClientURLForAcceptsBaseURLWithAPIPath(t *testing.T) {
client := NewClient(ClientConfig{BaseURL: "http://example.test/osi/api/"})
got := client.urlFor("/auto/jkda/find")
want := "http://example.test/osi/api/auto/jkda/find"
if got != want {
t.Fatalf("urlFor = %q, want %q", got, want)
}
}
+34
View File
@@ -0,0 +1,34 @@
package osi
import (
"fmt"
"strings"
)
const (
ServiceIDJKDAFind = "JKDA00002"
)
const codeRetryableTimeout = "405"
var servicePaths = map[string]string{
ServiceIDJKDAFind: "/auto/jkda/find",
}
func PathOf(serviceID string) (string, error) {
path, ok := servicePaths[serviceID]
if !ok {
return "", fmt.Errorf("unknown serviceId %q", serviceID)
}
return path, nil
}
func IsSuccessCode(code string) bool {
code = strings.TrimSpace(code)
code = strings.TrimLeft(code, "0")
return code == "1"
}
func IsRetryableCode(code string) bool {
return strings.TrimSpace(code) == codeRetryableTimeout
}
+33
View File
@@ -0,0 +1,33 @@
package osi
import "testing"
func TestCodeClassificationNormalizesLeadingZeroes(t *testing.T) {
for _, code := range []string{"1", "01", "001"} {
if !IsSuccessCode(code) {
t.Fatalf("IsSuccessCode(%q) = false", code)
}
}
if IsSuccessCode("0") {
t.Fatal("IsSuccessCode accepted zero")
}
if !IsRetryableCode("405") {
t.Fatal("405 should be retryable")
}
if IsRetryableCode("01") {
t.Fatal("success code should not be retryable")
}
}
func TestPathOfKnownServiceID(t *testing.T) {
path, err := PathOf(ServiceIDJKDAFind)
if err != nil {
t.Fatalf("PathOf: %v", err)
}
if path != "/auto/jkda/find" {
t.Fatalf("path = %q", path)
}
if _, err := PathOf("UNKNOWN"); err == nil {
t.Fatal("PathOf accepted unknown serviceId")
}
}