玄隐开放平台(Xuaninn Open Platform)API 方案 v1.0
适用对象:第三方分销商 / OTA / TMC 差旅 / 企业客户 / 集团自有小程序与渠道中台 底层业务依据:
backend/src/main/java/com/xuaninn/platform/PublicController.java(C 端 53 个方法级路由) 文档状态:除商城(P3)外全部已实现(2026-09-23):P0 只读、P1 交易、P2a Webhook、P2b 通用幂等、P2c 会员 OAuth2。 已落地端点见 §6.0;尚未落地的(会员 OAuth2、商城、Webhook 投递、OA 授权端点)标注「⏳」,按 §15.4 分期推进。 最后更新:2026-09-23(P1:报价/下单/订单/改期/取消/支付/退款)
实现位置速查:网关与接口在
backend/src/main/java/com/xuaninn/openapi/; 建表在backend/src/main/resources/schema.sql;配置在application.yml的app.open.*; Next 侧仅有一条/open/:path*反代;契约测试npm run test:openapi。
目录
- 方案定位与范围
- 总体架构
- 接入准备:环境与域名
- 鉴权机制
- 通用调用约定
- 接口分类总览
- 接口详细定义
- Webhook 事件推送
- 频率限制
- 错误码列表
- 版本管理策略
- 沙箱环境说明
- 开发者文档结构
- 接入流程指引
- 落地实施路线与现状差距
1. 方案定位与范围
1.1 一句话
把玄隐现有的「官网/小程序自用 C 端接口」封装为面向第三方应用的、有鉴权、有配额、有版本、有契约保障的开放 API,让外部渠道可以安全完成「查询可售 → 试算价格 → 下单占房 → 支付 → 退改 → 售后」的全链路。
1.2 与现有接口的关系(关键界定)
| 层 | 路径前缀 | 使用者 | 鉴权 | 是否对外承诺兼容 |
|---|---|---|---|---|
| 内部 C 端接口 | /api/** | 玄隐自有官网 H5 | 无 / 客人 Cookie xuaninn_guest | 不承诺,可随时调整 |
| 后台管理接口 | /api/admin/**(151 个路由) | 玄隐后台 | Cookie xuaninn_admin + RBAC | 内部 |
| 开放平台 API(本方案) | /open/v1/** | 第三方应用 | AppKey 签名 / OAuth2 Bearer | 受版本策略保护 |
不要直接调用
/api/**。 现有 C 端接口存在若干不适合直接外露的形态:
- 无统一响应信封(各接口返回裸 Map,如
{quote}/{orders}/{ok:true});- 错误消息为中文自然语言且无机器可读码(
@RestControllerAdvice返回{"error":"..."});- 部分接口「订单号即凭证」或「手机号 query 即凭证」(
GET /api/booking/orders/{id}、GET /api/mall/mine/orders?phone=),直接外露会导致越权遍历;- 金额单位混用(报价为「元」,订单/库存/券为「分」),极易被误读 100 倍;
- 无任何限流、无 appKey、无回调验签。
开放网关层的职责就是把这些内部形态翻译成稳定、安全、可版本的对外契约。
1.3 一期开放范围
一期开放「只读 + 交易」两条主干,不开放后台管理、租户配置、财务对账、密钥管理。
| 域 | 一期是否开放 | 备注 |
|---|---|---|
| 酒店 / 房型 / 价格计划内容 | 开放(只读) | |
| 可售性与报价 | 开放 | 高频接口,重配额 |
| 预订下单 / 改期 / 取消 | 开放 | |
| 支付单创建与状态 | 开放 | 一期仅 sandbox / front_desk,正式支付通道二期开放 |
| 退款申请与查询 | 开放 | 走既有审批流,第三方发起为 pending |
| 订单查询 | 开放 | 严格归属到自己 appId 创建的订单 |
| 会员(权益/积分/券) | 开放 | 需 OAuth2 用户授权 |
| 商城商品 / 下单 | 灰度 | 视租户开通 mall module |
| 评价 | 只读一期 | 写入需用户授权 |
| CMS 内容 | 开放(只读) | 条款 / 政策 / 新闻 |
| Webhook 事件推送 | 开放 | 支付 / 订单状态 / 退款 |
2. 总体架构
第三方服务器 玄隐平台
+----------------+ HTTPS + 签名 +--------------------------------------+
| Partner App | ----------------> | Nginx / WAF |
| (Server) | | | |
+----------------+ | v |
| Next.js 16 站点层 (:3010) |
+----------------+ OAuth2 授权码 | +-- / -> 官网 H5 |
| 用户浏览器 | ---------------> | +-- /api/:path* -> proxy ------+ |
+----------------+ | +-- /open/:path* -> proxy ------+---+
+----------------------------------+-----+
v
+----------------------------------------+
| Java 中台 (:8080) |
| +----------------------------------+ |
| | OpenApiGateway(新增) | |
| | - AppId/Secret 校验 + HMAC 签名 | |
| | - OAuth2 token 签发/校验 | |
| | - Scope 校验 / 租户绑定 | |
| | - 限流(令牌桶 + 配额) | |
| | - 幂等键 / Request-Id | |
| | - 单位换算(元 <-> 分) | |
| | - 统一信封 + 错误码 | |
| | - OpenApiAudit 审计 | |
| +---------------+-------------------+ |
| v 复用既有内部方法 |
| PlatformStore / PublicController |
| - planQuotes() / priceQuote() |
| - createReservation() |
| - guestOrderDetail() |
| - pay() / modifyQuoteOf() |
| ------------------------------------- |
| PostgreSQL(schema.sql 唯一裁决) |
+----------------------------------------+
2.1 三条硬约束(沿用项目既有铁律)
- 算价唯一入口:所有对外报价必须复用
PlatformStore.planQuotes()/priceQuote(),计价顺序固定「房价(含加床费)→ 会员价 → 券 → 积分」,禁止在网关层重写第二套算价。 - 库存为准:
ebooking模式房价房量只读本库InventoryDay,禁止回落 PMS;pms模式无凭证明确失败,不静默降级。 - 租户隔离:AppKey 在创建时绑定唯一
tenantId,网关从密钥推导租户,永不接受请求参数传入的 tenantId。
3. 接入准备:环境与域名
| 环境 | Base URL | 用途 | 数据 |
|---|---|---|---|
| 沙箱 Sandbox | https://sandbox-open.xuaninn.cn/open/v1 | 联调、验收 | 独立沙箱租户数据,每周重置 |
| 生产 Production | https://open.xuaninn.cn/open/v1 | 正式交易 | 真实租户数据 |
- 全站强制 HTTPS/TLS 1.2+。
- 仅
application/json; charset=utf-8。 - 沙箱与生产密钥完全隔离,同一
appId在两个环境是不同密钥对。
3.1 站点 / 租户识别
站点路由沿用既有规则(优先级从高到低):X-XN-Site-Host → X-Forwarded-Host → Host。
多语言:X-XN-Locale 头或 locale query,缺省按站点默认 locale。
4. 鉴权机制
玄隐开放平台提供两套并存的鉴权模式,按场景选用:
| 模式 | 适用场景 | 凭证 | 操作身份 |
|---|---|---|---|
| A. AppKey 签名(AK/SK) | 服务端直调:查房、报价、下单、支付、商城 | X-XN-AppId + HMAC-SHA256 签名 | 应用自身(渠道)身份,不可读取会员隐私 |
| B. OAuth2 授权码 + Bearer | 涉及用户资源:会员档案、我的订单/券/积分、代客下单 | Authorization: Bearer <access_token> | 用户 + 应用双重身份 |
一期强制:读写会员个人信息(
/open/v1/members/**)只能用模式 B;其余用模式 A。
4.1 模式 A:AppKey 签名
4.1.1 密钥形态
| 项 | 格式 | 说明 |
|---|---|---|
appId | xn_ak_ + 24 位小写十六进制 | 公开,随请求头传输 |
appSecret | xn_sk_ + 48 位 Base64URL 字符 | 仅创建时明文返回一次,平台以 AES-GCM 加密落库(主密钥存 KMS / 环境变量) |
sandbox 标志 | 布尔 | 沙箱与生产密钥不可互用 |
4.1.2 请求头
X-XN-AppId: xn_ak_9f2c...e1
X-XN-Timestamp: 1758614400 # Unix 秒
X-XN-Nonce: 7f3a9c2e4b1d # 每次请求唯一,12~32 字符
X-XN-Signature: BASE64(HMAC-SHA256(appSecret, stringToSign))
X-XN-Sign-Version: 1 # 固定 1
Idempotency-Key: <可选,写接口强烈建议>
X-Request-Id: <可选,链路追踪;缺失由网关生成>
4.1.3 待签串构造(Sign-Version = 1)
stringToSign =
HTTPMethod + "\n" +
CanonicalURI + "\n" + // path 段内 percent-encode,保留 '/' '-' '_' '.' '~'
CanonicalQueryString + "\n" + // query 按 key 字典序升序,k=v & 拼接;空则 ""
"x-xn-appid:" + <AppId> + "\n" +
"x-xn-nonce:" + <Nonce> + "\n" +
"x-xn-timestamp:" + <Timestamp> + "\n" +
HashedPayload
HashedPayload = lowerHex(SHA256(requestBodyRawBytes)) // GET/无 body 用空串哈希
最后:
Signature = BASE64( HMAC-SHA256( appSecret, UTF8(stringToSign) ) )
要点
- 参与签名的 URI / Query 必须是未经框架重排的原始值;不要先反序列化再重拼;
BASE64是标准 Base64(字符集含+/),不是 Base64URL(-_); 平台侧曾误用 Base64URL 导致第三方 100%signature_mismatch,是最容易吃的一个亏;HashedPayload是小写十六进制,不是 Base64;- 时间戳单位是秒(10 位);传毫秒会立刻
timestamp_expired; - 平台侧对这批头做
.trim(),但不要依赖它容错——带前导空格的 Nonce 大概率与你的预期不一致。
4.1.4 参考实现(Node.js)
import crypto from 'node:crypto';
function sign({ method, path, query = {}, body = '', appId, secret, nonce, ts }) {
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
const canonicalUri = path.split('/').map(enc).join('/');
const canonicalQuery = Object.keys(query).sort()
.map((k) => `${enc(k)}=${enc(String(query[k]))}`).join('&');
const hashedPayload = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
const sts = [
method.toUpperCase(), canonicalUri, canonicalQuery,
`x-xn-appid:${appId}`,
`x-xn-nonce:${nonce}`,
`x-xn-timestamp:${ts}`,
hashedPayload,
].join('\n');
return crypto.createHmac('sha256', secret).update(sts, 'utf8').digest('base64');
}
// 使用
const ts = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(8).toString('hex');
const body = JSON.stringify(payload); // 必须与最终发送的字节完全一致
const signature = sign({
method: 'POST', path: '/open/v1/booking/quote', query: {}, body,
appId: process.env.XN_APP_ID, secret: process.env.XN_APP_SECRET, nonce, ts,
});
4.1.5 服务端校验顺序
X-XN-AppId存在且应用状态enabled→ 否则40301 app_disabledabs(now − X-XN-Timestamp) <= 300s→ 否则40103 timestamp_expired- Redis
SETNX app:nonce:{appId}:{nonce}TTL 600s → 命中则40104 nonce_replayed - 重算签名做常数时间比较 → 不等则
40102 signature_mismatch - Scope 校验 →
40107 insufficient_scope - 租户一致性校验 →
40302 tenant_mismatch
4.2 模式 B:OAuth 2.0 授权码 + PKCE
用于第三方需要「代表用户」访问会员资源的场景(如差旅平台帮员工查积分、使用会员权益下单)。
4.2.1 端点(✅ 已实现)
| 用途 | 端点 | 说明 |
|---|---|---|
| 授权页 | GET /oauth/authorize | 返回自带「登录 + 同意授权」的 HTML 页 |
| 同意/拒绝 | POST /oauth/authorize/approve | 授权页表单提交,返回 {redirectTo} |
| 换取令牌 | POST /oauth/token | 只接受 application/x-www-form-urlencoded(RFC 6749 要求) |
| 吊销令牌 | POST /oauth/revoke | 幂等,令牌不存在也返回 200 |
| 令牌自省 | POST /oauth/introspect | RFC 7662 精简版 |
| JWKS | — | ⏳ 未实现(当前 access_token 是不透明随机串而非 JWT,无需 JWKS) |
实现口径与文档初版的差异(以本节为准)
access_token是不透明随机串,不是 JWT:因此没有 JWKS、也不能离线验签; 校验一律走/oauth/introspect或直接调用开放接口。这比 JWT 更容易吊销(库里有revokedAt)。- 必须使用 PKCE(S256):缺
code_challenge直接拒绝,不提供「机密客户端免 PKCE」的口子。- 错误体是 RFC 6749 格式(
{"error":"invalid_grant","error_description":"…"}), 不是开放平台的统一信封——OAuth 客户端库按 RFC 解析,这里必须守规范。- 授权码「先消费、后校验」:
redirect_uri或 PKCE 校验失败时授权码已被烧毁, 同一 code 不能重试(防 verifier 爆破)。客户端需重新走授权流程。- 令牌有效期:授权码 60 秒、access_token 120 分钟、refresh_token 30 天; refresh 采用一次性轮换(用旧 refresh 换新的一对,旧的立即失效)。
- 授权页形态:当前是 Java 中台渲染的极简页(无租户品牌)。若要品牌化,应在官网 H5 里 实现该页,仅调用
POST /oauth/authorize/approve签发授权码;后端无需改动。appSecret即client_secret:令牌端点必须带client_id+client_secret;redirect_uri需先在后台应用的redirectUris白名单里精确配置(不接受通配)。
4.2.2 授权码 + PKCE 流程
用户浏览器 --> /oauth/authorize
?response_type=code
&client_id=xn_ak_xxx
&redirect_uri=https%3A%2F%2Fpartner.com%2Fcb
&scope=member.read+order.read+booking.write
&state=xyz
&code_challenge=BASE64URL(SHA256(verifier))
&code_challenge_method=S256
|
+-- 玄隐校验 client_id / redirect_uri 白名单精确匹配(禁止通配、禁止 open redirect)
+-- 未登录 -> 引导「手机号 + OTP / 密码」登录(复用 /api/member/login)
+-- 用户确认授权页(列出 scope 明文授权项)
+-- 302 -> https://partner.com/cb?code=<一次性 code, 60s>&state=xyz
第三方服务端 --> POST /oauth/token
grant_type=authorization_code
&code=<code>&redirect_uri=<同上>
&client_id=<appId>&code_verifier=<原始 verifier>
<-- {access_token, refresh_token, expires_in:7200, token_type:"Bearer", scope, member_id}
4.2.3 令牌规则
| 项 | 值 |
|---|---|
access_token | JWT(RS256,网关私钥签发,公钥 JWKS 公开),有效期 2 小时 |
refresh_token | 不透明串,30 天,一次性轮换(rotation),旧值立刻失效 |
| Token claims | iss / sub(appId) / tenantId / memberId / scope / exp / jti |
| 吊销 | 用户在「会员中心 - 授权管理」自助吊销;平台侧可强制吊销 |
| 调用方式 | Authorization: Bearer <access_token>;此时免签名(建议仍带 X-Request-Id) |
4.2.4 刷新令牌
POST /oauth/token
grant_type=refresh_token&refresh_token=<rt>&client_id=<appId>
响应同 4.2.2,返回新的 refresh_token(旧的同时作废)。
4.3 Scope 清单
| Scope | 授予能力 |
|---|---|
hotel.read | 酒店 / 房型 / 政策 / CMS 内容只读 |
availability.read | 报价、价格日历、试算 |
booking.write | 创建 / 修改 / 取消预订 |
order.read | 读取本应用创建的订单 |
payment.write | 创建支付单、查询支付状态 |
refund.write | 发起退款、查询退款 |
member.read | 读取会员档案 / 等级 / 积分 / 券(需用户授权) |
member.write | 代客改资料(一期不开放) |
mall.read | 商城商品只读 |
mall.order.write | 商城下单 / 取消 |
review.read | 评价只读 |
webhook.manage | 配置 Webhook 端点 |
tenantId不在 scope 里 —— 它由 AppKey 静态绑定,不可请求切换。
4.4 安全基线
| 要求 | 说明 |
|---|---|
| 传输安全 | 强制 TLS 1.2+;禁止在浏览器/小程序前端存放 appSecret(违反立即停用) |
| 重放防护 | 时间戳窗口 ±300s + Nonce 去重 TTL 600s |
| 密钥轮换 | 支持双密钥(主/备共存 24h);泄露可一键吊销,旧密钥立即失效 |
| 最小权限 | 默认只给 hotel.read + availability.read;交易类 scope 需申请审核 |
| 限源 | 可选配置服务端出口 IP 白名单(CIDR) |
| 数据脱敏 | 网关出口对手机号/邮箱统一脱敏(138****8888),除非用户显式授权且接口声明返回原文 |
| 审计 | 全部写操作落 OpenApiAudit(appId, path, requestId, 参数摘要, 响应码, 耗时) |
5. 通用调用约定
5.1 请求
| 规则 | 约定 |
|---|---|
| 编码 | UTF-8 |
| Query vs Body | GET/DELETE 用 query;POST/PUT/PATCH 用 JSON body |
| 时间格式 | 请求:YYYY-MM-DD(日期)、RFC3339(日期时间);响应统一 RFC3339 带时区 |
| 时区 | 默认租户时区 Asia/Shanghai;可用 X-XN-Timezone 覆盖(IANA 名) |
| 金额 | 一律最小值货币单位(分)+ 显式 Fen 后缀。10000 = 100.00 元。网关负责与内部的「元」互转 |
| 货币 | currency 字段,一期恒为 CNY |
| 分页 | page(1 起)、pageSize(默认 20,最大 100),响应 {list, page, pageSize, total, hasMore} |
| 空值 | 不传即「不参与」;null 视为「显式置空」(仅 PATCH 语义) |
5.2 幂等
写接口(POST)建议带 Idempotency-Key(UUID v4,长度 <= 64)。行为已实现如下:
| 场景 | 平台行为 |
|---|---|
| 首次请求 | 正常处理,把「状态码 + 响应体」缓存 24 小时 |
| 同 key + 同请求体 | 直接回放首次响应(状态码与 body 完全一致),响应头 X-XN-Idempotent-Replay: true |
| 同 key + 不同请求体 | 40903 idempotency_conflict(不静默当成重复请求,避免「换了参数却拿到旧结果」) |
| 同 key 并发 | 第二个请求抢不到占位 → 409 conflict(提示「正在处理中」,不排队、不等待) |
5xx / 429 | 不缓存,占位立即释放,允许第三方用同一个 key 重试 |
| 「确定性 4xx」(如缺参) | 会缓存:重试同一个 key 得到同样的错误,便于排查 |
| 超过 256KB 的响应 | 放弃缓存(退化为不做回放),不影响业务结果 |
GET / DELETE | 不参与幂等(天然幂等),带该头也不会冲突 |
与业务幂等的分工:
Idempotency-Key是「HTTP 层」的通用兜底;下单另有partnerOrderNo、 退款另有内部幂等键,两者并存且互补。只带Idempotency-Key而不带partnerOrderNo时, 重放能拿到同一个订单;但一旦缓存过期(24h)后再重试,就会新建订单——所以对账请始终用partnerOrderNo。
5.3 统一响应信封
与内部
/api/**的裸 Map 完全不同,开放层必须统一:
{
"code": "ok",
"message": "success",
"data": {},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
失败:
{
"code": "inventory_insufficient",
"message": "所选房型在 2026-10-02 已售完",
"errors": [
{ "field": "quote.roomTypeId", "code": "sold_out", "message": "2026-10-02 无可用库存" }
],
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00",
"documentUrl": "https://open.xuaninn.cn/docs/errors/inventory_insufficient"
}
5.4 响应头
| 头 | 说明 |
|---|---|
X-Request-Id | 全链路追踪 ID,排查问题请提供 |
X-RateLimit-Limit / -Remaining / -Reset | 限流窗口信息 |
X-RateLimit-Bucket | 命中的限流桶(read / availability / member / write) |
X-XN-Idempotent-Replay | 命中幂等回放 |
X-XN-Api-Version | 实际处理的服务版本,如 v1.4.2 |
Deprecation / Sunset | 接口废弃标记 |
Retry-After | 429 / 503 时的建议重试秒数 |
6. 接口分类总览
路径前缀省略 Base URL。鉴权列:
AK= AppKey 签名;OA= OAuth2 Bearer。
6.0 实现状态(截至 2026-09-23)
| 状态 | 接口 |
|---|---|
| ✅ P0 只读 10 个 | GET /whoami(自检)、GET /hotels、GET /hotels/{hotelId}、GET /hotels/{hotelId}/room-types、GET /room-types/{roomTypeId}、GET /availability/rates、GET /availability/price-calendar、GET /reviews、GET /content/articles、GET /content/articles/{slug} |
| ✅ P1 交易 9 个 | POST /booking/quote、POST /booking/reservations、GET /booking/reservations/{no}、GET …/{no}/modify-quote、POST …/{no}/modify、GET …/{no}/cancel-quote、POST …/{no}/cancel、POST /payments、GET /payments/{paymentNo}、POST /payments/sandbox/complete、POST /refunds、GET /refunds/{refundNo} |
| ✅ 后台 4 个 | `GET |
| ✅ P2 Webhook(8 个) | GET /webhooks/events、`GET |
| ✅ 通用幂等(横切) | 全部 POST /open/v1/** 支持 Idempotency-Key(重放 / 冲突 / 并发保护 / 24h TTL) |
| ✅ P2c 会员与 OAuth2(6 个) | GET /oauth/authorize、POST /oauth/authorize/approve、`POST /oauth/token |
| ⏳ 规划中 | 商城(/mall/**)、POST /payments/{no}/close、JWKS(当前不用 JWT,无必要) |
| ✅ 已就绪的基础设施 | 应用注册表与密钥轮换、HMAC 签名与时间窗、Nonce 防重放、三级限流、统一信封与机器码、开放侧审计、归属索引 OpenResourceLink、幂等表、OAuth 令牌表(Bearer 校验已在网关生效,缺签发端点) |
P1 与 P0 的两个关键差异(读接口文档时容易按旧口径理解):
- 报价/下单不接受客户端金额(§7.3.1)——内部接口信任客户端传来的
quote.amount,开放层必须自己算;- 支付渠道只有
alipay_qr/wechat_qr/front_desk,「沙箱支付」是环境能力而不是渠道(§7.4.1)。
6.1 酒店内容域 Hotels
内部映射:/api/booking/hotels、/api/booking/rooms、/api/booking/room/{id}
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| GET | /hotels | AK | hotel.read | 酒店列表(含评分、起价) |
| GET | /hotels/{hotelId} | AK | hotel.read | 酒店详情 + 政策 |
| GET | /hotels/{hotelId}/room-types | AK | hotel.read | 房型列表(含图片/设施/余房) |
| GET | /room-types/{roomTypeId} | AK | hotel.read | 房型详情 + 酒店政策 |
6.2 可售性与价格域 Availability
内部映射:/api/booking/rates、/api/booking/price-calendar
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| GET | /availability/rates | AK | availability.read | 区间平铺报价(含逐日) |
| GET | /availability/price-calendar | AK | availability.read | 价格日历(≤92 天) |
6.3 预订域 Booking
内部映射:/api/booking/quote、/reservations、/cancel-quote、/cancel、/modify-quote、/modify
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| POST | /booking/quote | AK | availability.read | 下单前精确试算(不占房) |
| POST | /booking/reservations | AK | booking.write | 创建预订(占房) |
| GET | /booking/reservations/{reservationNo} | AK | order.read | 预订详情 |
| GET | /booking/reservations/{reservationNo}/modify-quote | AK/OA | booking.write | 改期试算 |
| POST | /booking/reservations/{reservationNo}/modify | AK/OA | booking.write | 执行改期 |
| GET | /booking/reservations/{reservationNo}/cancel-quote | AK/OA | booking.write | 取消试算 |
| POST | /booking/reservations/{reservationNo}/cancel | AK/OA | booking.write | 执行取消 |
6.4 支付域 Payments
内部映射:/api/pay/create、/api/pay/status、/api/pay/mock-complete
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| POST | /payments | AK | payment.write | 创建支付单 |
| GET | /payments/{paymentNo} | AK | payment.write | 查询支付状态 |
| POST | /payments/{paymentNo}/close | AK | payment.write | 关闭未支付单、释放库存 |
| POST | /payments/sandbox/complete | AK | payment.write | 仅沙箱:模拟支付成功 |
6.5 退款域 Refunds
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| POST | /refunds | AK | refund.write | 发起退款(落到 pending 待后台审核) |
| GET | /refunds/{refundNo} | AK | refund.write | 退款详情(含审批日志) |
6.6 会员域 Members(须用户授权)
内部映射:/api/member/**
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| GET | /members/me | OA | member.read | 当前授权会员档案 |
| GET | /members/me/tier | OA | member.read | 等级与权益、下一等级进度 |
| GET | /members/me/points | OA | member.read | 积分余额与流水 |
| GET | /members/me/coupons | OA | member.read | 可用券包 |
| GET | /members/me/orders | OA | member.read | 会员名下订单(不限于本应用) |
6.7 商城域 Mall(灰度)
内部映射:/api/mall/**
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| GET | /mall/products | AK | mall.read | 在售商品列表(含 SKU) |
| GET | /mall/products/{productId} | AK | mall.read | 商品详情 |
| POST | /mall/orders | AK | mall.order.write | 创建商城订单 |
| GET | /mall/orders/{orderNo} | AK | mall.order.write | 商城订单详情 |
| POST | /mall/orders/{orderNo}/pay | AK | payment.write | 商城支付(默认 sandbox) |
| POST | /mall/orders/{orderNo}/cancel | AK | mall.order.write | 取消商城订单 |
6.8 评价域 Reviews
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| GET | /reviews | AK | review.read | 酒店公开评价列表 + 聚合分 |
6.9 内容域 Content
内部映射:/api/cms/**、/api/site/content
| 方法 | 路径 | 鉴权 | Scope | 说明 |
|---|---|---|---|---|
| GET | /content/articles | AK | hotel.read | 已发布文章列表 |
| GET | /content/articles/{slug} | AK | hotel.read | 文章详情(会员专享内容无权时裁剪) |
6.10 Webhook 管理
| 方法 | 路径 | Scope |
|---|---|---|
| POST / GET | /webhooks/endpoints | webhook.manage |
| PATCH / DELETE | /webhooks/endpoints/{id} | webhook.manage |
| POST | /webhooks/endpoints/{id}/rotate-secret | webhook.manage |
| POST | /webhooks/endpoints/{id}/redeliver | webhook.manage |
7. 接口详细定义
每个子节格式:Scope → 内部映射 → 请求参数 → 示例 → 响应 → 边界约束。 金额单位:除特别说明,全部为分(CNY 最小单位),字段名以
Fen结尾。
7.1 酒店内容域
7.1.1 GET /hotels 酒店列表
- Scope:
hotel.read;内部映射:GET /api/booking/hotels
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
city | query | string | 否 | 城市名/代码,不传返回租户全量酒店 |
updatedSince | query | datetime | 否 | 增量拉取(RFC3339) |
page / pageSize | query | int | 否 | 默认 1 / 20 |
curl -X GET "https://sandbox-open.xuaninn.cn/open/v1/hotels?pageSize=2" \
-H "X-XN-AppId: $XN_APP_ID" -H "X-XN-Timestamp: $TS" \
-H "X-XN-Nonce: $NONCE" -H "X-XN-Signature: $SIG" -H "X-XN-Sign-Version: 1"
响应 200:
{
"code": "ok",
"message": "success",
"data": {
"list": [
{
"hotelId": "htl_7075ddc83cac",
"slug": "h-11415b",
"name": "宝盛西湖店演示",
"city": "hangzhou",
"address": "浙江省杭州市萧山区市心中路 618 号",
"phone": "0571-88888888",
"bookingMode": "ebooking",
"status": "published",
"reviewScore": 4.7,
"reviewCount": 128,
"lowestPriceFen": 68800,
"currency": "CNY"
}
],
"page": 1, "pageSize": 20, "total": 12, "hasMore": false
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
约束(与实现一致,勿按旧版示例开发)
- 字段就是上面这些:当前
Hotel表只有 id/slug/name/city/address/phone/bookingMode/status, 不存在starRating/coordinates/nameEn/images(历史示例曾写过,属于规划项,未实现);city是内部城市串(如hangzhou),不是结构化城市对象;lowestPriceFen为未来 90 天最低价快照且可为null(null= 该期间无任何可售库存, 内部用 0 表示无价,对外统一转成null,避免被误读为「免费房」);它不是实时报价,不可作为成交依据;- 只返回
status=published的酒店;bookingMode=pms的酒店也会列出,但其价格需找平台单独开通(见 §7.2.1)。
7.1.2 GET /hotels/{hotelId} 酒店详情 + 政策
- Scope:
hotel.read - 内部
policy原字段:hotelId, checkInFrom, checkOutUntil, depositRequired, petAllowed, childAllowed, latestArrival
{
"code": "ok",
"data": {
"hotel": {
"hotelId": "htl_7075ddc83cac",
"slug": "h-11415b",
"name": "宝盛西湖店演示",
"city": "hangzhou",
"address": "",
"phone": "",
"bookingMode": "ebooking",
"status": "published",
"reviewScore": 0,
"reviewCount": 0,
"lowestPriceFen": null,
"currency": "CNY",
"policy": {
"hotelId": "htl_7075ddc83cac",
"checkInFrom": "14:00",
"checkOutUntil": "12:00",
"latestArrival": "23:59",
"depositRequired": false,
"petAllowed": false,
"childAllowed": true
}
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
policy直接复用中台hotelPolicy()的结果;字段随租户配置而定,可能缺省(届时按null处理)。 图片、设施、星级当前不在本接口范围(Hotel表无对应列),需要图片请用房型接口的images。
7.1.3 GET /hotels/{hotelId}/room-types 房型列表
- Scope:
hotel.read;内部映射:GET /api/booking/rooms
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
checkIn / checkOut | query | date | 否 | 不传则不返回价格与余房 |
rooms | query | int | 否 | 默认 1,上限 5 |
guests | query | int | 否 | 默认 2 |
响应核心:
{
"code": "ok",
"data": {
"list": [
{
"roomTypeId": "rt_deluxe_king",
"name": "豪华大床房",
"occupancy": 2,
"areaSqm": 42,
"bedType": "1 张 1.8 米大床",
"floorRange": "12-18F",
"maxExtraBed": 1,
"images": ["https://cdn.xuaninn.cn/rt/dk-01.jpg"],
"amenities": ["免费 Wi-Fi", "迷你吧", "独立淋浴"],
"reviewCount": 36,
"reviewScore": 4.8,
"nights": 3,
"availableRooms": 4,
"minPriceFen": 206400,
"breakfast": "双早",
"cancelPolicyText": "入住前 24 小时可免费取消",
"fitsGuests": true,
"plans": [
{
"ratePlanId": "rp_flex",
"ratePlanName": "灵活价",
"amountFen": 206400,
"available": true,
"availableRooms": 4,
"breakfast": "双早",
"mealCode": "DBL_BF",
"cancelHours": 24,
"cancelPolicy": "入住前 24 小时可免费取消"
}
]
}
],
"page": 1, "pageSize": 20, "total": 8, "hasMore": false
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
约束
plans[].amountFen是区间总价(nights × rooms);内部RateQuote.amount单位为元,网关 ×100 转换。逐日明细请用/availability/rates;- 房型级余房叫
availableRooms(整数),计划级available是布尔——内部两个层级都叫available且都是整数,对外刻意拆开,避免第三方把「余房 3 间」当成true;plans[]只包含当前可订的计划(中台已过滤余房不足/未发布的),所以「计划不在列表里」就等于不可订,不要试图按房型随便挑一个下单(见 §7.3.1)。
7.1.4 GET /room-types/{roomTypeId} 房型详情
同 7.1.3 单项结构,额外返回 policy 对象。checkIn/checkOut 可选;不传则 plans 为空数组,仅返回物理房型内容。
7.2 可售性与价格域
7.2.1 GET /availability/rates 区间平铺报价
- Scope:
availability.read;内部映射:GET /api/booking/rates - 用途:抓取某城市/某酒店在一段日期内所有可售组合的逐日价格
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
city | query | string | 否 | 与 hotelId 至少一个 |
hotelId | query | string | 否 | 酒店 slug 或 id |
checkIn | query | date | 是 | |
checkOut | query | date | 是 | 晚于 checkIn,最长 92 晚 |
rooms / guests | query | int | 否 | 默认 1 / 2 |
{
"code": "ok",
"data": {
"checkIn": "2026-09-29",
"checkOut": "2026-10-01",
"nights": 2,
"rooms": 1,
"list": [
{
"hotelId": "htl_7075ddc83cac",
"hotelSlug": "h-11415b",
"hotelName": "宝盛西湖店演示",
"roomTypeId": "rt_49a18f412d3c",
"roomTypeName": "标准间",
"ratePlanId": "rp_xxx",
"ratePlanName": "灵活价",
"checkIn": "2026-09-29",
"checkOut": "2026-10-01",
"nights": 2,
"rooms": 1,
"available": true,
"availableRooms": 6,
"breakfast": "双早",
"mealCode": "DBL_BF",
"cancelHours": 24,
"cancelPolicy": "入住前 24 小时可免费取消",
"totalFen": 60000,
"currency": "CNY"
}
],
"page": 1, "pageSize": 20, "total": 1, "hasMore": false
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
约束(与实现一致)
totalFen是整个区间的总价(= 单晚价 ×nights×rooms),不是单晚价; 要单晚价请用totalFen / nights / rooms,或改用 §7.2.2 的价格日历取每日最低价;- 没有
daily逐日明细(历史示例曾列出,属规划项):中台planQuotes()只返回区间总价, 逐日价格请走/availability/price-calendar;- 本接口实时读库存,建议本地缓存 60s TTL,不得用于高频「探价」轮询;
- 只覆盖
bookingMode=ebooking的酒店(房量来自本库InventoryDay)。PMS 直连酒店不返回报价, 其开放属二期;available=false的计划也会出现在列表里(便于前台灰显),下单时会返回40901。
7.2.2 GET /availability/price-calendar 价格日历
- Scope:
availability.read;内部映射:GET /api/booking/price-calendar
| 参数 | 必填 | 说明 |
|---|---|---|
hotelId / roomTypeId | 二选一 | 酒店维度返回最低价,房型维度返回该房型价 |
from | 是 | 起始日期 |
to | 是 | 结束日期,超过 92 天自动截断 |
rooms | 否 | 默认 1 |
{
"code": "ok",
"data": {
"hotelId": "h-11415b",
"roomTypeId": null,
"from": "2026-09-29",
"to": "2026-10-01",
"maxDays": 92,
"days": [
{ "date": "2026-09-29", "minPriceFen": 30000, "soldOut": false, "weekend": false },
{ "date": "2026-09-30", "minPriceFen": 30000, "soldOut": false, "weekend": false },
{ "date": "2026-10-01", "minPriceFen": 30000, "soldOut": false, "weekend": false }
]
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
约束:① 返回值把查询条件一起回显(
hotelId/roomTypeId/from/to/maxDays),便于第三方做缓存键; ② 区间超过 92 天由中台自动截断,to仍回显请求值,请以days实际长度为准; ③ 与/availability/rates一样只覆盖bookingMode=ebooking的酒店, PMS 直连酒店会整段返回soldOut=true(其房态不在本库,不是「真卖完了」)。
7.3 预订域(核心交易链路)
7.3.1 POST /booking/quote 下单前精确试算
- Scope:
availability.read - 特性:不占房、不落库。与下游
reservations共用同一段计价代码,保证试算价与成交价同源 - ⚠️ 请求体是扁平的,且不接受金额:服务端按
hotelId + roomTypeId + ratePlanId + 日期 + 间数自己定价。 内部/api/booking/quote是「客户端把amount回传给服务端」的用法,开放层刻意不沿用—— 否则第三方把amount改成 1 就能 1 元入住。传了amount会被忽略(不会报错,但也不生效)。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
hotelId | string | 是 | 酒店 id 或 slug |
roomTypeId | string | 是 | 房型 ID,必须先经 /hotels/{id}/room-types 取得 |
ratePlanId | string | 是 | 房价计划 ID,必须精确命中(不回落同房型其他计划) |
checkIn / checkOut | date | 是 | YYYY-MM-DD,checkOut > checkIn |
rooms | int | 否 | 默认 1,上限 5 |
guests | int | 否 | 默认 2;超过房型可住人数会报 business_rule_violation |
extraBeds | int | 否 | 加床数量,上限 maxExtraBed × rooms |
guestPhone | string | 否 | 传了才会计入会员折扣与券可用性;一期仅允许传自己持有授权的会员号 |
couponIssueId | string | 否 | 指定券,不传则不使用(不自动择券) |
pointsToUse | int | 否 | 使用积分数量,受余额约束 |
curl -X POST "https://sandbox-open.xuaninn.cn/open/v1/booking/quote" \
-H "Content-Type: application/json" \
-H "X-XN-AppId: $XN_APP_ID" -H "X-XN-Timestamp: $TS" \
-H "X-XN-Nonce: $NONCE" -H "X-XN-Signature: $SIG" -H "X-XN-Sign-Version: 1" \
-d '{
"hotelId": "h-11415b",
"roomTypeId": "rt_49a18f412d3c",
"ratePlanId": "rp_54598dec3a82",
"checkIn": "2026-09-29",
"checkOut": "2026-10-01",
"rooms": 1,
"guests": 2,
"extraBeds": 0,
"pointsToUse": 0
}'
响应 200(既回显定位到的房型/计划上下文,又给出分单位明细):
{
"code": "ok",
"message": "success",
"data": {
"quote": {
"hotelId": "htl_7075ddc83cac",
"hotelName": "宝盛西湖店演示",
"roomTypeId": "rt_49a18f412d3c",
"roomTypeName": "标准间",
"ratePlanId": "rp_54598dec3a82",
"ratePlanName": "门市价",
"checkIn": "2026-09-29",
"checkOut": "2026-10-01",
"nights": 2,
"rooms": 1,
"guests": 2,
"mealCode": "DBL_BF",
"currency": "CNY",
"roomFen": 60000,
"extraBeds": 0,
"extraBedFeePerNightFen": 15000,
"extraBedFeeFen": 0,
"baseFen": 60000,
"tierDiscountFen": 0,
"netAmountFen": 60000,
"couponIssueId": null,
"couponFen": 0,
"pointsUsed": 0,
"pointsFen": 0,
"payableFen": 60000,
"member": null,
"breakfast": true,
"cancelHours": 24,
"cancelPolicy": "入住前 24 小时可免费取消",
"holdMinutes": 30
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
breakfast在报价里是布尔(是否有早),房型/计划接口里是文本(如「双早」)。
单位换算对照(内部元 → 外部分)(重要):
| 内部字段(元) | 外部字段(分) | 换算 |
|---|---|---|
roomYuan | roomFen | x100 |
extraBedFeeYuan / extraBedFeePerNightYuan | extraBedFeeFen / extraBedFeePerNightFen | x100 |
baseYuan(已含加床费) | baseFen | x100 |
tierDiscountYuan | tierDiscountFen | x100 |
netAmountYuan | netAmountFen | x100 |
couponYuan | couponFen | x100 |
pointsYuan | pointsFen | x100 |
payableYuan | payableFen | x100 |
边界约束
payableFen = baseFen − tierDiscountFen − couponFen − pointsFen,最低 0,不会出现负数;- 券门槛按会员折后价(
netAmountFen)判定,不是原价;couponIssueId不传就是不使用券(不会自动挑一张最省钱的);- 试算通过不代表库存被锁,下单时仍可能返回
40901 inventory_insufficient(并发被抢);amount字段:传了也会被忽略。服务端取价来源是「房型 + 计划」的可订列表,与/hotels/{id}/room-types完全同源;- 报价时效:一期不返回
expiresAt(内部报价快照没有落库有效期),下单以当时的可订性与价格为准;- 报错口径:房型整体不存在 →
40402;房型存在但目标日期没有可订库存 →40901;房价计划不在可订列表 →40404。
7.3.2 POST /booking/reservations 创建预订
- Scope:
booking.write - 副作用:立即占房(内部
InventoryDay.held),内置超卖防护;未支付记录holdExpiresAt,30 分钟后自动释放(前台现付除外) - 幂等:
partnerOrderNo即幂等键(见下方约束 5)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
hotelId / roomTypeId / ratePlanId | string | 是 | 与 §7.3.1 完全同参,服务端据此取价 |
checkIn / checkOut | date | 是 | |
rooms | int | 否 | 默认 1,上限 5 |
guests | int | 否 | 入住总人数,默认 2(不是名单数组,见 roomGuests) |
guestName | string | 是 | 入住联系人 |
guestPhone | string | 是 | 11 位手机号,必须 1 开头,否则 40001 |
privacyConsent | boolean | 是 | 必须为 true(隐私合规硬要求,缺失返回 40002) |
roomGuests[] | array | 否 | 每间房入住人:{roomIndex, guestName, guestPhone, isContact};不传则用联系人自动补齐 |
children | int | 否 | 儿童数 |
extraBeds | int | 否 | 加床数量,上限 maxExtraBed × rooms |
arrivalTime | string | 否 | HH:mm 预计到店 |
specialRequest | string | 否 | 备注,<=500 字 |
invoiceTitle / invoiceTaxNo | string | 否 | 发票信息 |
payChannel | string | 否 | alipay_qr / wechat_qr / front_desk;不传则只占房不发起支付 |
couponIssueId / pointsToUse | string/int | 否 | 与 §7.3.1 同口径 |
partnerOrderNo | string | 强烈建议 | 第三方订单号,<=64 字符;同一 appId 下唯一,用于幂等回放与对账 |
curl -X POST "https://sandbox-open.xuaninn.cn/open/v1/booking/reservations" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8a7e1f2c-3b4d-4f5a-9c8b-1d2e3f4a5b6c" \
-H "X-XN-AppId: $XN_APP_ID" -H "X-XN-Timestamp: $TS" \
-H "X-XN-Nonce: $NONCE" -H "X-XN-Signature: $SIG" -H "X-XN-Sign-Version: 1" \
-d '{
"hotelId": "h-11415b",
"roomTypeId": "rt_49a18f412d3c",
"ratePlanId": "rp_54598dec3a82",
"checkIn": "2026-09-29",
"checkOut": "2026-10-01",
"rooms": 1,
"guests": 2,
"guestName": "张三",
"guestPhone": "13800008888",
"privacyConsent": true,
"arrivalTime": "18:00",
"payChannel": "front_desk",
"partnerOrderNo": "PA-20260923-0001"
}'
响应 201 Created:
{
"code": "ok",
"message": "success",
"data": {
"reservation": {
"reservationNo": "XN-BK-20260923-000738",
"partnerOrderNo": "PA-20260923-0001",
"orderId": "ord_01J9K3Z9",
"status": "pending",
"payStatus": "unpaid",
"payChannel": "front_desk",
"hotelId": "htl_baosheng_plaza",
"hotelName": "杭州宝盛广场酒店",
"roomTypeId": "rt_deluxe_king",
"roomTypeName": "豪华大床房",
"ratePlanId": "rp_flex",
"checkIn": "2026-10-01",
"checkOut": "2026-10-04",
"nights": 3,
"rooms": 1,
"guestName": "张三",
"guestPhone": "138****8888",
"guests": [{ "roomIndex": 1, "guestName": "张三", "guestPhone": "138****8888", "isContact": true }],
"amountFen": 185760,
"couponFen": 0,
"pointsUsed": 0,
"tierDiscountFen": 20640,
"extraBedCount": 0,
"refundStatus": "none",
"createdAt": "2026-09-23T14:30:12+08:00",
"holdExpiresAt": "2026-09-23T15:00:12+08:00",
"holdMinutes": 30,
"cancelQuote": {
"cancellable": true,
"freeCancel": true,
"freeCancelBefore": "2026-09-30T14:00:00+08:00",
"penaltyFen": 0,
"policyText": "入住前 24 小时可免费取消"
}
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
边界约束
privacyConsent: true缺失 →40002 missing_parameter(不会先占房再报错,校验在占房之前);guestPhone非 11 位 →40001 invalid_parameter;- 库存不足 / 目标日期不可订 →
40901 inventory_insufficient;房价计划不在可订列表 →40404;guestPhone命中会员时自动应用等级折扣(与 §7.3.1 试算同口径);- 幂等:带
partnerOrderNo时,重复提交返回首次创建的订单(HTTP200+X-XN-Idempotent-Replay: true), 不会重复占房;首次创建返回201。不带partnerOrderNo则每次都会新建订单(仅靠Idempotency-Key头兜底,一期未强制);payChannel=front_desk为前台现付:不生成在线支付单,且库存不会被超时释放;- 订单归属于当前 AppKey(写入
OpenResourceLink),其他 appId 查同单号一律40403。
7.3.3 GET /booking/reservations/{reservationNo} 预订详情
- Scope:
order.read - 内部映射:
guestOrderDetail()(不是orderById()—— 后者缺guests/logs/cancelQuote)
{
"code": "ok",
"data": {
"reservation": {
"reservationNo": "XN-BK-20260923-000738",
"status": "confirmed",
"payStatus": "paid",
"amountFen": 185760,
"dueFen": 0,
"refundedFen": 0,
"reviewable": true,
"logs": [
{ "action": "create", "label": "创建订单", "actor": "partner:xn_ak_9f2c", "at": "2026-09-23T14:30:12+08:00", "note": null },
{ "action": "pay", "label": "支付成功", "actor": "partner:xn_ak_9f2c", "at": "2026-09-23T14:31:02+08:00", "note": null }
]
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:35:12+08:00"
}
订单状态枚举:pending / pending_confirm / confirmed / rejected / checked_in / checked_out / cancelled。
越权处理:非本应用创建的订单 →
40403 order_not_found(故意返回 404 而非 403,避免订单号枚举探测)。
7.3.4 GET /booking/reservations/{reservationNo}/cancel-quote 取消试算
- Scope:
booking.write;内部映射:GET /api/booking/reservations/{id}/cancel-quote
{
"code": "ok",
"data": {
"cancelQuote": {
"paid": true,
"cancellable": true,
"freeCancel": true,
"freeCancelBefore": "2026-09-30T14:00:00+08:00",
"penaltyFen": 0,
"refundableFen": 185760,
"policyText": "入住前 24 小时可免费取消"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:36:12+08:00"
}
7.3.5 POST /booking/reservations/{reservationNo}/cancel 执行取消
- Scope:
booking.write;幂等:建议 - 请求:
{ "reason": "行程变更" }(可空对象{})
{
"code": "ok",
"data": {
"reservation": {
"reservationNo": "XN-BK-20260923-000738",
"status": "cancelled",
"refundStatus": "pending",
"refundedFen": 0
},
"refund": {
"refundNo": "XN-RF-20260923-000101",
"status": "pending",
"amountFen": 185760,
"requestedAt": "2026-09-23T14:37:02+08:00"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:37:02+08:00"
}
取消成功后必定生成一张
pending退款单(需后台审核),refundStatus按退款单实际结果写回。
7.3.6 GET /booking/reservations/{reservationNo}/modify-quote 改期试算
- Scope:
booking.write - 参数:
checkIn(必)checkOut(必)ratePlanId(选)
{
"code": "ok",
"data": {
"modifyQuote": {
"modifiable": true,
"bookable": true,
"reason": null,
"orderStatus": "confirmed",
"before": { "checkIn": "2026-10-01", "checkOut": "2026-10-04", "nights": 3, "amountFen": 185760 },
"checkIn": "2026-10-02",
"checkOut": "2026-10-05",
"nights": 3,
"available": 3,
"breakfast": "双早",
"cancelPolicy": "入住前 24 小时可免费取消",
"roomAmountFen": 206400,
"tierDiscountFen": 20640,
"keepCouponFen": 0,
"keepPointsFen": 0,
"afterFen": 185760,
"diffFen": 0,
"payFen": 0,
"refundFen": 0,
"policyText": "入住前 24 小时可免费取消"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:38:12+08:00"
}
7.3.7 POST /booking/reservations/{reservationNo}/modify 执行改期
- 请求:
{ "checkIn": "2026-10-02", "checkOut": "2026-10-05", "ratePlanId": "rp_flex" } - 幂等:必须
- 响应:
{ data: { reservation: {...}, refund: {...} | null, dueFen: 0 } }
三条硬约束(沿用内部设定,不可绕过)
- 目标房价计划必须精确命中,找不到返回
40404 rate_plan_not_found,绝不回落同房型其他计划;- 房量「先占新日期,成功再放旧日期」,且已支付订单新房量记
sold而非held;- 券与积分原样保留,
afterFen = 新房价折后 − 原券额 − 原积分抵扣。差额diffFen > 0→ 生成dueFen待补款;< 0→ 生成退款单。
7.4 支付域
7.4.1 POST /payments 创建支付单
- Scope:
payment.write - ⚠️ 「沙箱支付」不是渠道:渠道只有
alipay_qr/wechat_qr/front_desk三个, 非法值直接40001而不是悄悄回落成支付宝(内部normaliseChannel会静默回落,开放层刻意不沿用)。 沙箱环境想模拟收款,请用 §7.4.4。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
reservationNo | string | 是 | 必须是自己 appId 创建的订单 |
channel | string | 否 | alipay_qr(默认)/ wechat_qr / front_desk |
returnUrl | string | 否 | 一期未使用(不做支付完成跳转,请轮询或等 Webhook) |
{
"code": "ok",
"data": {
"payment": {
"paymentNo": "yp_1aa79434f352394e",
"reservationNo": "BS20260923452331",
"channel": "alipay_qr",
"status": "pending",
"amountFen": 60000,
"orderAmountFen": 60000,
"dueFen": 0,
"extraCharge": false,
"offline": false,
"sandbox": true,
"provider": "yeepay",
"payUrl": "https://lossom.xuaninn.cn/hotel/pay/ord_xxx?channel=alipay_qr&txn=yp_1aa79434f352394e",
"qrCodeUrl": "https://lossom.xuaninn.cn/api/pay/qr?txn=yp_1aa79434f352394e",
"expiresAt": "2026-09-23T14:45:12Z",
"message": null,
"currency": "CNY"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
约束
paymentNo就是内部支付流水的providerTxnId(形如yp_+ 8 位十六进制)。不要自己另造一套单号;status取值:pending(在线待支付)/offline(前台现付);channel=front_desk返回offline: true、payUrl: null,且库存不会被自动释放;amountFen是本次应支付金额:改期补差价时会等于dueFen而不是订单总额(orderAmountFen才是总额);- 一期已付清且无补差价的订单再调本接口 →
40902 order_status_conflict(内部文案「订单已支付」)。
7.4.2 GET /payments/{paymentNo} 查询支付状态
{
"code": "ok",
"data": {
"payment": {
"paymentNo": "yp_1aa79434f352394e",
"status": "paid",
"amountFen": 60000,
"dueFen": 0,
"currency": "CNY"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:31:30+08:00"
}
约束
status是内部支付流水状态(pending/paid/failed等),不是订单状态;订单状态请看 §7.3.3;- 金额刻意从订单推算(
dueFen > 0 ? dueFen : amountFen),因为内部Payment.amount在「正常单」和「补差价单」里存的是元、而Refund是分,口径不统一;- 仍建议以 Webhook
payment.succeeded为准(Webhook 属 P2),轮询仅作补偿:间隔 >= 3s,最长 15 分钟。
7.4.3 POST /payments/{paymentNo}/close 关闭支付单
一期未开放(内部没有「关闭支付单并释放占房」的对外方法)。需要取消时请走
POST /booking/reservations/{no}/cancel;未支付订单由占房超时机制自动释放。
7.4.4 POST /payments/sandbox/complete 沙箱模拟支付成功
- 仅
env=sandbox的 AppKey 可用;生产 AppKey 调用返回40400 resource_not_found(对外不暴露该能力是否存在) - 请求:
{ "paymentNo": "yp_1aa79434f352394e" } - 响应:
{ payment: { paymentNo, paid: true, duplicate: false } }(duplicate表示重复调用,幂等安全) - 内部映射:
/api/pay/mock-complete;成功后订单payStatus变paid、status变confirmed(或pending_confirm)
7.5 退款域
7.5.1 POST /refunds 发起退款
- Scope:
refund.write;幂等:必须(内部已用rf-{orderId}-{amountFen}兜底)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
reservationNo | string | 是 | 预订号 |
amountFen | int | 否 | 不传按可退上限全额 |
reason | string | 否 | <=200 字 |
{
"code": "ok",
"data": {
"refund": {
"refundNo": "RF12251819826B0",
"reservationNo": "BS20260923012469",
"status": "pending",
"amountFen": 60000,
"currency": "CNY",
"method": "original",
"reason": "渠道申请退款",
"remark": null,
"requestedAt": "2026-09-23T08:15:18+08:00",
"processedAt": null,
"pendingHours": 0,
"overdue": false
},
"maxRefundableFen": 60000,
"replayed": false
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:40:02+08:00"
}
可退上限口径(照此计算,勿盯订单总额):
maxRefundableFen = amountFen(订单总额) − dueFen(待补差价)改期补过差价的订单,直接拿
amountFen当上限会多退。 不传amountFen时平台按上式的全额退款;maxRefundableFen会在响应里回显。幂等:优先用请求头
Idempotency-Key;不传时平台兜底用open:{appId}:{reservationNo}:{amountFen}, 因此「同一渠道对同一订单的同额退款」重复提交只会存在一张单(响应replayed: true)。 想对同一订单分两笔退不同金额时,请显式传不同的Idempotency-Key。
7.5.2 GET /refunds/{refundNo} 退款详情
{
"code": "ok",
"data": {
"refund": {
"refundNo": "RF12251819826B0",
"reservationNo": "BS20260923012469",
"status": "succeeded",
"amountFen": 60000,
"currency": "CNY",
"method": "original",
"reason": "渠道测试取消",
"remark": null,
"requestedAt": "2026-09-23T00:15:18+00:00",
"processedAt": null,
"pendingHours": 0,
"overdue": false,
"logs": [
{ "action": "submit", "fromStatus": null, "toStatus": "pending", "actor": "guest", "at": "2026-09-23T00:15:18+00:00", "remark": null },
{ "action": "retry", "fromStatus": "pending", "toStatus": "succeeded", "actor": "system", "at": "2026-09-23T00:15:18+00:00", "remark": null }
]
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:42:00+08:00"
}
退款状态:pending / processing / succeeded / failed / rejected;超过 24h 未终态 → overdue: true。
环境差异:一期没有正式退款通道,本地/沙箱环境会自动把退款推进到
succeeded; 生产环境(接正式通道前)由后台人工审批,第三方发起的退款会停在pending,请以 Webhook 或轮询为准。 因此不要把status=succeeded当作「已到账」的充要条件去驱动发货类逻辑。
7.6 会员域(✅ 已实现,必须 OAuth2 用户授权)
两条硬口径(与实现一致)
- 只认 Bearer 令牌里的 memberId:手机号由服务端反查,不接受任何请求参数传手机号 (否则就是「手机号即凭证」,可遍历他人会员数据)。用 AppKey 调这些接口会得到
403 insufficient_scope(提示必须用用户授权)。- 手机号与邮箱一律脱敏:
138****8888/z***n@example.com。第三方拿不到可直接用于 二次营销的完整联系方式——这是产品明确要求,也符合最小必要原则。
7.6.1 GET /members/me 会员档案
- Scope:
member.read;鉴权:Authorization: Bearer <access_token> - 内部映射:复用中台
memberMe()(与官网 H5「会员中心」同一份数据)
{
"code": "ok",
"data": {
"member": {
"memberId": "mbr_01J9K3Z8",
"memberNo": "BS88008888",
"phone": "138****8888",
"name": "张三",
"email": "z***@example.com",
"status": "active",
"growth": 1280,
"points": 3560,
"couponCount": 3,
"unreadNotices": 1,
"joinedAt": "2026-01-12T10:02:33+08:00",
"expiresAt": "2027-01-12T10:02:33+08:00"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:45:12+08:00"
}
7.6.2 GET /members/me/tier 等级与权益
{
"code": "ok",
"data": {
"tier": {
"code": "gold",
"name": "金卡会员",
"themeColor": "#C8A45C",
"discountPermille": 100,
"pointsRatePermille": 150,
"benefits": ["延迟退房至 14:00", "免费双早", "房型升级券 x2"],
"expiresAt": "2027-01-12T10:02:33+08:00"
},
"nextTier": { "code": "platinum", "name": "白金卡", "growthRequired": 3000, "growthGap": 1720, "progressPermille": 426 },
"allTiers": [
{ "code": "silver", "name": "银卡", "growthRequired": 0, "current": false },
{ "code": "gold", "name": "金卡", "growthRequired": 1000, "current": true },
{ "code": "platinum", "name": "白金卡", "growthRequired": 3000, "current": false }
]
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:45:12+08:00"
}
同口径要求:等级数据以
MemberTier表为唯一来源,discountPermille必须在报价与结算两处一致(内部createReservation与/api/booking/benefits已强制同源),网关不重算,只透传。
7.6.3 GET /members/me/points 积分余额与流水
{
"code": "ok",
"data": {
"balance": 3560,
"list": [
{ "type": "earn", "points": 278, "reason": "订单 XN-BK-20260918-000512 支付累计积分", "createdAt": "2026-09-18T15:20:11+08:00" },
{ "type": "spend", "points": -100, "reason": "订单 XN-BK-20260920-000622 积分抵扣", "createdAt": "2026-09-20T09:11:02+08:00" }
],
"page": 1, "pageSize": 20, "total": 2, "hasMore": false
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:45:12+08:00"
}
7.6.4 GET /members/me/coupons 券包
{
"code": "ok",
"data": {
"list": [
{
"issueId": "ci_01J9K3Z8",
"name": "满 500 减 50",
"type": "cash_off",
"thresholdFen": 50000,
"discountFen": 5000,
"validFrom": "2026-09-01T00:00:00+08:00",
"validTo": "2026-10-31T23:59:59+08:00",
"status": "active",
"scopeHotelIds": ["htl_baosheng_plaza"]
}
],
"page": 1, "pageSize": 20, "total": 1, "hasMore": false
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:45:12+08:00"
}
仅
type=cash_off且status=active的券参与报价抵扣;门槛thresholdFen按折后价判定;scopeHotelIds为空表示全店通用。
7.6.5 GET /members/me/orders 会员名下订单
结构与 7.3.3 一致,但不限于本应用创建(覆盖 channel=official 全部归属订单)。
7.7 商城域(⏳ P3 未实现)
本节端点在代码库中尚不存在(调用会 404)。内部商城接口
/api/mall/**已具备,缺的是开放层封装。
7.7.1 GET /mall/products 商品列表
- Scope:
mall.read;内部映射:GET /api/mall/products(已自动过滤on_shelf+official_h5渠道)
{
"code": "ok",
"data": {
"list": [
{
"productId": "prd_01J9K3Z8",
"type": "voucher",
"name": "双人下午茶套餐",
"subtitle": "大堂吧 · 限时礼遇",
"coverUrl": "https://cdn.xuaninn.cn/p/tea.jpg",
"category": "餐饮",
"minPriceFen": 19800,
"listPriceFen": 26800,
"refundable": true,
"skus": [
{ "skuId": "sku_01", "specLabel": "工作日款", "priceFen": 19800, "listPriceFen": 26800, "stock": 42 }
]
}
],
"page": 1, "pageSize": 20, "total": 15, "hasMore": false
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:50:12+08:00"
}
7.7.2 GET /mall/products/{productId} 商品详情
在列表结构基础上追加:gallery[]、detailMode、detailHtml、voucherSpec(券类商品)、comboItems[](套餐明细)。未上架或无官方渠道 → 40400。
7.7.3 POST /mall/orders 创建商城订单
- Scope:
mall.order.write;幂等:必须 - 请求:
{ "skuId", "quantity" = 1, "guestName", "guestPhone", "remark"? }
{
"code": "ok",
"data": {
"order": {
"orderNo": "XN-MO-20260923-000218",
"status": "pending",
"payStatus": "unpaid",
"amountFen": 19800,
"lines": [
{
"productId": "prd_01J9K3Z8", "skuId": "sku_01", "productType": "voucher",
"titleSnapshot": "双人下午茶套餐", "skuLabelSnapshot": "工作日款",
"quantity": 1, "priceFen": 19800, "amountFen": 19800
}
]
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:52:12+08:00"
}
库存不足 → 40901 inventory_insufficient(内部已做并发防超卖)。
7.7.4 GET /mall/orders/{orderNo} 商城订单详情
在 7.7.3 基础上追加 payments[] 支付记录。
7.7.5 POST /mall/orders/{orderNo}/pay 商城支付
请求 { "channel": "sandbox" };响应 { paymentNo, payUrl, amountFen, expiresAt },有效期 15 分钟。
7.7.6 POST /mall/orders/{orderNo}/cancel 取消商城订单
仅 pending 可取消;已支付走 §7.5 退款流程。
7.8 评价域(只读已实现)
GET /reviews已实现;评价写入未开放(需要用户授权,属 P2/P3)。
7.8.1 GET /reviews 酒店公开评价
- Scope:
review.read;内部映射:GET /api/booking/reviews
| 参数 | 必填 | 说明 |
|---|---|---|
hotelId | 是 | |
roomTypeId | 否 | |
page / pageSize | 否 | pageSize 上限 50 |
{
"code": "ok",
"data": {
"summary": { "scoreTotal": 4.7, "scoreClean": 4.8, "scoreService": 4.7, "scoreLocation": 4.6, "scoreFacility": 4.6, "count": 128 },
"list": [
{
"reviewId": "rvw_01J9K3Z8",
"roomTypeName": "豪华大床房",
"authorName": "138****8888",
"scoreTotal": 5,
"content": "房间很干净,前台服务细致。",
"images": ["https://cdn.xuaninn.cn/rv/1.jpg"],
"replyContent": "感谢您的认可,期待再次光临。",
"createdAt": "2026-09-18T11:02:00+08:00"
}
],
"page": 1, "pageSize": 10, "total": 128, "hasMore": true
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:55:12+08:00"
}
约束:仅返回
status=visible;永远不返回手机号原文(作者名即脱敏串);聚合分只算可见评价;count=0时summary为null。
7.9 内容域
7.9.1 GET /content/articles 文章列表
内部映射:GET /api/cms/published;参数 page / pageSize / tag(如 member-only)。
7.9.2 GET /content/articles/{slug} 文章详情
{
"code": "ok",
"data": {
"article": {
"slug": "member-benefits-2026",
"title": "2026 会员权益全新升级",
"summary": "四大等级权益全面提档",
"category": "news",
"coverUrl": "https://cdn.xuaninn.cn/a/mb.jpg",
"coverAlt": "会员权益",
"content": "<p>正文 HTML</p>",
"tags": ["member-only"],
"memberOnly": true,
"locked": false,
"publishedAt": "2026-09-01 09:00:00"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:58:12+08:00"
}
约束
- 内部字段
excerpt对外统一叫summary;publishedAt沿用库里的字符串格式(不是 RFC3339);- 站点解析不靠 Host:开放域名不是注册过的 SiteHost。默认取该租户第一个启用站点, 多站点租户用请求头
X-XN-Site-Code指定(如X-XN-Site-Code: hotel);- 会员专享文章:携带 OAuth 令牌且会员有效时返回全文;否则
content: ""+locked: true(由中台在服务端裁剪,不依赖前端隐藏);- 列表接口返回的
content恒为空串(省流量),正文请取详情接口。
8. Webhook 事件推送(✅ 已实现,商城事件除外)
实现位置:
OpenWebhookStore(端点与投递存储)、OpenWebhookEmitter(事件产生)、OpenWebhookDispatcher(投递与退避重试)、OpenWebhookController(自助管理接口)。事件产生的机制值得第三方了解:本系统没有事件总线,事件靠定期扫描状态迁移日志 (
OrderLog/RefundLog)产生,因此后台人工操作(确认订单、办理入住、审批退款)同样会推送, 而不是只有 API 发起的动作才有事件。代价是延迟约等于扫描间隔(默认 10s)。
8.1 事件清单
| 事件名 | 触发时机 | payload 关键字段 |
|---|---|---|
reservation.created | 订单创建(下单即触发) | reservationNo, status, payStatus, action |
payment.succeeded | 收款成功(pay 全额 / paid_diff 补差价) | reservationNo, payStatus, kind(full/due) |
reservation.confirmed | 商家确认订单(自动确认不触发,见下方说明) | reservationNo, status |
reservation.modified | 改期成功 | reservationNo, status |
reservation.checked_in | 办理入住 | reservationNo, status |
reservation.checked_out | 办理离店 | reservationNo, status |
reservation.cancelled | 订单取消 | reservationNo, status |
reservation.rejected | 商家拒单(第三方应据此释放自己的占位) | reservationNo, status |
reservation.expired | 未支付占房超时被自动释放 | reservationNo, status |
refund.succeeded | 退款成功 | refundNo, reservationNo, amountFen, status |
refund.rejected | 退款被驳回 | refundNo, reservationNo, status |
两条必须知道的语义
- 自动确认的酒店不会发
reservation.confirmed:订单在支付成功的同一瞬间就被确认, 没有独立的确认日志。此时payment.succeeded的payStatus=paid就代表可入住,第三方应以此为准。data.status是扫描时的最新状态,可能已经继续推进(例如支付成功事件送达时订单已被取消 →status=cancelled)。要判断「该事件导致的结果」,请用data.action(create/paid/cancel/check_in…) 与data.payStatus,不要只看status。后端实现细节:事件由
OrderLog/RefundLog的追加日志逐条翻译,每条日志只产生一个事件 (去重键eventKey = {日志表}:{日志 id}:{事件名}),因此不会重复推送,也不会因为状态变化太快而漏推。
8.2 端点管理(webhook.manage)
前缀 /open/v1,全部需要 AppKey 签名 + webhook.manage scope。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /webhooks/events | 事件字典(可订阅事件 + 重试间隔 + 验签模板;无需 scope,接入前先看这个) |
| POST | /webhooks/endpoints | 创建:{url, events[]},响应含仅此一次的 secret |
| GET | /webhooks/endpoints | 列表(永不回传密钥) |
| PATCH | /webhooks/endpoints/{id} | 改地址 / 改订阅 / 启停 |
| DELETE | /webhooks/endpoints/{id} | 删除(投递记录一并清理) |
| POST | /webhooks/endpoints/{id}/rotate-secret | 轮换端点签名密钥(旧密钥立即失效) |
| GET | /webhooks/deliveries | 投递记录(排错主入口:状态 / 尝试次数 / 响应码 / 错误摘要) |
| POST | /webhooks/deliveries/{deliveryId}/redeliver | 手工重投 |
地址校验(安全相关)
| 规则 | 说明 |
|---|---|
必须 https:// | 沙箱应用例外,允许 http:// 以便本机联调(受 OPEN_API_WEBHOOK_ALLOW_INSECURE 控制) |
| 禁止内网/回环地址 | 平台服务器会主动请求该 URL,放行等于给第三方一个 SSRF 探针(云元数据 169.254.169.254 是典型目标)。生产环境一律拒绝;沙箱环境放行以便本机自测 |
| 单应用最多 5 个端点 | 防止一个应用挂几百个回调把投递线程打满 |
| 非法事件名直接 400 | 不做静默过滤,避免第三方以为订阅成功其实没有 |
创建响应示例:
{
"code": "ok",
"data": {
"endpoint": {
"id": "whe_01J9K3Z8",
"url": "https://partner.com/webhook/xuaninn",
"events": ["payment.succeeded", "reservation.cancelled"],
"secret": "whsec_9f2cE1a4...",
"enabled": true,
"createdAt": "2026-09-23T15:00:00+08:00"
}
},
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T15:00:00+08:00"
}
secret仅在创建 / 轮换时返回一次,请立即落安全存储。
8.3 推送格式与验签
POST /your-webhook HTTP/1.1
Host: partner.com
Content-Type: application/json
X-XN-Event: payment.succeeded
X-XN-Delivery-Id: dlv_01J9K3Z8
X-XN-Timestamp: 1758614400
X-XN-Signature: BASE64(HMAC-SHA256(endpointSecret, timestamp + "." + rawBody))
{
"id": "evt_01J9K3Z8",
"type": "payment.succeeded",
"createdAt": "2026-09-23T14:31:02+08:00",
"tenantId": "baosheng",
"appId": "xn_ak_9f2c...e1",
"data": {
"paymentNo": "XN-PY-20260923-000345",
"reservationNo": "XN-BK-20260923-000738",
"amountFen": 185760,
"paidAt": "2026-09-23T14:31:02+08:00"
}
}
接收端要求:
- 先验签再处理(时间戳窗口 ±300s,防重放);
2xx视为成功,其余一律重试;- 必须幂等:以
X-XN-Delivery-Id去重(同一事件可能投递多次); - 响应耗时 <= 3s,超时按失败处理;建议「先落库再异步处理」。
- 不保证严格有序,请以
createdAt与业务状态机做乱序保护。
8.4 重试策略
| 重试次数 | 距上次间隔 |
|---|---|
| 1 | 10s |
| 2 | 30s |
| 3 | 2min |
| 4 | 10min |
| 5 | 30min |
| 6 | 2h |
| 7 | 6h |
| 8(终态) | 24h |
累计 8 次仍失败 → 端点标记 failing 停投并告警,修复后可 redeliver。
9. 频率限制
9.1 配额模型
按优先级取最严:AppKey 级 → 租户级 → IP 级。令牌桶实现,突发允许 1.5× bucket。
9.2 默认配额表
| 级别 | 接口范围 | 默认配额 | 说明 |
|---|---|---|---|
| L0 只读内容 | /hotels、/room-types、/content/**、/reviews | 50 QPS / AppKey | 日调用无上限 |
| L1 可售 / 报价 | /availability/**、/booking/quote | 20 QPS / AppKey;200 QPS / 租户 | 建议本地缓存 60s |
| L2 会员读取 | /members/** | 10 QPS / AppKey;100 QPS / 租户 | 需用户授权路径 |
| L3 交易写 | /booking/reservations、/payments、/refunds、/mall/orders | 5 QPS / AppKey;租户每分钟 60 单创建 | 保护库存与资金 |
| L4 支付状态轮询 | /payments/{no}、/payments/sandbox/complete | 10 QPS | 强烈建议改用 Webhook |
| IP 兜底 | 全部 | 200 QPS / 出口 IP | 防误配导致单点风暴 |
| 并发限制 | 写接口 | 8 并发 / AppKey | 超限返回 42900 |
9.3 响应头
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1758614460 # 窗口重置的 Unix 秒
X-RateLimit-Bucket: availability
命中限流:
HTTP/1.1 429 Too Many Requests
Retry-After: 3
{
"code": "rate_limit_exceeded",
"message": "请求频率超出配额,请稍后重试",
"requestId": "req_01J9K3Z8F2NQ7V",
"timestamp": "2026-09-23T14:30:12+08:00"
}
9.4 客户端退避建议
- 指数退避 + 抖动:
min(30s, base * 2^n * (0.5 + random()/2)),最多重试 5 次; - 仅对
429、50000、50201、50301重试; - 对
4xxxx业务错误不要重试(40903幂等冲突除外); - 批量抓取请串行 + sleep,或使用 Webhook 做增量。
9.5 提额流程
控制台「配额」页提交申请:填写预估峰值 QPS、调用场景、是否有缓存。默认在 3 个工作日内批复;临时大促峰值可申请限时提额(按天)。
10. 错误码列表
10.1 HTTP 状态码映射
| HTTP | 含义 | 是否可重试 |
|---|---|---|
| 200 / 201 | 成功 | — |
| 202 | 已接受(异步处理中) | — |
| 204 | 成功无内容 | — |
| 400 | 参数错误 | 否 |
| 401 | 未通过鉴权 | 否(先修凭证) |
| 403 | 通过鉴权但无权操作 | 否 |
| 404 | 资源不存在(含越权伪装) | 否 |
| 409 | 资源状态冲突 | 视具体 code |
| 422 | 业务规则不允许 | 否 |
| 429 | 限流 | 是(退避) |
| 500 | 平台内部错误 | 是(幂等键安全) |
| 502 / 503 | 上游不可用 / 维护中 | 是(退避) |
10.2 业务错误码
| code(机器码) | 数字码 | HTTP | 中文说明 | 处理建议 |
|---|---|---|---|---|
ok | 0 | 200 | 成功 | — |
invalid_parameter | 40001 | 400 | 参数格式/取值非法 | 对照 errors[].field 修正 |
missing_parameter | 40002 | 400 | 缺少必填参数 | 常见:privacyConsent |
invalid_json | 40003 | 400 | Body 非合法 JSON | 检查 Content-Type 与编码 |
invalid_amount | 40004 | 400 | 金额非法(负数/精度) | 金额必须是整数分 |
date_range_invalid | 40005 | 400 | 日期区间非法 | checkOut > checkIn,<= 92 晚 |
invalid_signature_version | 40006 | 400 | X-XN-Sign-Version 不支持 | 固定传 1 |
unauthorized | 40100 | 401 | 未提供任何凭证 | 补齐签名头或 Bearer |
invalid_app_id | 40101 | 401 | AppId 不存在 | 检查环境(沙箱/生产)是否混淆 |
signature_mismatch | 40102 | 401 | 签名不匹配 | 核对 canonical string 与 body 原始字节 |
timestamp_expired | 40103 | 401 | 时间偏差超 ±300s | 校准服务器时钟(建议 NTP) |
nonce_replayed | 40104 | 401 | Nonce 已使用 | 每次请求重新生成随机串 |
invalid_token | 40105 | 401 | access_token 无效 | 重新走授权流程 |
token_expired | 40106 | 401 | access_token 过期 | 用 refresh_token 刷新 |
insufficient_scope | 40107 | 403 | 应用未获此 scope | 控制台申请后重试 |
consumer_mismatch | 40108 | 401 | OAuth code/token 与 client_id 不符 | 检查 client_id |
forbidden | 40300 | 403 | 禁止访问 | 联系平台开通 |
app_disabled | 40301 | 403 | 应用被停用 | 联系平台运营 |
tenant_mismatch | 40302 | 403 | 资源不属于该应用绑定租户 | 禁止在请求里传 tenantId |
not_owner | 40303 | 403 | 资源不属于当前凭证主体 | 检查订单创建方 |
member_banned | 40304 | 403 | 会员已被封禁 | 拒绝交易,联系客服 |
resource_not_found | 40400 | 404 | 资源不存在 | 确认 id / slug |
hotel_not_found | 40401 | 404 | 酒店不存在或未对渠道开放 | 检查 hotelId |
room_type_not_found | 40402 | 404 | 房型不存在 | 用 /room-types 重新拉取 |
order_not_found | 40403 | 404 | 订单不存在(含非本应用订单) | 越权也返回此项 |
rate_plan_not_found | 40404 | 404 | 房价计划不存在或不可订 | 不会自动回落到其他计划 |
endpoint_not_found | 40405 | 404 | 接口不存在 | 检查版本号与拼写 |
conflict | 40900 | 409 | 通用冲突 | 重新读取资源状态 |
inventory_insufficient | 40901 | 409 | 库存不足 / 已售完 | 改日期或房型 |
order_status_conflict | 40902 | 409 | 订单当前状态不允许此操作 | 先 GET 详情再动作 |
idempotency_conflict | 40903 | 409 | 幂等键复用但 body 不同 | 换新 key;若确是重复请求请忽略 |
duplicate_review | 40904 | 409 | 该订单已评价(一单一评) | 不可覆盖 |
quote_expired | 40905 | 409 | 报价快照过期 | 重新调用 /booking/quote |
business_rule_violation | 42200 | 422 | 违反业务规则 | 看 message 说明 |
exceed_max_rooms | 42201 | 422 | 超过单笔最大房间数 | 拆单(上限 5 间) |
coupon_not_eligible | 42202 | 422 | 券不可用(未达门槛/已过期/门店不符) | 清除 couponIssueId 重试 |
points_insufficient | 42203 | 422 | 积分不足 | 降低 pointsToUse |
member_not_enrolled | 42204 | 422 | 手机号非会员 | 走原价下单或引导注册 |
rate_limit_exceeded | 42900 | 429 | 超过频率限制 | 指数退避 |
quota_exceeded | 42901 | 429 | 超过日/月调用配额 | 提交提额申请 |
internal_error | 50000 | 500 | 平台内部错误 | 带 requestId 联系支持 |
upstream_unavailable | 50201 | 502 | 依赖服务(PMS/支付)不可用 | 稍后重试;PMS 模式不会静默降级 |
maintenance | 50301 | 503 | 系统维护中 | 关注 Retry-After 与状态页 |
10.3 客户端容错要求(强制)
- 未知
code必须按通用错误降级处理,不得崩溃; - 未知响应字段必须忽略,不得反序列化失败;
- 展示给用户的文案以
message(中文)为准,documentUrl指向最新说明。
11. 版本管理策略
11.1 版本标识
- 主版本在 URL 路径:
/open/v1/**(一期只有 v1); - 次要/补丁版本不进 URL,通过响应头
X-XN-Api-Version暴露(如v1.4.2),并在 changelog 公布。
11.2 兼容性判定
| 变更类型 | 是否破坏性 | 处理方式 |
|---|---|---|
| 新增接口 | 否 | 直接发布 + changelog |
| 新增可选请求字段 | 否 | 旧客户端不受影响 |
| 新增响应字段 | 否 | 客户端必须容忍未知字段(强制要求) |
| 新增错误码 | 否 | 客户端按「未知 code → 通用错误」降级 |
| 新增枚举取值 | 视情况 | 新增可选值视为兼容;枚举含义缩窄视为破坏性 |
| 修改默认值 / 放宽校验 | 需要评估 | 走评审 + 提前 30 天公告 |
| 删除 / 重命名 / 必填化字段 | 是 | 只能进新主版本 /open/v2 |
| 修改字段语义或单位 | 是 | 同上 |
| 收紧权限 / 配额 | 是 | 至少提前 90 天通知 |
11.3 弃用流程
T 日 公告 + 邮件/站内信 + 响应头标记
Deprecation: version="v1", date="2027-03-01"
Sunset: Sat, 01 Mar 2027 00:00:00 GMT
T + 90 天 控制台「应用健康度」标记仍在使用的应用
T + 180 天 灰度拒绝 5% 请求(返回 299 + 警告)
T + 270 天 下线 v1,仅 v2 可用(返回 410 Gone)
- 最短通知周期 180 天;涉及资金 / 下单的关键路径最短 270 天;
- 兼容层:同一业务的 v1/v2 由网关做字段双向适配,不允许出现第二份算价逻辑。
11.4 Beta 通道
- 预发布接口以
/open/beta/**暴露,无 SLA、随时变更,需申请 beta 白名单; - Beta 转正后保留 >= 30 天双通道期。
11.5 变更日志
发布至 https://open.xuaninn.cn/changelog(RSS + 邮件订阅)。所有第三方 SDK 集成必须指定主版本,并在 CI 中断言 X-XN-Api-Version 主版本未变。
12. 沙箱环境说明
12.1 基本信息
| 项 | 值 |
|---|---|
| Base URL | https://sandbox-open.xuaninn.cn/open/v1 |
| 授权端点 | https://sandbox-open.xuaninn.cn/oauth/authorize |
| 数据 | 与生产完全隔离的沙箱 PostgreSQL 实例(独立库) |
| 租户 | 每个应用自动分配到 sandbox-{tenantCode} 沙箱租户 |
| 密钥 | 与生产不同(appId 相同、appSecret 不同),不可混用 |
| 配额 | 生产配额的 20%,足够完整链路联调 |
| 数据重置 | 每周一 02:00(Asia/Shanghai)全量重置;控制台支持手动重置 |
12.2 沙箱专属能力
| 能力 | 说明 | 内部依赖 |
|---|---|---|
| 模拟支付成功 | POST /open/v1/payments/sandbox/complete | POST /api/pay/mock-complete(生产不存在) |
| 商城模拟支付 | POST /open/v1/mall/orders/{no}/pay body {channel:"sandbox"} | 默认 sandbox 通道 |
| OTP 回显 | 验证码在响应中返回 devCode,无需真实短信 | /api/sms/otp 的 mock:true 分支 |
| 免审核流转 | 退款自动从 pending 流转到 succeeded | 生产需后台人工审批 |
| Webhook 调试 | 控制台可查看最近 200 条投递详情与响应体,支持手动 redeliver | — |
| Webhook 本地调试 | 沙箱应用允许注册 http://127.0.0.1:xxxx/hook 回调,配合 GET /webhooks/deliveries 排错 | 已实现 |
| 故障注入 | ⏳ 未实现(规划:X-XN-Chaos 头强制返回指定错误) | 便于编写异常分支测试 |
12.3 沙箱限制
- 不发送真实短信、不发生真实资金流动;
- 单笔支付 / 退款上限 10000000 分(10 万元),超出返回
40004 invalid_amount; - 不建议做压力测试(有配额且会干扰数据重置节奏),压测请提前申请专用席位;
- 沙箱不保证 SLA。
12.4 冒烟测试序列
当前(P0)可用
# 0. 自检:确认签名算法、租户绑定、scope
GET /open/v1/whoami
# 1. 拉取酒店与房型
GET /open/v1/hotels?pageSize=5
GET /open/v1/hotels/{hotelIdOrSlug}/room-types?checkIn=2026-10-01&checkOut=2026-10-04
GET /open/v1/room-types/{roomTypeId}?checkIn=...&checkOut=...
# 2. 报价
GET /open/v1/availability/rates?hotelId=...&checkIn=...&checkOut=...
GET /open/v1/availability/price-calendar?hotelId=...&from=...&to=...
# 3. 内容与口碑
GET /open/v1/reviews?hotelId=...
GET /open/v1/content/articles
GET /open/v1/content/articles/{slug}
# 4. 负面用例(接入期必测)
# 错误密钥 → 401 signature_mismatch
# 重复 Nonce → 401 nonce_replayed
# 时间戳偏移 10 分钟 → 401 timestamp_expired
# 缺少必填参数 → 400 missing_parameter
# 不存在的酒店 → 404 hotel_not_found
# 突发超过配额 → 429 rate_limit_exceeded(带 Retry-After)
P1 交易链路(已可用)
# 5. 报价(服务端定价,不要传 amount)
POST /open/v1/booking/quote
# 6. 下单 + 幂等回放
POST /open/v1/booking/reservations # 带 partnerOrderNo;重复提交返回 200 + X-XN-Idempotent-Replay
GET /open/v1/booking/reservations/{reservationNo}
# 7. 支付闭环(沙箱)
POST /open/v1/payments # { reservationNo, channel: "alipay_qr" }
POST /open/v1/payments/sandbox/complete # { paymentNo }
GET /open/v1/payments/{paymentNo} # status 应为 paid
# 8. 取消 / 改期 / 退款
GET /open/v1/booking/reservations/{no}/cancel-quote
POST /open/v1/booking/reservations/{no}/cancel # 返回 reservation + refund
GET /open/v1/booking/reservations/{no}/modify-quote?checkIn=…&checkOut=…
POST /open/v1/booking/reservations/{no}/modify
POST /open/v1/refunds # 幂等:Idempotency-Key
GET /open/v1/refunds/{refundNo}
P2/P3 落地后才可用(现调用会 404,勿据此判定环境问题)
GET /open/v1/members/me # OAuth Bearer(P2)
GET /open/v1/mall/products # P3
13. 开发者文档结构
13.1 站点信息架构
open.xuaninn.cn
├── / 首页(能力概览 / 场景 / 快速开始)
├── /docs
│ ├── getting-started/
│ │ ├── 00-overview 平台能力地图与名词表
│ │ ├── 01-application 创建应用与密钥管理
│ │ ├── 02-authentication 签名算法(含多语言示例)
│ │ ├── 03-oauth OAuth2 授权码接入
│ │ ├── 04-sandbox 沙箱使用规范
│ │ └── 05-go-live 上线验收清单
│ ├── guides/
│ │ ├── booking-flow 标准预订流程(含状态机图)
│ │ ├── pricing-model 计价顺序与金额单位
│ │ ├── modify-and-cancel 退改规则与差额补退
│ │ ├── inventory-and-hold 占房、释放与超卖防护
│ │ ├── idempotency 幂等最佳实践
│ │ ├── webhooks 接收事件与验签
│ │ ├── rate-limits 限流与退避
│ │ └── errors 错误处理矩阵
│ ├── api/ 【核心】逐接口参考
│ │ ├── hotels/ GET /hotels … + Try it
│ │ ├── availability/
│ │ ├── booking/
│ │ ├── payments/
│ │ ├── refunds/
│ │ ├── members/
│ │ ├── mall/
│ │ ├── reviews/
│ │ └── content/
│ ├── webhooks/events 事件字典
│ ├── errors/ 错误码字典(每码一页)
│ ├── sdks/ 官方 SDK 与社区 SDK
│ └── changelog/ 版本变更(RSS)
├── /status 服务状态与历史可用性
├── /console 开发者控制台(应用/密钥/配额/Webhook/日志)
└── /support 工单、FAQ、联系方式、SLA 说明
13.2 每个 API 页面固定 8 段结构(模板)
- 一句话说明 + 业务场景
- 请求:
Method /path、鉴权模式、所需 scope、是否幂等、是否计费配额桶 - 请求参数表:字段 / 类型 / 必填 / 默认值 / 约束 / 说明
- 请求示例:curl + 官方 SDK(Node / Java / Python)
- 响应参数表:字段 / 类型 / 说明(含内部字段映射备注)
- 响应示例:成功(200/201)+ 至少 2 个典型失败(400/409)
- 错误与重试:本接口专属错误码 + 是否可重试
- 变更历史:
Added in v1.x / Changed in v1.y
13.3 文档工程化要求
| 要求 | 说明 |
|---|---|
| 单一数据源 | 所有接口以 OpenAPI 3.1 规范文件(openapi/openapi-v1.yaml)为唯一真相源,文档站点自动生成 |
| 一致性校验 | CI 中运行 spectral lint + 契约测试,接口变更必须同步更新 spec 才能合入 |
| 示例真实性 | 文档中的示例由「沙箱录制」生成,禁止手写过期 JSON |
| 可运行 | 每个接口提供 Try it(基于沙箱密钥的在线调试台,只读接口开放) |
| 多格式下载 | 提供 openapi.json、Postman Collection、Insomnia、Apifox 导入包 |
| 本地化 | 中文为权威版本,英文版本随 v1 GA 发布 |
13.4 官方 SDK 规划
| SDK | 一期 | 说明 |
|---|---|---|
| Java (JDK 17+) | GA | 与中台同栈,优先保障 |
| Node.js (18+) | GA | 含 Express webhook 中间件 |
| Python (3.10+) | 二期 | |
| Go | 二期 | |
| PHP | 二期 |
SDK 统一能力:签名、自动重试(幂等安全)、分页迭代器、Webhook 验签中间件、结构化错误类型、内置 X-Request-Id。
14. 接入流程指引
14.1 七步接入图
① 注册开发者账号 ② 创建应用并绑定租户 ③ 申请 Scope 与配额
open.xuaninn.cn → appId / appSecret → 提交用途说明与场景
| | |
v v v
④ 沙箱联调 ⑤ 联调自检(验收清单) ⑥ 上线审核
按 §12.4 冒烟 → §14.3 全部打勾 → 平台技术 + 业务双审(<= 3 工作日)
|
v
⑦ 生产切换:换生产密钥 → 灰度 5% → 100%,持续观察 7 天
14.2 各阶段产物与责任人
| 步骤 | 参与者 | 产物 | 耗时 |
|---|---|---|---|
| ① 注册 | 第三方 + 平台运营 | 开发者账号、签署《开放平台接入协议》与《数据处理附录》 | 1 天 |
| ② 建应用 | 第三方 | appId / appSecret(沙箱 + 生产各一对)、租户绑定确认单 | 即时 |
| ③ 申请 scope | 第三方 + 平台审核 | Scope 批复单、配额批复单、IP 白名单 | 1~3 天 |
| ④ 沙箱联调 | 第三方研发 | 接入自测报告、异常场景用例 | 3~10 天 |
| ⑤ 自检 | 第三方 | 勾选 §14.3 清单的截图与日志样本 | 1 天 |
| ⑥ 上线审核 | 平台技术 + 租户业务方 | 上线许可 + 首笔验证订单 | <= 3 工作日 |
| ⑦ 生产切换 | 双方 | 灰度放行时间点、回滚预案 | 1~7 天 |
14.3 上线验收清单(必须全部打勾)
鉴权与安全
-
appSecret仅存服务端,未出现在前端代码、日志、错误信息中 - 所有写接口均携带
Idempotency-Key,重试不会产生重复订单/退款 - 服务器时钟已 NTP 同步,偏差 < 60s
- 每次请求使用全新随机 Nonce
- Webhook 接收端已实现验签 +
Delivery-Id幂等去重 -
redirect_uri已精确白名单配置(OAuth 场景)
业务正确性
- 报价与下单走同一
/booking/quote结果,未本地重算金额 - 金额全部按分处理,未按元解析(可用
payableFen反推核对) - 券可用性与会员折扣已由
/members/me/coupons校验,未假设默认折扣 - 处理了
40901 inventory_insufficient(库存不足)降级路径 - 处理了
40404 rate_plan_not_found(房价计划不命中)降级路径 - 退款上限按「
amountFen − dueFen + 已退」计算 - 改期差额补/退两条分支均已实现
- 订单状态机以 Webhook 为准,轮询仅作补偿
健壮性与合规
- 对未知枚举、未知字段、未知错误码有降级处理
- 429 / 5xx 走指数退避 + 抖动,且有最大重试次数
- 日志记录了
requestId,可用于对账 - 用户手机号等个人信息仅在持有授权时处理,且已脱敏展示
- 已提供
partnerOrderNo用于双方对账
14.4 支持与 SLA
| 项 | 承诺 |
|---|---|
| 接口可用性 | >= 99.9% / 月(只读);>= 99.5% / 月(交易写) |
| 故障响应 | P1(全站不可用)15 分钟内响应;P2 2 小时内 |
| 工单渠道 | /support 提交,附 requestId 可加速定位 |
| 状态页 | https://open.xuaninn.cn/status,维护窗口提前 72h 公告 |
| 计划内维护窗口 | 每周二 02:00–04:00(Asia/Shanghai),交易接口不中断 |
15. 交付现状与后续路线
15.1 已交付(P0 只读 + P1 交易,2026-09-23)
开工前的现状核对(用于说明为什么这套东西必须新建):/open/** 路由、appKey/appSecret、OAuth/cookie 之外的令牌、回调验签、限流、统一信封与机器码全部为「无」;
可复用的是完整业务内核(PublicController 53 个 C 端路由 + PlatformStore)。因此工程量集中在网关层,业务逻辑 100% 复用。
本期交付清单
| 类别 | 内容 | 位置 |
|---|---|---|
| 数据库 | 9 张新表(含 OpenResourceLink)+ Order 补 2 列 | backend/src/main/resources/schema.sql |
| 网关 | 签名/时间窗/Nonce/IP 白名单/限流/审计/请求体缓存/信封/错误码 | com.xuaninn.openapi.OpenAuthFilter 等 12 个类 |
| 只读接口 | 10 个(见 §6.0) | OpenHotelController/OpenAvailabilityController/OpenContentController/OpenDiagnosticController |
| 交易接口 | 12 个(报价/下单/查询/改期/取消/支付/退款) | OpenBookingController/OpenPaymentController/OpenRefundController |
| Webhook | 8 个管理接口 + 事件产生 + 投递 | OpenWebhookController/OpenWebhookEmitter/OpenWebhookDispatcher |
| 通用幂等 | 全部 POST 支持 Idempotency-Key | OpenIdempotencyStore |
| 会员与 OAuth2 | 授权页 + token/revoke/introspect + 5 个会员接口 | OpenOAuthController/OpenOAuthStore/OpenMemberController |
| 归属索引 | 单号 → (内部主键, appId, 手机号),越权一律 404 | OpenResourceLink + OpenResourceLink 表 |
| 后台接口 | 4 个(应用创建/列表/修改/轮换密钥) | OpenAppAdminController |
| 配置 | app.open.*(含全部限流阈值、密钥加密主密钥、总开关) | application.yml |
| 反代 | /open/:path* → Java | next.config.ts |
| 测试 | 26 条契约断言(算法镜像 + 源码契约) | npm run test:openapi |
已验证行为(本机 :8080 实跑:P0 31/31、P1 37/37、Webhook 35/35、幂等 18/18、OAuth+会员 42/42)
鉴权与网关:
- ✅ 正确签名 → 200,且回显
tenantId=baosheng(由 AppKey 推导,不接受请求参数) - ✅ 错误密钥 → 401
signature_mismatch;重复 Nonce → 401nonce_replayed;时间戳偏移 → 401timestamp_expired - ✅ 未带凭证 → 401
unauthorized;越权资源 → 404;缺参 → 400missing_parameter - ✅ 突发请求触发 429,带
Retry-After与X-RateLimit-*;OpenApiAudit落库含 requestId 与耗时
交易链路:
- ✅ 防篡改价格:请求里塞
amount: 1,报价与下单金额仍为服务端算出的 60000 分 - ✅ 房价计划精确命中:不存在的
ratePlanId→40404,不回落同房型其他计划 - ✅ 幂等:同
partnerOrderNo重复下单 → 200 +X-XN-Idempotent-Replay: true,返回同一订单 - ✅ 归属隔离:另一个 appId 查同单号 →
40403 order_not_found(不是 403,避免单号枚举) - ✅ Scope:无
booking.write的应用下单 →403 insufficient_scope - ✅ 支付闭环:创建支付单 → 沙箱 complete → 支付状态
paid→ 订单payStatus=paid - ✅ 渠道白名单:非法
channel→40001(不静默回落成支付宝) - ✅ 取消 → 退款单 → 审批日志:取消后返回
reservation + refund,退款详情含 2 条日志 - ✅ 退款幂等:同额重复提交命中同一张退款单
- ✅ 金额一律整数「分」;手机号一律脱敏(
138****8888)
回归:
- ✅
npm run test:openapi16/16;npm run test:platform5/5;/api/**与后台行为未变
15.2 数据库表(已落地,实际表名)
依据项目铁律:表结构以
schema.sql为唯一裁决者,中文注释由scripts/db/apply-comments.sh补齐。
| 表名 | 用途 | 备注 |
|---|---|---|
OpenApp | 第三方应用注册 | appId 唯一;env=sandbox/prod;secretVersion 指向当前密钥版本 |
OpenAppSecret | 密钥多版本 | 轮换后新旧并存 24 小时自动停用旧版;明文不落库(AES-GCM) |
OpenResourceLink | 开放侧归属索引(单号 → 内部主键 + appId + 手机号) | P1 新增。开放层归属的唯一依据,越权一律 404 |
OpenIdempotency | 幂等键 → 结果快照 | 表已建;一期未启用,下单幂等靠 OpenResourceLink.partnerNo |
OpenApiAudit | 开放侧审计 | 与后台 Audit 分离,避免第三方调用污染内部审计口径 |
OpenWebhookEndpoint / OpenWebhookDelivery | Webhook 端点与投递 | 表已建,尚无投递器(P2) |
OAuthAuthorizationCode / OAuthToken | 授权码(60s)与令牌 | 表已建;Bearer 校验已在网关生效,缺签发端点(P2) |
Order.partnerAppId / partnerOrderNo | 订单归属冗余标记 | P1 会在下单后 best-effort 回写(失败只记日志);判权仍以 OpenResourceLink 为准 |
15.3 Java 组件(已落地,实际文件名)
backend/src/main/java/com/xuaninn/openapi/
├── OpenAuthFilter.java OncePerRequestFilter:凭证→时间窗→签名→Nonce→IP→限流→审计
├── OpenSignatureVerifier.java canonical string 构造 + HMAC + 常数时间比较
├── OpenCrypto.java HMAC/SHA256/AES-GCM/密钥与令牌生成
├── OpenNonceStore.java Nonce 去重(进程内 + 定时清理)
├── OpenRateLimiter.java 令牌桶(按秒)+ 固定窗口(按分钟)
├── OpenAppStore.java 应用注册表 + 30s 缓存 + 密钥轮换 + 后台增删改
├── OpenResourceLink.java 归属索引:link/find/findByPartnerNo/markOrder
├── OpenApiError.java 机器可读错误码字典
├── OpenApiException.java 异常 + 内部状态码→开放错误码的**唯一映射点**(mapFrom)
├── OpenApiEnvelope.java 统一信封 + requestId + 时间格式(秒级 +08:00)
├── OpenApiErrors.java 异常收敛(按路径分流,不干扰 /api/**)
├── OpenApiScope.java @OpenApiScope 注解
├── OpenScopeInterceptor.java Scope 校验(靠近 HandlerMethod,能读到注解)
├── OpenWebConfig.java 注册拦截器到 /open/**
├── OpenCachedBodyRequest.java 请求体缓存(验签要原始字节,且不能吃掉 @RequestBody)
├── OpenUnit.java 元→分换算(**全平台唯一换算点**)
├── OpenViews.java 内部 Map → 对外 DTO 白名单映射(quote/order/payment/refund…)
├── OpenBaseController.java 认证上下文 / 信封 / 内部异常翻译
├── OpenDiagnosticController.java /whoami
├── OpenHotelController.java /hotels、/room-types
├── OpenAvailabilityController.java /availability/*
├── OpenBookingController.java /booking/*(**自算价 + 归属校验 + 幂等回放**)
├── OpenPaymentController.java /payments(渠道白名单 + 沙箱 complete)
├── OpenRefundController.java /refunds(可退上限 + 幂等)
├── OpenWebhookStore.java 端点 CRUD + 投递存储 + 地址校验(https/SSRF)
├── OpenEventCatalog.java 事件字典(对外契约,只增不改名)
├── OpenWebhookEmitter.java 扫 OrderLog/RefundLog 产生事件(@Scheduled)
├── OpenWebhookDispatcher.java 投递 + 退避重试 + 失败停投(@Scheduled)
├── OpenWebhookController.java /webhooks/**(自助管理)
├── OpenIdempotencyStore.java 通用幂等:begin/finish/abandon + 24h 清理
├── OpenOAuthController.java /oauth/*(授权页 + token/revoke/introspect)
├── OpenOAuthStore.java 授权码与令牌(只存哈希、先消费后校验、refresh 轮换)
├── OpenScopeCatalog.java scope 字典(授权页展示中文说明)
├── OpenMemberController.java /members/**(Bearer + 脱敏)
├── OpenContentController.java /content/*、/reviews
└── OpenAppAdminController.java /api/admin/open-apps(后台,Cookie + platform 管理员 scope,平台专属,租户不可见/不可建)
这套 Controller 只做「DTO 映射 + 单位换算 + 归属过滤」,所有业务一律调
PlatformStore;严禁复制算价代码。
15.4 后续分期
| 阶段 | 范围 | 状态 |
|---|---|---|
| P0 基础能力 + 只读接口 | AppKey/签名/Nonce/限流/信封/错误码/审计 + 酒店·房型·报价·日历·评价·内容 + 后台应用管理 | ✅ 已完成(2026-09-23) |
| P1 交易链路 | 报价(自算价)、下单(partnerOrderNo 幂等)、订单查询、改期/取消、支付(沙箱)、退款、归属索引 | ✅ 已完成(2026-09-23) |
| P2a Webhook 事件推送 | 端点自助管理 + 扫状态迁移日志产生事件 + 投递器(退避重试 / 手工重投 / 签名)+ SSRF 地址校验 | ✅ 已完成(2026-09-23) |
| P2b 通用幂等 | 全 POST 接口的 Idempotency-Key:响应缓存回放 / 指纹冲突检测 / 并发占位 / 24h 清理 | ✅ 已完成(2026-09-23) |
| P2c 用户授权 | OAuth2 授权页 + token/revoke/introspect + 强制 PKCE + /members/**(含脱敏) | ✅ 已完成(2026-09-23) |
| P3 商城与生态 | /mall/**、SDK(Java/Node)、开发者门户与控制台 UI、OpenAPI spec、状态页 | ⏳ 待做 |
P2 施工提示(已铺好的地基与已知缺口)
OAuthToken表的 Bearer 校验已在网关生效(OpenAuthFilter.authenticateToken),只缺签发/刷新端点与 PKCE 校验;- Webhook 只缺一个投递器(含 §8.4 的退避重试)与
X-XN-Channel之外的四个事件触发器,签名算法可复用OpenCrypto.hmacSha256Base64; OpenIdempotency表尚未启用:目前只有下单/退款有幂等(分别靠partnerOrderNo与内部idempotencyKey), 支付创建、改期等写接口仍可能被重复提交,建议 P2 补一个作用于全部写接口的幂等拦截器;- 会员域需要把手机号绑定到 OAuth 主体:
OpenResourceLink已经示范了「开放层为什么需要自己的归属表」,会员域同理(token → memberId → phone)。
15.5 施工时的十七个高风险点(全是本期实际踩到或主动规避的)
- 签名必须用标准 Base64:本期实现曾用 Base64URL(
-_),导致第三方 100%signature_mismatch且极难自查。契约测试已锁死。 - 组件扫描:开放平台在
com.xuaninn.openapi(与启动类不同包),必须显式@ComponentScan。漏了会静默 404,没有任何报错——本期先踩后修。 - 金额单位:
Order.amount内部是元,Refund/InventoryDay/Coupon是分;而Payment.amount在「正常单」与「补差价单」里都是元。换算必须在OpenUnit一处完成;契约测试会扫描「其他文件里出现*100」并直接失败。 - 内部接口信任客户端价格:
createReservation→priceQuote的基价来自请求里的quote.amount(官网 H5 的用法)。开放层必须自己按房型+计划取价,否则改个数就能 1 元入住。契约测试断言OpenBookingController不读客户端的amount。 available的语义陷阱:中台房型/计划/报价三处的available都是整数余房且不可订项已被过滤。对外拆成「计划级available布尔 +availableRooms整数」,房型级只有availableRooms。- 订单归属:现有
GET /api/booking/orders/{id}是「订单号即凭证」、GET /api/mall/mine/orders?phone=是「手机号即凭证」,都不能直接出网。开放层用OpenResourceLink把 appId ↔ 单号 ↔ 手机号绑起来,越权返回 404(不是 403,避免单号枚举探测)。 - AES 主密钥不能从
session-secret派生:本期踩到「带.env启动」与「手工java -jar启动」派生出两把密钥,历史 appSecret 静默无法解密,表象只是「应用未配置有效密钥」。已改为固定开发种子 + 启动 WARN,生产必须显式配OPEN_API_ENCRYPTION_KEY。 enum/文案不能当契约:内部错误的 HTTP 状态码(400/404/409)决定对外错误码大类,只有「库存/房型/酒店/日期」四类做关键词细化,避免把随时会改的中文文案当契约(OpenApiException.mapFrom)。- 事件不要扫「当前状态」:最初按
Order.updatedAt扫当前状态,结果「下单 → 支付确认 → 立即取消」这种快速流程里,中间态confirmed在两次扫描之间就过去了,第三方永远收不到reservation.confirmed。必须扫OrderLog/RefundLog这类追加型状态迁移日志(每条都不会被覆盖)。 - 对账游标不能用随机 id 做秒内 tiebreak:
(时间, id) > (游标)看似严谨,但日志 id 是随机串而非自增,同一微秒内「id 字典序更小」的新行会被永久跳过——实测漏掉了refund.succeeded与reservation.checked_*。正确做法是只比时间且用>=,重复扫到的行由(endpointId, eventKey)唯一约束吃掉:宁可重扫,绝不漏扫。 - 回调地址是 SSRF 入口:平台会主动请求第三方配置的 URL。生产必须强制
https且拒绝内网/回环/云元数据地址(127.*、10.*、172.16-31.*、192.168.*、169.254.*、localhost)。沙箱放行是为了本机联调,靠OPEN_API_WEBHOOK_ALLOW_INSECURE显式开关,生产务必设 false。 - 响应缓冲必须无条件回写:幂等要用
ContentCachingResponseWrapper缓存响应体,任何分支漏调copyBodyToResponse()都会让客户端拿到空响应(HTTP 200 但 body 为空,最难查的一类问题)。 - E2E 测试会真实吃掉演示库存:开放平台的交易测试是真占房(下单扣
held、支付扣sold),演示酒店每夜仅 7 间,跑几轮就全售罄,之后所有报价返回空/inventory_insufficient(表象是「本机没有可订房型」)。已提供scripts/dev/reset-demo-inventory.sql(psql -v slug=<演示酒店 slug> -f)恢复本地房态。 - OAuth 的
redirect_uri必须精确匹配白名单:它是唯一能把授权码交出去的地方。前缀匹配、通配、#片段都会被开放重定向利用。参数非法时渲染错误页而不是 302;授权页还要X-Frame-Options: DENY防点击劫持。 - 授权码要「先消费、后校验」:
consumeCode用条件更新(WHERE consumedAt IS NULL)原子置位,然后才校验redirect_uri与 PKCE。这样失败尝试会烧毁授权码,攻击者无法拿同一个 code 爆破code_verifier;代价是客户端写错 verifier 必须重新授权(本来就该如此)。授权码与令牌一律只存 SHA-256 哈希。 - 会员域绝不能让手机号当凭证:开放层的会员接口只认 Bearer 令牌里的
memberId,手机号由服务端反查;手机号与邮箱在出口统一脱敏。这和「订单号即凭证」是同一类坑。 client_secret就是appSecret:令牌端点必须校验,且用常数时间比较;grant_type=refresh_token要轮换(吊销旧的、签发新的),否则刷新令牌一旦泄露就是长期通行证。- 表结构变更 + 并发编辑:新表一律写进
schema.sql(CREATE TABLE IF NOT EXISTS),不要写 Prisma(已彻底下线),随后由SchemaRunner启动同步、apply-comments.sh补中文注释,顺序不能反。另外PlatformStore.java会被多端同时编辑,本期就遇到过一次「另一端改到一半导致mvn package编译失败、本地服务起不来」——开放层的适配只读它,不改它。
附录 A:术语表
| 术语 | 含义 |
|---|---|
| 租户 Tenant | 一个酒店集团客户,如 baosheng;AppKey 静态绑定唯一租户 |
| 站点 Site | 租户下的独立官网;开放侧的 tenantId 由 AppKey 决定,不可请求切换 |
| 房型 RoomType | 物理房型(豪华大床房等) |
| 房价计划 RatePlan | 房型下的销售政策(是否含早、取消规则、价格来源) |
| 占房 Hold | 下单时扣 InventoryDay.held,30 分钟未支付自动释放(front_desk 除外) |
| 待补差价 dueFen | 改期后需补付的差额;可退上限 = amountFen − dueFen |
| 分 Fen | CNY 最小单位,1 元 = 100 分;开放层唯一金额单位 |
| Scope | 应用级权限粒度,租户级 Tenant 之外的第二道闸门 |
| 归属索引 OpenResourceLink | 对外单号 → (内部主键, appId, 手机号),开放层判权的唯一依据;漏了它退化成「单号即凭证」 |
附录 B:文档维护约定
- 本文件
docs/开放平台_API方案.md为方案正本,随每次开放平台变更更新,并在docs/DEV_LOG.md追加一条记录。 - 设计决定与硬规则长期沉淀到
.codebuddy/rules/xuaninn-context.md。 - 实现阶段的接口条约正本放在
openapi/openapi-v1.yaml,本文中的示例若与 spec 冲突,以 spec 为准。