app-integration.md 13 KB

满额免运 App 端对接文档(008,2026-10-19)

后端已上线(分支 codex/036-multi-store-checkout 提交 e907f511,v2 线 test-202609v2 提交 25d7bcdb)。 完整契约见 contracts/free-delivery.md;本文面向 App(用户端)开发,只讲怎么接。

1. 功能与范围

  • 商家可配置「原价满 X 元免配送费」促销(type=5)。用户无需领券,商品原价合计达到门槛自动生效。
  • 命中后客户运费实付为 0,运费由商家承担;骑手配送费照旧按原运费结算,App 不用关心商家与骑手之间的交款。
  • 报价入口已统一(2026-10-19 追加 FR-037):/deliveryQuote 支持全部用餐类型——平台骑手外送评估满额免运;商家自配送运费照算但免运不生效;自取/堂食免地址、无运费。闪送、多店合并下单不适用。
  • 预约单(delryTime 非空)同样支持,配送模式与活动资格都按预约时段判断。

2. 金额模型(展示口径以此为准)

符号 字段 含义
O originalAmount 商品原价合计(含规格加价,不含运费)
G finalAmount 商品应付(已扣促销/新客/券)。沿用旧接口语义,不含运费
R deliveryFee 原配送费(骑手结算基数)
F deliveryDiscountAmount 满额免运减免额(命中=R,未命中=0)
D customerDeliveryFee 客户实付配送费(命中=0,未命中=R)
A payableAmount 客户整单应付 = G + D。下单、支付都用这个数

结算页展示(命中示例:原价 100、运费 30):

项目 金额
商品应付 G 100
原配送费 R 30
满额免运优惠 −F −30
用户实付 A 100

未命中(如原价 99 < 门槛 100):没有免运行,customerDeliveryFee=30,实付 = G + 30。

3. 新接口:完整配送报价

POST /app/userPromotionCalc/deliveryQuote(Header:token;与旧 /calculate 并存,旧接口行为不变)

请求(结算页每次进入/改商品/换券/切路径都调用):

{
  "storeId": 1,
  "orderType": 0,
  "addressId": 8,
  "delryTime": null,
  "items": [{ "productId": 10, "quantity": 2, "specValueIds": [301] }],
  "couponId": null,
  "forcePath": null
}

各场景返回差异(促销/优惠券/预算规则完全一致,App 无需分支判断,一律读 payableAmount):

orderType addressId 运费 R 免运 F 实付 A
0 平台骑手外送 必填 服务端路线跳表算 命中门槛则 F=R G + D
0 商家自配送(服务端判定) 必填 同一套跳表算 恒为 0 G + R
1 自取 / 2 堂食 不需要(传入忽略) 0 0 = G

要点:

  • items 只传 productId / quantity(1~99) / specValueIds。不传价格、不传运费——商品价、规格加价、运费全部由服务端按数据库和现行路线/运价计算,传了也不采信。specValueIds 以商品详情返回的 foodSku 选项为准(规格唯一来源为关联关系表;商品没返回规格选项就不要传)。报错文案会带商品ID(如“商品[9115]无效或不属于该门店”),便于定位。
  • couponId / forcePath 语义与旧接口相同;自取/堂食同样会返回促销/券明细与可用券列表。

响应 data 关键字段(对应上文金额模型):

{
  "pricingVersion": 2,
  "originalAmount": 100,
  "finalAmount": 100,
  "deliveryFee": 30,
  "deliveryDiscountAmount": 30,
  "customerDeliveryFee": 0,
  "payableAmount": 100,
  "freeDeliveryActivityId": 51,
  "freeDeliveryThreshold": 100,
  "discountBudget": 20,
  "remainingDiscountBudget": 0,
  "appliedCouponId": null,
  "details": [
    { "type": "promotion", "subType": 5, "name": "原价满100免配送费", "refId": 51,
      "reduce": 30, "originalAmount": 100, "deliveryFee": 30,
      "customerDeliveryFee": 0, "threshold": 100 }
  ],
  "availableCoupons": []
}
  • details 里 subType=5 的明细就是免运行,直接渲染名称和 reduce。
  • freeDeliveryActivityId 非 null 即命中;为 null 未命中(不要伪造免运行)。
  • 券实际生效以 appliedCouponId 为准(仅实减>0 时返回)。couponId 只是用户选择意图,实减为 0 时不会被核销。
  • 命中免运时 availableCoupons 中免配送费券 usable=false,unusableReason 已本地化("本单已享受满额免运"),直接展示即可。
  • 路径 A/B 已由服务端按整单应付 A(含运费、含预算封顶后)自动选优,App 沿用 optimalPath 展示即可;用户手动切换仍传 forcePath。

