文件上传成功
This commit is contained in:
@@ -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: <uploadToken>`
|
||||
|
||||
## 生成步骤
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<nav class="nav" id="main-nav"></nav>
|
||||
</header>
|
||||
|
||||
<p class="muted-note">FileAuth.java 生产 token:<strong>scope=仅 bucket 名</strong>。上传为 PDF 二进制体(<code>Content-Type: application/pdf</code>),不用 multipart。403 且 body 为 <code>404</code> 表示 token 验签失败。</p>
|
||||
<p class="muted-note">FileAuth.java 生产 token:<strong>scope=仅 bucket 名</strong>。上传为 §2.3.4 <code>multipart/form-data</code> 表单(字段 <code>file</code>),请求头 <code>X-Authorization</code>。403 且 body 为 <code>404</code> 表示 token 验签失败。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<label>PDF 文件 <input type="file" id="file" accept=".pdf,application/pdf"></label>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user