This commit is contained in:
李琦
2026-05-22 08:42:14 +08:00
parent f3e6917421
commit 1ae21d83f1
10 changed files with 183 additions and 11 deletions

3
.env
View File

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

View File

@@ -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=
# truePDF 上传走 FORWARD_BASE_URL/mng/file/auth/upload不直连政务云 28211
FILE_UPLOAD_VIA_FORWARD=true

View File

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

View File

@@ -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 // truePDF 上传经 forward /mng/file/auth/upload不直连政务云
MySQLDSN string // 本机中转库 DSNhy_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),

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,8 @@ func uploadHTTPClient() *http.Client {
//
// uploadURLFILE_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 {

View File

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

View File

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