init: multi-language OpenAPI demos (java/php/nodejs/python)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
echo 2026-07-20 13:40:28 +08:00
commit 653a5e4a23
15 changed files with 493 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.env
*.class
node_modules/
__pycache__/
.idea/
*.iml

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# 代收 OpenAPI Demo
多语言对接示例:签名算法 + 下单 / 查单 / 查余额。
- 仓库https://git.weilai-pay.com/echo/open-pay-demo
- 文档https://sh.weilai-pay.com/doc/qxmwpk/
- 网关占位:`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` | 通道编码 | `3084` |
## 快速跑(以 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=3084
python3 demo.py balance
python3 demo.py submit
python3 demo.py query <商户订单号>
```
## 签名规则(各语言一致)
1. 去掉 `Sign` 字段;值为 `null` 或空字符串 `""` 的字段不参与
2. 按字段名字典序ASCII拼接`k=v&k2=v2`
3. 末尾追加 `&SecretKey=<密钥>`
4. 对整串做 MD532 位小写)得到 `Sign`

98
java/Demo.java Normal file
View File

@ -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 <OrderNo>
*/
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", "3084");
public static void main(String[] args) throws Exception {
if (args.length < 1) {
System.out.println("usage: java Demo balance|submit|query <OrderNo>");
return;
}
String cmd = args[0];
if ("balance".equals(cmd)) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("Timestamp", System.currentTimeMillis() / 1000);
body.put("AccessKey", ACCESS_KEY);
System.out.println(post("/api/apiv1/open/pay/querybalance", body));
} else if ("submit".equals(cmd)) {
String orderNo = "OrderNo" + (System.currentTimeMillis() / 1000);
Map<String, Object> 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("Payer", "张三");
body.put("PayerNo", "123456789@qq.com");
body.put("PayerAddress", "支付宝");
System.out.println(post("/api/apiv1/open/pay/submit", body));
} else if ("query".equals(cmd)) {
if (args.length < 2) {
System.out.println("usage: java Demo query <OrderNo>");
return;
}
Map<String, Object> 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/pay/queryorder", body));
} else {
System.out.println("unknown command: " + cmd);
}
}
static String post(String path, Map<String, Object> 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<String, Object> map) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> 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();
}
}

7
java/README.md Normal file
View File

@ -0,0 +1,7 @@
# Java
```bash
javac -encoding UTF-8 *.java
export BASE_URL=https://example.com
java Demo balance
```

41
java/SignUtil.java Normal file
View File

@ -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<String, Object> params, String secretKey) throws Exception {
TreeMap<String, Object> sorted = new TreeMap<>();
for (Map.Entry<String, Object> e : params.entrySet()) {
if ("Sign".equals(e.getKey())) {
continue;
}
sorted.put(e.getKey(), e.getValue());
}
List<String> parts = new ArrayList<>();
for (Map.Entry<String, Object> 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();
}
}

8
nodejs/README.md Normal file
View File

@ -0,0 +1,8 @@
# Node.js
需要 Node 18+(内置 fetch
```bash
export BASE_URL=https://example.com
node demo.js balance
```

64
nodejs/demo.js Normal file
View File

@ -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 || '3084';
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 <OrderNo>');
process.exit(1);
}
if (cmd === 'balance') {
console.log(JSON.stringify(await post('/api/apiv1/open/pay/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/pay/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: '-',
Payer: '张三',
PayerNo: '123456789@qq.com',
PayerAddress: '支付宝',
}), null, 2));
} else if (cmd === 'query') {
if (!orderNo) {
console.log('usage: node demo.js query <OrderNo>');
process.exit(1);
}
console.log(JSON.stringify(await post('/api/apiv1/open/pay/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);
});

1
nodejs/package.json Normal file
View File

@ -0,0 +1 @@
{"name":"open-api-demo","private":true,"type":"commonjs"}

19
nodejs/sign.js Normal file
View File

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

8
php/README.md Normal file
View File

@ -0,0 +1,8 @@
# PHP
需要 php-curl。
```bash
export BASE_URL=https://example.com
php demo.php balance
```

62
php/demo.php Normal file
View File

@ -0,0 +1,62 @@
<?php
require __DIR__ . '/sign.php';
$BASE_URL = rtrim(getenv('BASE_URL') ?: 'https://example.com', '/');
$ACCESS_KEY = getenv('ACCESS_KEY') ?: 'your_access_key';
$SECRET_KEY = getenv('SECRET_KEY') ?: 'your_secret_key';
$PAY_CHANNEL_ID = getenv('PAY_CHANNEL_ID') ?: '3084';
function post_json(string $path, array $body): array {
global $BASE_URL, $SECRET_KEY;
$body['Sign'] = build_sign($body, $SECRET_KEY);
$ch = curl_init($BASE_URL . $path);
curl_setopt_array($ch, [
CURLOPT_POST => 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/pay/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/pay/submit', [
'Timestamp' => time(),
'AccessKey' => $ACCESS_KEY,
'PayChannelId' => $PAY_CHANNEL_ID,
'OrderNo' => $orderNo,
'Amount' => '100.01',
'CallbackUrl' => 'https://xxxxx.com/xxxx/callback',
'Ext' => '-',
'Payer' => '张三',
'PayerNo' => '123456789@qq.com',
'PayerAddress' => '支付宝',
]), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), PHP_EOL;
} elseif ($cmd === 'query') {
$orderNo = $argv[2] ?? '';
if ($orderNo === '') {
fwrite(STDERR, "usage: php demo.php query <OrderNo>\n");
exit(1);
}
echo json_encode(post_json('/api/apiv1/open/pay/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 <OrderNo>\n");
exit(1);
}

19
php/sign.php Normal file
View File

@ -0,0 +1,19 @@
<?php
/** 空值不参与字典序MD5 小写 */
function build_sign(array $params, string $secretKey): string {
unset($params['Sign']);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $k => $v) {
if ($v === null) {
continue;
}
$s = (string)$v;
if ($s === '') {
continue;
}
$parts[] = $k . '=' . $s;
}
$raw = implode('&', $parts) . '&SecretKey=' . $secretKey;
return md5($raw);
}

8
python/README.md Normal file
View File

@ -0,0 +1,8 @@
# Python
```bash
export BASE_URL=https://example.com
export ACCESS_KEY=...
export SECRET_KEY=...
python3 demo.py balance
```

85
python/demo.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""代收 Demobalance / 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", "3084")
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/pay/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/pay/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": "-",
"Payer": "张三",
"PayerNo": "123456789@qq.com",
"PayerAddress": "支付宝",
}), ensure_ascii=False, indent=2))
def cmd_query(order_no: str) -> None:
print(json.dumps(post("/api/apiv1/open/pay/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 <OrderNo>")
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 <OrderNo>")
sys.exit(1)
cmd_query(sys.argv[2])
else:
print("unknown command:", cmd)
sys.exit(1)
if __name__ == "__main__":
main()

22
python/sign.py Normal file
View File

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