更新
This commit is contained in:
3
.env
3
.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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
internal/hy/crypto_parity_test.go
Normal file
147
internal/hy/crypto_parity_test.go
Normal 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 ""
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user