fix(t244): accept headerless ERP images safely
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
package shunyunbao
|
package shunyunbao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -15,6 +19,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
ProductImagePath = "/api/p/file"
|
ProductImagePath = "/api/p/file"
|
||||||
maxProductImageBytes = 20 << 20
|
maxProductImageBytes = 20 << 20
|
||||||
|
productImageSniffLen = 512
|
||||||
)
|
)
|
||||||
|
|
||||||
func (manager *SessionManager) FetchProductImage(
|
func (manager *SessionManager) FetchProductImage(
|
||||||
@@ -86,11 +91,17 @@ func (manager *SessionManager) FetchProductImage(
|
|||||||
domain.ErrFreightSourceUnavailable
|
domain.ErrFreightSourceUnavailable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mediaType, _, err := mime.ParseMediaType(
|
if response.ContentLength > maxProductImageBytes {
|
||||||
|
response.Body.Close()
|
||||||
|
manager.logERPBinaryResponse(request, response)
|
||||||
|
return usecase.FreightSourceImage{},
|
||||||
|
domain.ErrFreightImageInvalid
|
||||||
|
}
|
||||||
|
mediaType, content, err := productImageContent(
|
||||||
response.Header.Get("Content-Type"),
|
response.Header.Get("Content-Type"),
|
||||||
|
response.Body,
|
||||||
)
|
)
|
||||||
if err != nil || !supportedProductImageType(mediaType) ||
|
if err != nil {
|
||||||
response.ContentLength > maxProductImageBytes {
|
|
||||||
response.Body.Close()
|
response.Body.Close()
|
||||||
manager.logERPBinaryResponse(request, response)
|
manager.logERPBinaryResponse(request, response)
|
||||||
return usecase.FreightSourceImage{},
|
return usecase.FreightSourceImage{},
|
||||||
@@ -98,11 +109,64 @@ func (manager *SessionManager) FetchProductImage(
|
|||||||
}
|
}
|
||||||
manager.logERPBinaryResponse(request, response)
|
manager.logERPBinaryResponse(request, response)
|
||||||
return usecase.FreightSourceImage{
|
return usecase.FreightSourceImage{
|
||||||
Content: response.Body,
|
Content: content,
|
||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func productImageContent(
|
||||||
|
contentType string,
|
||||||
|
body io.ReadCloser,
|
||||||
|
) (string, io.ReadCloser, error) {
|
||||||
|
contentType = strings.TrimSpace(contentType)
|
||||||
|
if contentType != "" {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
mediaType = strings.ToLower(mediaType)
|
||||||
|
if supportedProductImageType(mediaType) {
|
||||||
|
return mediaType, body, nil
|
||||||
|
}
|
||||||
|
if mediaType != "application/octet-stream" {
|
||||||
|
return "", nil, domain.ErrFreightImageInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReaderSize(body, productImageSniffLen)
|
||||||
|
prefix, err := reader.Peek(productImageSniffLen)
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(prefix) == 0 {
|
||||||
|
return "", nil, domain.ErrFreightImageInvalid
|
||||||
|
}
|
||||||
|
mediaType := detectProductImageType(prefix)
|
||||||
|
if !supportedProductImageType(mediaType) {
|
||||||
|
return "", nil, domain.ErrFreightImageInvalid
|
||||||
|
}
|
||||||
|
return mediaType, struct {
|
||||||
|
io.Reader
|
||||||
|
io.Closer
|
||||||
|
}{
|
||||||
|
Reader: reader,
|
||||||
|
Closer: body,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectProductImageType(prefix []byte) string {
|
||||||
|
mediaType := strings.ToLower(http.DetectContentType(prefix))
|
||||||
|
if supportedProductImageType(mediaType) {
|
||||||
|
return mediaType
|
||||||
|
}
|
||||||
|
if len(prefix) >= 12 &&
|
||||||
|
bytes.Equal(prefix[:4], []byte("RIFF")) &&
|
||||||
|
bytes.Equal(prefix[8:12], []byte("WEBP")) {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
return mediaType
|
||||||
|
}
|
||||||
|
|
||||||
func supportedProductImageType(value string) bool {
|
func supportedProductImageType(value string) bool {
|
||||||
switch strings.ToLower(value) {
|
switch strings.ToLower(value) {
|
||||||
case "image/jpeg", "image/png", "image/webp":
|
case "image/jpeg", "image/png", "image/webp":
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package shunyunbao
|
package shunyunbao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -10,6 +14,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmroubao/backend-api/internal/domain"
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/platform/assetstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint(
|
func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint(
|
||||||
@@ -56,6 +61,219 @@ func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionManagerFetchProductImageSniffsMissingContentType(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
body := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
|
||||||
|
manager, closeServer := imageSessionManager(
|
||||||
|
t,
|
||||||
|
func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write(body)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer closeServer()
|
||||||
|
|
||||||
|
image, err := manager.FetchProductImage(context.Background(), "190")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchProductImage() error = %v", err)
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(image.Content)
|
||||||
|
if closeErr := image.Content.Close(); closeErr != nil {
|
||||||
|
t.Fatalf("close image content: %v", closeErr)
|
||||||
|
}
|
||||||
|
if err != nil || image.MediaType != "image/jpeg" ||
|
||||||
|
!bytes.Equal(content, body) {
|
||||||
|
t.Fatalf(
|
||||||
|
"image = %q / %x / %v",
|
||||||
|
image.MediaType,
|
||||||
|
content,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingContentTypeImageCanBeNormalizedAndStored(t *testing.T) {
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
source := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||||
|
source.Set(0, 0, color.RGBA{R: 255, A: 255})
|
||||||
|
source.Set(1, 0, color.RGBA{G: 255, A: 255})
|
||||||
|
source.Set(0, 1, color.RGBA{B: 255, A: 255})
|
||||||
|
source.Set(1, 1, color.RGBA{R: 255, G: 255, A: 255})
|
||||||
|
if err := jpeg.Encode(
|
||||||
|
&encoded,
|
||||||
|
source,
|
||||||
|
&jpeg.Options{Quality: 90},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("jpeg.Encode() error = %v", err)
|
||||||
|
}
|
||||||
|
manager, closeServer := imageSessionManager(
|
||||||
|
t,
|
||||||
|
func(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = writer.Write(encoded.Bytes())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer closeServer()
|
||||||
|
|
||||||
|
sourceImage, err := manager.FetchProductImage(
|
||||||
|
context.Background(),
|
||||||
|
"190",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchProductImage() error = %v", err)
|
||||||
|
}
|
||||||
|
defer sourceImage.Content.Close()
|
||||||
|
files, err := assetstore.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("assetstore.New() error = %v", err)
|
||||||
|
}
|
||||||
|
stored, err := files.Put(
|
||||||
|
context.Background(),
|
||||||
|
"00000000-0000-4000-8000-000000000190",
|
||||||
|
sourceImage.MediaType,
|
||||||
|
sourceImage.Content,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put() error = %v", err)
|
||||||
|
}
|
||||||
|
if sourceImage.MediaType != "image/jpeg" ||
|
||||||
|
stored.MediaType != domain.NormalizedImageMediaType ||
|
||||||
|
stored.SizeBytes <= 0 || stored.StorageKey == "" {
|
||||||
|
t.Fatalf("stored image = %+v / %q", stored, sourceImage.MediaType)
|
||||||
|
}
|
||||||
|
content, err := files.Open(context.Background(), stored.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open() error = %v", err)
|
||||||
|
}
|
||||||
|
defer content.Close()
|
||||||
|
_, format, err := image.Decode(content)
|
||||||
|
if err != nil || format != "jpeg" {
|
||||||
|
t.Fatalf("stored format = %q, %v", format, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductImageContentSniffsGenericBinaryAndPreservesStream(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentType string
|
||||||
|
body []byte
|
||||||
|
wantType string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing JPEG",
|
||||||
|
body: []byte{0xff, 0xd8, 0xff, 0xe1, 'j', 'p', 'e', 'g'},
|
||||||
|
wantType: "image/jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic PNG",
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
body: []byte{
|
||||||
|
0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 'p', 'n', 'g',
|
||||||
|
},
|
||||||
|
wantType: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic WebP",
|
||||||
|
contentType: "APPLICATION/OCTET-STREAM",
|
||||||
|
body: []byte{
|
||||||
|
'R', 'I', 'F', 'F', 0x04, 0x00, 0x00, 0x00,
|
||||||
|
'W', 'E', 'B', 'P',
|
||||||
|
},
|
||||||
|
wantType: "image/webp",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
body := &trackingImageBody{
|
||||||
|
Reader: bytes.NewReader(test.body),
|
||||||
|
}
|
||||||
|
mediaType, content, err := productImageContent(
|
||||||
|
test.contentType,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("productImageContent() error = %v", err)
|
||||||
|
}
|
||||||
|
got, err := io.ReadAll(content)
|
||||||
|
if err != nil || mediaType != test.wantType ||
|
||||||
|
!bytes.Equal(got, test.body) {
|
||||||
|
t.Fatalf(
|
||||||
|
"content = %q / %x / %v",
|
||||||
|
mediaType,
|
||||||
|
got,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := content.Close(); err != nil {
|
||||||
|
t.Fatalf("Close() error = %v", err)
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Fatal("source body was not closed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductImageContentRejectsUntrustedFallbacks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentType string
|
||||||
|
body []byte
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "explicit JSON overrides image bytes",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: []byte{0xff, 0xd8, 0xff},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing JSON",
|
||||||
|
body: []byte(`{"status":false}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic HTML",
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
body: []byte("<!doctype html><title>error</title>"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generic SVG",
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
body: []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed media type",
|
||||||
|
contentType: "image/jpeg; invalid",
|
||||||
|
body: []byte{0xff, 0xd8, 0xff},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
body := &trackingImageBody{
|
||||||
|
Reader: bytes.NewReader(test.body),
|
||||||
|
}
|
||||||
|
_, content, err := productImageContent(
|
||||||
|
test.contentType,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
if err == nil || content != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"productImageContent() = %#v, %v",
|
||||||
|
content,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if body.closed {
|
||||||
|
t.Fatal("helper unexpectedly closed source body")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) {
|
func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) {
|
||||||
manager, closeServer := imageSessionManager(
|
manager, closeServer := imageSessionManager(
|
||||||
t,
|
t,
|
||||||
@@ -115,6 +333,16 @@ func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type trackingImageBody struct {
|
||||||
|
*bytes.Reader
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (body *trackingImageBody) Close() error {
|
||||||
|
body.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionManagerFetchProductImageRejectsUntrustedReferences(
|
func TestSessionManagerFetchProductImageRejectsUntrustedReferences(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-29
|
- 日期:2026-07-29
|
||||||
- 阶段:T-243 已完成货运删除的采购需求草稿边界修复
|
- 阶段:T-244 已完成 ERP 图片缺失媒体类型兼容
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
`FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情
|
`FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情
|
||||||
`created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存
|
`created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存
|
||||||
`productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权
|
`productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权
|
||||||
本地路由、货运列表和详情中显示图片、状态与采购字段。T-242/T-243 允许 ADMIN 从
|
本地路由、货运列表和详情中显示图片、状态与采购字段。T-244 对缺失 `Content-Type`
|
||||||
|
或通用二进制的 ERP 图片使用 512 字节受控嗅探,完整解码和 20 MB 限制不变。
|
||||||
|
T-242/T-243 允许 ADMIN 从
|
||||||
详情页删除本地货运快照、缓存图以及无参考图/任务的采购需求草稿;已绑定参考图或已生成
|
详情页删除本地货运快照、缓存图以及无参考图/任务的采购需求草稿;已绑定参考图或已生成
|
||||||
采购任务时分别原子拒绝,不修改 ERP、同步运行历史或增量水位,完整订单号可重新导入。
|
采购任务时分别原子拒绝,不修改 ERP、同步运行历史或增量水位,完整订单号可重新导入。
|
||||||
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
|
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||||
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
||||||
- 后端测试:T-226 至 T-243 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
|
- 后端测试:T-226 至 T-244 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
|
||||||
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
|
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
|
||||||
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
||||||
验证也通过,均未访问真实 ERP;
|
验证也通过,均未访问真实 ERP;
|
||||||
@@ -60,7 +62,8 @@
|
|||||||
- 货运管理展示:列表按外部商品 ID 确定性展示第一条当前商品的 56px 代表图、标题和
|
- 货运管理展示:列表按外部商品 ID 确定性展示第一条当前商品的 56px 代表图、标题和
|
||||||
总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、
|
总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、
|
||||||
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
|
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
|
||||||
破图、遮挡和控制台错误。
|
破图、遮挡和控制台错误。ERP 缺失图片媒体类型时,JPEG/PNG 使用标准嗅探,WebP 使用
|
||||||
|
严格文件签名,再由 asset store 完整验证并保存为本地归一化 JPEG。
|
||||||
- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE;
|
- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE;
|
||||||
删除事务覆盖全部历史商品并清理无引用的 `BLOCKED`、`NEEDS_IMAGE`、`SOURCE_CHANGED`
|
删除事务覆盖全部历史商品并清理无引用的 `BLOCKED`、`NEEDS_IMAGE`、`SOURCE_CHANGED`
|
||||||
采购草稿;已绑定参考图或采购任务分别返回稳定冲突,任务优先。提交后以有界预算清理
|
采购草稿;已绑定参考图或采购任务分别返回稳定冲突,任务优先。提交后以有界预算清理
|
||||||
|
|||||||
+21
-10
@@ -4,7 +4,7 @@ title: 兼容 ERP 图片响应缺少媒体类型
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-241
|
- T-241
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-29
|
created: 2026-07-29
|
||||||
context_ref: 24eef29
|
context_ref: 24eef29
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -32,7 +32,8 @@ write_paths:
|
|||||||
|
|
||||||
- HTTP 2xx 且 `Content-Type` 为 `image/jpeg`、`image/png`、`image/webp` 时沿用现有路径。
|
- HTTP 2xx 且 `Content-Type` 为 `image/jpeg`、`image/png`、`image/webp` 时沿用现有路径。
|
||||||
- `Content-Type` 缺失或规范化后为 `application/octet-stream` 时,只读取正文前最多
|
- `Content-Type` 缺失或规范化后为 `application/octet-stream` 时,只读取正文前最多
|
||||||
512 字节并使用 Go 标准库 `http.DetectContentType` 判定真实类型。
|
512 字节;JPEG/PNG 使用 Go 标准库 `http.DetectContentType`,WebP 使用严格
|
||||||
|
`RIFF....WEBP` 文件签名判定。
|
||||||
- 嗅探只接受 JPEG、PNG、WebP;空正文、JSON、HTML、SVG、未知二进制继续返回
|
- 嗅探只接受 JPEG、PNG、WebP;空正文、JSON、HTML、SVG、未知二进制继续返回
|
||||||
`ErrFreightImageInvalid`。
|
`ErrFreightImageInvalid`。
|
||||||
- 已读取的前缀必须无损拼回返回流,后续本地存储继续执行完整解码、声明类型一致性、
|
- 已读取的前缀必须无损拼回返回流,后续本地存储继续执行完整解码、声明类型一致性、
|
||||||
@@ -55,14 +56,14 @@ write_paths:
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] 缺少 `Content-Type` 的有效 JPEG 保存为本系统归一化 JPEG,图片状态为 `READY`。
|
- [x] 缺少 `Content-Type` 的有效 JPEG 保存为本系统归一化 JPEG,图片状态为 `READY`。
|
||||||
- [ ] `application/octet-stream` 的有效 PNG/WebP 可以进入既有解码与存储路径。
|
- [x] `application/octet-stream` 的有效 PNG/WebP 可以进入既有解码与存储路径。
|
||||||
- [ ] 嗅探不会丢失正文前缀,完整内容只读取一次并最终关闭 ERP 响应 body。
|
- [x] 嗅探不会丢失正文前缀,完整内容只读取一次并最终关闭 ERP 响应 body。
|
||||||
- [ ] JSON、HTML、SVG、空正文、未知二进制和明确不支持的媒体类型仍被拒绝。
|
- [x] JSON、HTML、SVG、空正文、未知二进制和明确不支持的媒体类型仍被拒绝。
|
||||||
- [ ] 已知或流式超限图片仍受 20 MB 限制,不能绕过 asset store。
|
- [x] 已知或流式超限图片仍受 20 MB 限制,不能绕过 asset store。
|
||||||
- [ ] 401/403、404、非 2xx、会话清理、固定 URL 和数字 ID 校验不回归。
|
- [x] 401/403、404、非 2xx、会话清理、固定 URL 和数字 ID 校验不回归。
|
||||||
- [ ] 货运同步仍为图片 best-effort,不因单张图片失败回滚业务数据。
|
- [x] 货运同步仍为图片 best-effort,不因单张图片失败回滚业务数据。
|
||||||
- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。
|
- [x] 标准 Go 测试、race、vet 和三个入口构建通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -78,3 +79,13 @@ write_paths:
|
|||||||
`ERP_IMAGE_INVALID`;受控响应探测确认 HTTP 200 正文为有效 JPEG,但
|
`ERP_IMAGE_INVALID`;受控响应探测确认 HTTP 200 正文为有效 JPEG,但
|
||||||
`Content-Type` 和 `Content-Length` 均缺失。根因位于 source adapter 的响应头强校验,
|
`Content-Type` 和 `Content-Length` 均缺失。根因位于 source adapter 的响应头强校验,
|
||||||
不是 ERP 鉴权、数字图片 ID、图片不存在、本地目录或图片体积。
|
不是 ERP 鉴权、数字图片 ID、图片不存在、本地目录或图片体积。
|
||||||
|
- 2026-07-29:适配器仅对缺失媒体类型或 `application/octet-stream` 响应使用固定
|
||||||
|
512 字节 buffered peek;支持头直接沿用,明确 JSON/HTML/SVG 等类型保持 fail-closed。
|
||||||
|
成功流组合 buffered reader 与原 body closer,既不丢前缀,也不复制完整图片。
|
||||||
|
- 2026-07-29:测试确认 Go 1.23 的 `http.DetectContentType` 不识别 WebP,因此增加
|
||||||
|
严格 `RIFF....WEBP` 前缀检测;asset store 随后仍执行完整 WebP 解码、媒体类型一致性、
|
||||||
|
尺寸/像素与 20 MB 校验。端到端测试已将缺失响应头的有效 JPEG 归一化保存并重新解码。
|
||||||
|
- 2026-07-29:`go test ./...`、`go test -race ./...`、`go vet ./...` 和 API、
|
||||||
|
authctl、migrate 三个入口构建通过。测试覆盖缺失/通用媒体类型、JPEG/PNG/WebP、
|
||||||
|
前缀完整性、body 关闭语义、明确非图片、嗅探非图片、已知超限、401/403、404 与非 2xx;
|
||||||
|
自动化验证未访问真实 ERP。
|
||||||
|
|||||||
Reference in New Issue
Block a user