Files
shaguabijia-app-server/docs/superpowers/specs/2026-06-30-h5-home-feed-h2b-design.md
guke a563c1ca4b @ (#111)
docs/api目录文档分类和补全

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #111
2026-07-03 15:00:37 +08:00

8.6 KiB

H2b — 首页 feed 真接入设计(H5 端 + 安卓 getLocation)

仓库:shaguabijia-app-server(分支 feat/h5-sgbridge-gk)+ shaguabijia-app-android 日期:2026-06-30 里程碑:H5-WebView 改造方案 M3(首页)子项 H2b 配套:docs/H5-WebView改造方案.md、原生参考 ui/home/HomeViewModel.kt + HomeScreen.kt

1. 目标

h5/home/index.html写死的 7 张 mock feed 卡换成真实 /api/v1/meituan/* 数据,三个排序 tab(智能推荐 / 距离最近 / 销量最高)接通后端,「抢」按钮接换链拉起美团 —— 忠实移植原生 HomeViewModel + HomeScreen 的 feed 逻辑(全功能,非精简版)。

已确认决策:

  • D1:本轮一并接安卓真实定位 —— 安卓 SGBridge.ktgetLocation(),H5 据此拿经纬度;拿不到落北京默认。
  • D2:对齐原生全功能 —— 触底翻页 + 每 tab 缓存 + 三态 + 去重 + 防竞态 + 未授权不打接口。

2. 范围(两仓改动;后端零改动)

文件 改动
app-server h5/shared/bridge.js 新增 getLocation()(查询类,同步返 {longitude, latitude};无桥 mock 北京)
app-server h5/home/index.html feed 模块重写:渲染真实卡 + 三 tab 接端点 + 翻页/缓存/三态 + 抢按钮
android ui/webview/SGBridge.kt 新增 @JavascriptInterface fun getLocation(): String(返宿主缓存的 lastLocation JSON,空串=拿不到)
android ui/webview/WebViewScreen.kt FusedLocationProviderClient.lastLocation(3s 超时)缓存,经构造器 locationProvider 注入 SGBridge

后端不动: /api/v1/meituan/feed/top-sales/referral-linkCouponCard 均已就绪。

3. 数据契约(已存在,只读)

  • POST /api/v1/meituan/feed {longitude, latitude, page, page_size?, tab}{items: CouponCard[], has_next, page, status}
    • tab="rec" 智能推荐(离线库,佣金≥3%,分页);tab="distance" 距离最近(实时打美团,一次性返回 has_next=false)。
  • POST /api/v1/meituan/top-sales {page, page_size?, platform?}{items, has_next, status}(销量最高,离线库,不传经纬度)。
  • POST /api/v1/meituan/referral-link {product_view_sign, platform, biz_line, link_type_list:[1,3]}{link, link_map}(link_map["3"]=deeplink,["1"]=H5 长链)。
  • 三个端点均不鉴权;status:ok 有数据 / empty 暂无 / degraded 上游失败已降级。

4. tab → 端点映射(对齐 HomeViewModel.FeedSort + loadPage)

tab(data-key) label 端点 备注
rec(默认激活) 智能推荐 /feed tab=rec 传经纬度;离线库,坐标实际不影响结果
distance 距离最近 /feed tab=distance 传经纬度;一次性,has_next=false 天然不触底翻页
sales 销量最高 /top-sales 不传经纬度;离线库按销量降序

现有点击处理器(行 6664)里的 asc/desc 方向翻转是 mock 残留,后端不支持方向 → 删除,改为真实拉数据。

5. 定位坐标流程(D1)

bridge.js getLocation()(新增,查询类同步):

function getLocation() {
  if (hasNative && native.getLocation) {
    var o = safeParse(native.getLocation(), null);
    if (o && typeof o.longitude === 'number' && typeof o.latitude === 'number') return o;
    return null;                      // 原生拿不到(空串/未授权)→ null,调用方落默认
  }
  return { longitude: 116.404, latitude: 39.928 };  // 浏览器 mock:北京
}

H5 home:

  • 进页 / visibilitychange 可见时取 var c = SGBridge.getLocation() || {longitude:116.404, latitude:39.928}(北京默认,对齐原生 HomeViewModel 初值)。
  • 坐标变化 → 清空所有 tab 缓存并重拉当前 tab 第一页(对齐 updateLocation)。

android SGBridge.getLocation(): String: 返宿主缓存的 {"longitude":..,"latitude":..}""@JavascriptInterface 同步、不能等异步 GPS,故由 WebViewScreen 预取 FusedLocationProviderClient.lastLocation(镜像 HomeScreen.awaitLastLocation,3s 超时)缓存到 remember 状态,经构造器 locationProvider: () -> Pair<Double,Double>? 注入。未授权 / 拿不到 → ""(H5 落北京默认,rec/sales 不受影响,distance 暂北京中心,回前台拿到后刷新转真实)。

6. 卡片渲染(1:1 照 FeedCard HomeScreen.kt:1694,复用现有 .feed-card 标记/CSS)

卡片元素 CSS CouponCard 字段 规则
头图 .feed-img img@src head_image_url 直用(后端已缩放转 WebP)
品类徽章 .feed-cat-badge platform 1→「外卖」cat-waimai;否则→「团购」cat-tuangou
店名 .feed-title .shop-name brand_name 空则不渲染店名 + 分隔符「|」
标题 .feed-title(分隔符后) name + available_poi_num name+(available_poi_num? 「|N店通用」);CSS 已 2 行省略
距离·店名 .feed-meta .meta-left distance_text + poi_name distanceText+" "+poiName(rec 无距离→仅店名)
销量 .feed-meta .meta-right sale_volume 直用(可空,空则不渲染)
优惠标签 .feed-tag price_label / rank_label 二者非空中随机取一(按 sign 固定);都空则不渲染
现价 .feed-price sell_price splitPrice→整数 + 小数;¥+整数+<small>.x</small>
原价 .feed-original original_price ¥{original_price}(删除线)
.feed-grab 点击→换链→openMeituan

⚠️ 与 mock 的差异(随原生): mock 卡的 .feed-tag 显示「比团购省 X 元」,但原生 FeedCard 只用 price_label/rank_label,不展示「比团购省」已确认(2026-06-30):随原生去掉「比团购省」。通则:原生实现与 H5 原型冲突时,一律以原生为准(原生是已上线、经产品校准的真实行为)。

7. 列表行为(照 HomeViewModel)

  • 翻页: currentPage/hasNext,触底(滚到底部阈值)loadNextPage;append 时按 product_view_sign 去重再拼接;distance/saleshas_next 由后端控制。feedJob/AbortController 在跑则不重入。
  • 每 tab 缓存: {coupons, page, hasNext, loadedAt},TTL 5 分钟。切 tab 命中新鲜缓存 → 直接恢复列表+分页、不转圈;过期/无缓存 → 重置拉第一页。坐标变化清空全部缓存(rec/distance 依赖坐标)。
  • 三态(复用 H2a 容器):
    • loading:首次进 tab 转圈(用 feedLoadedOnce 区分「首次加载中」vs「已加载但空」,防空态闪现)。
    • empty:已加载且列表空 → 复用 #homeFeedNoDealEmpty(「附近暂无…去比价」)。
    • degraded:status=degraded → 「服务繁忙,下拉重试」提示。
  • 防竞态: 切 tab abort 旧请求(旧 tab 响应晚到不覆盖新 tab)。
  • 未授权定位: 三 tab 都显示 #homeFeedEmpty(「去授权」),切 tab 只切高亮不打接口(对齐 selectTabWithoutLoad,尤其 distance 避免拿默认坐标空打美团)。复用 H2a applyLocationState 联动。

点「抢」→ POST /referral-link {product_view_sign, platform, biz_line, link_type_list:[1,3]}deeplink = link_map["3"],h5 = link_map["1"] || linkSGBridge.openMeituan(deeplink || h5);两者皆空 → SGBridge.toast('暂时无法跳转')。请求中给按钮加 loading/防连点。

9. 调用方式

feed/top-sales/referral-link 均不鉴权 → 用fetch + 相对 /api/v1(对齐 home 现有 /api/v1/platform/* 调法,无需引 api.js)。POST、Content-Type: application/json

10. 验收 / 测试

  • 浏览器(无桥,mock 北京): 三 tab 切换各自拉到数据;触底翻页累加;切回命中缓存不转圈;空/降级态文案正确;点抢 console 打印 openMeituan
  • 真机: 距离最近按真实定位由近及远;rec/sales 正常;点抢拉起美团 App / H5。未授权时三 tab 均「去授权」空态、不打接口;授权回前台自动出 feed。
  • 后端: 现有 pytest(meituan feed 冒烟)仍通过(本设计不改后端)。

11. 风险

风险 缓解
getLocation 同步 vs GPS 异步 宿主预取并缓存 lastLocation,getLocation() 同步返缓存;首次可能空→落北京默认,回前台刷新转真实
安卓改动跨仓 安卓侧仅 +1 桥方法 + 宿主取定位;H5 用 SGBridge.getLocation() 抽象,安卓未上线时浏览器/真机均落北京默认,H5 不阻塞
distance 一次性返回被误触底翻页 has_next=false 天然挡住;翻页逻辑只认 hasNext
缓存与定位变化不一致 坐标变化统一清空全部 tab 缓存