Files
jiu/docs/design/public-page-speedup.html

174 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>公开商品页提速方案 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:22px;margin:0 0 4px;}
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
h3{font-size:14px;margin:18px 0 8px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;}
.lead{font-size:13.5px;color:var(--ink);}
table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border-bottom:1px solid var(--border);}
td{padding:8px 10px;border-bottom:1px solid #EEF1F5;vertical-align:top;}
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
pre{background:#1E2430;color:#D8E0EC;border-radius:8px;padding:12px 14px;overflow:auto;font-size:12px;line-height:1.55;}
pre .c{color:#7A8699;}
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;}
.tag.risk{background:var(--danger-bg);color:var(--danger);}
.tag.ok{background:var(--success-bg);color:var(--success);}
.tag.warn{background:var(--warn-bg);color:var(--warn);}
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:10px 14px;border-radius:4px;margin:12px 0;font-size:13px;}
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
.callout.ok{border-color:var(--success);background:var(--success-bg);}
.callout b{color:var(--ink);}
ul,ol{margin:6px 0;padding-left:22px;}
li{margin:3px 0;font-size:13px;}
</style>
</head>
<body>
<h1>公开页提速方案(传输压缩 + 公开页全面去 Flutter 化)</h1>
<div class="sub">2026-07-07 设计 · 触发问题:<span class="mono">jiu.51yanmei.com/app/product/&lt;public_id&gt;?code=P1331</span> 首开约 30 秒 · 目标机 ali(3Mbps 固定带宽)· 用户拍板:所有公开页不使用 Flutter</div>
<h2>一、问题与实测</h2>
<div class="card lead">
<p>公开商品页(顾客扫标签二维码 / 微信分享打开)走的是 Flutter Web 深链:无论从 <code>/product/&lt;id&gt;</code> 短链还是 <code>/app/product/&lt;id&gt;</code> 进来,后端 <code>ProductPage</code> 都返回注入了 OG 标签的 <b>Flutter 应用壳 index.html</b>,浏览器随后要下完整个管理端应用才渲染出一个商品卡。</p>
<table>
<tr><th>环节</th><th>大小</th><th>实测耗时</th><th>问题</th></tr>
<tr><td>HTML(含 OG 注入)</td><td>2.1 KB</td><td>0.05s</td><td><span class="tag ok">正常</span></td></tr>
<tr><td>公开商品 API</td><td>830 B</td><td>0.05s</td><td><span class="tag ok">正常</span></td></tr>
<tr><td class="mono">main.dart.js</td><td>5.3 MB</td><td>11.8s</td><td><span class="tag risk">未压缩</span> 无 gzip、无缓存头</td></tr>
<tr><td class="mono">canvaskit.wasm</td><td>7.2 MB</td><td>16.4s</td><td><span class="tag risk">未压缩</span></td></tr>
</table>
<p><b>三因叠加</b>:① Flutter Web 首包 ~12.5MB;② nginx 全程无压缩(响应无 <code>content-encoding</code>);③ ECS 出网带宽固定 3MbpsPayByBandwidth)→ 12.5MB ÷ 3Mbps ≈ <b>33 秒</b>。重复访问可靠 etag 304,但<b>每个第一次扫码的顾客</b>都要全量拉。</p>
</div>
<h2>二、方案总览(两条腿,独立生效、互相叠加)</h2>
<div class="card lead">
<table>
<tr><th>方案</th><th>解决什么</th><th>改动面</th><th>预期效果</th></tr>
<tr><td><b>A′ · 传输层压缩 + 缓存(瘦身版)</b></td><td>员工 Web 版首开、API JSON 响应</td><td>仅 nginx conf ~10 行</td><td>Web 版首开 12.5MB → ~4.4MB,约 33s → <b>~12s</b>API 列表响应 70%</td></tr>
<tr><td><b>B · 公开页全面 SSR(去 Flutter</b></td><td>顾客扫码 / 分享动线与 App 彻底解耦</td><td>backend 直出<b>商品页 + 店铺列表页</b>两张模板</td><td>公开页首开 ~20KB<b>&lt; 0.5s</b>,全程零 Flutter</td></tr>
</table>
<p><b>公开面盘点(B 的完整范围)</b>:App 中匿名可访问的页面只有两张——商品公开页(<code>/product/:public_id</code>)与店铺公开商品列表页(Flutter 路由 <code>/shop/:shop_code</code>,现仅能从商品页点门店名进入,<b>无独立浏览器短链</b>:直开 <code>/shop/S001</code> 落营销站 404)。两张全部 SSR 化并互相跳转后,顾客动线不再触碰任何 Flutter 资源。登录/注册属 App 本体入口(员工用)不在范围;官网营销站本就是静态站。</p>
<p>A 仍然值得做——员工浏览器开 Web 版、以及公开页上「在 App 中打开」的兜底路径都吃到红利。</p>
</div>
<h2>三、方案 A′:nginx 运行时 gzip + 缓存策略(瘦身版)</h2>
<div class="card lead">
<div class="callout"><b>2026-07-07 用户评审后瘦身:</b>B(公开页 SSR)落地后,「顾客要下 12.5MB」的场景消失,原方案 A1「CI 预压缩 + gzip_static」重武器不再必要,<b>整体砍掉</b>——client 流水线零改动。保留的 A′ 只有 nginx 里 ~10 行运行时配置,随 server 发版同车带出,覆盖剩余收益:员工浏览器开 Web 版首开 33s→12s(低频、一次性)、API JSON 响应压缩(库存列表类响应小 ~70%)、公开页「在 App 中打开」兜底路径。</div>
<h3>A1 · nginx 运行时 gzip</h3>
<pre><span class="c"># server 块(deploy/nginx-jiu-ali.conf):低频大文件现压(2vCPU 压 5MB 约 0.1s,可接受)</span>
gzip on;
gzip_comp_level 5;
gzip_min_length 1k;
gzip_vary on;
gzip_proxied any;
gzip_types application/javascript application/wasm application/json
text/css text/plain image/svg+xml application/octet-stream;</pre>
<p><b>图片不在列</b>JPEG/PNG 本身是压缩格式,gzip 无收益。图片链路现状已健康——上传时后端统一缩到最长边 1200px + JPEG q85(线上实测中位数 ~91KB/张),<code>/images/</code> 已配 <code>expires 30d + immutable</code>,不动。</p>
<h3>A′2 · 缓存策略:协商缓存(304),不做长缓存</h3>
<p>Flutter Web 产物文件名<b>无内容 hash</b>main.dart.js 固定名),长缓存会在发版后出现新旧混用(main.dart.js 与 canvaskit 版本错配会白屏)。因此:</p>
<table>
<tr><th>路径</th><th>策略</th><th>说明</th></tr>
<tr><td class="mono">/app/index.html</td><td>no-store(现状不变)</td><td>入口永远最新</td></tr>
<tr><td class="mono">/app/ 其余全部</td><td><code>Cache-Control: no-cache</code>(协商)</td><td>复访全部 etag 304,零字节;Flutter 自带 service worker 二次访问另有本地缓存</td></tr>
</table>
</div>
<h2>四、方案 B:公开页后端轻量直出(商品页 + 店铺列表页)</h2>
<div class="card lead">
<h3>B1 · 现状与改造点</h3>
<p><code>backend/internal/handler/public.go</code><code>ProductPage</code>:读 Flutter index.html → 注入 OG 标签 → 返回应用壳。<b>改造为:直接渲染一张自包含的轻量商品 HTML 页</b>Go <code>html/template</code> + <code>go:embed</code> 内嵌模板),不再依赖 Flutter 产物。</p>
<table>
<tr><th></th><th>设计</th></tr>
<tr><td>模板位置</td><td><code>backend/internal/handler/templates/product_page.html</code>(go:embed,二进制自包含,部署不变)</td></tr>
<tr><td>页面内容</td><td>主图(多图横滑,纯 CSS scroll-snap)· 品牌/名称(系列)· 规格 · 商品编号(<code>?code=</code> 参数回显,与现 App 公开页口径一致,实施时核对)· 产地/保质期/储存条件/介绍(基础数据字典字段,取自现有公开查询)· 门店名 + 联系电话 · 页脚「岩美酒库 · 正品溯源」</td></tr>
<tr><td>数据来源</td><td>复用 <code>GetProduct</code><code>/api/v1/public/products/:id</code>)已有的查询/字段组装逻辑,抽出共用函数——<b>不新增查询口径,公开字段集与 API 完全一致</b>(成本/进价等敏感字段本就不在其中)</td></tr>
<tr><td>OG 兼容</td><td>OG metatitle/description/image/url)直接写进模板 head<b>复用现有 <code>buildProductOG</code>/<code>buildOGTitle</code></b>;微信/飞书分享卡片行为不变,且不再需要 UA 分流</td></tr>
<tr><td>样式</td><td>内联 CSS,全页 &lt; 20KBgzip 后 ~6KB);配色沿用设计系统主题 A 色值(品牌蓝 #2563AC 系),移动优先单列卡片</td></tr>
<tr><td>图片加载</td><td><b>全部异步拉取(2026-07-07 用户拍板)</b>:HTML 先到先渲染,图片不阻塞——首图 <code>decoding="async"</code> 优先加载,其余图 <code>loading="lazy"</code> 滚动到位才拉;店铺列表页同规则</td></tr>
<tr><td>兜底</td><td>商品不存在 → 渲染同风格「商品不存在或已下架」页(HTTP 200,现状对爬虫友好的行为保持)</td></tr>
<tr><td>互跳</td><td>门店名可点 → <code>/shop/&lt;shop_code&gt;</code>(B4 店铺 SSR 页);页脚小链接「在 App 中查看」→ <code>/app/product/&lt;id&gt;</code>(员工/深链兜底,普通顾客不需要)</td></tr>
</table>
<h3>B4 · 店铺公开商品列表页 SSR(新增短链)</h3>
<p>现状:Flutter 内部路由 <code>/shop/:shop_code</code>(从商品公开页点门店名进入),功能 = 该店公开商品列表 + 关键字搜索 + 分页,数据源 <code>ListShopProducts</code><code>/api/v1/public/shops/:shop_code/products</code>)。SSR 化设计:</p>
<table>
<tr><th></th><th>设计</th></tr>
<tr><td>新路由</td><td><code>GET /shop/:shop_code</code>backendrouter.go 公开区)——店铺公开页从此有独立浏览器短链,可印店铺码/分享</td></tr>
<tr><td>模板</td><td><code>templates/shop_page.html</code>(同 go:embed / 同风格):店铺名+logo · 搜索框(GET 表单 <code>?q=</code>,服务端过滤)· 商品卡列表(图/品牌型号版本/编号,图片 <code>loading="lazy"</code> 原生懒加载——20 卡×~90KB 主图只按滚动加载)· 上一页/下一页链接(<code>?page=N</code>,沿用 API 的 page_size=20 上限)· 每张商品卡链接 <code>/product/&lt;public_id&gt;</code></td></tr>
<tr><td>数据来源</td><td>复用 <code>ListShopProducts</code> 的查询组装(与 API 同一函数抽共用),公开字段集一致,零新增口径</td></tr>
<tr><td>OG</td><td>head 输出店铺级 OG(店名 + logo + 「共 N 件商品」),微信分享店铺链接出卡片</td></tr>
<tr><td>限流/反爬</td><td>路由挂与 shops API 同一套闸:<code>shopListIP</code> 分钟限流 + <code>dqShopList</code> 日配额(300/IP/日);nginx 新增 <code>location ~ ^/shop/</code>,进 <code>jiu_shoplist</code> 2r/s 专属 zone + <code>$jiu_bad_ua_lib</code> + limit_conn 10(与 shops API location 同参数,且同样须放在通用 location 之前)</td></tr>
<tr><td>兜底</td><td>店铺不存在 / 未开公开页 → 同风格「店铺不存在」页;无商品 → 空态文案</td></tr>
<tr><td>App 内导航</td><td>Flutter <code>/shop/:shop_code</code> 内部路由保留不动(员工 App 内预览用,不发 HTTP)</td></tr>
</table>
<h3>B2 · 入口矩阵(改造后各入口去向)</h3>
<table>
<tr><th>入口</th><th>现状</th><th>改造后</th></tr>
<tr><td>标签二维码 / App「复制公开链接」= <code>/product/&lt;id&gt;</code></td><td>Flutter 壳(~30s</td><td><span class="tag ok">轻量页 &lt;0.5s</span> 历史已印标签直接受益,无需换码</td></tr>
<tr><td>浏览器直开 / 刷新 <code>/app/product/&lt;id&gt;</code></td><td>nginx rewrite → 同一 ProductPage</td><td>同上轻量页(nginx 该 location 不动)</td></tr>
<tr><td>App 内浏览公开页(Flutter 路由 <code>/product/:public_id</code><code>/shop/:shop_code</code></td><td>客户端内部导航,不发 HTTP</td><td><span class="tag ok">不受影响</span></td></tr>
<tr><td>浏览器直开 <code>/shop/&lt;shop_code&gt;</code></td><td><span class="tag warn">404</span>(落营销站,无此入口)</td><td><span class="tag ok">店铺 SSR 页</span> 新增短链,可印店铺码</td></tr>
<tr><td>商品公开页点门店名</td><td>App 内跳 Flutter 店铺页</td><td>SSR 商品页 → SSR 店铺页,全程零 Flutter</td></tr>
<tr><td>微信/飞书爬虫抓分享卡片</td><td>OG 注入壳</td><td>OG 在轻量页 head,行为不变(店铺页新增店铺级 OG)</td></tr>
<tr><td>公开 API <code>/api/v1/public/products/:id</code><code>/api/v1/public/shops/:code/products</code></td><td>App 数据源</td><td><span class="tag ok">零改动</span></td></tr>
</table>
<h3>B3 · 反爬与限流</h3>
<p>路由与闸全部沿用现状:<code>publicReadIP</code> 分钟限流 + <code>dqProduct</code> 日配额(1000/IP/日)+ nginx <code>$jiu_bad_ua_lib</code>/limit_conn 不动。轻量页比现在还省服务器资源(不再读盘 index.html,模板编译一次常驻)。</p>
</div>
<h2>五、发版与回滚</h2>
<div class="card lead">
<table>
<tr><th>批次</th><th>内容</th><th>tag</th></tr>
<tr><td>唯一一批</td><td>方案 B(商品页 + 店铺页 SSR+ 方案 A′(nginx gzip/缓存头,含新增 /shop location)——全部 server 流水线一次带出,<b>client 零改动</b></td><td><code>server-v1.1.8</code></td></tr>
</table>
<ul>
<li><b>测试</b>ProductPage / ShopPage 单测(有图/无图/不存在/含 code 参数/搜索/分页的 HTML 断言 + OG 标签断言 + 敏感字段零暴露断言,沿用 public_anticrawl_test 写法);nginx 变更上线后 curl 验收。</li>
<li><b>回滚</b>server 回退上一 tag 即回到现状;nginx 压缩指令独立无状态,回退同批完成。</li>
</ul>
<h3>验收清单(上线后逐条打勾)</h3>
<ol>
<li><code>curl -H 'Accept-Encoding: gzip' --compressed -so /dev/null -w '%{size_download}' https://jiu.51yanmei.com/app/main.dart.js</code> → 响应带 <code>content-encoding: gzip</code>,下载量 ~1.3MB(原 5.3MB</li>
<li><code>/product/&lt;id&gt;</code> 首开 &lt; 0.5stotal time),页面含商品名/图/门店信息</li>
<li>微信发送 <code>/product/&lt;id&gt;</code> 链接 → 分享卡片(标题/图)正常</li>
<li>响应体不含 cost / purchase_price 等敏感字段名</li>
<li>App 内「公开页预览」导航正常;<code>/app/product/&lt;id&gt;</code> 刷新落到轻量页</li>
<li><code>/shop/&lt;code&gt;</code> 直开出店铺列表页,搜索(?q=)与翻页(?page=)可用;商品卡点进 <code>/product/&lt;id&gt;</code>;超日配额出 429</li>
<li>手机弱网(蜂窝)实测扫标签二维码体感</li>
</ol>
</div>
<h2>六、明确不做(本期)</h2>
<div class="card lead">
<ul>
<li><b>brotli</b>ali 的 nginx 1.24 无 brotli 模块,装第三方模块运维成本 &gt; 收益(gzip 已够),不做。</li>
<li><b>CDN / 带宽升配</b>:方案 B 落地后顾客路径只剩 ~20KB,3Mbps 足够;员工 Web 版首开 ~12s 可接受(App 端无此问题)。留作后续可选项,不在本期。</li>
<li><b>Flutter 产物瘦身</b>deferred loading / wasm 构建):改造面大、收益被方案 B 覆盖,不做。</li>
<li><b>换标签二维码内容</b>:短链 <code>/product/&lt;id&gt;</code> 保持不变,历史标签零作废。</li>
</ul>
</div>
</body>
</html>