58a0bbca1b
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
174 lines
16 KiB
HTML
174 lines
16 KiB
HTML
<!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/<public_id>?code=P1331</span> 首开约 30 秒 · 目标机 ali(3Mbps 固定带宽)· 用户拍板:所有公开页不使用 Flutter</div>
|
||
|
||
<h2>一、问题与实测</h2>
|
||
<div class="card lead">
|
||
<p>公开商品页(顾客扫标签二维码 / 微信分享打开)走的是 Flutter Web 深链:无论从 <code>/product/<id></code> 短链还是 <code>/app/product/<id></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 出网带宽固定 3Mbps(PayByBandwidth)→ 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>< 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>A′1 · 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 meta(title/description/image/url)直接写进模板 head,<b>复用现有 <code>buildProductOG</code>/<code>buildOGTitle</code></b>;微信/飞书分享卡片行为不变,且不再需要 UA 分流</td></tr>
|
||
<tr><td>样式</td><td>内联 CSS,全页 < 20KB(gzip 后 ~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/<shop_code></code>(B4 店铺 SSR 页);页脚小链接「在 App 中查看」→ <code>/app/product/<id></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>(backend,router.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/<public_id></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/<id></code></td><td>Flutter 壳(~30s)</td><td><span class="tag ok">轻量页 <0.5s</span> 历史已印标签直接受益,无需换码</td></tr>
|
||
<tr><td>浏览器直开 / 刷新 <code>/app/product/<id></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/<shop_code></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/<id></code> 首开 < 0.5s(total time),页面含商品名/图/门店信息</li>
|
||
<li>微信发送 <code>/product/<id></code> 链接 → 分享卡片(标题/图)正常</li>
|
||
<li>响应体不含 cost / purchase_price 等敏感字段名</li>
|
||
<li>App 内「公开页预览」导航正常;<code>/app/product/<id></code> 刷新落到轻量页</li>
|
||
<li><code>/shop/<code></code> 直开出店铺列表页,搜索(?q=)与翻页(?page=)可用;商品卡点进 <code>/product/<id></code>;超日配额出 429</li>
|
||
<li>手机弱网(蜂窝)实测扫标签二维码体感</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<h2>六、明确不做(本期)</h2>
|
||
<div class="card lead">
|
||
<ul>
|
||
<li><b>brotli</b>:ali 的 nginx 1.24 无 brotli 模块,装第三方模块运维成本 > 收益(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/<id></code> 保持不变,历史标签零作废。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
</body>
|
||
</html>
|