澜湄合作平台 · Open API 接入指南

面向第三方应用(B 端)的开放接口技术文档  |  版本 v1.0

1. 概述

澜湄合作平台 Open API 为第三方应用提供标准化的数据访问接口,包括地区数据、城市信息、CMS 内容等。所有接口基于 HTTPS,采用 AppId + HMAC-SHA256 签名进行身份验证。

接入流程

  1. 联系平台管理员,申请 AppIdAppSecret
  2. 确认所需权限范围(Scope)
  3. 按照本文档实现签名算法
  4. 调用 /api/open/v1/ping 验证连通性
  5. 正式接入业务接口
安全提示 AppSecret 是应用的核心凭证,切勿暴露在前端代码、URL 参数或日志中。Secret 仅在创建和重置时展示一次,平台不会再次提供明文。

2. 认证机制

每个 Open API 请求必须携带以下 4 个 HTTP Header:

Header类型说明
X-App-IdString应用标识,如 lm_a1b2c3d4e5f6
X-TimestampLongUnix 时间戳(秒),与服务器偏差不得超过 5 分钟
X-NonceString随机唯一字符串(防重放攻击,同一 nonce 10 分钟内不可重复使用)
X-SignStringHMAC-SHA256 签名,详见下一节

验证流程

  1. 检查 4 个 Header 是否齐全
  2. 验证时间戳偏差 ≤ 300 秒
  3. 验证 Nonce 未被使用过(Redis 去重,TTL 600 秒)
  4. 检查应用状态(存在、已启用、未过期)
  5. 检查 IP 白名单(如已配置)
  6. 检查请求频率(默认 100 次/分钟)
  7. 验证 HMAC-SHA256 签名
  8. 验证 Scope 权限
开发环境 开发调试阶段可联系管理员临时关闭签名验证,直接使用 X-App-Id Header 即可访问。

3. 签名计算

3.1 签名内容拼接

将以下 7 项用换行符 \n 拼接:

#字段说明
1appId应用 ID
2timestamp与 X-Timestamp 一致
3nonce与 X-Nonce 一致
4method请求方法大写,如 GETPOST
5path请求路径,如 /api/open/v1/regions/countries
6sortedQueryString查询参数按 key 字典序排列,RFC 3986 编码;无参数时为空字符串
7bodySha256请求体的 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 构造规则

  1. 将所有查询参数按 key 字典序排列
  2. 每个参数格式:key=RFC3986(value)
  3. 参数间用 & 连接
  4. 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:readCMS 文章读取文章接口
cms:page:readCMS 单页读取单页接口
cms:category:readCMS 分类读取分类接口
cms:tag:readCMS 标签读取标签接口
city:read城市数据读取城市接口
biz:org:read商务机构读取商务机构接口
biz:resource:read商务资源读取商务资源接口
media:read媒体读取媒体接口
material:read素材读取素材接口
data:report:read数据报告读取数据报告接口
product:read商品读取商品接口
region:read地区数据读取地区接口
search:read搜索搜索接口
ssoSSO 单点登录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

参数类型必填说明
langString语言代码,默认 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

参数类型必填说明
parentIdLong父级地区 ID
langString语言代码,默认 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

参数类型必填说明
langString语言代码,默认 zh-CN
countryCodeString按国家代码筛选

响应示例

{
  "code": 200,
  "data": {
    "records": [
      { "id": 1, "name": "昆明", "countryCode": "CN", ... }
    ],
    "total": 100
  }
}

城市详情

GET/api/open/v1/cities/{id}

Scope: city:read

参数类型必填说明
idLong城市 ID(路径参数)
langString语言代码,默认 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

参数类型必填说明
langString语言代码,默认 zh-CN

标签详情(按 slug)

GET/api/open/v1/cms/tag/slug/{slug}

Scope: cms:tag:read

参数类型必填说明
slugString标签 slug(路径参数)
langString语言代码,默认 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

参数类型必填说明
langString语言代码,默认 zh-CN

返回树形结构,每个节点含 children 数组。

分类详情(按 slug)

GET/api/open/v1/cms/category/slug/{slug}

Scope: cms:category:read

参数类型必填说明
slugString分类 slug(路径参数)
langString语言代码,默认 zh-CN

5.6 CMS 文章(写入)

以下接口用于第三方应用向平台推送(创建/更新)与撤稿 CMS 文章,需 cms:article:write 写权限域。文章以多语言副表(cms_article_i18n)承载各语言文案,主表仅存结构化字段。

撤稿(逻辑删除)

DELETE/api/open/v1/cms/article/{id}

Scope: cms:article:write

参数类型必填说明
idLong文章 ID(路径参数)

撤销已推送的文章。采用逻辑删除:主表 cms_articledeleted=1,数据保留可恢复;同时级联物理清理 cms_article_i18ncms_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

参数类型必填说明
codeString一次性授权码(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 节响应示例)。

流程图

A端(澜湄平台) B端(第三方应用) ───────────── ───────────── │ │ │ 1. 管理员点击「跳转B端」按钮 │ │ ──────────────────────────────────────► │ │ (A端后端生成一次性 code, 存入 Redis) │ │ 返回签名重定向 URL │ │ │ │ 2. 浏览器 302 跳转到 B端 │ │ ──────────────────────────────────────► │ │ URL: https://b.com/sso/callback? │ │ code=xxx&appId=lm_xxx&... │ │ │ │ 3. B端后端用 code │ │ 换取用户信息 │ │ ◄──────────────── │ │ GET /api/open/ │ │ v1/sso/token │ │ ?code=xxx │ │ (带 HMAC Header)│ │ │ │ 4. B端创建/匹配 │ │ 本地用户,完成 │ │ 自动登录 │ │ │

重定向 URL 参数

参数说明
code一次性授权码,5 分钟有效,使用后立即失效
appId应用 ID
timestamp时间戳(秒级)
nonce随机字符串
signHMAC-SHA256 签名(注意:签名格式与普通 API 不同,见下方说明)

重定向签名验证

跳转 URL 中的签名使用与普通 API 不同的签名内容格式,B 端收到跳转请求后应验证签名以确保来源合法。

签名内容格式(换行符分隔)

appId
timestamp
nonce
SSO          ← 固定标识(注意:不是请求方法)
code

验证步骤

  1. 从 URL 参数中提取 appIdtimestampnoncecodesign
  2. 验证 timestamp 是否在合理范围内(建议 5 分钟内)
  3. 用 AppSecret 按上述格式拼接签名内容,计算 HMAC-SHA256
  4. 比对计算结果与 URL 中的 sign 参数
  5. 签名验证通过后,用 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 分钟)
2003Nonce 已使用(重放攻击)
2004应用不存在或已禁用
2005IP 不在白名单
2006请求频率超限
2007签名验证失败
2008Scope 权限不足
2009应用已过期
1701SSO code 无效或不存在
1702SSO code 已过期
1703SSO code 已被使用
1704SSO 用户不存在
1705SSO 用户已禁用
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: 签名验证总是失败怎么办?

请按以下步骤排查:

  1. 确认签名内容各项之间使用真实的换行符 \n(LF),最后一项后不加换行
  2. 确认 sortedQueryString 的参数已按 key 字典序排列
  3. 确认 GET 请求的 bodySha256 为空字符串的 SHA-256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  4. 确认 HMAC-SHA256 输出为十六进制小写
  5. 确认服务器时间与本地时间偏差不超过 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 立即失效。