4. 下单:版本 2 显式接入

下单接口不变:仍是 POST /system/userOrder/createOrder,没有新增下单接口;promotionPricingVersion 只是显式开关,不传就完全走旧流程(旧版本 App 不受任何影响)。

各场景下单方式(算价已统一,下单按场景区分):

场景 结算页报价 下单方式 服务端处理差异
平台骑手外送 /deliveryQuote 传 version=2(推荐) 同一事务内重新报价并核对客户端 amount/freight/父单应付,不符返回 quoteChanged;父单金额、food JSON、优惠快照全部以服务端为准
商家自配送 /deliveryQuote 传 version=2(推荐) 与上完全同流程,免运快照 F=0;运费结算归商家由后端订单快照 selfDelivery=1 处理,App 无感知
自取 / 堂食 /deliveryQuote(A=G) 旧流程,不带 version=2 下单字段结构与旧版一致(food/amount 原样传);服务端不重算,金额以结算页报价为准;误传 version=2 返回 unsupportedOrder
旧版本 App /calculate 旧流程 完全不变

版本2请求体(外送示例,自取/堂食不要带 version=2),在 items[0] 增加 promotionPricingVersion: 2:

{
  "ddId": "DD10086",
  "shdzId": 8,
  "type": 0,
  "totalAmount": 130,
  "actualPayAmount": 100,
  "paymentMethod": "2",
  "items": [{
    "mdId": 1,
    "type": 0,
    "amount": 100,
    "freight": 30,
    "foodAmount": 100,
    "promotionPricingVersion": 2,
    "delryTime": null,
    "cartItems": [{ "productId": 10, "quantity": 2, "specValueIds": [301] }],
    "couponId": null,
    "forcePath": null,
    "food": []
  }]
}

入参相对旧契约的变化(版本2)

结构上只多两个可选字段,其余字段全部沿用:

新字段 位置 作用 不传的后果
promotionPricingVersion items[0] 传 2 显式接入新契约:服务端同事务重算并固化快照;不传则完全走旧流程(新旧 App 共存、灰度、回滚都靠它) 旧流程处理:不算免运、不重算不核对、金额按客户端传入入库
specValueIds items[0].cartItems[m] 只报"选了哪些规格值",规格加价由服务端查数据库计算(旧 specPrice 是客户端自报金额,不采信) 无规格商品可不传;有规格商品必传,缺失/伪造返回 invalidSpec

旧流程已有字段在版本2下的取值来源变化:

字段 旧流程 version=2
items[0].amount 客户端自算 必须 = 报价 payableAmount(A),服务端核对
items[0].freight 客户端自算 必须 = 报价 deliveryFee(R),免运时也传 R 不传 0
input.actualPayAmount 客户端传 必须等于 A
input.totalAmount / totalDiscount 客户端传什么存什么 传了也被服务端覆盖(O+R / (O+R)−A)
items[0].food / foodAmount / specPrice 客户端数据原样入库 服务端按数据库价格重写,客户端值不采信

报价返回字段的回传规则:下单只需回传两个数——payableAmount(→ amount 和父级 actualPayAmount)与 deliveryFee(→ freight)。F/D、freeDeliveryActivityId、appliedCouponId 等其余扩展字段一律不用传(服务端重算后自行固化);merchantPayToRider/riderPayToMerchant/customerCollectAmount 是查询侧只读派生值,任何接口都不接收。

必须遵守:

  • items 必须只有 1 个、父/子 type 都为 0(版本2下单限外送:平台骑手与商家自配送门店均可,自配送单免运快照 F=0);自取/堂食下单走旧流程不带 version=2;违反返回 unsupportedOrder。
  • item.amount = 报价的 payableAmount(A),item.freight = 报价的 deliveryFee(R)——免运时 freight 也照传 30,不要改成 0;父单 actualPayAmount = A。服务端会在同一事务内重新报价并逐项核对。
  • cartItems 必传(含 specValueIds);food 里的价格服务端会忽略并用数据库价格重写。

