玄隐开放平台(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。


目录

  1. 方案定位与范围
  2. 总体架构
  3. 接入准备:环境与域名
  4. 鉴权机制
  5. 通用调用约定
  6. 接口分类总览
  7. 接口详细定义
  8. Webhook 事件推送
  9. 频率限制
  10. 错误码列表
  11. 版本管理策略
  12. 沙箱环境说明
  13. 开发者文档结构
  14. 接入流程指引
  15. 落地实施路线与现状差距

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 三条硬约束(沿用项目既有铁律)

  1. 算价唯一入口:所有对外报价必须复用 PlatformStore.planQuotes() / priceQuote(),计价顺序固定「房价(含加床费)→ 会员价 → 券 → 积分」,禁止在网关层重写第二套算价。
  2. 库存为准:ebooking 模式房价房量只读本库 InventoryDay,禁止回落 PMS;pms 模式无凭证明确失败,不静默降级。
  3. 租户隔离:AppKey 在创建时绑定唯一 tenantId,网关从密钥推导租户,永不接受请求参数传入的 tenantId。

3. 接入准备:环境与域名

环境Base URL用途数据
沙箱 Sandboxhttps://sandbox-open.xuaninn.cn/open/v1联调、验收独立沙箱租户数据,每周重置
生产 Productionhttps://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 密钥形态

项格式说明
appIdxn_ak_ + 24 位小写十六进制公开,随请求头传输
appSecretxn_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) ) )

要点

  1. 参与签名的 URI / Query 必须是未经框架重排的原始值;不要先反序列化再重拼;
  2. BASE64 是标准 Base64(字符集含 + /),不是 Base64URL(- _); 平台侧曾误用 Base64URL 导致第三方 100% signature_mismatch,是最容易吃的一个亏;
  3. HashedPayload 是小写十六进制,不是 Base64;
  4. 时间戳单位是秒(10 位);传毫秒会立刻 timestamp_expired;
  5. 平台侧对这批头做 .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 服务端校验顺序

  1. X-XN-AppId 存在且应用状态 enabled → 否则 40301 app_disabled
  2. abs(now − X-XN-Timestamp) <= 300s → 否则 40103 timestamp_expired
  3. Redis SETNX app:nonce:{appId}:{nonce} TTL 600s → 命中则 40104 nonce_replayed
  4. 重算签名做常数时间比较 → 不等则 40102 signature_mismatch
  5. Scope 校验 → 40107 insufficient_scope
  6. 租户一致性校验 → 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/introspectRFC 7662 精简版
JWKS—⏳ 未实现(当前 access_token 是不透明随机串而非 JWT,无需 JWKS)

实现口径与文档初版的差异(以本节为准)

  1. access_token 是不透明随机串,不是 JWT:因此没有 JWKS、也不能离线验签; 校验一律走 /oauth/introspect 或直接调用开放接口。这比 JWT 更容易吊销(库里有 revokedAt)。
  2. 必须使用 PKCE(S256):缺 code_challenge 直接拒绝,不提供「机密客户端免 PKCE」的口子。
  3. 错误体是 RFC 6749 格式({"error":"invalid_grant","error_description":"…"}), 不是开放平台的统一信封——OAuth 客户端库按 RFC 解析,这里必须守规范。
  4. 授权码「先消费、后校验」:redirect_uri 或 PKCE 校验失败时授权码已被烧毁, 同一 code 不能重试(防 verifier 爆破)。客户端需重新走授权流程。
  5. 令牌有效期:授权码 60 秒、access_token 120 分钟、refresh_token 30 天; refresh 采用一次性轮换(用旧 refresh 换新的一对,旧的立即失效)。
  6. 授权页形态:当前是 Java 中台渲染的极简页(无租户品牌)。若要品牌化,应在官网 H5 里 实现该页,仅调用 POST /oauth/authorize/approve 签发授权码;后端无需改动。
  7. 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_tokenJWT(RS256,网关私钥签发,公钥 JWKS 公开),有效期 2 小时
refresh_token不透明串,30 天,一次性轮换(rotation),旧值立刻失效
Token claimsiss / 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 BodyGET/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-After429 / 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 的两个关键差异(读接口文档时容易按旧口径理解):

  1. 报价/下单不接受客户端金额(§7.3.1)——内部接口信任客户端传来的 quote.amount,开放层必须自己算;
  2. 支付渠道只有 alipay_qr / wechat_qr / front_desk,「沙箱支付」是环境能力而不是渠道(§7.4.1)。

