From 5703b93a6601cd1da64aaa18d56886fa89716b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 4 Jun 2026 08:15:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E6=88=90?= =?UTF-8?q?=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FILEAUTH.md | 2 +- internal/fileauth/upload.go | 2 +- internal/hyfile/upload.go | 44 +++++++++++++++++++++---- internal/hyfile/upload_test.go | 58 +++++++++++++++++++++++++-------- internal/logweb/web/upload.html | 2 +- internal/sync/sync.go | 2 +- 6 files changed, 87 insertions(+), 23 deletions(-) diff --git a/docs/FILEAUTH.md b/docs/FILEAUTH.md index faeea75..01d2c10 100644 --- a/docs/FILEAUTH.md +++ b/docs/FILEAUTH.md @@ -10,7 +10,7 @@ - `scope`:**仅 bucket 名**(如 `other-doc`),**不要** `bucket:filename` - **无** `returnBody` -- HTTP 上传为 **PDF 二进制体**(`Content-Type: application/pdf`),**不使用** multipart +- HTTP 上传为 **§2.3.4 表单上传**:`multipart/form-data`,字段名 **`file`**,请求头 `X-Authorization: ` ## 生成步骤 diff --git a/internal/fileauth/upload.go b/internal/fileauth/upload.go index c3092cf..a31209a 100644 --- a/internal/fileauth/upload.go +++ b/internal/fileauth/upload.go @@ -109,7 +109,7 @@ func BuildUploadTokenPartsForUpload(accessKey, secret, bucket string, deadlineUn return parts, nil } -// BuildUploadTokenPartsAtFile filename 不参与 scope(HTTP 为 PDF 二进制体,非 multipart)。 +// BuildUploadTokenPartsAtFile filename 不参与 scope(token policy scope=仅 bucket)。 func BuildUploadTokenPartsAtFile(accessKey, secret, bucket, filename string, deadlineUnix int64) (UploadTokenParts, error) { _ = filename return BuildUploadTokenPartsForUpload(accessKey, secret, bucket, deadlineUnix) diff --git a/internal/hyfile/upload.go b/internal/hyfile/upload.go index 8a3d312..87f6b24 100644 --- a/internal/hyfile/upload.go +++ b/internal/hyfile/upload.go @@ -1,8 +1,8 @@ // Package hyfile 处方监管 PDF 生成与上传。 // // HtmlToPDF:chromedp 将 xk-api 打印 HTML 转为 PDF。 -// UploadPDF:POST PDF 二进制体(Content-Type: application/pdf),Header X-Authorization 为 uploadToken; -// 不使用 multipart/form-data。默认经 forward-go /mng/file/auth/upload 转发至政务云 28211。 +// UploadPDF:§2.3.4 multipart/form-data 表单上传(字段 file),Header X-Authorization 为 uploadToken; +// 默认经 forward-go /mng/file/auth/upload 转发至政务云 28211。 package hyfile import ( @@ -11,12 +11,17 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "strings" "sync" "time" ) +const uploadFormFieldFile = "file" +const defaultUploadFilename = "prescription.pdf" + var ( uploadInsecureSkipVerify bool uploadMu sync.RWMutex @@ -51,10 +56,9 @@ func uploadHTTPClient() *http.Client { // // uploadURL:FILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。 // uploadToken:transit 按 FileAuth.java 本地生成(scope=仅 bucket),放在 X-Authorization 请求头。 -// filename 仅用于本机落盘命名,不参与 HTTP 请求体。 +// filename:multipart 表单 file 字段文件名;空时默认 prescription.pdf。 // forwardToken:非空时设置 X-Forward-Token(与 forward-go FORWARD_SHARED_SECRET 对应)。 func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string) (string, error) { - _ = filename if len(pdf) == 0 { return "", fmt.Errorf("empty pdf") } @@ -65,11 +69,16 @@ func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string return "", fmt.Errorf("upload token empty") } - req, err := http.NewRequest(http.MethodPost, uploadURL, bytes.NewReader(pdf)) + contentType, body, err := buildMultipartPDFBody(pdf, filename) if err != nil { return "", err } - req.Header.Set("Content-Type", "application/pdf") + + req, err := http.NewRequest(http.MethodPost, uploadURL, body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", contentType) req.Header.Set("X-Authorization", uploadToken) if forwardToken != "" { req.Header.Set("X-Forward-Token", forwardToken) @@ -111,6 +120,29 @@ func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string return fileID, nil } +func buildMultipartPDFBody(pdf []byte, filename string) (contentType string, body io.Reader, err error) { + filename = strings.TrimSpace(filename) + if filename == "" { + filename = defaultUploadFilename + } + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + partHeader := make(textproto.MIMEHeader) + partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, uploadFormFieldFile, filename)) + partHeader.Set("Content-Type", "application/pdf") + part, err := w.CreatePart(partHeader) + if err != nil { + return "", nil, fmt.Errorf("multipart file part: %w", err) + } + if _, err := part.Write(pdf); err != nil { + return "", nil, fmt.Errorf("multipart write: %w", err) + } + if err := w.Close(); err != nil { + return "", nil, fmt.Errorf("multipart close: %w", err) + } + return w.FormDataContentType(), bytes.NewReader(buf.Bytes()), nil +} + func formatUploadHTTPError(status int, raw []byte, uploadURL string) error { body := strings.TrimSpace(string(truncate(raw, 512))) msg := fmt.Sprintf("upload http %d", status) diff --git a/internal/hyfile/upload_test.go b/internal/hyfile/upload_test.go index 75ce449..9a04ce5 100644 --- a/internal/hyfile/upload_test.go +++ b/internal/hyfile/upload_test.go @@ -18,6 +18,8 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { ResetUploadThrottleForTest() const wantToken = "accessKey:sign:policyB64" + const wantFilename = "test.pdf" + pdf := []byte("%PDF-1.4 test") var gotAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -25,12 +27,24 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { if r.Method != http.MethodPost { t.Errorf("method=%s", r.Method) } - if r.Header.Get("Content-Type") != "application/pdf" { - t.Errorf("content-type=%q want application/pdf", r.Header.Get("Content-Type")) + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "multipart/form-data") { + t.Errorf("content-type=%q want multipart/form-data", ct) } - body, _ := io.ReadAll(r.Body) - if !strings.HasPrefix(string(body), "%PDF") { - t.Errorf("body should be raw pdf, got %q", truncateStr(string(body), 32)) + if err := r.ParseMultipartForm(32 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + f, hdr, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile(file): %v", err) + } + defer f.Close() + if hdr.Filename != wantFilename { + t.Errorf("filename=%q want %q", hdr.Filename, wantFilename) + } + got, _ := io.ReadAll(f) + if string(got) != string(pdf) { + t.Errorf("file body=%q want %q", got, pdf) } _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, @@ -39,7 +53,7 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { })) defer srv.Close() - fileID, err := UploadPDF([]byte("%PDF-1.4 test"), "", srv.URL, wantToken, "") + fileID, err := UploadPDF(pdf, wantFilename, srv.URL, wantToken, "") if err != nil { t.Fatalf("UploadPDF: %v", err) } @@ -51,6 +65,31 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { } } +func TestUploadPDF_defaultFilename(t *testing.T) { + disableUploadThrottle(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseMultipartForm(32 << 20) + _, hdr, err := r.FormFile("file") + if err != nil { + t.Fatalf("FormFile: %v", err) + } + if hdr.Filename != defaultUploadFilename { + t.Fatalf("filename=%q want %q", hdr.Filename, defaultUploadFilename) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "record": map[string]string{"fileId": "id"}, + }) + })) + defer srv.Close() + + _, err := UploadPDF([]byte("%PDF"), "", srv.URL, "tok", "") + if err != nil { + t.Fatal(err) + } +} + func TestUploadPDF_forbiddenForward(t *testing.T) { disableUploadThrottle(t) @@ -104,13 +143,6 @@ func TestUploadPDF_forbiddenGovRateLimitHint(t *testing.T) { } } -func truncateStr(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] -} - func disableUploadThrottle(t *testing.T) { t.Helper() SetUploadMinInterval(0) diff --git a/internal/logweb/web/upload.html b/internal/logweb/web/upload.html index 7dc4014..4c48ef8 100644 --- a/internal/logweb/web/upload.html +++ b/internal/logweb/web/upload.html @@ -18,7 +18,7 @@ -

FileAuth.java 生产 token:scope=仅 bucket 名。上传为 PDF 二进制体(Content-Type: application/pdf),不用 multipart。403 且 body 为 404 表示 token 验签失败。

+

FileAuth.java 生产 token:scope=仅 bucket 名。上传为 §2.3.4 multipart/form-data 表单(字段 file),请求头 X-Authorization。403 且 body 为 404 表示 token 验签失败。

diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 06a6ecf..0ec2ea2 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -325,7 +325,7 @@ func (r *Runner) ensureRecipeFileID(item xkapi.PullItem, payload map[string]any) applog.Pushf("saved local pdf: %s", saved) } - // 步骤 4:经 forward-go 上传至政务云文件服务,得到 recipeFileId(token scope 含实际上传文件名) + // 步骤 4:经 forward-go 上传至政务云文件服务,得到 recipeFileId(§2.3.4 multipart 字段 file) filename := hyfile.BuildPDFFilename(pdfName) token, err := r.uploadTokenForFile(filename) if err != nil {