与计算阶段的衔接: version=2 下单时服务端会用下单时刻的最新报价重算(活动可能已结束/运价变化),所以结算页拿到的金额若已过期,下单会收到 quoteChanged——App 重新调 deliveryQuote 刷新后让用户确认即可,不要静默重试提交。

错误处理:(业务错误走既有 AjaxResult 错误机制,msg 返回按用户语言本地化后的文案;下表 key 仅作对照,不要拿 key 字符串做 UI 判断)

key 场景 App 处理
promotion.freeDelivery.quoteChanged 报价过期/金额被改动(活动结束、改价、运价变化) 提示"价格已变化",自动重新调 deliveryQuote 刷新结算页,让用户确认新金额后再下单
promotion.freeDelivery.duplicateOrder ddId 重复提交 提示勿重复下单,刷新订单列表
promotion.freeDelivery.unsupportedOrder 自取/堂食/多店传了 version=2 下单,或报价时 orderType 不是 0/1/2 报价不会遇到(0/1/2 都支持);下单时这些场景不要带 promotionPricingVersion,走旧流程
promotion.freeDelivery.freightUnavailable 路线/运价暂不可用 提示稍后再试,结算页运费显示加载失败,不要默认 0
promotion.freeDelivery.invalidAddress / invalidProduct / invalidSpec 地址不归属/商品下架/规格失效 提示并引导重新选择

5. 订单查询侧的新字段

订单详情/列表(返回 PosOrder 的接口,如 getParentOrderDetail)新增三个派生交款字段(仅外送单返回,其余为 null;金额由后端按订单快照和当前支付方式计算,App 只展示、不计算):

场景(G100/R30/F30/D0/A100) merchantPayToRider riderPayToMerchant customerCollectAmount
在线已付 30 0 0
到付(payType=1) 0 70 100
到付且 A<R(如 A20/R30) 10 0 20
  • 到付场景骑手端向客户收 customerCollectAmount;用户切换支付方式后重新查询即返回新方向。
  • 订单上 freight 仍是原运费 R=30、amount=A,不是 bug,不要在 UI 上把 freight 当客户运费展示;客户运费请用 customerDeliveryFee(旧订单该字段为 null 时回退显示 freight)。

6. 明确不要做的事

  1. 不要用旧 /calculate 的 finalAmount 当整单应付——它只是商品应付 G,不含运费。外送结算页请整体切到 deliveryQuote。
  2. 不要客户端自己判断门槛/算免运金额——预算优先级(免运先全额预留、其余优惠用剩余预算、半价豁免)都在服务端,App 展示返回值即可。
  3. 不要在下单时把 freight 清零或传自算运费——服务端核对不一致会拒绝下单。
  4. 未接入 version=2 前不要在旧下单流程里传免运相关字段——旧契约原样保留,混传会被当普通请求处理或报错。
  5. 结算页不要按用餐类型分接口——目标态是所有场景只调 /deliveryQuote;旧 /calculate 仅为线上旧版本保留,新版本不要新增调用。

7. App 验收清单

  • 结算页(外送)改调 deliveryQuote;展示 G / R / 免运行(−F) / 实付 A
  • 门槛边界:原价 100 命中、99 不命中;命中后再加券/促销 G 降低仍免运
  • 命中时免配送费券置灰并展示"本单已享受满额免运";实减 0 的券不核销
  • forcePath 切换 A/B 后金额正确(服务端已按 A 选优)
  • 下单传 promotionPricingVersion=2;篡改 amount/freight 触发 quoteChanged 并能自动重新报价
  • 到付/在线订单详情三方交款字段正确;切换支付方式后方向更新
  • 预约单(delryTime)报价与下单一致
  • 自取/堂食结算页调用 /deliveryQuote(A=G,无免运行);下单不带 version=2,行为与旧版一致
  • 商家自配送门店结算页:运费正常显示(服务端算),无免运行;version=2 下单成功且 F=0
  • 重复点击下单被 duplicateOrder 拦截
  • 四语言错误提示正常(错误文案由后端 i18n 返回)

8. 依赖与未决

  • 后端依赖数据库执行 updatesql/sql.md 末尾 2026-10-19 的 3 条 ALTER TABLE pos_order(各环境各自执行后方可下单 version=2)。
  • 商家端(foodie-store)第五 Tab 已上线,测试商户可自行配置门槛活动。
  • 骑手端/商家 App 的展示不在本文范围(派生字段已在订单数据中,两端按需取用)。