From 82c7f3406dc353d9824b44b3c0cfe4dd333707b3 Mon Sep 17 00:00:00 2001 From: echo Date: Mon, 20 Jul 2026 13:40:29 +0800 Subject: [PATCH] init: multi-language OpenAPI demos (java/php/nodejs/python) Co-authored-by: Cursor --- .gitignore | 6 +++ README.md | 45 +++++++++++++++++++++ java/Demo.java | 98 +++++++++++++++++++++++++++++++++++++++++++++ java/README.md | 7 ++++ java/SignUtil.java | 41 +++++++++++++++++++ nodejs/README.md | 8 ++++ nodejs/demo.js | 64 +++++++++++++++++++++++++++++ nodejs/package.json | 1 + nodejs/sign.js | 19 +++++++++ php/README.md | 8 ++++ php/demo.php | 62 ++++++++++++++++++++++++++++ php/sign.php | 19 +++++++++ python/README.md | 8 ++++ python/demo.py | 85 +++++++++++++++++++++++++++++++++++++++ python/sign.py | 22 ++++++++++ 15 files changed, 493 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 java/Demo.java create mode 100644 java/README.md create mode 100644 java/SignUtil.java create mode 100644 nodejs/README.md create mode 100644 nodejs/demo.js create mode 100644 nodejs/package.json create mode 100644 nodejs/sign.js create mode 100644 php/README.md create mode 100644 php/demo.php create mode 100644 php/sign.php create mode 100644 python/README.md create mode 100644 python/demo.py create mode 100644 python/sign.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..364fa8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +*.class +node_modules/ +__pycache__/ +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..edd2f92 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# 代付 OpenAPI Demo + +多语言对接示例:签名算法 + 下单 / 查单 / 查余额。 + +- 仓库:https://git.weilai-pay.com/echo/open-withdrawal-demo +- 文档:https://sh.weilai-pay.com/doc/znhtbr/ +- 网关占位:`https://example.com`(请换成实际域名) + +## 目录 + +| 语言 | 目录 | +| --- | --- | +| Java | `java/` | +| PHP | `php/` | +| Node.js | `nodejs/` | +| Python | `python/` | + +## 配置(环境变量) + +| 变量 | 说明 | 示例 | +| --- | --- | --- | +| `BASE_URL` | 网关根地址 | `https://example.com` | +| `ACCESS_KEY` | 商户号 | `your_access_key` | +| `SECRET_KEY` | 商户密钥 | `your_secret_key` | +| `PAY_CHANNEL_ID` | 通道编码 | `821` | + +## 快速跑(以 Python 为例) + +```bash +cd python +export BASE_URL=https://example.com +export ACCESS_KEY=your_access_key +export SECRET_KEY=your_secret_key +export PAY_CHANNEL_ID=821 +python3 demo.py balance +python3 demo.py submit +python3 demo.py query <商户订单号> +``` + +## 签名规则(各语言一致) + +1. 去掉 `Sign` 字段;值为 `null` 或空字符串 `""` 的字段不参与 +2. 按字段名字典序(ASCII)拼接:`k=v&k2=v2` +3. 末尾追加 `&SecretKey=<密钥>` +4. 对整串做 MD5(32 位小写)得到 `Sign` diff --git a/java/Demo.java b/java/Demo.java new file mode 100644 index 0000000..2c02f41 --- /dev/null +++ b/java/Demo.java @@ -0,0 +1,98 @@ +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 代付 Demo。编译:javac -encoding UTF-8 *.java + * 运行:java Demo balance|submit|query + */ +public class Demo { + static final String BASE_URL = env("BASE_URL", "https://example.com").replaceAll("/$", ""); + static final String ACCESS_KEY = env("ACCESS_KEY", "your_access_key"); + static final String SECRET_KEY = env("SECRET_KEY", "your_secret_key"); + static final String PAY_CHANNEL_ID = env("PAY_CHANNEL_ID", "821"); + + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.out.println("usage: java Demo balance|submit|query "); + return; + } + String cmd = args[0]; + if ("balance".equals(cmd)) { + Map body = new LinkedHashMap<>(); + body.put("Timestamp", System.currentTimeMillis() / 1000); + body.put("AccessKey", ACCESS_KEY); + System.out.println(post("/api/apiv1/open/withdrawal/querybalance", body)); + } else if ("submit".equals(cmd)) { + String orderNo = "OrderNo" + (System.currentTimeMillis() / 1000); + Map body = new LinkedHashMap<>(); + body.put("Timestamp", System.currentTimeMillis() / 1000); + body.put("AccessKey", ACCESS_KEY); + body.put("PayChannelId", PAY_CHANNEL_ID); + body.put("OrderNo", orderNo); + body.put("Amount", "100.01"); + body.put("CallbackUrl", "https://xxxxx.com/xxxx/callback"); + body.put("Ext", "-"); + body.put("Payee", "张三"); + body.put("PayeeNo", "6235753100001261596"); + body.put("PayeeAddress", "招商银行"); + System.out.println(post("/api/apiv1/open/withdrawal/submit", body)); + } else if ("query".equals(cmd)) { + if (args.length < 2) { + System.out.println("usage: java Demo query "); + return; + } + Map body = new LinkedHashMap<>(); + body.put("Timestamp", System.currentTimeMillis() / 1000); + body.put("AccessKey", ACCESS_KEY); + body.put("OrderNo", args[1]); + System.out.println(post("/api/apiv1/open/withdrawal/queryorder", body)); + } else { + System.out.println("unknown command: " + cmd); + } + } + + static String post(String path, Map body) throws Exception { + body.put("Sign", SignUtil.buildSign(body, SECRET_KEY)); + String json = toJson(body); + HttpURLConnection conn = (HttpURLConnection) new URL(BASE_URL + path).openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(30000); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + try (OutputStream os = conn.getOutputStream()) { + os.write(json.getBytes(StandardCharsets.UTF_8)); + } + try (InputStream in = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + static String env(String k, String def) { + String v = System.getenv(k); + return v == null || v.isEmpty() ? def : v; + } + + /** 极简 JSON(仅本 Demo 字段类型) */ + static String toJson(Map map) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : map.entrySet()) { + if (!first) sb.append(','); + first = false; + sb.append('"').append(e.getKey()).append('"').append(':'); + Object v = e.getValue(); + if (v instanceof Number) { + sb.append(v); + } else { + sb.append('"').append(String.valueOf(v).replace("\\", "\\\\").replace("\"", "\\\"")).append('"'); + } + } + return sb.append('}').toString(); + } +} diff --git a/java/README.md b/java/README.md new file mode 100644 index 0000000..c3cf134 --- /dev/null +++ b/java/README.md @@ -0,0 +1,7 @@ +# Java + +```bash +javac -encoding UTF-8 *.java +export BASE_URL=https://example.com +java Demo balance +``` diff --git a/java/SignUtil.java b/java/SignUtil.java new file mode 100644 index 0000000..44e33b2 --- /dev/null +++ b/java/SignUtil.java @@ -0,0 +1,41 @@ +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** 空值不参与,字典序,MD5 小写 */ +public final class SignUtil { + private SignUtil() {} + + public static String buildSign(Map params, String secretKey) throws Exception { + TreeMap sorted = new TreeMap<>(); + for (Map.Entry e : params.entrySet()) { + if ("Sign".equals(e.getKey())) { + continue; + } + sorted.put(e.getKey(), e.getValue()); + } + List parts = new ArrayList<>(); + for (Map.Entry e : sorted.entrySet()) { + Object v = e.getValue(); + if (v == null) { + continue; + } + String s = String.valueOf(v); + if (s.isEmpty()) { + continue; + } + parts.add(e.getKey() + "=" + s); + } + String raw = String.join("&", parts) + "&SecretKey=" + secretKey; + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] dig = md.digest(raw.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : dig) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/nodejs/README.md b/nodejs/README.md new file mode 100644 index 0000000..21fc1fb --- /dev/null +++ b/nodejs/README.md @@ -0,0 +1,8 @@ +# Node.js + +需要 Node 18+(内置 fetch)。 + +```bash +export BASE_URL=https://example.com +node demo.js balance +``` diff --git a/nodejs/demo.js b/nodejs/demo.js new file mode 100644 index 0000000..41ad3da --- /dev/null +++ b/nodejs/demo.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +'use strict'; +const { buildSign } = require('./sign'); + +const BASE_URL = (process.env.BASE_URL || 'https://example.com').replace(/\/$/, ''); +const ACCESS_KEY = process.env.ACCESS_KEY || 'your_access_key'; +const SECRET_KEY = process.env.SECRET_KEY || 'your_secret_key'; +const PAY_CHANNEL_ID = process.env.PAY_CHANNEL_ID || '821'; + +async function post(path, body) { + const payload = { ...body, Sign: buildSign(body, SECRET_KEY) }; + const res = await fetch(BASE_URL + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); +} + +async function main() { + const [cmd, orderNo] = process.argv.slice(2); + if (!cmd) { + console.log('usage: node demo.js balance|submit|query '); + process.exit(1); + } + if (cmd === 'balance') { + console.log(JSON.stringify(await post('/api/apiv1/open/withdrawal/querybalance', { + Timestamp: Math.floor(Date.now() / 1000), + AccessKey: ACCESS_KEY, + }), null, 2)); + } else if (cmd === 'submit') { + const OrderNo = `OrderNo${Math.floor(Date.now() / 1000)}`; + console.log(JSON.stringify(await post('/api/apiv1/open/withdrawal/submit', { + Timestamp: Math.floor(Date.now() / 1000), + AccessKey: ACCESS_KEY, + PayChannelId: PAY_CHANNEL_ID, + OrderNo, + Amount: '100.01', + CallbackUrl: 'https://xxxxx.com/xxxx/callback', + Ext: '-', + Payee: '张三', + PayeeNo: '6235753100001261596', + PayeeAddress: '招商银行', + }), null, 2)); + } else if (cmd === 'query') { + if (!orderNo) { + console.log('usage: node demo.js query '); + process.exit(1); + } + console.log(JSON.stringify(await post('/api/apiv1/open/withdrawal/queryorder', { + Timestamp: Math.floor(Date.now() / 1000), + AccessKey: ACCESS_KEY, + OrderNo: orderNo, + }), null, 2)); + } else { + console.log('unknown command:', cmd); + process.exit(1); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/nodejs/package.json b/nodejs/package.json new file mode 100644 index 0000000..f51e335 --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1 @@ +{"name":"open-api-demo","private":true,"type":"commonjs"} diff --git a/nodejs/sign.js b/nodejs/sign.js new file mode 100644 index 0000000..eda85be --- /dev/null +++ b/nodejs/sign.js @@ -0,0 +1,19 @@ +'use strict'; +const crypto = require('crypto'); + +/** 空值不参与,字典序,MD5 小写 */ +function buildSign(params, secretKey) { + const keys = Object.keys(params).filter((k) => k !== 'Sign').sort(); + const parts = []; + for (const k of keys) { + const v = params[k]; + if (v === null || v === undefined) continue; + const s = String(v); + if (s === '') continue; + parts.push(`${k}=${s}`); + } + const raw = parts.join('&') + `&SecretKey=${secretKey}`; + return crypto.createHash('md5').update(raw, 'utf8').digest('hex'); +} + +module.exports = { buildSign }; diff --git a/php/README.md b/php/README.md new file mode 100644 index 0000000..71a6f8b --- /dev/null +++ b/php/README.md @@ -0,0 +1,8 @@ +# PHP + +需要 php-curl。 + +```bash +export BASE_URL=https://example.com +php demo.php balance +``` diff --git a/php/demo.php b/php/demo.php new file mode 100644 index 0000000..e9f3c97 --- /dev/null +++ b/php/demo.php @@ -0,0 +1,62 @@ + true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + $resp = curl_exec($ch); + if ($resp === false) { + throw new RuntimeException(curl_error($ch)); + } + curl_close($ch); + return json_decode($resp, true) ?: []; +} + +$cmd = $argv[1] ?? ''; +if ($cmd === 'balance') { + echo json_encode(post_json('/api/apiv1/open/withdrawal/querybalance', [ + 'Timestamp' => time(), + 'AccessKey' => $ACCESS_KEY, + ]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL; +} elseif ($cmd === 'submit') { + $orderNo = 'OrderNo' . time(); + echo json_encode(post_json('/api/apiv1/open/withdrawal/submit', [ + 'Timestamp' => time(), + 'AccessKey' => $ACCESS_KEY, + 'PayChannelId' => $PAY_CHANNEL_ID, + 'OrderNo' => $orderNo, + 'Amount' => '100.01', + 'CallbackUrl' => 'https://xxxxx.com/xxxx/callback', + 'Ext' => '-', + 'Payee' => '张三', + 'PayeeNo' => '6235753100001261596', + 'PayeeAddress' => '招商银行', + ]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL; +} elseif ($cmd === 'query') { + $orderNo = $argv[2] ?? ''; + if ($orderNo === '') { + fwrite(STDERR, "usage: php demo.php query \n"); + exit(1); + } + echo json_encode(post_json('/api/apiv1/open/withdrawal/queryorder', [ + 'Timestamp' => time(), + 'AccessKey' => $ACCESS_KEY, + 'OrderNo' => $orderNo, + ]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL; +} else { + fwrite(STDERR, "usage: php demo.php balance|submit|query \n"); + exit(1); +} diff --git a/php/sign.php b/php/sign.php new file mode 100644 index 0000000..9d0b2e8 --- /dev/null +++ b/php/sign.php @@ -0,0 +1,19 @@ + $v) { + if ($v === null) { + continue; + } + $s = (string)$v; + if ($s === '') { + continue; + } + $parts[] = $k . '=' . $s; + } + $raw = implode('&', $parts) . '&SecretKey=' . $secretKey; + return md5($raw); +} diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..0c36ca6 --- /dev/null +++ b/python/README.md @@ -0,0 +1,8 @@ +# Python + +```bash +export BASE_URL=https://example.com +export ACCESS_KEY=... +export SECRET_KEY=... +python3 demo.py balance +``` diff --git a/python/demo.py b/python/demo.py new file mode 100644 index 0000000..36f26f8 --- /dev/null +++ b/python/demo.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""代付 Demo:balance / submit / query""" +from __future__ import annotations + +import json +import os +import sys +import time +import urllib.request + +from sign import build_sign + +BASE_URL = os.environ.get("BASE_URL", "https://example.com").rstrip("/") +ACCESS_KEY = os.environ.get("ACCESS_KEY", "your_access_key") +SECRET_KEY = os.environ.get("SECRET_KEY", "your_secret_key") +PAY_CHANNEL_ID = os.environ.get("PAY_CHANNEL_ID", "821") + + +def post(path: str, body: dict) -> dict: + body = dict(body) + body["Sign"] = build_sign(body, SECRET_KEY) + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + BASE_URL + path, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def cmd_balance() -> None: + print(json.dumps(post("/api/apiv1/open/withdrawal/querybalance", { + "Timestamp": int(time.time()), + "AccessKey": ACCESS_KEY, + }), ensure_ascii=False, indent=2)) + + +def cmd_submit() -> None: + order_no = f"OrderNo{int(time.time())}" + print(json.dumps(post("/api/apiv1/open/withdrawal/submit", { + "Timestamp": int(time.time()), + "AccessKey": ACCESS_KEY, + "PayChannelId": PAY_CHANNEL_ID, + "OrderNo": order_no, + "Amount": "100.01", + "CallbackUrl": "https://xxxxx.com/xxxx/callback", + "Ext": "-", + "Payee": "张三", + "PayeeNo": "6235753100001261596", + "PayeeAddress": "招商银行", + }), ensure_ascii=False, indent=2)) + + +def cmd_query(order_no: str) -> None: + print(json.dumps(post("/api/apiv1/open/withdrawal/queryorder", { + "Timestamp": int(time.time()), + "AccessKey": ACCESS_KEY, + "OrderNo": order_no, + }), ensure_ascii=False, indent=2)) + + +def main() -> None: + if len(sys.argv) < 2: + print("usage: demo.py balance|submit|query ") + sys.exit(1) + cmd = sys.argv[1] + if cmd == "balance": + cmd_balance() + elif cmd == "submit": + cmd_submit() + elif cmd == "query": + if len(sys.argv) < 3: + print("usage: demo.py query ") + sys.exit(1) + cmd_query(sys.argv[2]) + else: + print("unknown command:", cmd) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/python/sign.py b/python/sign.py new file mode 100644 index 0000000..3080791 --- /dev/null +++ b/python/sign.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""OpenAPI 签名:空值不参与,字典序,MD5 小写。""" +from __future__ import annotations + +import hashlib +from typing import Any, Mapping + + +def build_sign(params: Mapping[str, Any], secret_key: str) -> str: + items = [] + for k in sorted(params.keys()): + if k == "Sign": + continue + v = params[k] + if v is None: + continue + s = str(v) + if s == "": + continue + items.append(f"{k}={s}") + raw = "&".join(items) + f"&SecretKey={secret_key}" + return hashlib.md5(raw.encode("utf-8")).hexdigest()