6.1 酒店内容域 Hotels

内部映射:/api/booking/hotels、/api/booking/rooms、/api/booking/room/{id}

方法路径鉴权Scope说明
GET/hotelsAKhotel.read酒店列表(含评分、起价)
GET/hotels/{hotelId}AKhotel.read酒店详情 + 政策
GET/hotels/{hotelId}/room-typesAKhotel.read房型列表(含图片/设施/余房)
GET/room-types/{roomTypeId}AKhotel.read房型详情 + 酒店政策

6.2 可售性与价格域 Availability

内部映射:/api/booking/rates、/api/booking/price-calendar

方法路径鉴权Scope说明
GET/availability/ratesAKavailability.read区间平铺报价(含逐日)
GET/availability/price-calendarAKavailability.read价格日历(≤92 天)

6.3 预订域 Booking

内部映射:/api/booking/quote、/reservations、/cancel-quote、/cancel、/modify-quote、/modify

方法路径鉴权Scope说明
POST/booking/quoteAKavailability.read下单前精确试算(不占房)
POST/booking/reservationsAKbooking.write创建预订(占房)
GET/booking/reservations/{reservationNo}AKorder.read预订详情
GET/booking/reservations/{reservationNo}/modify-quoteAK/OAbooking.write改期试算
POST/booking/reservations/{reservationNo}/modifyAK/OAbooking.write执行改期
GET/booking/reservations/{reservationNo}/cancel-quoteAK/OAbooking.write取消试算
POST/booking/reservations/{reservationNo}/cancelAK/OAbooking.write执行取消

6.4 支付域 Payments

内部映射:/api/pay/create、/api/pay/status、/api/pay/mock-complete

方法路径鉴权Scope说明
POST/paymentsAKpayment.write创建支付单
GET/payments/{paymentNo}AKpayment.write查询支付状态
POST/payments/{paymentNo}/closeAKpayment.write关闭未支付单、释放库存
POST/payments/sandbox/completeAKpayment.write仅沙箱:模拟支付成功

6.5 退款域 Refunds

方法路径鉴权Scope说明
POST/refundsAKrefund.write发起退款(落到 pending 待后台审核)
GET/refunds/{refundNo}AKrefund.write退款详情(含审批日志)

6.6 会员域 Members(须用户授权)

