feat(t240): cache freight item images

This commit is contained in:
QiuSW
2026-07-29 15:12:05 +08:00
parent 5f17fc11aa
commit c7e152aaa1
28 changed files with 1728 additions and 30 deletions
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 15 {
t.Fatalf("initial Up() applied = %d, want 15", applied)
} else if applied != 16 {
t.Fatalf("initial Up() applied = %d, want 16", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v16) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v15) error = %v", err)
@@ -74,9 +77,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v5-v15) over historical data error = %v", err)
} else if applied != 11 {
t.Fatalf("Up(v5-v15) applied = %d, want 11", applied)
t.Fatalf("Up(v5-v16) over historical data error = %v", err)
} else if applied != 12 {
t.Fatalf("Up(v5-v16) applied = %d, want 12", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v16) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
@@ -141,9 +149,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v15) error = %v", err)
} else if applied != 12 {
t.Fatalf("final Up(v4-v15) applied = %d, want 12", applied)
t.Fatalf("final Up(v4-v16) error = %v", err)
} else if applied != 13 {
t.Fatalf("final Up(v4-v16) applied = %d, want 13", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -379,6 +387,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v16) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v15) error = %v", err)
}
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 15 {
t.Fatalf("Up() applied = %d, want 15", applied)
if applied != 16 {
t.Fatalf("Up() applied = %d, want 16", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
@@ -46,6 +46,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
13: true,
14: true,
15: true,
16: true,
})
applied, err = runner.Up(context.Background())
@@ -74,7 +75,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
12: true,
13: true,
14: true,
15: false,
15: true,
16: false,
})
applied, err = runner.Up(context.Background())
@@ -100,6 +102,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
13: true,
14: true,
15: true,
16: true,
})
}
@@ -0,0 +1,136 @@
package shunyunbao
import (
"context"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
const (
ProductImagePath = "/api/p/file"
maxProductImageBytes = 20 << 20
)
func (manager *SessionManager) FetchProductImage(
ctx context.Context,
productThumbRef string,
) (usecase.FreightSourceImage, error) {
productThumbRef = strings.TrimSpace(productThumbRef)
value, err := strconv.ParseUint(productThumbRef, 10, 64)
if err != nil || value == 0 ||
strconv.FormatUint(value, 10) != productThumbRef {
return usecase.FreightSourceImage{}, domain.ErrFreightImageInvalid
}
manager.mu.Lock()
if !manager.configuredLocked() {
manager.mu.Unlock()
return usecase.FreightSourceImage{},
domain.ErrFreightSourceNotConfigured
}
if !manager.authenticated {
manager.mu.Unlock()
return usecase.FreightSourceImage{},
domain.ErrFreightSourceSessionNeeded
}
baseURL := manager.baseURL
headers := manager.headers.Clone()
client := manager.http
manager.mu.Unlock()
query := url.Values{}
query.Set("id", productThumbRef)
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
baseURL+ProductImagePath+"?"+query.Encode(),
nil,
)
if err != nil {
return usecase.FreightSourceImage{},
domain.ErrFreightSourceUnavailable
}
for name, values := range headers {
request.Header[name] = append([]string(nil), values...)
}
request.Header.Set("Accept", "image/jpeg, image/png, image/webp")
manager.logERPRequest(request)
response, err := client.Do(request)
if err != nil {
manager.logERPTransportFailure(request)
return usecase.FreightSourceImage{},
domain.ErrFreightSourceUnavailable
}
if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices {
defer response.Body.Close()
manager.logERPBinaryResponse(request, response)
switch response.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
manager.mu.Lock()
manager.clearAuthenticatedLocked()
manager.mu.Unlock()
return usecase.FreightSourceImage{},
domain.ErrFreightSourceSessionNeeded
case http.StatusNotFound:
return usecase.FreightSourceImage{},
domain.ErrFreightImageNotFound
default:
return usecase.FreightSourceImage{},
domain.ErrFreightSourceUnavailable
}
}
mediaType, _, err := mime.ParseMediaType(
response.Header.Get("Content-Type"),
)
if err != nil || !supportedProductImageType(mediaType) ||
response.ContentLength > maxProductImageBytes {
response.Body.Close()
manager.logERPBinaryResponse(request, response)
return usecase.FreightSourceImage{},
domain.ErrFreightImageInvalid
}
manager.logERPBinaryResponse(request, response)
return usecase.FreightSourceImage{
Content: response.Body,
MediaType: mediaType,
}, nil
}
func supportedProductImageType(value string) bool {
switch strings.ToLower(value) {
case "image/jpeg", "image/png", "image/webp":
return true
default:
return false
}
}
func (manager *SessionManager) logERPBinaryResponse(
request *http.Request,
response *http.Response,
) {
if !manager.diagnosticsOn {
return
}
byteCount := "unknown"
if response.ContentLength >= 0 {
byteCount = strconv.FormatInt(response.ContentLength, 10)
}
manager.diagnosticLog(
"erp_response method=" + request.Method +
" path=" + request.URL.EscapedPath() +
" status=" + strconv.Itoa(response.StatusCode) +
" content_type=" +
diagnosticContentType(response.Header.Get("Content-Type")) +
" bytes=" + byteCount + " body=omitted_binary",
)
}
var _ usecase.FreightImageSource = (*SessionManager)(nil)
@@ -0,0 +1,190 @@
package shunyunbao
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cmroubao/backend-api/internal/domain"
)
func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint(
t *testing.T,
) {
var requestPath, rawQuery, accept string
manager, closeServer := imageSessionManager(
t,
func(writer http.ResponseWriter, request *http.Request) {
requestPath = request.URL.Path
rawQuery = request.URL.RawQuery
accept = request.Header.Get("Accept")
if cookie, err := request.Cookie("authenticated"); err != nil ||
cookie.Value != "yes" {
t.Fatalf("image cookie = %v, %v", cookie, err)
}
writer.Header().Set("Content-Type", "image/png")
writer.Header().Set("Content-Length", "4")
_, _ = writer.Write([]byte("png!"))
},
)
defer closeServer()
image, err := manager.FetchProductImage(context.Background(), "190")
if err != nil {
t.Fatalf("FetchProductImage() error = %v", err)
}
defer image.Content.Close()
content, err := io.ReadAll(image.Content)
if err != nil {
t.Fatalf("read image: %v", err)
}
if requestPath != ProductImagePath || rawQuery != "id=190" ||
image.MediaType != "image/png" || string(content) != "png!" ||
!strings.Contains(accept, "image/jpeg") {
t.Fatalf(
"image request/result = %q / %q / %q / %q / %q",
requestPath,
rawQuery,
accept,
image.MediaType,
content,
)
}
}
func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) {
manager, closeServer := imageSessionManager(
t,
func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Query().Get("id") {
case "190":
writer.WriteHeader(http.StatusNotFound)
case "191":
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"status":false}`))
case "192":
writer.Header().Set("Location", ProductImagePath+"?id=190")
writer.WriteHeader(http.StatusFound)
case "193":
writer.Header().Set(
"Content-Type",
"image/jpeg",
)
writer.Header().Set(
"Content-Length",
"20971521",
)
default:
writer.WriteHeader(http.StatusUnauthorized)
}
},
)
defer closeServer()
tests := []struct {
ref string
want error
}{
{"190", domain.ErrFreightImageNotFound},
{"191", domain.ErrFreightImageInvalid},
{"192", domain.ErrFreightSourceUnavailable},
{"193", domain.ErrFreightImageInvalid},
{"194", domain.ErrFreightSourceSessionNeeded},
}
for _, test := range tests {
image, err := manager.FetchProductImage(
context.Background(),
test.ref,
)
if image.Content != nil {
image.Content.Close()
}
if !errors.Is(err, test.want) {
t.Fatalf("FetchProductImage(%q) error = %v", test.ref, err)
}
}
if _, err := manager.FetchProductImage(
context.Background(),
"195",
); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
t.Fatalf("request after unauthorized error = %v", err)
}
}
func TestSessionManagerFetchProductImageRejectsUntrustedReferences(
t *testing.T,
) {
manager := testSessionManager(t, "http://127.0.0.1:1", "user", "password")
for _, value := range []string{
"",
"0",
"0190",
"-1",
"1.5",
"https://invalid.example/image",
} {
if _, err := manager.FetchProductImage(
context.Background(),
value,
); !errors.Is(err, domain.ErrFreightImageInvalid) {
t.Fatalf("FetchProductImage(%q) error = %v", value, err)
}
}
}
func imageSessionManager(
t *testing.T,
imageHandler http.HandlerFunc,
) (*SessionManager, func()) {
t.Helper()
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,"username":"test-user"}}}`,
),
)
case UserPath:
_, _ = writer.Write(
[]byte(
`{"status":true,"data":{"id":1,"username":"test-user"}}`,
),
)
case ProductImagePath:
imageHandler(writer, request)
default:
writer.WriteHeader(http.StatusNotFound)
}
}))
manager := testSessionManager(
t,
server.URL,
"test-user",
"test-password",
)
loginForSource(t, manager)
return manager, server.Close
}