From ee78b80cd3e6ec8a3c74cb794235cdfacc0965c9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 20 Jul 2026 16:34:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AD=BE=E5=90=8D=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/middleware/open_auth.go | 55 ++++++++++------- docs/开放接口-皮肤源头对接.md | 77 +++++++++++++++++------- frontend/src/pages/OpenApiDocs.tsx | 30 ++++++--- 3 files changed, 108 insertions(+), 54 deletions(-) diff --git a/backend/internal/middleware/open_auth.go b/backend/internal/middleware/open_auth.go index 5dda9f0..73a1cdd 100644 --- a/backend/internal/middleware/open_auth.go +++ b/backend/internal/middleware/open_auth.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "io" + "sort" "strconv" "strings" "sync" @@ -37,7 +38,6 @@ func newNonceStore() *nonceStore { func (s *nonceStore) seen(nonce string, now, ttl int64) bool { s.mu.Lock() defer s.mu.Unlock() - // 清理过期 for k, exp := range s.data { if exp < now { delete(s.data, k) @@ -52,11 +52,15 @@ func (s *nonceStore) seen(nonce string, now, ttl int64) bool { // OpenAuth 校验 X-Api-Key + 时间戳 + nonce + HMAC-SHA256 签名 // -// 待签名字符串(UTF-8,\n 换行): +// 待签名参数(value 原样,不做 URL encode): // -// {apiKey}\n{timestamp}\n{nonce}\n{METHOD}\n{path}\n{body} +// api_key, body, method, nonce, path, timestamp // -// path 为 URL.Path(不含 query),METHOD 大写;GET 时 body 为空字符串。 +// 按 key 字典序排序后拼接: +// +// k1=v1&k2=v2&... +// +// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。 // sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。 func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc { if cfg.SkewSeconds <= 0 { @@ -105,7 +109,6 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc { return } - // 防重放:同一 nonce 在时间窗口内只能用一次 if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) { response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)") c.Abort() @@ -124,15 +127,7 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc { path := c.Request.URL.Path body := string(bodyBytes) - stringToSign := strings.Join([]string{ - apiKey, - timestamp, - nonce, - method, - path, - body, - }, "\n") - + stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body) expected := hmacSHA256Hex(cfg.APISecret, stringToSign) if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) { response.Unauthorized(c, "签名校验失败") @@ -144,6 +139,28 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc { } } +// BuildSignString 生成待签名字符串:参数字典序 + & 拼接 +func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string { + params := map[string]string{ + "api_key": apiKey, + "body": body, + "method": strings.ToUpper(method), + "nonce": nonce, + "path": path, + "timestamp": timestamp, + } + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+params[k]) + } + return strings.Join(parts, "&") +} + func hmacSHA256Hex(secret, content string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(content)) @@ -159,13 +176,5 @@ func abs64(v int64) int64 { // BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致) func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string { - stringToSign := strings.Join([]string{ - apiKey, - timestamp, - nonce, - strings.ToUpper(method), - path, - body, - }, "\n") - return hmacSHA256Hex(apiSecret, stringToSign) + return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body)) } diff --git a/docs/开放接口-皮肤源头对接.md b/docs/开放接口-皮肤源头对接.md index c20c5db..029244f 100644 --- a/docs/开放接口-皮肤源头对接.md +++ b/docs/开放接口-皮肤源头对接.md @@ -1,6 +1,6 @@ -# 店铺订单对接 API 说明(发给皮肤源头) +# 店铺订单对接 API 文档 -> 文档对象:皮肤源头 / 发货系统技术人员 +> 文档对象:发货系统技术人员 > 版本:v1 > 更新说明:鉴权为 **ApiKey + HMAC 签名**,请按本文实现,勿只传 Key。 @@ -16,13 +16,13 @@ --- -## 1. 环境信息(由店铺方填写后发给你们) +## 1. 环境信息 | 项 | 值(示例 / 请替换) | |----|---------------------| -| **API 根地址 Base URL** | `https://api.example.com`(开发:`http://主机:8080`) | -| **X-Api-Key** | 由店铺方分配,如 `sk_xxxx` | -| **api_secret** | 由店铺方单独发送,**只用于本地算签名,不要写在 URL/Header** | +| **API 根地址 Base URL** | `https://http://221329.cc.cd` 开发临时地址 | +| **X-Api-Key** | 测试使用,如 `sk_source_dev_key_3ad3d1bbacda0e54` | +| **api_secret** | 测试使用,**只用于本地算签名,不要写在 URL/Header**, `sk_source_dev_secret_5a43ec55cd020d83` | | **时间偏差** | 默认允许 ±300 秒 | 统一响应格式: @@ -49,23 +49,43 @@ | `X-Nonce` | 是 | 随机串,长度 8~64;同一 Key 在有效期内不可重复 | | `X-Sign` | 是 | 见下方算法 | -### 2.1 签名字符串(6 行,用 `\n` 连接) +### 2.1 签名字符串(参数字典序 + `&` 拼接) + +参与签名的参数: + +| 参数名 | 说明 | +|--------|------| +| `api_key` | 与 Header `X-Api-Key` 相同 | +| `timestamp` | 与 Header `X-Timestamp` 相同 | +| `nonce` | 与 Header `X-Nonce` 相同 | +| `method` | 大写:`GET` / `POST` | +| `path` | 仅路径,**不要**域名、**不要** query。例:`/api/open/v1/orders/O123` | +| `body` | 原始 HTTP body;GET 用**空字符串** | + +规则: + +1. value **原样拼接,不做 URL encode** +2. 按参数名 **ASCII 字典序** 排序 +3. 拼成:`k1=v1&k2=v2&k3=v3...` +4. `X-Sign = hex( HMAC-SHA256( api_secret, 签名字符串 ) )`,**小写**十六进制 + +排序后参数名顺序固定为: ```text -{api_key} -{timestamp} -{nonce} -{METHOD} -{path} -{body} +api_key, body, method, nonce, path, timestamp ``` -| 字段 | 规则 | -|------|------| -| METHOD | 大写:`GET` / `POST` | -| path | 仅路径,**不要**域名、**不要** query。例:`/api/open/v1/orders/O123` | -| body | 原始 HTTP body 字符串;GET 用**空字符串** | -| X-Sign | `hex( HMAC-SHA256( api_secret, 签名字符串 ) )`,**小写**十六进制 | +**GET 示例**(body 为空): + +```text +api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O202607201550038000×tamp=1721450000 +``` + +**POST 示例**: + +```text +api_key=sk_xxx&body={"order_no":"O202607201550038000","ship_status":"success"}&method=POST&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/ship-notify×tamp=1721450000 +``` ### 2.2 Python 参考实现 @@ -76,10 +96,22 @@ API_KEY = "请替换为店铺下发的 key" API_SECRET = "请替换为店铺下发的 secret" BASE = "https://api.example.com" # 请替换 +def build_sign_string(api_key: str, timestamp: str, nonce: str, method: str, path: str, body: str = "") -> str: + params = { + "api_key": api_key, + "body": body, + "method": method.upper(), + "nonce": nonce, + "path": path, + "timestamp": timestamp, + } + # 字典序 + & 拼接,value 不 encode + return "&".join(f"{k}={params[k]}" for k in sorted(params.keys())) + def sign_headers(method: str, path: str, body: str = "") -> dict: ts = str(int(time.time())) nonce = uuid.uuid4().hex - raw = "\n".join([API_KEY, ts, nonce, method.upper(), path, body]) + raw = build_sign_string(API_KEY, ts, nonce, method, path, body) sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest() return { "X-Api-Key": API_KEY, @@ -101,8 +133,9 @@ print(requests.post(BASE + path, headers=headers, data=body.encode("utf-8")).jso **注意:** -- POST 时 `json.dumps` 后的字符串要原样发送,不要一边签名一边再改空格/字段顺序。 -- 签名失败常见原因:secret 错、path 多了 query、body 不一致、时间戳过期、nonce 重复。 +- POST 时签名用的 `body` 必须与实际发送的 body **字节级一致**(不要一边签名一边再改空格/字段顺序)。 +- value **不要** URL encode。 +- 签名失败常见原因:secret 错、path 多了 query、body 不一致、时间戳过期、nonce 重复、参数未按字典序拼接。 --- diff --git a/frontend/src/pages/OpenApiDocs.tsx b/frontend/src/pages/OpenApiDocs.tsx index 39312de..b6d1f38 100644 --- a/frontend/src/pages/OpenApiDocs.tsx +++ b/frontend/src/pages/OpenApiDocs.tsx @@ -12,10 +12,22 @@ API_KEY = "sk_source_dev_key_change_me" API_SECRET = "sk_source_dev_secret_change_me" BASE = "${baseUrl}" +def build_sign_string(api_key, timestamp, nonce, method, path, body=""): + params = { + "api_key": api_key, + "body": body, + "method": method.upper(), + "nonce": nonce, + "path": path, + "timestamp": timestamp, + } + # 字典序 + & 拼接,value 不 URL encode + return "&".join(f"{k}={params[k]}" for k in sorted(params.keys())) + def sign_headers(method: str, path: str, body: str = "") -> dict: ts = str(int(time.time())) nonce = uuid.uuid4().hex - raw = "\\n".join([API_KEY, ts, nonce, method.upper(), path, body]) + raw = build_sign_string(API_KEY, ts, nonce, method, path, body) sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest() return { "X-Api-Key": API_KEY, @@ -79,15 +91,15 @@ export default function OpenApiDocs() { label: '签名规则', children: ( - -
{`{api_key}
-{timestamp}
-{nonce}
-{METHOD}
-{path}
-{body}`}
+ + + 参数:api_key / body / method / nonce / path / timestamp → 按 key 排序后拼成 + k=v&k=v…(value URL encode) + +
{`api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O123×tamp=1721450000`}
+
{`api_key=sk_xxx&body={"order_no":"O123","ship_status":"success"}&method=POST&nonce=...&path=/api/open/v1/orders/ship-notify×tamp=1721450000`}
- 大写 GET / POST + 大写 GET / POST URL.Path,不含 query,如 /api/open/v1/orders/O123