feat(t227): query ERP directly from Go

This commit is contained in:
QiuSW
2026-07-29 10:05:25 +08:00
parent a5a61c05d0
commit a822edd1b2
10 changed files with 682 additions and 97 deletions
+2 -15
View File
@@ -14,7 +14,6 @@ import (
"cmroubao/backend-api/internal/config"
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/erpconnector"
"cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/password"
"cmroubao/backend-api/internal/platform/shunyunbao"
@@ -219,18 +218,6 @@ func buildRouter(
if err != nil {
return nil, err
}
erpTimeout := cfg.ERPConnectorTimeout
if erpTimeout <= 0 {
erpTimeout = 90 * time.Second
}
erpClient, err := erpconnector.New(
cfg.ERPConnectorURL,
cfg.ERPConnectorAPIKey,
erpTimeout,
)
if err != nil {
return nil, err
}
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
BaseURL: cfg.ShunyunbaoURL,
Username: cfg.ShunyunbaoUsername,
@@ -242,10 +229,10 @@ func buildRouter(
}
freight, err := usecase.NewFreightService(
store,
erpClient,
erpSession,
clock,
ids,
erpTimeout,
90*time.Second,
)
if err != nil {
return nil, err
@@ -212,6 +212,7 @@ func (manager *SessionManager) Login(
LoginPath,
payload,
true,
false,
)
if err != nil {
return manager.statusLocked(), err
@@ -251,6 +252,7 @@ func (manager *SessionManager) validateLocked(ctx context.Context) (any, error)
UserPath,
nil,
false,
true,
)
if err != nil {
return nil, err
@@ -266,6 +268,7 @@ func (manager *SessionManager) requestJSONLocked(
method, path string,
body []byte,
loginRequest bool,
requireSession bool,
) (any, error) {
var content io.Reader
if body != nil {
@@ -300,19 +303,28 @@ func (manager *SessionManager) requestJSONLocked(
return nil, domain.ErrFreightSourceUnavailable
}
var envelope struct {
Status bool `json:"status"`
Status *bool `json:"status"`
Code json.RawMessage `json:"code"`
Data json.RawMessage `json:"data"`
}
decoder := json.NewDecoder(bytes.NewReader(contentBytes))
if err := decoder.Decode(&envelope); err != nil || len(envelope.Data) == 0 {
if err := decoder.Decode(&envelope); err != nil || envelope.Status == nil ||
len(envelope.Data) == 0 {
return nil, domain.ErrFreightSourceProtocol
}
if !envelope.Status {
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, domain.ErrFreightSourceProtocol
}
if !*envelope.Status {
if loginRequest {
return nil, ErrLoginRejected
}
manager.authenticated = false
return nil, domain.ErrFreightSourceSessionNeeded
if requireSession || unauthenticatedCode(envelope.Code) {
manager.authenticated = false
return nil, domain.ErrFreightSourceSessionNeeded
}
return nil, domain.ErrFreightSourceProtocol
}
var data any
dataDecoder := json.NewDecoder(bytes.NewReader(envelope.Data))
@@ -320,9 +332,29 @@ func (manager *SessionManager) requestJSONLocked(
if err := dataDecoder.Decode(&data); err != nil {
return nil, domain.ErrFreightSourceProtocol
}
if err := dataDecoder.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, domain.ErrFreightSourceProtocol
}
return data, nil
}
func unauthenticatedCode(raw json.RawMessage) bool {
var value any
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if len(raw) == 0 || decoder.Decode(&value) != nil {
return false
}
switch typed := value.(type) {
case json.Number:
return typed == "-2"
case string:
return strings.TrimSpace(typed) == "-2"
default:
return false
}
}
func (manager *SessionManager) applyHeaders(request *http.Request) {
for name, values := range manager.headers {
request.Header[name] = append([]string(nil), values...)
@@ -0,0 +1,295 @@
package shunyunbao
import (
"context"
"encoding/json"
"reflect"
"strconv"
"cmroubao/backend-api/internal/domain"
)
const (
sourcePageSize = 20
sourceMaxMatches = 100
)
// QueryOrder implements the freight source port using the manager's single
// in-memory ERP session. It never exposes the ERP list or detail responses.
func (manager *SessionManager) QueryOrder(
ctx context.Context,
orderNumber string,
) (domain.FreightSourceBatch, error) {
query, err := OrderNumberQuery(orderNumber)
if err != nil {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
records, err := manager.queryRecords(ctx, query)
if err != nil {
return domain.FreightSourceBatch{}, err
}
if len(records) == 0 {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceNotFound
}
return NormalizeFreightResult(
RawQuery{Mode: domain.FreightSyncOrderNumber},
records,
)
}
// QueryCreatedRange implements the bounded Asia/Shanghai date-window source
// port. The caller owns larger-window splitting and watermark semantics.
func (manager *SessionManager) QueryCreatedRange(
ctx context.Context,
createdFrom, createdTo string,
) (domain.FreightSourceBatch, error) {
query, err := CreatedRangeQuery(createdFrom, createdTo)
if err != nil {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
records, err := manager.queryRecords(ctx, query)
if err != nil {
return domain.FreightSourceBatch{}, err
}
return NormalizeFreightResult(
RawQuery{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
},
records,
)
}
func (manager *SessionManager) queryRecords(
ctx context.Context,
query QueryCondition,
) ([]RawRecord, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
if !manager.configuredLocked() {
return nil, domain.ErrFreightSourceNotConfigured
}
if !manager.authenticated {
return nil, domain.ErrFreightSourceSessionNeeded
}
if _, err := manager.validateLocked(ctx); err != nil {
return nil, err
}
stocks, err := manager.queryStocksLocked(ctx, query)
if err != nil {
return nil, err
}
if len(stocks) == 0 {
return []RawRecord{}, nil
}
details, err := manager.queryDetailsLocked(ctx, stocks)
if err != nil {
return nil, err
}
records := make([]RawRecord, 0, len(stocks))
for _, stock := range stocks {
stockID, err := sourceExternalID(stock["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
detail, exists := details[stockID]
if !exists {
return nil, domain.ErrFreightSourceProtocol
}
records = append(records, RawRecord{Stock: stock, Detail: detail})
}
return records, nil
}
func (manager *SessionManager) queryStocksLocked(
ctx context.Context,
query QueryCondition,
) ([]map[string]any, error) {
firstPayload, err := StockListPayload(query, 0, 1, sourcePageSize)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
firstBody, err := json.Marshal(firstPayload)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
totalData, err := manager.requestJSONLocked(
ctx,
"POST",
StockListTotalPath,
firstBody,
false,
false,
)
if err != nil {
return nil, err
}
total, err := sourceTotal(totalData)
if err != nil || total > sourceMaxMatches {
return nil, domain.ErrFreightSourceProtocol
}
if total == 0 {
return []map[string]any{}, nil
}
rows := make([]map[string]any, 0, total)
for start := 0; start < total; start += sourcePageSize {
payload, err := StockListPayload(
query,
start,
start/sourcePageSize+1,
sourcePageSize,
)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
body, err := json.Marshal(payload)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
data, err := manager.requestJSONLocked(
ctx,
"POST",
StockListPath,
body,
false,
false,
)
if err != nil {
return nil, err
}
page, err := sourceObjectList(data)
if err != nil || len(page) > sourcePageSize {
return nil, domain.ErrFreightSourceProtocol
}
rows = append(rows, page...)
}
if len(rows) != total {
return nil, domain.ErrFreightSourceProtocol
}
stocksByID := make(map[string]map[string]any, len(rows))
stocks := make([]map[string]any, 0, len(rows))
for _, stock := range rows {
stockID, err := sourceExternalID(stock["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
if existing, exists := stocksByID[stockID]; exists {
if !reflect.DeepEqual(existing, stock) {
return nil, domain.ErrFreightSourceProtocol
}
continue
}
stocksByID[stockID] = stock
stocks = append(stocks, stock)
}
return stocks, nil
}
func (manager *SessionManager) queryDetailsLocked(
ctx context.Context,
stocks []map[string]any,
) (map[string]map[string]any, error) {
ids := make([]string, 0, len(stocks))
for _, stock := range stocks {
stockID, err := sourceExternalID(stock["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
ids = append(ids, stockID)
}
detailsByID := make(map[string]map[string]any, len(ids))
requestedIDs := make(map[string]struct{}, len(ids))
for _, id := range ids {
requestedIDs[id] = struct{}{}
}
for start := 0; start < len(ids); start += maxDetailIDs {
end := start + maxDetailIDs
if end > len(ids) {
end = len(ids)
}
payload, err := StockDetailPayload(ids[start:end])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
body, err := json.Marshal(payload)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
data, err := manager.requestJSONLocked(
ctx,
"POST",
StockDetailPath+"?hist=0",
body,
false,
false,
)
if err != nil {
return nil, err
}
values, err := sourceObjectList(data)
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
for _, detail := range values {
detailID, err := sourceExternalID(detail["id"])
if err != nil {
return nil, domain.ErrFreightSourceProtocol
}
if _, requested := requestedIDs[detailID]; !requested {
return nil, domain.ErrFreightSourceProtocol
}
if existing, exists := detailsByID[detailID]; exists &&
!reflect.DeepEqual(existing, detail) {
return nil, domain.ErrFreightSourceProtocol
}
detailsByID[detailID] = detail
}
}
if len(detailsByID) != len(ids) {
return nil, domain.ErrFreightSourceProtocol
}
return detailsByID, nil
}
func sourceTotal(value any) (int, error) {
var text string
switch typed := value.(type) {
case json.Number:
text = string(typed)
case string:
text = typed
default:
return 0, strconv.ErrSyntax
}
parsed, err := strconv.ParseInt(text, 10, 0)
if err != nil || parsed < 0 || parsed > int64(sourceMaxMatches) {
return 0, strconv.ErrSyntax
}
return int(parsed), nil
}
func sourceObjectList(value any) ([]map[string]any, error) {
data, ok := value.(map[string]any)
if !ok {
return nil, strconv.ErrSyntax
}
rawList, ok := data["list"].([]any)
if !ok {
return nil, strconv.ErrSyntax
}
result := make([]map[string]any, 0, len(rawList))
for _, value := range rawList {
row, ok := value.(map[string]any)
if !ok {
return nil, strconv.ErrSyntax
}
result = append(result, row)
}
return result, nil
}
func sourceExternalID(value any) (string, error) {
return externalID(value)
}
@@ -0,0 +1,261 @@
package shunyunbao
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"cmroubao/backend-api/internal/domain"
)
func TestSessionManagerQueryOrderUsesVerifiedSessionAndAllowlist(t *testing.T) {
calls := make([]string, 0, 8)
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
calls = append(calls, request.URL.Path)
switch request.URL.Path {
case CaptchaPath:
http.SetCookie(writer, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
writer.Header().Set("Content-Type", "image/png")
_, _ = writer.Write([]byte("captcha"))
case LoginPath:
assertERPHeaders(t, request)
if cookie, err := request.Cookie("captcha"); err != nil || cookie.Value != "ready" {
t.Fatalf("login captcha cookie = %v / %v", cookie, err)
}
http.SetCookie(writer, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
_, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":1}}}`))
case UserPath:
if cookie, err := request.Cookie("authenticated"); err != nil || cookie.Value != "yes" {
t.Fatalf("user cookie = %v / %v", cookie, err)
}
_, _ = writer.Write([]byte(`{"status":true,"data":{"id":1}}`))
case StockListTotalPath:
assertStockPayload(t, request, "SOURCE-12", 0, 1, 20)
_, _ = writer.Write([]byte(`{"status":true,"data":1}`))
case StockListPath:
assertStockPayload(t, request, "SOURCE-12", 0, 1, 20)
_, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"code":"SOURCE-12","orderCode":"PLATFORM-12","receiver":"private-recipient","receiverTel":"private-phone"}]}}`))
case StockDetailPath:
if request.URL.Query().Get("hist") != "0" {
t.Fatalf("detail query = %q", request.URL.RawQuery)
}
assertDetailPayload(t, request, []uint64{12})
_, _ = writer.Write([]byte(`{"status":true,"data":{"list":[{"id":12,"shopName":"测试店铺","created":"2026-07-28 08:00:00","details":[{"id":88,"productTitle":"商品一","productSpec":"黑色,L","sku":"BLACK-L","productQty":2,"receiverTel":"private-item-phone"},{"id":89,"productTitle":"商品二","productSpec":"黑色,XL","sku":"BLACK-XL","productQty":1}]}]}}`))
default:
writer.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
manager := testSessionManager(t, server.URL, "test-user", "test-password")
loginForSource(t, manager)
result, err := manager.QueryOrder(context.Background(), "SOURCE-12")
if err != nil {
t.Fatalf("QueryOrder() error = %v", err)
}
if result.SchemaVersion != 1 || result.Query.Mode != domain.FreightSyncOrderNumber ||
len(result.Orders) != 1 || result.Orders[0].ExternalStockID != "12" ||
result.Orders[0].ShopName == nil || *result.Orders[0].ShopName != "测试店铺" ||
len(result.Orders[0].Items) != 2 || result.Orders[0].Items[0].SKU != "BLACK-L" ||
result.Orders[0].Items[1].SKU != "BLACK-XL" {
t.Fatalf("QueryOrder() = %#v", result)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
for _, forbidden := range []string{"private-recipient", "private-phone", "private-item-phone"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("result leaked raw ERP value %q: %s", forbidden, encoded)
}
}
wantCalls := []string{
CaptchaPath, LoginPath, UserPath, UserPath,
StockListTotalPath, StockListPath, StockDetailPath,
}
if strings.Join(calls, ",") != strings.Join(wantCalls, ",") {
t.Fatalf("endpoint calls = %#v, want %#v", calls, wantCalls)
}
}
func TestSessionManagerQueryCreatedRangePaginatesAndDeduplicates(t *testing.T) {
listCalls := 0
detailIDs := make([]uint64, 0)
server := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
switch request.URL.Path {
case CaptchaPath:
http.SetCookie(writer, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
writer.Header().Set("Content-Type", "image/png")
_, _ = writer.Write([]byte("captcha"))
case LoginPath:
http.SetCookie(writer, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
_, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":1}}}`))
case UserPath:
_, _ = writer.Write([]byte(`{"status":true,"data":{"id":1}}`))
case StockListTotalPath:
assertStockPayload(t, request, "2026-07-22,2026-07-28", 0, 1, 20)
_, _ = writer.Write([]byte(`{"status":true,"data":21}`))
case StockListPath:
listCalls++
if listCalls == 1 {
assertStockPayload(t, request, "2026-07-22,2026-07-28", 0, 1, 20)
_, _ = writer.Write(stockListEnvelope(1, 20))
return
}
assertStockPayload(t, request, "2026-07-22,2026-07-28", 20, 2, 20)
_, _ = writer.Write(stockListIDsEnvelope([]int{20}))
case StockDetailPath:
ids := decodeDetailPayload(t, request)
detailIDs = append(detailIDs, ids...)
_, _ = writer.Write(stockDetailEnvelope(ids))
default:
writer.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
manager := testSessionManager(t, server.URL, "test-user", "test-password")
loginForSource(t, manager)
result, err := manager.QueryCreatedRange(
context.Background(),
"2026-07-22",
"2026-07-28",
)
if err != nil {
t.Fatalf("QueryCreatedRange() error = %v", err)
}
if listCalls != 2 || len(detailIDs) != 20 || len(result.Orders) != 20 ||
result.Query.CreatedFrom == nil || *result.Query.CreatedFrom != "2026-07-22" ||
result.Query.CreatedTo == nil || *result.Query.CreatedTo != "2026-07-28" {
t.Fatalf("range query result = %#v, list calls = %d, detail IDs = %#v", result, listCalls, detailIDs)
}
seen := make(map[string]struct{}, len(result.Orders))
for _, order := range result.Orders {
if _, exists := seen[order.ExternalStockID]; exists {
t.Fatalf("duplicate normalized order = %q", order.ExternalStockID)
}
seen[order.ExternalStockID] = struct{}{}
}
}
func TestSessionManagerQueryRequiresConfiguredAuthenticatedSession(t *testing.T) {
manager := testSessionManager(t, "http://127.0.0.1:1", "", "")
if _, err := manager.QueryOrder(context.Background(), "SOURCE-12"); !errors.Is(err, domain.ErrFreightSourceNotConfigured) {
t.Fatalf("unconfigured QueryOrder() error = %v", err)
}
manager = testSessionManager(t, "http://127.0.0.1:1", "test-user", "test-password")
if _, err := manager.QueryOrder(context.Background(), "SOURCE-12"); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
t.Fatalf("unauthenticated QueryOrder() error = %v", err)
}
}
func loginForSource(t *testing.T, manager *SessionManager) {
t.Helper()
status, err := manager.FetchCaptcha(context.Background())
if err != nil {
t.Fatalf("FetchCaptcha() error = %v", err)
}
if _, err := manager.Login(context.Background(), status.CaptchaTicket, "1234"); err != nil {
t.Fatalf("Login() error = %v", err)
}
}
func assertStockPayload(
t *testing.T,
request *http.Request,
wantQuery string,
wantStart, wantPage, wantLength int,
) {
t.Helper()
assertERPHeaders(t, request)
if request.Method != http.MethodPost {
t.Fatalf("stock method = %s", request.Method)
}
var payload struct {
Length int `json:"length"`
Start int `json:"start"`
PageIndex int `json:"pageIndex"`
Queries []struct {
Value string `json:"dvalue"`
} `json:"queries"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("decode stock payload: %v", err)
}
if payload.Length != wantLength || payload.Start != wantStart ||
payload.PageIndex != wantPage || len(payload.Queries) != 1 ||
payload.Queries[0].Value != wantQuery {
t.Fatalf("stock payload = %#v", payload)
}
}
func assertDetailPayload(t *testing.T, request *http.Request, want []uint64) {
t.Helper()
got := decodeDetailPayload(t, request)
if len(got) != len(want) {
t.Fatalf("detail ids = %#v, want %#v", got, want)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("detail ids = %#v, want %#v", got, want)
}
}
}
func decodeDetailPayload(t *testing.T, request *http.Request) []uint64 {
t.Helper()
assertERPHeaders(t, request)
var payload struct {
IDs []uint64 `json:"ids"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("decode detail payload: %v", err)
}
return payload.IDs
}
func stockListEnvelope(first, last int) []byte {
ids := make([]int, 0, last-first+1)
for value := first; value <= last; value++ {
ids = append(ids, value)
}
return stockListIDsEnvelope(ids)
}
func stockListIDsEnvelope(ids []int) []byte {
rows := make([]map[string]any, 0, len(ids))
for _, id := range ids {
rows = append(rows, map[string]any{"id": id, "code": "SOURCE-" + strconv.Itoa(id)})
}
content, _ := json.Marshal(map[string]any{
"status": true,
"data": map[string]any{"list": rows},
})
return content
}
func stockDetailEnvelope(ids []uint64) []byte {
rows := make([]map[string]any, 0, len(ids))
for _, id := range ids {
rows = append(rows, map[string]any{
"id": id,
"details": []any{},
})
}
content, _ := json.Marshal(map[string]any{
"status": true,
"data": map[string]any{"list": rows},
})
return content
}
@@ -672,7 +672,7 @@ func daysInclusive(start, end time.Time) int {
func freightSourceErrorCode(err error) string {
switch {
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
return "ERP_CONNECTOR_NOT_CONFIGURED"
return "ERP_NOT_CONFIGURED"
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
return "ERP_SESSION_REQUIRED"
case errors.Is(err, domain.ErrFreightSourceNotFound):
@@ -680,7 +680,7 @@ func freightSourceErrorCode(err error) string {
case errors.Is(err, domain.ErrFreightSourceProtocol):
return "ERP_RESPONSE_INVALID"
default:
return "ERP_CONNECTOR_UNAVAILABLE"
return "ERP_UNAVAILABLE"
}
}
@@ -55,6 +55,24 @@ func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime(
}
}
func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) {
cases := []struct {
err error
want string
}{
{domain.ErrFreightSourceNotConfigured, "ERP_NOT_CONFIGURED"},
{domain.ErrFreightSourceSessionNeeded, "ERP_SESSION_REQUIRED"},
{domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"},
{domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"},
{errors.New("temporary source failure"), "ERP_UNAVAILABLE"},
}
for _, testCase := range cases {
if got := freightSourceErrorCode(testCase.err); got != testCase.want {
t.Fatalf("freightSourceErrorCode(%v) = %q, want %q", testCase.err, got, testCase.want)
}
}
}
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
source := &recordingDateSource{}
service := &FreightService{source: source}