内部映射:/api/member/**

方法路径鉴权Scope说明
GET/members/meOAmember.read当前授权会员档案
GET/members/me/tierOAmember.read等级与权益、下一等级进度
GET/members/me/pointsOAmember.read积分余额与流水
GET/members/me/couponsOAmember.read可用券包
GET/members/me/ordersOAmember.read会员名下订单(不限于本应用)

6.7 商城域 Mall(灰度)

内部映射:/api/mall/**

方法路径鉴权Scope说明
GET/mall/productsAKmall.read在售商品列表(含 SKU)
GET/mall/products/{productId}AKmall.read商品详情
POST/mall/ordersAKmall.order.write创建商城订单
GET/mall/orders/{orderNo}AKmall.order.write商城订单详情
POST/mall/orders/{orderNo}/payAKpayment.write商城支付(默认 sandbox)
POST/mall/orders/{orderNo}/cancelAKmall.order.write取消商城订单

6.8 评价域 Reviews

方法路径鉴权Scope说明
GET/reviewsAKreview.read酒店公开评价列表 + 聚合分

6.9 内容域 Content

内部映射:/api/cms/**、/api/site/content

方法路径鉴权Scope说明
GET/content/articlesAKhotel.read已发布文章列表
GET/content/articles/{slug}AKhotel.read文章详情(会员专享内容无权时裁剪)

6.10 Webhook 管理

方法路径Scope
POST / GET/webhooks/endpointswebhook.manage
PATCH / DELETE/webhooks/endpoints/{id}webhook.manage
POST/webhooks/endpoints/{id}/rotate-secretwebhook.manage
POST/webhooks/endpoints/{id}/redeliverwebhook.manage

7. 接口详细定义

每个子节格式:Scope → 内部映射 → 请求参数 → 示例 → 响应 → 边界约束。 金额单位:除特别说明,全部为分(CNY 最小单位),字段名以 Fen 结尾。

7.1 酒店内容域

7.1.1 GET /hotels 酒店列表

  • Scope:hotel.read;内部映射:GET /api/booking/hotels
参数位置类型必填说明
cityquerystring否城市名/代码,不传返回租户全量酒店
updatedSincequerydatetime否增量拉取(RFC3339)
page / pageSizequeryint否默认 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"
}

约束(与实现一致,勿按旧版示例开发)

  1. 字段就是上面这些:当前 Hotel 表只有 id/slug/name/city/address/phone/bookingMode/status, 不存在 starRating/coordinates/nameEn/images(历史示例曾写过,属于规划项,未实现);
  2. city 是内部城市串(如 hangzhou),不是结构化城市对象;
  3. lowestPriceFen 为未来 90 天最低价快照且可为 null(null = 该期间无任何可售库存, 内部用 0 表示无价,对外统一转成 null,避免被误读为「免费房」);它不是实时报价,不可作为成交依据;
  4. 只返回 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 / checkOutquerydate否不传则不返回价格与余房
roomsqueryint否默认 1,上限 5
guestsqueryint否默认 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"
}

约束

  1. plans[].amountFen 是区间总价(nights × rooms);内部 RateQuote.amount 单位为元,网关 ×100 转换。逐日明细请用 /availability/rates;
  2. 房型级余房叫 availableRooms(整数),计划级 available 是布尔——内部两个层级都叫 available 且都是整数,对外刻意拆开,避免第三方把「余房 3 间」当成 true;
  3. 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
  • 用途:抓取某城市/某酒店在一段日期内所有可售组合的逐日价格
参数位置类型必填说明
cityquerystring否与 hotelId 至少一个
hotelIdquerystring否酒店 slug 或 id
checkInquerydate是
checkOutquerydate是晚于 checkIn,最长 92 晚
rooms / guestsqueryint否默认 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"
}

约束(与实现一致)

  1. totalFen 是整个区间的总价(= 单晚价 × nights × rooms),不是单晚价; 要单晚价请用 totalFen / nights / rooms,或改用 §7.2.2 的价格日历取每日最低价;
  2. 没有 daily 逐日明细(历史示例曾列出,属规划项):中台 planQuotes() 只返回区间总价, 逐日价格请走 /availability/price-calendar;
  3. 本接口实时读库存,建议本地缓存 60s TTL,不得用于高频「探价」轮询;
  4. 只覆盖 bookingMode=ebooking 的酒店(房量来自本库 InventoryDay)。PMS 直连酒店不返回报价, 其开放属二期;
  5. 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 会被忽略(不会报错,但也不生效)。
字段类型必填说明
hotelIdstring是酒店 id 或 slug
roomTypeIdstring是房型 ID,必须先经 /hotels/{id}/room-types 取得
ratePlanIdstring是房价计划 ID,必须精确命中(不回落同房型其他计划)
checkIn / checkOutdate是YYYY-MM-DD,checkOut > checkIn
roomsint否默认 1,上限 5
guestsint否默认 2;超过房型可住人数会报 business_rule_violation
extraBedsint否加床数量,上限 maxExtraBed × rooms
guestPhonestring否传了才会计入会员折扣与券可用性;一期仅允许传自己持有授权的会员号
couponIssueIdstring否指定券,不传则不使用(不自动择券)
pointsToUseint否使用积分数量,受余额约束
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 在报价里是布尔(是否有早),房型/计划接口里是文本(如「双早」)。

单位换算对照(内部元 → 外部分)(重要):

内部字段(元)外部字段(分)换算
roomYuanroomFenx100
extraBedFeeYuan / extraBedFeePerNightYuanextraBedFeeFen / extraBedFeePerNightFenx100
baseYuan(已含加床费)baseFenx100
tierDiscountYuantierDiscountFenx100
netAmountYuannetAmountFenx100
couponYuancouponFenx100
pointsYuanpointsFenx100
payableYuanpayableFenx100

边界约束

  1. payableFen = baseFen − tierDiscountFen − couponFen − pointsFen,最低 0,不会出现负数;
  2. 券门槛按会员折后价(netAmountFen)判定,不是原价;couponIssueId 不传就是不使用券(不会自动挑一张最省钱的);
  3. 试算通过不代表库存被锁,下单时仍可能返回 40901 inventory_insufficient(并发被抢);
  4. amount 字段:传了也会被忽略。服务端取价来源是「房型 + 计划」的可订列表,与 /hotels/{id}/room-types 完全同源;
  5. 报价时效:一期不返回 expiresAt(内部报价快照没有落库有效期),下单以当时的可订性与价格为准;
  6. 报错口径:房型整体不存在 → 40402;房型存在但目标日期没有可订库存 → 40901;房价计划不在可订列表 → 40404。

7.3.2 POST /booking/reservations 创建预订

  • Scope:booking.write
  • 副作用:立即占房(内部 InventoryDay.held),内置超卖防护;未支付记录 holdExpiresAt,30 分钟后自动释放(前台现付除外)
  • 幂等:partnerOrderNo 即幂等键(见下方约束 5)
字段类型必填说明
hotelId / roomTypeId / ratePlanIdstring是与 §7.3.1 完全同参,服务端据此取价
checkIn / checkOutdate是
roomsint否默认 1,上限 5
guestsint否入住总人数,默认 2(不是名单数组,见 roomGuests)
guestNamestring是入住联系人
guestPhonestring是11 位手机号,必须 1 开头,否则 40001
privacyConsentboolean是必须为 true(隐私合规硬要求,缺失返回 40002)
roomGuests[]array否每间房入住人:{roomIndex, guestName, guestPhone, isContact};不传则用联系人自动补齐
childrenint否儿童数
extraBedsint否加床数量,上限 maxExtraBed × rooms
arrivalTimestring否HH:mm 预计到店
specialRequeststring否备注,<=500 字
invoiceTitle / invoiceTaxNostring否发票信息
payChannelstring否alipay_qr / wechat_qr / front_desk;不传则只占房不发起支付
couponIssueId / pointsToUsestring/int否与 §7.3.1 同口径
partnerOrderNostring强烈建议第三方订单号,<=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"
}

边界约束

  1. privacyConsent: true 缺失 → 40002 missing_parameter(不会先占房再报错,校验在占房之前);
  2. guestPhone 非 11 位 → 40001 invalid_parameter;
  3. 库存不足 / 目标日期不可订 → 40901 inventory_insufficient;房价计划不在可订列表 → 40404;
  4. guestPhone 命中会员时自动应用等级折扣(与 §7.3.1 试算同口径);
  5. 幂等:带 partnerOrderNo 时,重复提交返回首次创建的订单(HTTP 200 + X-XN-Idempotent-Replay: true), 不会重复占房;首次创建返回 201。不带 partnerOrderNo 则每次都会新建订单(仅靠 Idempotency-Key 头兜底,一期未强制);
  6. payChannel=front_desk 为前台现付:不生成在线支付单,且库存不会被超时释放;
  7. 订单归属于当前 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 } }

三条硬约束(沿用内部设定,不可绕过)

  1. 目标房价计划必须精确命中,找不到返回 40404 rate_plan_not_found,绝不回落同房型其他计划;
  2. 房量「先占新日期,成功再放旧日期」,且已支付订单新房量记 sold 而非 held;
  3. 券与积分原样保留,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。
字段类型必填说明
reservationNostring是必须是自己 appId 创建的订单
channelstring否alipay_qr(默认)/ wechat_qr / front_desk
returnUrlstring否一期未使用(不做支付完成跳转,请轮询或等 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"
}

约束

  1. paymentNo 就是内部支付流水的 providerTxnId(形如 yp_ + 8 位十六进制)。不要自己另造一套单号;
  2. status 取值:pending(在线待支付)/offline(前台现付);
  3. channel=front_desk 返回 offline: true、payUrl: null,且库存不会被自动释放;
  4. amountFen 是本次应支付金额:改期补差价时会等于 dueFen 而不是订单总额(orderAmountFen 才是总额);
  5. 一期已付清且无补差价的订单再调本接口 → 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"
}

约束

  1. status 是内部支付流水状态(pending / paid / failed 等),不是订单状态;订单状态请看 §7.3.3;
  2. 金额刻意从订单推算(dueFen > 0 ? dueFen : amountFen),因为内部 Payment.amount 在「正常单」和「补差价单」里存的是元、而 Refund 是分,口径不统一;
  3. 仍建议以 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} 兜底)
字段类型必填说明
reservationNostring是预订号
amountFenint否不传按可退上限全额
reasonstring否<=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 用户授权)

两条硬口径(与实现一致)

  1. 只认 Bearer 令牌里的 memberId:手机号由服务端反查,不接受任何请求参数传手机号 (否则就是「手机号即凭证」,可遍历他人会员数据)。用 AppKey 调这些接口会得到 403 insufficient_scope(提示必须用用户授权)。
  2. 手机号与邮箱一律脱敏: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"
}

约束

  1. 内部字段 excerpt 对外统一叫 summary;publishedAt 沿用库里的字符串格式(不是 RFC3339);
  2. 站点解析不靠 Host:开放域名不是注册过的 SiteHost。默认取该租户第一个启用站点, 多站点租户用请求头 X-XN-Site-Code 指定(如 X-XN-Site-Code: hotel);
  3. 会员专享文章:携带 OAuth 令牌且会员有效时返回全文;否则 content: "" + locked: true (由中台在服务端裁剪,不依赖前端隐藏);
  4. 列表接口返回的 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

两条必须知道的语义

  1. 自动确认的酒店不会发 reservation.confirmed:订单在支付成功的同一瞬间就被确认, 没有独立的确认日志。此时 payment.succeeded 的 payStatus=paid 就代表可入住,第三方应以此为准。
  2. 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"
  }
}

接收端要求:

  1. 先验签再处理(时间戳窗口 ±300s,防重放);
  2. 2xx 视为成功,其余一律重试;
  3. 必须幂等:以 X-XN-Delivery-Id 去重(同一事件可能投递多次);
  4. 响应耗时 <= 3s,超时按失败处理;建议「先落库再异步处理」。
  5. 不保证严格有序,请以 createdAt 与业务状态机做乱序保护。

8.4 重试策略

重试次数距上次间隔
110s
230s
32min
410min
530min
62h
76h
8(终态)24h

累计 8 次仍失败 → 端点标记 failing 停投并告警,修复后可 redeliver。


9. 频率限制

9.1 配额模型

按优先级取最严:AppKey 级 → 租户级 → IP 级。令牌桶实现,突发允许 1.5× bucket。

9.2 默认配额表

级别接口范围默认配额说明
L0 只读内容/hotels、/room-types、/content/**、/reviews50 QPS / AppKey日调用无上限
L1 可售 / 报价/availability/**、/booking/quote20 QPS / AppKey;200 QPS / 租户建议本地缓存 60s
L2 会员读取/members/**10 QPS / AppKey;100 QPS / 租户需用户授权路径
L3 交易写/booking/reservations、/payments、/refunds、/mall/orders5 QPS / AppKey;租户每分钟 60 单创建保护库存与资金
L4 支付状态轮询/payments/{no}、/payments/sandbox/complete10 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中文说明处理建议
ok0200成功—
invalid_parameter40001400参数格式/取值非法对照 errors[].field 修正
missing_parameter40002400缺少必填参数常见:privacyConsent
invalid_json40003400Body 非合法 JSON检查 Content-Type 与编码
invalid_amount40004400金额非法(负数/精度)金额必须是整数分
date_range_invalid40005400日期区间非法checkOut > checkIn,<= 92 晚
invalid_signature_version40006400X-XN-Sign-Version 不支持固定传 1
unauthorized40100401未提供任何凭证补齐签名头或 Bearer
invalid_app_id40101401AppId 不存在检查环境(沙箱/生产)是否混淆
signature_mismatch40102401签名不匹配核对 canonical string 与 body 原始字节
timestamp_expired40103401时间偏差超 ±300s校准服务器时钟(建议 NTP)
nonce_replayed40104401Nonce 已使用每次请求重新生成随机串
invalid_token40105401access_token 无效重新走授权流程
token_expired40106401access_token 过期用 refresh_token 刷新
insufficient_scope40107403应用未获此 scope控制台申请后重试
consumer_mismatch40108401OAuth code/token 与 client_id 不符检查 client_id
forbidden40300403禁止访问联系平台开通
app_disabled40301403应用被停用联系平台运营
tenant_mismatch40302403资源不属于该应用绑定租户禁止在请求里传 tenantId
not_owner40303403资源不属于当前凭证主体检查订单创建方
member_banned40304403会员已被封禁拒绝交易,联系客服
resource_not_found40400404资源不存在确认 id / slug
hotel_not_found40401404酒店不存在或未对渠道开放检查 hotelId
room_type_not_found40402404房型不存在用 /room-types 重新拉取
order_not_found40403404订单不存在(含非本应用订单)越权也返回此项
rate_plan_not_found40404404房价计划不存在或不可订不会自动回落到其他计划
endpoint_not_found40405404接口不存在检查版本号与拼写
conflict40900409通用冲突重新读取资源状态
inventory_insufficient40901409库存不足 / 已售完改日期或房型
order_status_conflict40902409订单当前状态不允许此操作先 GET 详情再动作
idempotency_conflict40903409幂等键复用但 body 不同换新 key;若确是重复请求请忽略
duplicate_review40904409该订单已评价(一单一评)不可覆盖
quote_expired40905409报价快照过期重新调用 /booking/quote
business_rule_violation42200422违反业务规则看 message 说明
exceed_max_rooms42201422超过单笔最大房间数拆单(上限 5 间)
coupon_not_eligible42202422券不可用(未达门槛/已过期/门店不符)清除 couponIssueId 重试
points_insufficient42203422积分不足降低 pointsToUse
member_not_enrolled42204422手机号非会员走原价下单或引导注册
rate_limit_exceeded42900429超过频率限制指数退避
quota_exceeded42901429超过日/月调用配额提交提额申请
internal_error50000500平台内部错误带 requestId 联系支持
upstream_unavailable50201502依赖服务(PMS/支付)不可用稍后重试;PMS 模式不会静默降级
maintenance50301503系统维护中关注 Retry-After 与状态页

10.3 客户端容错要求(强制)

  1. 未知 code 必须按通用错误降级处理,不得崩溃;
  2. 未知响应字段必须忽略,不得反序列化失败;
  3. 展示给用户的文案以 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 URLhttps://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/completePOST /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 段结构(模板)

  1. 一句话说明 + 业务场景
  2. 请求:Method /path、鉴权模式、所需 scope、是否幂等、是否计费配额桶
  3. 请求参数表:字段 / 类型 / 必填 / 默认值 / 约束 / 说明
  4. 请求示例:curl + 官方 SDK(Node / Java / Python)
  5. 响应参数表:字段 / 类型 / 说明(含内部字段映射备注)
  6. 响应示例:成功(200/201)+ 至少 2 个典型失败(400/409)
  7. 错误与重试:本接口专属错误码 + 是否可重试
  8. 变更历史: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
Webhook8 个管理接口 + 事件产生 + 投递OpenWebhookController/OpenWebhookEmitter/OpenWebhookDispatcher
通用幂等全部 POST 支持 Idempotency-KeyOpenIdempotencyStore
会员与 OAuth2授权页 + token/revoke/introspect + 5 个会员接口OpenOAuthController/OpenOAuthStore/OpenMemberController
归属索引单号 → (内部主键, appId, 手机号),越权一律 404OpenResourceLink + OpenResourceLink 表
后台接口4 个(应用创建/列表/修改/轮换密钥)OpenAppAdminController
配置app.open.*(含全部限流阈值、密钥加密主密钥、总开关)application.yml
反代/open/:path* → Javanext.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 → 401 nonce_replayed;时间戳偏移 → 401 timestamp_expired
  • ✅ 未带凭证 → 401 unauthorized;越权资源 → 404;缺参 → 400 missing_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:openapi 16/16;npm run test:platform 5/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 / OpenWebhookDeliveryWebhook 端点与投递表已建,尚无投递器(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 施工时的十七个高风险点(全是本期实际踩到或主动规避的)

  1. 签名必须用标准 Base64:本期实现曾用 Base64URL(- _),导致第三方 100% signature_mismatch 且极难自查。契约测试已锁死。
  2. 组件扫描:开放平台在 com.xuaninn.openapi(与启动类不同包),必须显式 @ComponentScan。漏了会静默 404,没有任何报错——本期先踩后修。
  3. 金额单位:Order.amount 内部是元,Refund/InventoryDay/Coupon 是分;而 Payment.amount 在「正常单」与「补差价单」里都是元。换算必须在 OpenUnit 一处完成;契约测试会扫描「其他文件里出现 *100」并直接失败。
  4. 内部接口信任客户端价格:createReservation → priceQuote 的基价来自请求里的 quote.amount(官网 H5 的用法)。开放层必须自己按房型+计划取价,否则改个数就能 1 元入住。契约测试断言 OpenBookingController 不读客户端的 amount。
  5. available 的语义陷阱:中台房型/计划/报价三处的 available 都是整数余房且不可订项已被过滤。对外拆成「计划级 available 布尔 + availableRooms 整数」,房型级只有 availableRooms。
  6. 订单归属:现有 GET /api/booking/orders/{id} 是「订单号即凭证」、GET /api/mall/mine/orders?phone= 是「手机号即凭证」,都不能直接出网。开放层用 OpenResourceLink 把 appId ↔ 单号 ↔ 手机号绑起来,越权返回 404(不是 403,避免单号枚举探测)。
  7. AES 主密钥不能从 session-secret 派生:本期踩到「带 .env 启动」与「手工 java -jar 启动」派生出两把密钥,历史 appSecret 静默无法解密,表象只是「应用未配置有效密钥」。已改为固定开发种子 + 启动 WARN,生产必须显式配 OPEN_API_ENCRYPTION_KEY。
  8. enum/文案不能当契约:内部错误的 HTTP 状态码(400/404/409)决定对外错误码大类,只有「库存/房型/酒店/日期」四类做关键词细化,避免把随时会改的中文文案当契约(OpenApiException.mapFrom)。
  9. 事件不要扫「当前状态」:最初按 Order.updatedAt 扫当前状态,结果「下单 → 支付确认 → 立即取消」这种快速流程里,中间态 confirmed 在两次扫描之间就过去了,第三方永远收不到 reservation.confirmed。必须扫 OrderLog / RefundLog 这类追加型状态迁移日志(每条都不会被覆盖)。
  10. 对账游标不能用随机 id 做秒内 tiebreak:(时间, id) > (游标) 看似严谨,但日志 id 是随机串而非自增,同一微秒内「id 字典序更小」的新行会被永久跳过——实测漏掉了 refund.succeeded 与 reservation.checked_*。正确做法是只比时间且用 >=,重复扫到的行由 (endpointId, eventKey) 唯一约束吃掉:宁可重扫,绝不漏扫。
  11. 回调地址是 SSRF 入口:平台会主动请求第三方配置的 URL。生产必须强制 https 且拒绝内网/回环/云元数据地址(127.*、10.*、172.16-31.*、192.168.*、169.254.*、localhost)。沙箱放行是为了本机联调,靠 OPEN_API_WEBHOOK_ALLOW_INSECURE 显式开关,生产务必设 false。
  12. 响应缓冲必须无条件回写:幂等要用 ContentCachingResponseWrapper 缓存响应体,任何分支漏调 copyBodyToResponse() 都会让客户端拿到空响应(HTTP 200 但 body 为空,最难查的一类问题)。
  13. E2E 测试会真实吃掉演示库存:开放平台的交易测试是真占房(下单扣 held、支付扣 sold),演示酒店每夜仅 7 间,跑几轮就全售罄,之后所有报价返回空/inventory_insufficient(表象是「本机没有可订房型」)。已提供 scripts/dev/reset-demo-inventory.sql(psql -v slug=<演示酒店 slug> -f)恢复本地房态。
  14. OAuth 的 redirect_uri 必须精确匹配白名单:它是唯一能把授权码交出去的地方。前缀匹配、通配、# 片段都会被开放重定向利用。参数非法时渲染错误页而不是 302;授权页还要 X-Frame-Options: DENY 防点击劫持。
  15. 授权码要「先消费、后校验」:consumeCode 用条件更新(WHERE consumedAt IS NULL)原子置位,然后才校验 redirect_uri 与 PKCE。这样失败尝试会烧毁授权码,攻击者无法拿同一个 code 爆破 code_verifier;代价是客户端写错 verifier 必须重新授权(本来就该如此)。授权码与令牌一律只存 SHA-256 哈希。
  16. 会员域绝不能让手机号当凭证:开放层的会员接口只认 Bearer 令牌里的 memberId,手机号由服务端反查;手机号与邮箱在出口统一脱敏。这和「订单号即凭证」是同一类坑。
  17. client_secret 就是 appSecret:令牌端点必须校验,且用常数时间比较;grant_type=refresh_token 要轮换(吊销旧的、签发新的),否则刷新令牌一旦泄露就是长期通行证。
  18. 表结构变更 + 并发编辑:新表一律写进 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
分 FenCNY 最小单位,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 为准。