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
@@ -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
}