澜湄合作平台 · Open API 接入指南
面向第三方应用(B 端)的开放接口技术文档 | 版本 v1.0
1. 概述
澜湄合作平台 Open API 为第三方应用提供标准化的数据访问接口,包括地区数据、城市信息、CMS 内容等。所有接口基于 HTTPS,采用 AppId + HMAC-SHA256 签名进行身份验证。
接入流程
- 联系平台管理员,申请
AppId和AppSecret - 确认所需权限范围(Scope)
- 按照本文档实现签名算法
- 调用
/api/open/v1/ping验证连通性 - 正式接入业务接口
2. 认证机制
每个 Open API 请求必须携带以下 4 个 HTTP Header:
| Header | 类型 | 说明 |
|---|---|---|
X-App-Id | String | 应用标识,如 lm_a1b2c3d4e5f6 |
X-Timestamp | Long | Unix 时间戳(秒),与服务器偏差不得超过 5 分钟 |
X-Nonce | String | 随机唯一字符串(防重放攻击,同一 nonce 10 分钟内不可重复使用) |
X-Sign | String | HMAC-SHA256 签名,详见下一节 |
验证流程
- 检查 4 个 Header 是否齐全
- 验证时间戳偏差 ≤ 300 秒
- 验证 Nonce 未被使用过(Redis 去重,TTL 600 秒)
- 检查应用状态(存在、已启用、未过期)
- 检查 IP 白名单(如已配置)
- 检查请求频率(默认 100 次/分钟)
- 验证 HMAC-SHA256 签名
- 验证 Scope 权限
X-App-Id Header 即可访问。
3. 签名计算
3.1 签名内容拼接
将以下 7 项用换行符 \n 拼接:
| # | 字段 | 说明 |
|---|---|---|
| 1 | appId | 应用 ID |
| 2 | timestamp | 与 X-Timestamp 一致 |
| 3 | nonce | 与 X-Nonce 一致 |
| 4 | method | 请求方法大写,如 GET、POST |
| 5 | path | 请求路径,如 /api/open/v1/regions/countries |
| 6 | sortedQueryString | 查询参数按 key 字典序排列,RFC 3986 编码;无参数时为空字符串 |
| 7 | bodySha256 | 请求体的 SHA-256 摘要(十六进制小写);无请求体时为空字符串的 SHA-256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 |
3.2 签名内容示例
请求:GET /api/open/v1/regions/countries?lang=zh-CN
lm_a1b2c3d4e5f6 1700000000 random-nonce-abc GET /api/open/v1/regions/countries lang=zh-CN e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
3.3 计算签名
使用 AppSecret 作为密钥,对签名内容计算 HMAC-SHA256,输出十六进制小写字符串:
signature = HMAC-SHA256(appSecret, signContent).toHexLowerCase()
3.4 sortedQueryString 构造规则
- 将所有查询参数按 key 字典序排列
- 每个参数格式:
key=RFC3986(value) - 参数间用
&连接 - RFC 3986 编码:除
A-Z a-z 0-9 - _ . ~外的字符均需%XX编码
示例:lang=zh-CN&page=1 → 排序后 lang=zh-CN&page=1
- 签名内容中的换行符是真实的
\n(LF),不是字面量\n - 最后一项之后不加换行符
- GET 请求的 bodySha256 固定为空字符串的 SHA-256
4. 权限范围(Scope)
每个应用在创建时由管理员分配 Scope,只有拥有对应 Scope 才能访问相应接口。
| Scope | 说明 | 相关接口 |
|---|---|---|
cms:article:read | CMS 文章读取 | 文章接口 |
cms:page:read | CMS 单页读取 | 单页接口 |
cms:category:read | CMS 分类读取 | 分类接口 |
cms:tag:read | CMS 标签读取 | 标签接口 |
city:read | 城市数据读取 | 城市接口 |
biz:org:read | 商务机构读取 | 商务机构接口 |
biz:resource:read | 商务资源读取 | 商务资源接口 |
media:read | 媒体读取 | 媒体接口 |
material:read | 素材读取 | 素材接口 |
data:report:read | 数据报告读取 | 数据报告接口 |
product:read | 商品读取 | 商品接口 |
region:read | 地区数据读取 | 地区接口 |
search:read | 搜索 | 搜索接口 |
sso | SSO 单点登录 | SSO Token 接口 |
5. API 端点一览
基础路径:/api/open/v1
5.1 连通性测试
| GET | /api/open/v1/ping |
无需 Scope。用于验证签名和连通性。
响应示例
{
"code": 200,
"msg": "success",
"data": {
"appId": "lm_a1b2c3d4e5f6",
"appName": "测试应用",
"scopes": ["region:read", "city:read"],
"message": "pong"
}
}
5.2 地区数据
获取国家列表
| GET | /api/open/v1/regions/countries |
Scope: region:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
lang | String | 否 | 语言代码,默认 zh-CN |
响应示例
{
"code": 200,
"data": [
{ "id": 1, "code": "CN", "name": "中国", "sort": 1 },
{ "id": 2, "code": "KH", "name": "柬埔寨", "sort": 2 }
]
}
获取子地区
| GET | /api/open/v1/regions/children |
Scope: region:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
parentId | Long | 是 | 父级地区 ID |
lang | String | 否 | 语言代码,默认 zh-CN |
响应示例
{
"code": 200,
"data": [
{ "id": 10, "code": "YN", "name": "云南省", "parentId": 1, "level": 1, "sort": 1 }
]
}
5.3 城市数据
城市列表
| GET | /api/open/v1/cities |
Scope: city:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
lang | String | 否 | 语言代码,默认 zh-CN |
countryCode | String | 否 | 按国家代码筛选 |
响应示例
{
"code": 200,
"data": {
"records": [
{ "id": 1, "name": "昆明", "countryCode": "CN", ... }
],
"total": 100
}
}
城市详情
| GET | /api/open/v1/cities/{id} |
Scope: city:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | Long | 是 | 城市 ID(路径参数) |
lang | String | 否 | 语言代码,默认 zh-CN |
响应示例
{
"code": 200,
"data": {
"city": { "id": 1, "name": "昆明", ... },
"i18n": [
{ "lang": "zh-CN", "name": "昆明" },
{ "lang": "en", "name": "Kunming" }
]
}
}
5.4 CMS 标签
标签列表
| GET | /api/open/v1/cms/tag |
Scope: cms:tag:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
lang | String | 否 | 语言代码,默认 zh-CN |
标签详情(按 slug)
| GET | /api/open/v1/cms/tag/slug/{slug} |
Scope: cms:tag:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
slug | String | 是 | 标签 slug(路径参数) |
lang | String | 否 | 语言代码,默认 zh-CN |
响应示例
{
"code": 200,
"data": {
"tag": { "id": 1, "slug": "cooperation", "name": "合作", ... },
"i18n": [
{ "lang": "zh-CN", "name": "合作" },
{ "lang": "en", "name": "Cooperation" }
]
}
}
5.5 CMS 分类
分类列表(树形)
| GET | /api/open/v1/cms/category |
Scope: cms:category:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
lang | String | 否 | 语言代码,默认 zh-CN |
返回树形结构,每个节点含 children 数组。
分类详情(按 slug)
| GET | /api/open/v1/cms/category/slug/{slug} |
Scope: cms:category:read
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
slug | String | 是 | 分类 slug(路径参数) |
lang | String | 否 | 语言代码,默认 zh-CN |
5.6 CMS 文章(写入)
以下接口用于第三方应用向平台推送(创建/更新)与撤稿 CMS 文章,需 cms:article:write 写权限域。文章以多语言副表(cms_article_i18n)承载各语言文案,主表仅存结构化字段。
撤稿(逻辑删除)
| DELETE | /api/open/v1/cms/article/{id} |
Scope: cms:article:write
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | Long | 是 | 文章 ID(路径参数) |
撤销已推送的文章。采用逻辑删除:主表 cms_article 置 deleted=1,数据保留可恢复;同时级联物理清理 cms_article_i18n、cms_article_tag 关联行,避免孤儿数据。门户侧按 deleted=0 过滤后将不再展示该文章。
返回 R.ok();当文章不存在时返回错误码 ARTICLE_NOT_FOUND(2002)。
# 请求示例 DELETE /api/open/v1/cms/article/123 # 成功 { "code": 0, "msg": "ok", "data": null } # 文章不存在 { "code": 2002, "msg": "Article not found", "data": null }
5.7 SSO Token 交换
| GET | /api/open/v1/sso/token |
Scope: sso
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | String | 是 | 一次性授权码(5 分钟有效,仅可使用一次) |
响应示例 — 管理员(userType=admin)
{
"code": 200,
"data": {
"userId": 1,
"username": "admin",
"realName": "管理员",
"role": "admin",
"avatar": "https://...",
"email": "admin@example.com",
"phone": "13800138000"
}
}
响应示例 — 前台用户(userType=user)
{
"code": 200,
"data": {
"userId": 1001,
"username": "zhangsan",
"nickname": "张三",
"role": "user",
"avatar": "https://...",
"email": "zhangsan@example.com",
"phone": "13900139000",
"memberId": 5,
"memberNo": "M20240001",
"memberName": "XX机构",
"memberType": "org" // city=城市会员 / org=机构会员 / media=媒体会员 / personal=个人会员
}
}
7. SSO 单点登录
SSO 允许平台用户(A 端)无缝跳转到第三方应用(B 端)并自动登录。A 端有两个 SSO 入口:
| 入口 | 请求地址 | 适用用户 | 默认 userType |
|---|---|---|---|
| 管理后台 | POST /api/admin/sso/url | 管理员 | admin |
| 门户前台 | POST /api/user/sso/url | 前台用户(含城市会员、机构会员、媒体会员、个人会员) | user |
两个入口生成的跳转 URL 格式完全相同,B 端无需区分来源。区别仅在于 code 关联的 userType 不同,兑换后返回的用户信息字段有所不同(见 5.6 节响应示例)。
流程图
重定向 URL 参数
| 参数 | 说明 |
|---|---|
code | 一次性授权码,5 分钟有效,使用后立即失效 |
appId | 应用 ID |
timestamp | 时间戳(秒级) |
nonce | 随机字符串 |
sign | HMAC-SHA256 签名(注意:签名格式与普通 API 不同,见下方说明) |
重定向签名验证
跳转 URL 中的签名使用与普通 API 不同的签名内容格式,B 端收到跳转请求后应验证签名以确保来源合法。
签名内容格式(换行符分隔)
appId timestamp nonce SSO ← 固定标识(注意:不是请求方法) code
验证步骤
- 从 URL 参数中提取
appId、timestamp、nonce、code、sign - 验证
timestamp是否在合理范围内(建议 5 分钟内) - 用 AppSecret 按上述格式拼接签名内容,计算 HMAC-SHA256
- 比对计算结果与 URL 中的
sign参数 - 签名验证通过后,用
code调用GET /api/open/v1/sso/token获取用户信息
- SSO 跳转 URL 的签名格式与普通 API 签名格式不同,不能混用
- code 仅可使用一次(GETDEL 原子操作),重复使用会返回错误
- code 有效期 5 分钟,过期需重新发起 SSO
- B 端应在回调接口中服务端换取用户信息,不要在前端 JavaScript 中调用
8. 错误码
所有接口返回统一的 JSON 结构:
{
"code": 错误码,
"msg": "错误描述",
"data": null
}
| code | 说明 |
|---|---|
200 | 成功 |
1400 | 请求参数错误 |
1500 | 服务器内部错误 |
2001 | 缺少认证 Header(X-App-Id / X-Timestamp / X-Nonce / X-Sign) |
2002 | 时间戳无效(偏差超过 5 分钟) |
2003 | Nonce 已使用(重放攻击) |
2004 | 应用不存在或已禁用 |
2005 | IP 不在白名单 |
2006 | 请求频率超限 |
2007 | 签名验证失败 |
2008 | Scope 权限不足 |
2009 | 应用已过期 |
1701 | SSO code 无效或不存在 |
1702 | SSO code 已过期 |
1703 | SSO code 已被使用 |
1704 | SSO 用户不存在 |
1705 | SSO 用户已禁用 |
1706 | 应用未授权 SSO 权限 |
9. 代码示例
8.1 Java 示例
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.*; // 1. 构造签名内容 String appId = "lm_a1b2c3d4e5f6"; String appSecret = "your-app-secret"; String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String nonce = UUID.randomUUID().toString().replace("-", ""); String method = "GET"; String path = "/api/open/v1/regions/countries"; String queryString = "lang=zh-CN"; // 按 key 排序 String bodySha256 = sha256Hex(""); // GET 请求无 body String signContent = String.join("\n", appId, timestamp, nonce, method, path, queryString, bodySha256); // 2. 计算 HMAC-SHA256 String sign = hmacSha256Hex(appSecret, signContent); // 3. 发送请求 // X-App-Id: lm_a1b2c3d4e5f6 // X-Timestamp: {timestamp} // X-Nonce: {nonce} // X-Sign: {sign} // --- 工具方法 --- static String hmacSha256Hex(String key, String data) throws Exception { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : hash) sb.append(String.format("%02x", b)); return sb.toString(); } static String sha256Hex(String data) throws Exception { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(data.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : hash) sb.append(String.format("%02x", b)); return sb.toString(); }
8.2 Python 示例
import hmac, hashlib, time, uuid, urllib.parse APP_ID = "lm_a1b2c3d4e5f6" APP_SECRET = "your-app-secret" def make_sign(method, path, query_params=None, body=b""): timestamp = str(int(time.time())) nonce = uuid.uuid4().hex # sorted query string sorted_qs = "" if query_params: items = sorted(query_params.items()) sorted_qs = "&".join( f"{k}={urllib.parse.quote(str(v), safe='-_.~')}" for k, v in items ) body_sha256 = hashlib.sha256(body).hexdigest() sign_content = "\n".join([ APP_ID, timestamp, nonce, method.upper(), path, sorted_qs, body_sha256 ]) sign = hmac.new( APP_SECRET.encode(), sign_content.encode(), hashlib.sha256 ).hexdigest() return { "X-App-Id": APP_ID, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Sign": sign, } # 使用示例 headers = make_sign("GET", "/api/open/v1/regions/countries", {"lang": "zh-CN"}) # requests.get("https://your-domain/api/open/v1/regions/countries?lang=zh-CN", headers=headers)
8.3 JavaScript / Node.js 示例
const crypto = require('crypto'); const APP_ID = 'lm_a1b2c3d4e5f6'; const APP_SECRET = 'your-app-secret'; function makeSign(method, path, queryParams = {}, body = '') { const timestamp = Math.floor(Date.now() / 1000).toString(); const nonce = crypto.randomUUID().replace(/-/g, ''); // sorted query string const sortedQs = Object.keys(queryParams).sort() .map(k => `${k}=${encodeURIComponent(queryParams[k])}`) .join('&'); const bodySha256 = crypto.createHash('sha256').update(body).digest('hex'); const signContent = [ APP_ID, timestamp, nonce, method.toUpperCase(), path, sortedQs, bodySha256 ].join('\n'); const sign = crypto.createHmac('sha256', APP_SECRET) .update(signContent).digest('hex'); return { 'X-App-Id': APP_ID, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Sign': sign, }; } // 使用示例 const headers = makeSign('GET', '/api/open/v1/regions/countries', { lang: 'zh-CN' }); // fetch('https://your-domain/api/open/v1/regions/countries?lang=zh-CN', { headers })
8.4 PHP 示例
// API 签名调用 $appId = 'lm_a1b2c3d4e5f6'; $appSecret = 'your-app-secret'; $timestamp = strval(time()); $nonce = md5(uniqid(mt_rand(), true)); $method = 'GET'; $path = '/api/open/v1/regions/countries'; $body = ''; // sortedQueryString $params = ['lang' => 'zh-CN']; ksort($params); $sortedQs = http_build_query($params); $bodySha256 = hash('sha256', $body); $signContent = implode("\n", [ $appId, $timestamp, $nonce, strtoupper($method), $path, $sortedQs, $bodySha256 ]); $sign = hash_hmac('sha256', $signContent, $appSecret); // cURL 请求 $ch = curl_init('https://your-domain' . $path . '?' . $sortedQs); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'X-App-Id: ' . $appId, 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Sign: ' . $sign, ], ]); $response = curl_exec($ch); curl_close($ch); // SSO 跳转签名验证(与 API 签名格式不同) function verifySsoRedirectSign($appSecret, $appId, $timestamp, $nonce, $code, $sign) { $signContent = implode("\n", [$appId, $timestamp, $nonce, 'SSO', $code]); $expected = hash_hmac('sha256', $signContent, $appSecret); return hash_equals($expected, $sign); }
10. 常见问题
Q: 签名验证总是失败怎么办?
请按以下步骤排查:
- 确认签名内容各项之间使用真实的换行符
\n(LF),最后一项后不加换行 - 确认
sortedQueryString的参数已按 key 字典序排列 - 确认 GET 请求的
bodySha256为空字符串的 SHA-256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - 确认 HMAC-SHA256 输出为十六进制小写
- 确认服务器时间与本地时间偏差不超过 5 分钟
Q: Nonce 重复使用会怎样?
同一 (appId, nonce) 组合在 10 分钟内只能使用一次,重复使用会返回 2003 错误。建议使用 UUID 或高精度时间戳 + 随机数生成。
Q: 请求频率超限怎么办?
默认限制为 100 次/分钟。如需更高配额,请联系平台管理员调整应用的 rateLimit 配置。
Q: 支持哪些语言参数?
目前支持的语言代码:zh-CN(简体中文)、en(英语)、th(泰语)、vi(越南语)、my(缅甸语)、lo(老挝语)、km(高棉语)。如果请求的语言无对应翻译,系统会按 请求语言 → en → 任意可用语言 的顺序回填。
Q: SSO code 过期了怎么办?
code 有效期 5 分钟,过期后需由 A 端重新发起 SSO 流程生成新的 code。
Q: AppSecret 忘记了怎么办?
Secret 仅在创建和重置时展示一次明文。如遗失,请联系平台管理员重置 Secret,重置后旧 Secret 立即失效。