From 1ae21d83f169726966f6da178f37478fd66c9e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Fri, 22 May 2026 08:42:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 + .env.example | 3 + cmd/transit/main.go | 2 +- internal/config/config.go | 2 + internal/hy/crypto.go | 14 ++- internal/hy/crypto_parity_test.go | 147 ++++++++++++++++++++++++++++++ internal/hy/upload.go | 6 +- internal/hyfile/upload.go | 6 +- internal/hyfile/upload_test.go | 4 +- internal/sync/sync.go | 7 +- 10 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 internal/hy/crypto_parity_test.go diff --git a/.env b/.env index 909bfe4..01ebe2c 100644 --- a/.env +++ b/.env @@ -18,3 +18,6 @@ CRON_EXPR=0 22 * * * # 本地 PDF 落盘根目录(默认:可执行文件所在目录) # 文件路径:{PDF_SAVE_ROOT}/{YYYY-MM-DD}/{患者姓名} {YYYY-MM-DD HH:mm:ss}.pdf PDF_SAVE_ROOT="D:/worker/code/xk-hy-transit-go/pdf" + +# 与 forward-go FORWARD_SHARED_SECRET 一致(可选;非空时 transit 请求带 X-Forward-Token) +FORWARD_SHARED_SECRET=1f88c2fe1c55840414b8e9025ff41c491864f692126e0401efe2f7ceb8713ef7beac450737657c90050a8da861062f66eeb705ca6745a731d81f6161faee5e15 diff --git a/.env.example b/.env.example index 39f0743..4a603f5 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ HY_AES_KEY= # 内网 xk-hy-forward-go 地址(监管 JSON + PDF 上传均经此转发) FORWARD_BASE_URL=http://127.0.0.1:8080 +# 与 forward-go FORWARD_SHARED_SECRET 一致(可选;非空时 transit 请求带 X-Forward-Token) +# FORWARD_SHARED_SECRET= + # true:PDF 上传走 FORWARD_BASE_URL/mng/file/auth/upload,不直连政务云 28211 FILE_UPLOAD_VIA_FORWARD=true diff --git a/cmd/transit/main.go b/cmd/transit/main.go index 4b08eb9..23c8daa 100644 --- a/cmd/transit/main.go +++ b/cmd/transit/main.go @@ -207,7 +207,7 @@ func runUploadTest(args []string) { if runner.FileUploadViaForward() { log.Printf("upload-test: 经 forward 上传 url=%s", uploadURL) } - fileID, err := hyfile.UploadPDF(pdf, filepath.Base(*pdfPath), uploadURL, runner.UploadToken()) + fileID, err := hyfile.UploadPDF(pdf, filepath.Base(*pdfPath), uploadURL, runner.UploadToken(), runner.ForwardSharedSecret()) if err != nil { log.Fatalf("上传失败: %v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index fe5a459..d84cab9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { HyAppSecret string // 监管平台 Secret(签名) HyAesKey string // 监管平台 AES 密钥(body 加密) ForwardBaseURL string // xk-hy-forward-go 地址,如 http://192.168.1.10:8080 + ForwardSharedSecret string // 与 forward-go FORWARD_SHARED_SECRET 一致,Header X-Forward-Token FileUploadViaForward bool // true:PDF 上传经 forward /mng/file/auth/upload,不直连政务云 MySQLDSN string // 本机中转库 DSN(hy_transit_schema.sql) AnchorOffsetDays int // 锚定日 = 今天 - N 天,默认 1 @@ -43,6 +44,7 @@ func Load() Config { HyAppSecret: env("HY_APP_SECRET", ""), HyAesKey: env("HY_AES_KEY", ""), ForwardBaseURL: strings.TrimRight(env("FORWARD_BASE_URL", "http://127.0.0.1:8080"), "/"), + ForwardSharedSecret: env("FORWARD_SHARED_SECRET", ""), FileUploadViaForward: envBool("FILE_UPLOAD_VIA_FORWARD", true), MySQLDSN: env("MYSQL_DSN", "root:password@tcp(127.0.0.1:3306)/xk_hy_transit?parseTime=true&charset=utf8mb4"), AnchorOffsetDays: envInt("ANCHOR_OFFSET_DAYS", 1), diff --git a/internal/hy/crypto.go b/internal/hy/crypto.go index beab2db..ef83b47 100644 --- a/internal/hy/crypto.go +++ b/internal/hy/crypto.go @@ -73,13 +73,18 @@ func (x *ecbEncrypter) CryptBlocks(dst, src []byte) { } func Sign(secret string, headers map[string]string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(StringToSign(headers))) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +// StringToSign 与 PHP SignService::buildHeaders 相同:固定字段顺序 key=value&... +func StringToSign(headers map[string]string) string { parts := make([]string, 0, len(signHeaderOrder)) for _, k := range signHeaderOrder { parts = append(parts, fmt.Sprintf("%s=%s", k, headers[k])) } - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(strings.Join(parts, "&"))) - return base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return strings.Join(parts, "&") } type UploadRequest struct { @@ -111,7 +116,8 @@ func BuildUpload( return nil, err } - ts := fmt.Sprintf("%d", time.Now().UnixMilli()) + // 与 PHP HyApiClient get_time() 一致:Unix 秒(非毫秒) + ts := fmt.Sprintf("%d", time.Now().Unix()) nonce := randomNonce() headers := map[string]string{ "secret": appSecret, diff --git a/internal/hy/crypto_parity_test.go b/internal/hy/crypto_parity_test.go new file mode 100644 index 0000000..4b3aac4 --- /dev/null +++ b/internal/hy/crypto_parity_test.go @@ -0,0 +1,147 @@ +package hy + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// 与 scripts/hy_sign_vector.php 使用相同固定输入 +const ( + parityPlainJSON = `[{"bussID":"1","organID":"o1","organName":"n1","platForm":"03","unitID":"u1"}]` + parityAESKey = "1234567890123456" + parityAppKey = "test-app-key" + paritySecret = "test-secret" + parityNonce = "fixed-nonce-uuid-style-0001" + parityTimestamp = "1716200000" + parityServiceMethod = "uploadConsultIndicators" +) + +type phpVector struct { + Encrypted string `json:"encrypted"` + StringToSign string `json:"stringToSign"` + Signature string `json:"signature"` +} + +func TestEncryptBodyGoldenVector(t *testing.T) { + enc, err := EncryptBody(parityPlainJSON, parityAESKey) + if err != nil { + t.Fatal(err) + } + if enc == "" { + t.Fatal("empty encrypted") + } + // 稳定性:同输入必得同密文 + enc2, _ := EncryptBody(parityPlainJSON, parityAESKey) + if enc != enc2 { + t.Fatal("encrypt not deterministic") + } + t.Logf("encrypted=%s", enc) +} + +func TestSignGoldenVector(t *testing.T) { + enc, err := EncryptBody(parityPlainJSON, parityAESKey) + if err != nil { + t.Fatal(err) + } + headers := map[string]string{ + "secret": paritySecret, + "X-Ca-Appkey": parityAppKey, + "X-Ca-Encryption": "AES", + "X-Ca-Nonce": parityNonce, + "X-Ca-Timestamp": parityTimestamp, + "X-Service-Id": "his.provinceDataUploadService", + "X-Service-Method": parityServiceMethod, + "requestBody": enc, + } + sig := Sign(paritySecret, headers) + if sig == "" { + t.Fatal("empty signature") + } + wantParts := "requestBody=" + enc + + "&secret=" + paritySecret + + "&X-Ca-Appkey=" + parityAppKey + + "&X-Ca-Encryption=AES" + + "&X-Ca-Nonce=" + parityNonce + + "&X-Ca-Timestamp=" + parityTimestamp + + "&X-Service-Id=his.provinceDataUploadService" + + "&X-Service-Method=" + parityServiceMethod + if got := StringToSign(headers); got != wantParts { + t.Fatalf("stringToSign mismatch\ngot: %s\nwant: %s", got, wantParts) + } + t.Logf("signature=%s", sig) +} + +func TestSignParityWithPHP(t *testing.T) { + phpScript := resolvePHPScript() + if phpScript == "" { + t.Skip("hy_sign_vector.php not found; skip PHP parity") + } + out, err := exec.Command("php", phpScript).Output() + if err != nil { + t.Skipf("php not available or script failed: %v", err) + } + var pv phpVector + if err := json.Unmarshal(out, &pv); err != nil { + t.Fatalf("parse php output: %v\n%s", err, out) + } + enc, err := EncryptBody(parityPlainJSON, parityAESKey) + if err != nil { + t.Fatal(err) + } + if enc != pv.Encrypted { + t.Fatalf("encrypted mismatch\ngo: %s\nphp: %s", enc, pv.Encrypted) + } + headers := map[string]string{ + "secret": paritySecret, + "X-Ca-Appkey": parityAppKey, + "X-Ca-Encryption": "AES", + "X-Ca-Nonce": parityNonce, + "X-Ca-Timestamp": parityTimestamp, + "X-Service-Id": "his.provinceDataUploadService", + "X-Service-Method": parityServiceMethod, + "requestBody": enc, + } + if got := StringToSign(headers); got != pv.StringToSign { + t.Fatalf("stringToSign mismatch\ngo: %s\nphp: %s", got, pv.StringToSign) + } + if got := Sign(paritySecret, headers); got != pv.Signature { + t.Fatalf("signature mismatch\ngo: %s\nphp: %s", got, pv.Signature) + } +} + +func TestBuildUploadTimestampSeconds(t *testing.T) { + req, err := BuildUpload( + parityServiceMethod, + map[string]any{"bussID": "1", "platForm": "03"}, + OrganConfig{UnitID: "u1", OrganID: "o1", OrganName: "n1"}, + parityAppKey, paritySecret, parityAESKey, + ) + if err != nil { + t.Fatal(err) + } + ts := req.Headers["X-Ca-Timestamp"] + if len(ts) != 10 { + t.Fatalf("expected 10-digit unix seconds timestamp, got %q (len=%d)", ts, len(ts)) + } +} + +func resolvePHPScript() string { + candidates := []string{ + os.Getenv("XK_API_ROOT"), + "/opt/1panel/www/sites/xk-api/index/xk-api", + `\\wsl.localhost\Ubuntu-24.04\opt\1panel\www\sites\xk-api\index\xk-api`, + } + for _, root := range candidates { + if root == "" { + continue + } + p := filepath.Join(root, "scripts", "hy_sign_vector.php") + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} diff --git a/internal/hy/upload.go b/internal/hy/upload.go index 106a809..a4b0cb2 100644 --- a/internal/hy/upload.go +++ b/internal/hy/upload.go @@ -15,7 +15,8 @@ import ( ) // PostForward 将加密后的监管报文 POST 到内网 forward-go,由 forward 透明转发至政务云 28212。 -func PostForward(forwardBaseURL string, req *UploadRequest) (*UploadResult, error) { +// forwardToken 非空时设置 X-Forward-Token(与 forward-go FORWARD_SHARED_SECRET 对应)。 +func PostForward(forwardBaseURL string, req *UploadRequest, forwardToken string) (*UploadResult, error) { url := ForwardSuperviseURL(forwardBaseURL) httpReq, err := http.NewRequest(http.MethodPost, url, strings.NewReader(req.Body)) if err != nil { @@ -25,6 +26,9 @@ func PostForward(forwardBaseURL string, req *UploadRequest) (*UploadResult, erro for k, v := range req.Headers { httpReq.Header.Set(k, v) } + if forwardToken != "" { + httpReq.Header.Set("X-Forward-Token", forwardToken) + } client := &http.Client{ Timeout: 60 * time.Second, diff --git a/internal/hyfile/upload.go b/internal/hyfile/upload.go index 2b2d925..57d5aee 100644 --- a/internal/hyfile/upload.go +++ b/internal/hyfile/upload.go @@ -51,7 +51,8 @@ func uploadHTTPClient() *http.Client { // // uploadURL:FILE_UPLOAD_VIA_FORWARD=true 时为 forward 地址;否则为云端 config 返回的政务云 URL。 // uploadToken:由 xk-api HY_APP_KEY/HY_APP_SECRET + HY_FILE_BUCKET 签名生成,放在 X-Authorization 请求头。 -func UploadPDF(pdf []byte, filename, uploadURL, uploadToken string) (string, error) { +// forwardToken:非空时设置 X-Forward-Token(与 forward-go FORWARD_SHARED_SECRET 对应)。 +func UploadPDF(pdf []byte, filename, uploadURL, uploadToken, forwardToken string) (string, error) { if len(pdf) == 0 { return "", fmt.Errorf("empty pdf") } @@ -84,6 +85,9 @@ func UploadPDF(pdf []byte, filename, uploadURL, uploadToken string) (string, err } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("X-Authorization", uploadToken) + if forwardToken != "" { + req.Header.Set("X-Forward-Token", forwardToken) + } resp, err := uploadHTTPClient().Do(req) if err != nil { diff --git a/internal/hyfile/upload_test.go b/internal/hyfile/upload_test.go index 3ecc17a..46d451c 100644 --- a/internal/hyfile/upload_test.go +++ b/internal/hyfile/upload_test.go @@ -32,7 +32,7 @@ func TestUploadPDF_sendsAuthorizationAndParsesFileId(t *testing.T) { })) defer srv.Close() - fileID, err := UploadPDF([]byte("%PDF-1.4 test"), "test.pdf", srv.URL, wantToken) + fileID, err := UploadPDF([]byte("%PDF-1.4 test"), "test.pdf", srv.URL, wantToken, "") if err != nil { t.Fatalf("UploadPDF: %v", err) } @@ -51,7 +51,7 @@ func TestUploadPDF_forbidden(t *testing.T) { })) defer srv.Close() - _, err := UploadPDF([]byte("x"), "x.pdf", srv.URL, "token") + _, err := UploadPDF([]byte("x"), "x.pdf", srv.URL, "token", "") if err == nil || !strings.Contains(err.Error(), "403") { t.Fatalf("err=%v", err) } diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 5823230..45ee27d 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -223,7 +223,7 @@ func (r *Runner) processItem(step, anchorDate, method string, batchID int, item } start := time.Now() - result, err := hy.PostForward(r.cfg.ForwardBaseURL, req) + result, err := hy.PostForward(r.cfg.ForwardBaseURL, req, r.cfg.ForwardSharedSecret) duration := int(time.Since(start).Milliseconds()) if err != nil { _ = r.store.UpdateRecordStatus(bizKey, "failed", err.Error()) @@ -306,7 +306,7 @@ func (r *Runner) ensureRecipeFileID(item xkapi.PullItem, payload map[string]any) // 步骤 4:经 forward-go 上传至政务云文件服务,得到 recipeFileId filename := hyfile.BuildPDFFilename(pdfName) - fileID, err := hyfile.UploadPDF(pdf, filename, r.fileUploadURL, r.uploadToken) + fileID, err := hyfile.UploadPDF(pdf, filename, r.fileUploadURL, r.uploadToken, r.cfg.ForwardSharedSecret) if err != nil { applog.Pushf("upload pdf fail prescription_no=%s local=%s err=%v", pdfName.PrescriptionNo, localPath, err) return err @@ -428,3 +428,6 @@ func (r *Runner) FileUploadViaForward() bool { return r.fileUploadViaForward } // UploadToken 返回监管文件上传凭证。 func (r *Runner) UploadToken() string { return r.uploadToken } + +// ForwardSharedSecret 与 forward-go FORWARD_SHARED_SECRET 一致(可选)。 +func (r *Runner) ForwardSharedSecret() string { return r.cfg.ForwardSharedSecret }