Compare commits

...

19 Commits

Author SHA1 Message Date
wangjia dbea2dc11a chore: release server-v1.1.8
Design Source Checks / design-source (push) Successful in 17s
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:06:52 +08:00
wangjia 58a0bbca1b docs: 公开页提速设计方案(压缩A′ + 公开页去Flutter化)+ 登记索引
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:02:21 +08:00
wangjia 451947c8d1 devops: nginx 运行时 gzip + /app 协商缓存 + /shop SSR 页边缘闸
- gzip on(js/wasm/json/css,图片刻意不压):Web 版首包 12.5MB → ~4.4MB
- /app/ 加 Cache-Control no-cache(etag 304 协商,产物无 hash 不做长缓存)
- 新增 /shop/ location:与 shops API 同参数(jiu_shoplist 2r/s + UA 拦截 + limit_conn 10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:02:21 +08:00
wangjia 9ea38a9b56 feat(backend): 公开页 SSR 化——商品页/店铺页后端直出,顾客动线零 Flutter
- /product/:public_id 从「Flutter 壳注入 OG」改为 go:embed 模板直出轻量页
  (~5KB vs 12.5MB 首包,3Mbps 带宽下 30s → <0.5s),OG 分享卡片行为不变
- 新增 /shop/:shop_code 店铺公开页短链:搜索(?q=)+分页(?page=),商品卡互跳
- 数据装配与公开 API 共用(loadPublicProduct / queryShopProducts),口径零漂移
- 图片异步拉取:首图 decoding=async、其余 loading=lazy
- 设计:docs/design/public-page-speedup.html

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:02:10 +08:00
wangjia 656a7459d8 chore: release client-v1.1.5
Design Source Checks / design-source (push) Successful in 16s
Deploy Client / build-client-web (push) Successful in 52s
Deploy Client / build-windows (push) Successful in 2m9s
Deploy Client / build-macos (push) Successful in 3m0s
Deploy Client / build-android (push) Successful in 1m47s
Deploy Client / build-ios (push) Successful in 3m23s
Deploy Client / release-deploy-client (push) Successful in 1m38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:34:08 +08:00
wangjia 31bf645c30 chore: release server-v1.1.7
Design Source Checks / design-source (push) Successful in 16s
Deploy Server / release-deploy-server (push) Successful in 1m3s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:33:17 +08:00
wangjia 3573f015c3 fix(client): 出入库录单 golden 随日期漂移——表单支持钉死单据日期
根因:表单 _orderDate 默认 DateTime.now(),golden 07-04 录制后每天必挂
(整屏 diff 只有日期格一个数字)。表单加 initialDate 测试注入口,
golden 测试钉死 2026-07-04,基准图零重录、永不随日期漂移。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:26 +08:00
wangjia d9f1f6956e feat(client): 库存卡片显示单价(进价)+ 成本按角色隐藏
- 手机库存卡副行追加进价(编号 · 规格 · ¥xxx),仅管理员可见
- 宽屏成本价/总价列、货值 KPI、商品抽屉成本行对非管理员隐藏(isAdminProvider)
- m-inventory 原型同步 + 移动 golden ×6 重录(管理员形态基准)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:15 +08:00
wangjia 75d0accb14 feat(backend): 库存成本仅管理员可见——List 抹空 unit_price、Summary 抹零货值
与出库 stripStockOutCost 同口径:operator/readonly 服务端兜底抹除,
防抓包看到成本;sale_price 与数量等字段不受影响。CLAUDE.md 口径同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:05 +08:00
wangjia 3fc0ca5f0c chore: release server-v1.1.6
Design Source Checks / design-source (push) Successful in 16s
Deploy Server / release-deploy-server (push) Successful in 58s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:44:40 +08:00
wangjia 4966892030 docs: DDoS/爬取洪峰应急 runbook + 安全三件套 plan 阅读版(todo #1)
拍板:不预购高防/CDN,接受极端容量攻击黑洞期风险;应急走 runbook
(判定→CC 处置→高防/CDN 切换→黑洞预案,DNS TTL 平时保持 ≤600s)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:42:23 +08:00
wangjia a49a962161 devops: 边缘层加固——nginx limit_conn/UA 拦截/防盗链/下载限速 + jiu_redis 容器(todo #1/#2/#3)
nginx:店铺列表专属 2r/s zone、公开路径 limit_conn、抓取库/空 UA 403(OG 页
不拦空 UA 防误杀社交爬虫)、/images 防盗链(none 放行微信 webview)、
/downloads 限并发+限速。compose 新增 jiu_redis(64mb noeviction 无持久化,
仅回环);env 模板加 REDIS_ADDR。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:42:23 +08:00
wangjia 290054dc8c feat(backend): 公开接口反爬轻量版——per-IP 日配额 + 列表收紧(todo #3)
DailyQuota 二级闸(单品 1000/日、店铺列表 300/日,0=关;出错放行不误伤);
店铺列表 page_size 上限 50→20(客户端固定传 20 无破坏);公开响应敏感字段
(cost/purchase_price/profit)零暴露回归测试。
明确不做:签名链接(QR 已印刷+UUIDv4 不可枚举)、滑块(杀零门槛分享)、登录墙。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:42:23 +08:00
wangjia 3c687e5e0b feat(backend): 限流/登录失败锁状态外置 Redis(todo #2)
新包 internal/ratelimit:内存/redis(GCRA+Lua) 双实现 + 出错逐调用降级内存
(fail-open 到内存不 fail-closed)。REDIS_ADDR 空=内存模式,行为与既往一致;
配置后跨重启保状态、支持多实例。miniredis 全覆盖测试,零真实外部依赖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:42:23 +08:00
wangjia e850a1987c chore(client): 客服邮箱全局统一为 support@51yanmei.com(单源 store_compliance)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:19:32 +08:00
wangjia fd45bece17 fix(scripts): local_test.sh 变量后紧跟全角括号致 unbound variable,加花括号
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:07:50 +08:00
wangjia b740cc6edf feat(client): iOS 上架合规——首启隐私同意弹窗 + 注销账号入口
个保法/审核 5.1.1:移动端首启全屏隐私同意(链接隐私政策/用户协议,
同意持久化;不同意 Android 退出、iOS 留提示);我的页 iOS 形态新增
「注销账号」(sheet 说明 + 预填 mailto 至 support@51yanmei.com)。
关于页隐私政策/服务条款链接已有,桌面/Web 零改动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:56:26 +08:00
wangjia dddf402d92 feat(client): iOS App Store 合规——授权页隐藏外部购买 UI,补中性联系客服卡
苹果 3.1.1:iOS 包不渲染在线购买卡与套餐价格链接;兑换券保留(3.1.3(b)),
新增「联系客服」卡(mailto:support@51yanmei.com,无购买引导字样)。
其余平台零改动;开关 hideExternalPurchaseUi 供设备上限弹窗等复用。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 07:27:31 +08:00
wangjia faaeee0bfe feat(docs): 收银台原型(扫码/手机拉起/三态)+ 客服邮箱切换 support@51yanmei.com
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:49:23 +08:00
64 changed files with 3175 additions and 398 deletions
+1
View File
@@ -155,6 +155,7 @@
<a class="kit" href="screens/users.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic45"/></svg></div><div class="kt"><b>用户管理</b><span>成员/角色 · 新增编辑弹窗</span></div></a>
<a class="kit" href="screens/pricing.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic07"/></svg></div><div class="kt"><b>购买授权</b><span>时长套餐 · 版本档位</span></div></a>
<a class="kit" href="screens/checkout.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic67"/></svg></div><div class="kt"><b>确认订单</b><span>下单 · 开票 · 无支付占位</span></div></a>
<a class="kit" href="screens/pay.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic08"/></svg></div><div class="kt"><b>收银台</b><span>订单信息 · 支付宝扫码 · 三态</span></div></a>
</div>
</section>
@@ -57,7 +57,7 @@ function render(){
if(!rows.length){ L.innerHTML=`<div class="m-empty"><svg viewBox="0 0 24 24"><use href="#i-box"/></svg>没有匹配的商品 · 调整筛选或搜索</div>`; return; }
L.innerHTML=rows.map((it,i)=>`<a class="m-card" onclick="openItem(${ITEMS.indexOf(it)})">
<div class="m-row">
<div class="mc-main"><div class="mc-nm">${it.name}</div><div class="mc-sub">${it.code} · ${it.spec}</div></div>
<div class="mc-main"><div class="mc-nm">${it.name}</div><div class="mc-sub">${it.code} · ${it.spec} · ${it.cost}</div></div><!-- 单价=成本进价,仅管理员可见(2026-07-06 -->
<div class="mc-right"><span class="badge b-${it.status}">${it.status}</span><span class="mc-amt"><span class="${it.qty<=10?'qty-low':''}">${it.qty}</span> ${it.unit}</span></div>
<div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div>
</div>
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="a">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>收银台 · 岩美酒库</title>
<link rel="stylesheet" href="../tokens.css">
<link rel="stylesheet" href="../atoms.css">
<style>
/* ====== 官网收银台(独立页,无 app 壳):App/官网「提交订单」后落地,替代支付宝托管扫码页 ====== */
body{margin:0; min-height:100vh; font-family:var(--font); color:var(--text); background:var(--bg);}
a{color:var(--primary); text-decoration:none;}
/* 顶部品牌条(对齐官网 nav 的精简版) */
.topbar{height:56px; background:var(--surface); border-bottom:1px solid var(--border); display:flex; align-items:center; padding:0 28px; gap:10px;}
.topbar .logo{width:28px; height:28px; border-radius:8px; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center;}
.topbar .logo svg{width:16px; height:16px; stroke-width:2;}
.topbar b{font-size:var(--fs-title); color:var(--heading);}
.topbar .sub{font-size:var(--fs-sm); color:var(--muted); border-left:1px solid var(--border); padding-left:10px;}
.topbar .safe{margin-left:auto; display:flex; align-items:center; gap:6px; font-size:var(--fs-xs); color:var(--muted);}
.topbar .safe svg{width:14px; height:14px; stroke-width:2; color:var(--success);}
.wrap{max-width:880px; margin:0 auto; padding:36px 24px 72px;}
.crumb{display:flex; align-items:center; gap:7px; font-size:var(--fs-sm); color:var(--muted); margin-bottom:12px;}
h1{margin:0 0 18px; font-size:var(--fs-h1); font-weight:700; color:var(--title); letter-spacing:.3px;}
.pay-grid{display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:22px; align-items:start;}
.card{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:20px 22px;}
.card h2{margin:0 0 14px; font-size:var(--fs-h2); color:var(--heading);}
/* 左:订单信息 */
.item{display:flex; align-items:center; gap:14px; padding:2px 0 14px; border-bottom:1px solid var(--border-subtle);}
.item .ic{width:46px; height:46px; border-radius:var(--r-md); background:var(--brand50); color:var(--primary); display:flex; align-items:center; justify-content:center; flex:none;}
.item .ic svg{width:24px; height:24px; stroke-width:1.7;}
.item .meta b{display:block; font-size:var(--fs-body); color:var(--heading); font-weight:600;}
.item .meta span{font-size:var(--fs-sm); color:var(--muted);}
.drow{display:flex; justify-content:space-between; gap:12px; padding:9px 0; font-size:var(--fs-sm); border-bottom:1px solid var(--border-subtle);}
.drow .k{color:var(--muted); flex:none;}
.drow .v{color:var(--text); text-align:right;}
.drow .v.mono{font-family:var(--font-mono);}
.sum{margin-top:12px;}
.sum-row{display:flex; justify-content:space-between; font-size:var(--fs-sm); color:var(--muted); padding:5px 0;}
.sum-total{display:flex; justify-content:space-between; align-items:baseline; padding-top:8px;}
.sum-total .lab{font-size:var(--fs-body); color:var(--heading); font-weight:600;}
.sum-total .val{font-size:var(--fs-display); font-weight:800; color:var(--primary); font-family:var(--font-mono); letter-spacing:-.5px;}
.fine{font-size:var(--fs-xs); color:var(--faint); margin-top:14px; line-height:1.7;}
/* 右:扫码支付 */
.qr-card{text-align:center; position:sticky; top:24px;}
.qr-card h2{display:flex; align-items:center; justify-content:center; gap:8px;}
.qr-card h2 .ali{width:22px; height:22px; border-radius:6px; background:#1677FF; color:#fff; display:inline-flex; align-items:center; justify-content:center; font-size:13px; font-weight:700;} /* ds-allow 支付宝品牌色 */
.qr-box{position:relative; width:216px; height:216px; margin:14px auto 4px; border:1px solid var(--border); border-radius:var(--r-md); padding:10px; background:#fff;} /* ds-allow 二维码底必须纯白 */
.qr-box canvas{display:block; width:100%; height:100%;}
.qr-badge{position:absolute; inset:0; margin:auto; width:40px; height:40px; border-radius:10px; background:#1677FF; color:#fff; display:flex; align-items:center; justify-content:center; font-size:20px; font-weight:700; border:3px solid #fff;} /* ds-allow 支付宝品牌色 */
.qr-amt{font-size:var(--fs-display); font-weight:800; color:var(--heading); font-family:var(--font-mono); margin-top:10px;}
.qr-amt s{font-size:var(--fs-sm); color:var(--faint); font-weight:400; margin-right:8px;}
.qr-hint{font-size:var(--fs-sm); color:var(--muted); margin-top:4px;}
.status{display:flex; align-items:center; justify-content:center; gap:8px; margin-top:14px; padding:10px; border-radius:var(--r-md); background:var(--bg); font-size:var(--fs-sm); color:var(--text);}
.spin{width:14px; height:14px; border-radius:50%; border:2px solid var(--border); border-top-color:var(--primary); animation:sp 1s linear infinite; flex:none;}
@keyframes sp{to{transform:rotate(360deg)}}
.ttl{font-size:var(--fs-xs); color:var(--faint); margin-top:10px;}
.ttl b{color:var(--warn); font-family:var(--font-mono); font-weight:600;}
/* 手机 Web 形态:扫码区换成拉起支付宝按钮(UA 判定,原型里按视宽/开关模拟) */
.wap-cta{display:none; flex-direction:column; align-items:center; gap:10px; margin:18px 0 6px;}
.wap-cta .btn{width:100%; max-width:280px; justify-content:center;}
.wap-cta .alt{font-size:var(--fs-xs); color:var(--faint);}
.wap .wap-cta{display:flex;}
.wap .qr-box,.wap .qr-hint{display:none;}
.wap.expired .wap-cta{display:none;}
.exp-box{display:none; flex-direction:column; align-items:center; gap:12px; padding:22px 0 10px;}
.exp-box p{margin:0; font-size:var(--fs-sm); color:var(--heading); font-weight:600;}
.wap.expired .exp-box{display:flex;}
/* 过期遮罩 */
.qr-mask{position:absolute; inset:0; border-radius:var(--r-md); background:color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter:blur(2px); display:none; flex-direction:column; align-items:center; justify-content:center; gap:12px;}
.qr-mask p{margin:0; font-size:var(--fs-sm); color:var(--heading); font-weight:600;}
.expired .qr-mask{display:flex;}
.expired .status,.expired .ttl{display:none;}
/* 成功态(整卡替换) */
.done{display:none; text-align:center; padding:26px 8px 10px;}
.done .ring{width:64px; height:64px; border-radius:50%; background:var(--ok-soft); color:var(--success); display:flex; align-items:center; justify-content:center; margin:0 auto 14px;}
.done .ring svg{width:34px; height:34px; stroke-width:2.4;}
.done h3{margin:0 0 6px; font-size:var(--fs-h2); color:var(--heading);}
.done p{margin:0 0 18px; font-size:var(--fs-sm); color:var(--muted); line-height:1.8;}
.paid .done{display:block;}
.paid .qr-box,.paid .qr-amt,.paid .qr-hint,.paid .status,.paid .ttl,.paid h2{display:none;}
@media (max-width:760px){ .pay-grid{grid-template-columns:1fr;} .qr-card{position:static; order:-1;} .wrap{padding-top:20px;} }
/* 设计期状态切换(评审用,不入实现) */
.ds-states{position:fixed; left:22px; bottom:22px; display:flex; gap:6px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-pill); padding:5px; box-shadow:var(--sh-2); z-index:var(--z-fab);}
.ds-states button{border:none; background:none; font:inherit; font-size:var(--fs-xs); color:var(--muted); padding:5px 12px; border-radius:var(--r-pill); cursor:pointer;}
.ds-states button.on{background:var(--brand50); color:var(--primary); font-weight:600;}
.ds-fab{position:fixed; right:22px; bottom:22px; width:44px; height:44px; border-radius:50%; background:var(--surface); color:var(--muted); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; box-shadow:var(--sh-2); z-index:var(--z-fab); opacity:.6; transition:.18s; text-decoration:none;}
.ds-fab:hover{opacity:1; color:var(--primary); border-color:var(--primary);}
.ds-fab svg{width:20px; height:20px; stroke-width:2; fill:none; stroke:currentColor;}
</style>
</head>
<body>
<div class="topbar">
<div class="logo"><svg viewBox="0 0 24 24"><use href="#i-bottle"/></svg></div>
<b>岩美酒库</b><span class="sub">收银台</span>
<span class="safe"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg>支付宝官方渠道 · 支付成功即时到账</span>
</div>
<div class="wrap">
<div class="crumb"><span>确认订单</span><span>/</span><span style="color:var(--heading)">支付</span></div>
<h1>扫码支付</h1>
<div class="pay-grid">
<!-- 左:订单信息 -->
<div class="card">
<h2>订单信息</h2>
<div class="item">
<div class="ic"><svg viewBox="0 0 24 24"><use href="#i-ic07"/></svg></div>
<div class="meta"><b>岩美酒库 · 高级版年付</b><span>365 天授权 · 到期时间自动叠加,不覆盖</span></div>
</div>
<div class="drow"><span class="k">开通门店</span><span class="v">鼎晟酒行(S001</span></div>
<div class="drow"><span class="k">订单号</span><span class="v mono">yanmei-20260705061422-e1e6bbef</span></div>
<div class="drow"><span class="k">下单时间</span><span class="v mono">2026-07-05 14:14</span></div>
<div class="drow"><span class="k">支付方式</span><span class="v">支付宝</span></div>
<div class="drow"><span class="k">收款方</span><span class="v">岩美(北京)技术有限公司</span></div>
<div class="sum">
<div class="sum-row"><span>套餐原价</span><span>¥5,999</span></div>
<div class="sum-row"><span>优惠</span><span> ¥0</span></div>
<div class="sum-total"><span class="lab">应付总额</span><span class="val">¥5,999</span></div>
</div>
<div class="fine">支付成功后授权即时到账,回到应用即可看到新的到期时间;如需发票或对公转账,请联系 support@51yanmei.com。</div>
</div>
<!-- 右:二维码 / 成功态 -->
<div class="card qr-card" id="qrCard">
<h2><span class="ali"></span>支付宝扫码支付</h2>
<div class="qr-box">
<canvas id="qr" width="196" height="196"></canvas>
<div class="qr-badge"></div>
<div class="qr-mask">
<p>二维码已失效</p>
<button class="btn primary" onclick="setState('pending')">重新生成二维码</button>
</div>
</div>
<div class="qr-amt"><s>¥6,999</s>¥5,999</div>
<div class="qr-hint">打开手机支付宝「扫一扫」完成付款</div>
<div class="wap-cta">
<button class="btn primary lg" onclick="return false">打开支付宝付款</button>
<span class="alt">未安装支付宝 App 时将打开网页版收银台</span>
</div>
<div class="exp-box">
<p>订单已失效</p>
<button class="btn primary" onclick="setState('pending')">重新下单</button>
</div>
<div class="status"><span class="spin"></span>等待支付 · 支付结果自动检测,无需刷新</div>
<div class="ttl">订单 <b id="ttl">14:32</b> 后失效,失效后可重新下单</div>
<div class="done">
<div class="ring"><svg viewBox="0 0 24 24"><use href="#i-ic10"/></svg></div>
<h3>支付成功</h3>
<p>高级版授权已到账,新到期时间 <b style="color:var(--heading)">2027-08-04</b><br>本页可以关闭,返回应用即可使用。</p>
<a class="btn ghost" href="#" onclick="return false">查看订单与发票</a>
</div>
</div>
</div>
</div>
<!-- 设计期控件:三态切换(仅评审) -->
<div class="ds-states">
<button class="on" onclick="setState('pending')">待支付</button>
<button onclick="setState('paid')">支付成功</button>
<button onclick="setState('expired')">已失效</button>
<button id="wapBtn" onclick="toggleWap()">手机Web</button>
</div>
<a class="ds-fab" href="../index.html" title="返回设计系统"><svg viewBox="0 0 24 24"><use href="#i-arrow-left"/></svg></a>
<script src="icons.js"></script>
<script>
/* 假二维码(原型示意) */
const cv=document.getElementById('qr'),ctx=cv.getContext('2d');
let seed=7; const rnd=()=>{ seed=(seed*16807)%2147483647; return seed/2147483647; };
ctx.fillStyle='#fff'; ctx.fillRect(0,0,196,196); ctx.fillStyle='#111'; // ds-allow 二维码示意黑白模块,实现期由真实 QR 库渲染
const n=29,s=196/n;
for(let y=0;y<n;y++)for(let x=0;x<n;x++){ if(rnd()>.52) ctx.fillRect(x*s,y*s,s,s); }
function finder(x,y){ ctx.fillStyle='#111'; ctx.fillRect(x,y,s*7,s*7); ctx.fillStyle='#fff'; ctx.fillRect(x+s,y+s,s*5,s*5); ctx.fillStyle='#111'; ctx.fillRect(x+2*s,y+2*s,s*3,s*3); } // ds-allow 二维码示意黑白模块
finder(0,0); finder(196-7*s,0); finder(0,196-7*s);
/* 倒计时示意 */
let t=14*60+32;
setInterval(()=>{ t=Math.max(0,t-1); const m=String(Math.floor(t/60)).padStart(2,'0'),sx=String(t%60).padStart(2,'0'); document.getElementById('ttl').textContent=m+':'+sx; },1000);
/* 三态切换(评审) */
function setState(st){
const c=document.getElementById('qrCard');
c.classList.toggle('paid',st==='paid');
c.classList.toggle('expired',st==='expired');
const order=['pending','paid','expired'];
document.querySelectorAll('.ds-states button:not(#wapBtn)').forEach((b,i)=>{ const hit=order[i]===st; b.classList.toggle('on',hit); });
}
/* 手机 Web(wap)形态:实现期按 UA 判定;原型里窄视宽自动 + 开关可强制 */
let forceWap=false;
const mq=matchMedia('(max-width:760px)');
function applyWap(){
const on=forceWap||mq.matches;
document.getElementById('qrCard').classList.toggle('wap',on);
document.getElementById('wapBtn').classList.toggle('on',on);
}
function toggleWap(){ forceWap=!forceWap; applyWap(); }
mq.addEventListener('change',applyWap); applyWap();
</script>
</body>
</html>
+13 -1
View File
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.5] - 2026-07-06
### 新功能
- 库存列表显示单价:手机端卡片副行、商品详情均可看到进价(仅管理员可见)。
- iOS 版新增首次启动隐私政策同意弹窗,「我的」页面新增注销账号入口。
### 改进
- 库存成本全面按角色隐藏:操作员/只读账号不再显示成本价、总价、库存货值
等成本信息(与出库单成本可见性同一口径)。
- iOS 版授权管理页改为「联系客服」入口(适配 App Store 上架要求)。
- 客服邮箱统一为 support@51yanmei.com(登录页、关于页、反馈入口)。
## [1.1.4] - 2026-07-05
### 新功能
@@ -621,7 +633,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 修复 Windows 桌面端打印卡死:打印字体由 OpenType/CFF 改为 TrueType(glyf)dart_pdf 渲染中文不再抛错(此前点「打印」后卡死)
### 改进
- 「关于我们」内容改为真实信息(服务商:岩美技术有限公司;网站:jiu.yanmei.com;邮箱:yammy2023@163.com),且统一从配置文件读取,不再硬编码
- 「关于我们」内容改为真实信息(服务商:岩美技术有限公司;网站:jiu.yanmei.com;邮箱:support@51yanmei.com),且统一从配置文件读取,不再硬编码
- Windows 桌面端窗口标题由 jiu_client 改为「岩美酒库」
## [1.0.13] - 2026-06-04
+33
View File
@@ -5,6 +5,39 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.8] - 2026-07-07
### 新功能
- 商品公开页全面提速:扫标签二维码/微信分享打开的商品页改为服务端直出轻量页面
(约 5KB),首次打开从 30 秒左右降到 1 秒内,不再需要加载完整应用。
- 新增店铺公开页 `jiu.51yanmei.com/shop/门店编码`:展示本店全部在售商品,
支持搜索(汉字/拼音/编号)与翻页,可作为「店铺码」印刷或发给客户;
商品页与店铺页互相跳转,微信分享卡片正常。
### 改进
- 服务器开启传输压缩:网页版应用首次加载体积从 12.5MB 降到约 4.4MB
接口列表响应也随之变小;图片链路维持原有压缩与 30 天缓存。
## [1.1.7] - 2026-07-06
### 改进
- 库存成本仅管理员可见:库存列表不再向操作员/只读账号返回进价,库存货值
汇总同步隐藏(与出库单成本可见性同一口径,防止非管理员通过接口看到成本)。
## [1.1.6] - 2026-07-05
### 新功能
- 安全加固三件套:限流与登录失败锁状态可外置 Redis(服务重启不再清零防护状态,
为多实例扩展铺路;未配置时行为不变)。
- 商品公开页反爬升级:新增按 IP 的每日访问配额(单品 1000 次/日、店铺列表
300 次/日),配合边缘层爬虫拦截与图片防盗链,扫码客户零感知。
### 改进
- 店铺公开商品列表单页上限收紧为 20 条(客户端本就按 20 取,无感知),
公开接口增加成本类敏感字段零暴露的回归保障。
- 边缘层(nginx):新增并发连接限制、常见抓取库/空 UA 拦截、安装包下载
限并发限速,防少量恶意连接拖垮服务。
## [1.1.5] - 2026-07-04
### 新功能
+1 -1
View File
@@ -221,7 +221,7 @@ cd client && flutter test
### 定价字段口径(2026-07 消歧后,唯一口径)
- 明细两侧价格分列:`cost_price`(成本/进价单价)+ `cost_amount`(成本小计);出库另有 `sale_price`(售价)+ `sale_amount`(售价小计,待定价=0)。**旧列 unit_price/total_price/total_amount 已弃用**(值已回填新列,观察一版后 DROP),新代码禁止读写旧列。
- 单据合计:入库 `cost_total`(应付=Σ总进价);出库 `sale_total`(应收=Σ售价小计)+ `profit_total`(总利润=Σ(售价>0?(售价-成本)×数量:0),建单落库,确认售价/确认进价联动重算,重算函数 `recalcStockOutProfit`)。
- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。
- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。**库存同口径**2026-07-06):库存 List 抹空 unit_price、Summary 抹零 stock_value/last_month_value`stripInventoryCost`),前端隐藏成本价/总价列、卡片单价、货值 KPI(`isAdminProvider`)、商品抽屉成本行。
- 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。
### 库存变更
+13
View File
@@ -14,6 +14,7 @@ type Config struct {
Storage StorageConfig
Session SessionConfig
RateLimit RateLimitConfig
Redis RedisConfig
Pay PayConfig
}
@@ -68,6 +69,14 @@ type RateLimitConfig struct {
PublicReadPerMin int `mapstructure:"public_read_per_min"` // 其余公开读接口(单品/release),按 IP
ShopRPS int `mapstructure:"shop_rps"` // 认证流量每店每秒,按 shop_id
ShopBurst int `mapstructure:"shop_burst"` // 认证流量每店突发
// 日配额(分钟级限流之上的第二道反爬闸,0=关闭该闸):同一 IP 每自然日累计上限
DailyProductPerIP int `mapstructure:"daily_product_per_ip"` // 单品详情(API + OG 页同池)
DailyShopListPerIP int `mapstructure:"daily_shoplist_per_ip"` // 店铺公开商品列表
}
// RedisConfig 限流/登录锁状态外置。Addr 为空 = 内存模式(单实例,重启清零)。
type RedisConfig struct {
Addr string `mapstructure:"addr"` // 如 127.0.0.1:6379
}
type StorageConfig struct {
@@ -99,6 +108,7 @@ func Load() {
_ = viper.BindEnv("pay.base_url", "PAY_BASE_URL")
_ = viper.BindEnv("pay.secret", "PAY_SECRET")
_ = viper.BindEnv("pay.return_url", "PAY_RETURN_URL")
_ = viper.BindEnv("redis.addr", "REDIS_ADDR")
// 默认值
viper.SetDefault("server.port", "8080")
@@ -123,6 +133,9 @@ func Load() {
viper.SetDefault("ratelimit.public_read_per_min", 60)
viper.SetDefault("ratelimit.shop_rps", 20)
viper.SetDefault("ratelimit.shop_burst", 40)
viper.SetDefault("ratelimit.daily_product_per_ip", 1000)
viper.SetDefault("ratelimit.daily_shoplist_per_ip", 300)
viper.SetDefault("redis.addr", "") // 空=内存模式
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("storage.upload_dir", "./uploads/images")
+6
View File
@@ -22,9 +22,11 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/alicebob/miniredis/v2 v2.38.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -33,6 +35,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-redis/redis_rate/v10 v10.0.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
@@ -51,6 +54,7 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.21.0 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
@@ -64,7 +68,9 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/image v0.25.0 // indirect
+12
View File
@@ -1,11 +1,15 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -31,6 +35,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -81,6 +87,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
@@ -128,8 +136,12 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+17
View File
@@ -47,6 +47,17 @@ type inventoryRow struct {
CreatedAt string `json:"created_at"`
}
// stripInventoryCost 服务端兜底:库存成本仅管理员可见(与出库 stripStockOutCost 同口径)。
// operator/readonly 的 List 响应把 unit_price(成本进价)置空,sale_price 不受影响。
func stripInventoryCost(role string, rows []inventoryRow) {
if role == "admin" || role == "superadmin" {
return
}
for i := range rows {
rows[i].UnitPrice = nil
}
}
// List GET /api/v1/inventory
func (h *InventoryHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
@@ -162,6 +173,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
return
}
stripInventoryCost(middleware.GetRole(c), rows)
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
}
@@ -210,6 +222,11 @@ func (h *InventoryHandler) Summary(c *gin.Context) {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
// 库存货值 = Σ(qty×进价),成本口径,仅管理员可见(同 stripInventoryCost
if role := middleware.GetRole(c); role != "admin" && role != "superadmin" {
s.StockValue = 0
s.LastMonthValue = 0
}
c.JSON(http.StatusOK, s)
}
@@ -393,3 +393,52 @@ func TestInventoryHandler_AfterStockInApprove(t *testing.T) {
invItem := data[0].(map[string]interface{})
assert.Equal(t, float64(20), invItem["quantity"])
}
// 库存成本仅管理员可见(stripInventoryCost):operator/readonly 的 List unit_price 抹空、
// Summary 货值抹零;admin 原样返回。与出库 stripStockOutCost 同口径。
func TestInventoryHandler_CostVisibility(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_COST")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
readonly := testutil.CreateTestUser(db, shop.ID, "ro", "pass", "readonly")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
r := setupProtectedRouter(db)
price := 100.0
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID,
Quantity: 10, UnitPrice: &price,
}).Error)
// adminunit_price / stock_value 原样可见
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
w := makeRequest(r, "GET", "/api/v1/inventory", adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
row := parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, float64(100), row["unit_price"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", adminToken, nil)
assert.Equal(t, float64(1000), parseResponse(w)["stock_value"].(float64))
// operator / readonlyunit_price 抹空、货值抹零,其余字段不受影响
for _, u := range []struct {
id uint64
role string
}{{operator.ID, "operator"}, {readonly.ID, "readonly"}} {
token := getAuthToken(u.id, shop.ID, u.role)
w = makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
row = parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Nil(t, row["unit_price"], "role=%s unit_price 应被抹空", u.role)
assert.Equal(t, float64(10), row["quantity"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(0), resp["stock_value"].(float64), "role=%s 货值应抹零", u.role)
assert.Equal(t, float64(0), resp["last_month_value"].(float64))
assert.Equal(t, float64(1), resp["sku_count"].(float64))
assert.Equal(t, float64(10), resp["in_stock_qty"].(float64))
}
}
+31 -172
View File
@@ -5,15 +5,12 @@ import (
"fmt"
"html"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
@@ -32,75 +29,42 @@ func NewPublicHandler(db *gorm.DB) *PublicHandler {
}
// GetProduct GET /api/v1/public/products/:public_id (no auth)
// 数据装配走 loadPublicProduct(与 SSR 商品页共用,公开字段口径单一实现)。
func (h *PublicHandler) GetProduct(c *gin.Context) {
publicID := c.Param("public_id")
var product model.Product
if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
Preload("Origin").
Preload("ShelfLife").
Preload("Storage").
Preload("DescriptionDoc").
First(&product).Error; err != nil {
d, err := h.loadPublicProduct(c.Param("public_id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
product := d.Product
// Fetch shop public info
var shop model.Shop
shopData := gin.H{}
if err := h.db.Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
if d.HasShop {
shopData = gin.H{
"name": shop.Name,
"code": shop.Code,
"address": shop.Address,
"phone": shop.Phone,
"business_hours": shop.BusinessHours,
"wechat_id": shop.WechatID,
"name": d.Shop.Name,
"code": d.Shop.Code,
"address": d.Shop.Address,
"phone": d.Shop.Phone,
"business_hours": d.Shop.BusinessHours,
"wechat_id": d.Shop.WechatID,
}
}
// Fetch latest inventory batch for this product
var inv model.Inventory
batchData := gin.H(nil)
if err := h.db.Where("product_id = ? AND quantity > 0 AND deleted_at IS NULL", product.ID).
Order("created_at DESC").
First(&inv).Error; err == nil {
if d.Batch != nil {
var pdStr *string
if inv.ProductionDate != nil {
s := inv.ProductionDate.Time.Format("2006-01-02")
if d.Batch.ProductionDate != nil {
s := d.Batch.ProductionDate.Time.Format("2006-01-02")
pdStr = &s
}
batchData = gin.H{
"production_date": pdStr,
"batch_no": inv.BatchNo,
"in_stock_date": inv.CreatedAt.Format("2006-01-02"),
"quantity": inv.Quantity,
"batch_no": d.Batch.BatchNo,
"in_stock_date": d.Batch.CreatedAt.Format("2006-01-02"),
"quantity": d.Batch.Quantity,
}
}
// 产地:空串表示无,前端不展示该行
origin := ""
if product.Origin != nil {
origin = product.Origin.Name
}
// 保质期:无关联时用默认话术
shelfLife := defaultShelfLife
if product.ShelfLife != nil {
shelfLife = product.ShelfLife.Name
}
// 储存方式:无关联时用默认话术
storage := defaultStorage
if product.Storage != nil {
storage = product.Storage.Name
}
// 介绍三级回退:描述文档 → 旧 Description → 通用中性兜底
descTitle, descBody, descKeywords := buildDescription(product)
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"id": product.ID,
@@ -112,12 +76,12 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
"brand": product.Brand,
"unit": product.Unit,
"sale_price": product.SalePrice,
"description": descBody,
"description_title": descTitle,
"description_keywords": descKeywords,
"origin": origin,
"shelf_life": shelfLife,
"storage": storage,
"description": d.DescBody,
"description_title": d.DescTitle,
"description_keywords": d.Keywords,
"origin": d.Origin,
"shelf_life": d.ShelfLife,
"storage": d.Storage,
"images": product.Images,
"shop": shopData,
"batch": batchData,
@@ -232,79 +196,18 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 50 {
// 上限 20(2026-07 反爬收紧:客户端固定传 20,调大只方便爬全店)
if pageSize < 1 || pageSize > 20 {
pageSize = 20
}
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
stockSub := h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shop.ID).
Group("product_id")
query := h.db.Model(&model.Product{}).
Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub).
Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shop.ID)
var total int64
if err := query.Count(&total).Error; err != nil {
// 查询装配走 queryShopProducts(与 SSR 店铺页共用);API 不开放 keyword(现状不变)
listData, total, err := h.queryShopProducts(shop.ID, page, pageSize, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var products []model.Product
offset := (page - 1) * pageSize
if err := query.Preload("Images").
Offset(offset).
Limit(pageSize).
Order("products.id DESC").
Find(&products).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小)
qtyMap := make(map[uint64]float64, len(products))
if len(products) > 0 {
pageIDs := make([]uint64, len(products))
for i, p := range products {
pageIDs[i] = p.ID
}
var stockRows []struct {
ProductID uint64
Qty float64
}
h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shop.ID, pageIDs).
Group("product_id").
Scan(&stockRows)
for _, s := range stockRows {
qtyMap[s.ProductID] = s.Qty
}
}
listData := make([]publicProductResp, len(products))
for i, p := range products {
imgs := make([]publicProductImage, len(p.Images))
for j, img := range p.Images {
imgs[j] = publicProductImage{URL: img.URL}
}
listData[i] = publicProductResp{
ID: p.ID,
PublicID: p.PublicID,
Code: p.Code,
Name: p.Name,
Series: p.Series,
Spec: p.Spec,
Brand: p.Brand,
Unit: p.Unit,
SalePrice: p.SalePrice,
Quantity: qtyMap[p.ID],
Images: imgs,
}
}
c.JSON(http.StatusOK, gin.H{
"data": listData,
"total": total,
@@ -313,51 +216,7 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
})
}
// ProductPage GET /product/:public_id
// 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。
// 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。
func (h *PublicHandler) ProductPage(c *gin.Context) {
publicID := c.Param("public_id")
// 读取 Flutter 构建产物 index.html
idxPath := config.C.Storage.WebDir + "/index.html"
idxBytes, err := os.ReadFile(idxPath)
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath)
return
}
idxHTML := string(idxBytes)
// 查商品(只取 OG 所需字段,轻量查询)
var product model.Product
if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id").
Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
First(&product).Error; err != nil {
// 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在"
c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes)
return
}
// 查门店名
var shop model.Shop
shopName := ""
if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
shopName = shop.Name
}
// 构造 OG 标签并注入 </head> 前
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
ogTitle := buildOGTitle(product)
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
// 用正则替换避免缩进空格导致字符串不匹配
titleRe := regexp.MustCompile(`<title>[^<]*</title>`)
out := titleRe.ReplaceAllString(idxHTML, "<title>"+html.EscapeString(ogTitle)+"</title>")
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
}
// ProductPage 已 SSR 化,实现移至 public_page.go2026-07-07 公开页去 Flutter 化)。
// buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列。
func buildOGTitle(product model.Product) string {
@@ -402,8 +261,8 @@ func buildProductOG(product model.Product, shopName, publicURL string) string {
siteName = shopName + " · " + siteName
}
// og:url
pageURL := publicURL + "/app/product/" + product.PublicID
// og:url:指向 SSR 短链(公开页规范入口)
pageURL := publicURL + "/product/" + product.PublicID
var sb strings.Builder
sb.WriteString("\n")
@@ -0,0 +1,90 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/testutil"
)
// 反爬收紧回归(2026-07):page_size 上限夹到 20;公开接口响应绝不出现
// 成本类敏感字段名(白名单 DTO 防未来 Preload 全量 struct 回归泄露)。
func setupPublicFullRouter(db *gorm.DB) *gin.Engine {
h := NewPublicHandler(db)
r := gin.New()
r.Use(gin.Recovery())
r.GET("/api/v1/public/shops/:shop_code/products", h.ListShopProducts)
r.GET("/api/v1/public/products/:public_id", h.GetProduct)
return r
}
func TestPublicListPageSizeClampedTo20(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PUBPS")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPublicFullRouter(db)
for i := 0; i < 25; i++ {
p := testutil.CreateTestProduct(db, shop.ID, fmt.Sprintf("酒%02d", i))
setPublicID(db, p.ID, fmt.Sprintf("pub-ps-%02d", i))
addInventory(db, shop.ID, wh.ID, p.ID, 1)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/public/shops/PUBPS/products?page_size=50", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, float64(25), resp["total"])
data := resp["data"].([]interface{})
assert.Len(t, data, 20, "page_size=50 应被夹到上限 20")
assert.Equal(t, float64(20), resp["page_size"])
}
// 公开响应不得出现的敏感字段名(成本/利润口径只对管理员,公开面零暴露)。
var sensitiveFieldNames = []string{"cost", "purchase_price", "profit"}
func assertNoSensitiveFields(t *testing.T, body string) {
t.Helper()
lower := strings.ToLower(body)
for _, f := range sensitiveFieldNames {
assert.NotContains(t, lower, f, "公开接口响应不应含敏感字段名 %q", f)
}
}
func TestPublicResponsesExcludeCostFields(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PUBSEC")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPublicFullRouter(db)
p := testutil.CreateTestProduct(db, shop.ID, "敏感字段酒")
setPublicID(db, p.ID, "pub-sec-1")
addInventory(db, shop.ID, wh.ID, p.ID, 3)
// 列表
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/public/shops/PUBSEC/products", nil)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
assertNoSensitiveFields(t, w.Body.String())
// 单品
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("GET", "/api/v1/public/products/pub-sec-1", nil)
r.ServeHTTP(w2, req2)
require.Equal(t, http.StatusOK, w2.Code)
assertNoSensitiveFields(t, w2.Body.String())
}
+426
View File
@@ -0,0 +1,426 @@
package handler
import (
"bytes"
"embed"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
// 公开页 SSR2026-07-07 设计 docs/design/public-page-speedup.html):
// 商品页 /product/:public_id 与店铺页 /shop/:shop_code 由后端直出轻量 HTML~20KB),
// 顾客扫码/分享动线不再加载 Flutter Web12.5MB 首包在 3Mbps 带宽下约 30s)。
// 数据装配与公开 API 共用同一函数(loadPublicProduct / queryShopProducts),字段口径零漂移。
//go:embed templates/public_product.html templates/public_shop.html
var pageTemplates embed.FS
var (
productPageTpl = template.Must(template.ParseFS(pageTemplates, "templates/public_product.html"))
shopPageTpl = template.Must(template.ParseFS(pageTemplates, "templates/public_shop.html"))
)
// publicProductData 公开商品数据装配结果——GetProductJSON API)与 ProductPageSSR)共用。
type publicProductData struct {
Product model.Product
Shop model.Shop
HasShop bool
Batch *model.Inventory
Origin string
ShelfLife string
Storage string
DescTitle string
DescBody string
Keywords []string
}
// loadPublicProduct 按 public_id 装配公开商品数据(公开字段口径的单一实现)。
func (h *PublicHandler) loadPublicProduct(publicID string) (*publicProductData, error) {
var product model.Product
if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
Preload("Origin").
Preload("ShelfLife").
Preload("Storage").
Preload("DescriptionDoc").
First(&product).Error; err != nil {
return nil, err
}
d := &publicProductData{Product: product, ShelfLife: defaultShelfLife, Storage: defaultStorage}
if err := h.db.Where("id = ?", product.ShopID).First(&d.Shop).Error; err == nil {
d.HasShop = true
}
// 最近一条有量库存作为批次信息
var inv model.Inventory
if err := h.db.Where("product_id = ? AND quantity > 0 AND deleted_at IS NULL", product.ID).
Order("created_at DESC").
First(&inv).Error; err == nil {
d.Batch = &inv
}
if product.Origin != nil {
d.Origin = product.Origin.Name
}
if product.ShelfLife != nil {
d.ShelfLife = product.ShelfLife.Name
}
if product.Storage != nil {
d.Storage = product.Storage.Name
}
d.DescTitle, d.DescBody, d.Keywords = buildDescription(product)
return d, nil
}
// queryShopProducts 店铺公开商品列表查询——ListShopProductsJSON API)与 ShopPageSSR)共用。
// keyword 为空时不过滤(API 现状);非空时按 名称/编号/全拼/首字母 模糊匹配(SSR 搜索框)。
func (h *PublicHandler) queryShopProducts(shopID uint64, page, pageSize int, keyword string) ([]publicProductResp, int64, error) {
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
stockSub := h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shopID).
Group("product_id")
query := h.db.Model(&model.Product{}).
Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub).
Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shopID)
if kw := strings.TrimSpace(keyword); kw != "" {
like := "%" + kw + "%"
query = query.Where(
"products.name LIKE ? OR products.code LIKE ? OR products.name_pinyin LIKE ? OR products.name_initials LIKE ?",
like, like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var products []model.Product
if err := query.Preload("Images").
Offset((page - 1) * pageSize).
Limit(pageSize).
Order("products.id DESC").
Find(&products).Error; err != nil {
return nil, 0, err
}
// 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小)
qtyMap := make(map[uint64]float64, len(products))
if len(products) > 0 {
pageIDs := make([]uint64, len(products))
for i, p := range products {
pageIDs[i] = p.ID
}
var stockRows []struct {
ProductID uint64
Qty float64
}
h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shopID, pageIDs).
Group("product_id").
Scan(&stockRows)
for _, s := range stockRows {
qtyMap[s.ProductID] = s.Qty
}
}
listData := make([]publicProductResp, len(products))
for i, p := range products {
imgs := make([]publicProductImage, len(p.Images))
for j, img := range p.Images {
imgs[j] = publicProductImage{URL: img.URL}
}
listData[i] = publicProductResp{
ID: p.ID,
PublicID: p.PublicID,
Code: p.Code,
Name: p.Name,
Series: p.Series,
Spec: p.Spec,
Brand: p.Brand,
Unit: p.Unit,
SalePrice: p.SalePrice,
Quantity: qtyMap[p.ID],
Images: imgs,
}
}
return listData, total, nil
}
// ── 商品公开页 SSR ──
type productPageVM struct {
PageTitle string
OGTags template.HTML
Found bool
Name string
Code string
Series string
Spec string
Unit string
Price string
Images []string
Keywords []string
DescTitle string
DescBody string
Origin string
ShelfLife string
Storage string
HasBatch bool
ProdDate string
BatchNo string
InDate string
HasShop bool
ShopName string
ShopCode string
ShopPhone string
ShopAddr string
ShopHours string
ShopWx string
AppURL string
}
// ProductPage GET /product/:public_id
// 后端直出轻量商品页(含 OG 标签,微信/飞书分享卡片与人看同一页)。
// 找不到商品时渲染同风格「商品不存在」页(HTTP 200,对爬虫友好的历史行为保持)。
func (h *PublicHandler) ProductPage(c *gin.Context) {
publicID := c.Param("public_id")
d, err := h.loadPublicProduct(publicID)
if err != nil {
renderPage(c, productPageTpl, productPageVM{PageTitle: "商品不存在 · 岩美酒库"})
return
}
p := d.Product
shopName := ""
if d.HasShop {
shopName = d.Shop.Name
}
// 页面主标题:品牌前缀(名称未含品牌时)+ 名称;系列走信息行与 chips
name := p.Name
if p.Brand != "" && !strings.Contains(p.Name, p.Brand) {
name = p.Brand + p.Name
}
vm := productPageVM{
PageTitle: buildOGTitle(p) + " · " + siteNameFor(shopName),
OGTags: template.HTML(buildProductOG(p, shopName, config.C.Storage.PublicURL)), // #nosec G203 -- buildProductOG 内部对所有值 html.EscapeString
Found: true,
Name: name,
Code: p.Code,
Series: p.Series,
Spec: p.Spec,
Unit: p.Unit,
Price: fmtYuan(p.SalePrice),
Keywords: d.Keywords,
DescTitle: d.DescTitle,
DescBody: d.DescBody,
Origin: d.Origin,
ShelfLife: d.ShelfLife,
Storage: d.Storage,
AppURL: "/app/product/" + p.PublicID,
}
for _, img := range p.Images {
if img.URL != "" {
vm.Images = append(vm.Images, img.URL)
}
}
if d.Batch != nil {
vm.HasBatch = true
if d.Batch.ProductionDate != nil {
vm.ProdDate = d.Batch.ProductionDate.Time.Format("2006-01-02")
}
vm.BatchNo = d.Batch.BatchNo
vm.InDate = d.Batch.CreatedAt.Format("2006-01-02")
}
if d.HasShop {
vm.HasShop = true
vm.ShopName = d.Shop.Name
vm.ShopCode = d.Shop.Code
vm.ShopPhone = d.Shop.Phone
vm.ShopAddr = d.Shop.Address
vm.ShopHours = d.Shop.BusinessHours
vm.ShopWx = d.Shop.WechatID
}
renderPage(c, productPageTpl, vm)
}
// ── 店铺公开页 SSR ──
type shopPageItemVM struct {
PublicID string
Name string
Sub string
Code string
Img string
Price string
}
type shopPageVM struct {
PageTitle string
OGTags template.HTML
Found bool
ShopName string
LogoURL string
Total int64
Q string
Page int
Items []shopPageItemVM
PrevURL string
NextURL string
}
// ShopPage GET /shop/:shop_code
// 店铺公开商品列表直出页:搜索(?q=)+ 分页(?page=),商品卡链接到 /product/:public_id。
func (h *PublicHandler) ShopPage(c *gin.Context) {
shopCode := c.Param("shop_code")
var shop model.Shop
if err := h.db.Where("code = ? AND deleted_at IS NULL", shopCode).First(&shop).Error; err != nil {
renderPage(c, shopPageTpl, shopPageVM{PageTitle: "店铺不存在 · 岩美酒库"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
const pageSize = 20 // 与公开 API 上限一致(反爬口径)
q := strings.TrimSpace(c.Query("q"))
items, total, err := h.queryShopProducts(shop.ID, page, pageSize, q)
if err != nil {
c.String(http.StatusInternalServerError, "查询失败")
return
}
vm := shopPageVM{
PageTitle: shop.Name + " · 在售商品 · 岩美酒库",
OGTags: template.HTML(buildShopOG(shop, total, config.C.Storage.PublicURL)), // #nosec G203 -- buildShopOG 内部对所有值 html.EscapeString
Found: true,
ShopName: shop.Name,
LogoURL: shop.LogoURL,
Total: total,
Q: q,
Page: page,
}
for _, it := range items {
sub := make([]string, 0, 2)
if it.Series != "" {
sub = append(sub, it.Series)
}
if it.Spec != "" {
sub = append(sub, it.Spec)
}
img := ""
if len(it.Images) > 0 {
img = it.Images[0].URL
}
name := it.Name
if it.Brand != "" && !strings.Contains(it.Name, it.Brand) {
name = it.Brand + it.Name
}
vm.Items = append(vm.Items, shopPageItemVM{
PublicID: it.PublicID,
Name: name,
Sub: strings.Join(sub, " · "),
Code: it.Code,
Img: img,
Price: fmtYuan(it.SalePrice),
})
}
if page > 1 {
vm.PrevURL = shopPageURL(q, page-1)
}
if int64(page*pageSize) < total {
vm.NextURL = shopPageURL(q, page+1)
}
renderPage(c, shopPageTpl, vm)
}
// buildShopOG 店铺页 OG 标签(值全部转义,同 buildProductOG 约定)。
func buildShopOG(shop model.Shop, total int64, publicURL string) string {
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(` <meta property="og:type" content="website">` + "\n")
sb.WriteString(` <meta property="og:site_name" content="岩美酒库">` + "\n")
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(publicURL+"/shop/"+shop.Code) + `">` + "\n")
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(shop.Name) + `">` + "\n")
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(fmt.Sprintf("在售商品 %d 件 · 正品保障", total)) + `">` + "\n")
if shop.LogoURL != "" {
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(publicURL+shop.LogoURL) + `">` + "\n")
}
return sb.String()
}
func shopPageURL(q string, page int) string {
v := url.Values{}
if q != "" {
v.Set("q", q)
}
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
if enc := v.Encode(); enc != "" {
return "?" + enc
}
return "?"
}
func siteNameFor(shopName string) string {
if shopName != "" {
return shopName
}
return "岩美酒库"
}
// fmtYuan 价格显示:0 或负数返回空串(模板隐藏该行);整数不带小数,千分位分组。
func fmtYuan(v float64) string {
if v <= 0 {
return ""
}
s := strconv.FormatFloat(v, 'f', 2, 64)
s = strings.TrimSuffix(s, ".00")
parts := strings.SplitN(s, ".", 2)
digits := parts[0]
var b strings.Builder
for i, ch := range digits {
if i > 0 && (len(digits)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(ch)
}
out := "¥" + b.String()
if len(parts) > 1 {
out += "." + parts[1]
}
return out
}
func renderPage(c *gin.Context, tpl *template.Template, vm interface{}) {
var buf bytes.Buffer
if err := tpl.Execute(&buf, vm); err != nil {
c.String(http.StatusInternalServerError, "render error")
return
}
// 内容含库存批次等动态信息,禁缓存(与旧 OG 注入行为一致,爬虫每次拿最新)
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
@@ -0,0 +1,156 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/testutil"
)
// SSR 公开页(2026-07-07 去 Flutter 化):商品页 /product/:id 与店铺页 /shop/:code
// 由后端直出轻量 HTML;断言内容、OG 标签、敏感字段零暴露、搜索与分页。
func setupPageRouter(db *gorm.DB) *gin.Engine {
h := NewPublicHandler(db)
r := gin.New()
r.Use(gin.Recovery())
r.GET("/product/:public_id", h.ProductPage)
r.GET("/shop/:shop_code", h.ShopPage)
return r
}
func getPage(r *gin.Engine, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestProductPage_SSR(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PPG01")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPageRouter(db)
p := testutil.CreateTestProduct(db, shop.ID, "茅台飞天53度")
setPublicID(db, p.ID, "pub-ssr-001")
addInventory(db, shop.ID, wh.ID, p.ID, 6)
w := getPage(r, "/product/pub-ssr-001")
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
body := w.Body.String()
// 轻量页:不再是 Flutter 壳
assert.NotContains(t, body, "flutter_bootstrap")
assert.NotContains(t, body, `<base href="/app/">`)
// 内容
assert.Contains(t, body, "茅台飞天53度")
assert.Contains(t, body, shop.Name)
// OG 标签(og:url 指向 SSR 短链)
assert.Contains(t, body, `property="og:title"`)
assert.Contains(t, body, "/product/pub-ssr-001")
// 门店互跳 + App 兜底链接
assert.Contains(t, body, "/shop/"+shop.Code)
assert.Contains(t, body, "/app/product/pub-ssr-001")
// 敏感字段零暴露(成本/进价永不出现在公开页)
for _, s := range []string{"cost", "purchase_price", "profit"} {
assert.NotContains(t, body, s, "公开页不得出现敏感字段名 %s", s)
}
}
func TestProductPage_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
r := setupPageRouter(db)
w := getPage(r, "/product/no-such-id")
// 历史行为保持:HTTP 200 + 友好文案(对爬虫不报错)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "商品不存在")
}
func TestShopPage_SSR_SearchAndPaging(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SPG01")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPageRouter(db)
// 25 个有库存商品 → 2 页;外加一个名称可搜索的
for i := 0; i < 25; i++ {
p := testutil.CreateTestProduct(db, shop.ID, "批量酒"+strings.Repeat("x", i%3))
setPublicID(db, p.ID, "sp-"+strings.Repeat("a", 1)+string(rune('A'+i%26))+strings.Repeat("b", i/26+1))
addInventory(db, shop.ID, wh.ID, p.ID, 3)
}
target := testutil.CreateTestProduct(db, shop.ID, "五粮液普五")
setPublicID(db, target.ID, "sp-target")
addInventory(db, shop.ID, wh.ID, target.ID, 2)
// 第 1 页:20 条 + 下一页链接
w := getPage(r, "/shop/"+shop.Code)
assert.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
assert.Contains(t, body, shop.Name)
assert.Contains(t, body, "26 件")
assert.Contains(t, body, "page=2")
assert.Equal(t, 20, strings.Count(body, `class="item"`), "第一页应有 20 张商品卡")
assert.NotContains(t, body, "flutter_bootstrap")
// 第 2 页:6 条 + 上一页链接
w = getPage(r, "/shop/"+shop.Code+"?page=2")
body = w.Body.String()
assert.Equal(t, 6, strings.Count(body, `class="item"`))
assert.Contains(t, body, "上一页")
// 搜索:只命中目标商品,商品卡链接到 SSR 商品页
w = getPage(r, "/shop/"+shop.Code+"?q="+urlQueryEscape("五粮液"))
body = w.Body.String()
assert.Equal(t, 1, strings.Count(body, `class="item"`))
assert.Contains(t, body, "/product/sp-target")
// 搜索无结果:空态文案
w = getPage(r, "/shop/"+shop.Code+"?q=NOPE")
assert.Contains(t, w.Body.String(), "没有找到")
}
func TestShopPage_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
r := setupPageRouter(db)
w := getPage(r, "/shop/NOSHOP")
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "店铺不存在")
}
// API 与 SSR 共用装配后的回归:ListShopProducts 行为不变(不受 keyword 影响)
func TestListShopProducts_UnaffectedByRefactor(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SPG02")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
p := testutil.CreateTestProduct(db, shop.ID, "剑南春")
setPublicID(db, p.ID, "sp2-x")
addInventory(db, shop.ID, wh.ID, p.ID, 1)
r := setupPublicRouter(db)
w := getPage(r, "/api/v1/public/shops/"+shop.Code+"/products")
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
}
func urlQueryEscape(s string) string {
// 测试用最小转义(仅中文场景)
var b strings.Builder
for _, c := range []byte(s) {
b.WriteString("%")
const hex = "0123456789ABCDEF"
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0xF])
}
return b.String()
}
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>{{.OGTags}}
<style>
:root{--primary:#2563AC;--primary-dark:#154072;--ink:#232934;--muted:#6E7888;
--border:#DCE2EB;--paper:#F5F7FA;--head:#F0F4FF;--ok:#2E8B57;--ok-bg:#E6F3EC;}
*{box-sizing:border-box;margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{background:var(--paper);color:var(--ink);line-height:1.65;font-size:15px;}
.wrap{max-width:640px;margin:0 auto;padding:14px 14px 40px;}
.card{background:#fff;border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;}
.gallery{display:flex;gap:8px;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;border-radius:12px;margin-bottom:12px;}
.gallery img{width:100%;max-width:640px;flex:0 0 100%;scroll-snap-align:center;border-radius:12px;object-fit:cover;background:#fff;border:1px solid var(--border);}
h1{font-size:20px;line-height:1.4;margin-bottom:6px;}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 2px;}
.chip{font-size:12px;padding:2px 10px;border-radius:10px;background:var(--head);color:var(--primary-dark);}
.price{font-size:22px;font-weight:700;color:var(--primary);margin-top:6px;}
.price small{font-size:12px;font-weight:400;color:var(--muted);}
.row{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #EEF1F5;font-size:14px;}
.row:last-child{border-bottom:none;}
.row span{color:var(--muted);flex:none;}
.row b{font-weight:600;text-align:right;word-break:break-all;}
.sec-t{font-size:13px;font-weight:600;color:var(--primary-dark);margin-bottom:8px;}
.desc{font-size:14px;color:var(--ink);white-space:pre-wrap;}
.verify{display:flex;align-items:center;gap:8px;background:var(--ok-bg);color:var(--ok);border-radius:10px;padding:10px 14px;font-size:13px;font-weight:600;margin-bottom:12px;}
a{color:var(--primary);text-decoration:none;}
.shop-name{font-size:16px;font-weight:700;}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;}
.foot a{color:var(--muted);text-decoration:underline;}
.empty{text-align:center;padding:60px 20px;color:var(--muted);}
.empty h1{font-size:18px;color:var(--ink);margin-bottom:8px;}
</style>
</head>
<body>
<div class="wrap">
{{if .Found}}
{{if .Images}}
<div class="gallery">
{{range $i, $img := .Images}}
<img src="{{$img}}" alt="{{$.Name}}" decoding="async"{{if $i}} loading="lazy"{{end}}>
{{end}}
</div>
{{end}}
<div class="card">
<h1>{{.Name}}</h1>
{{if .Keywords}}<div class="chips">{{range .Keywords}}<span class="chip">{{.}}</span>{{end}}</div>{{end}}
{{if .Price}}<div class="price">{{.Price}}{{if .Unit}} <small>/ {{.Unit}}</small>{{end}}</div>{{end}}
</div>
<div class="verify">✓ 正品保障 · 扫码验真 · 官方渠道供应</div>
<div class="card">
<div class="sec-t">商品信息</div>
{{if .Code}}<div class="row"><span>商品编号</span><b>{{.Code}}</b></div>{{end}}
{{if .Series}}<div class="row"><span>系列</span><b>{{.Series}}</b></div>{{end}}
{{if .Spec}}<div class="row"><span>规格</span><b>{{.Spec}}</b></div>{{end}}
{{if .Origin}}<div class="row"><span>产地</span><b>{{.Origin}}</b></div>{{end}}
<div class="row"><span>保质期</span><b>{{.ShelfLife}}</b></div>
<div class="row"><span>储存方式</span><b>{{.Storage}}</b></div>
</div>
{{if .HasBatch}}
<div class="card">
<div class="sec-t">批次信息</div>
{{if .ProdDate}}<div class="row"><span>生产日期</span><b>{{.ProdDate}}</b></div>{{end}}
{{if .BatchNo}}<div class="row"><span>批次号</span><b>{{.BatchNo}}</b></div>{{end}}
{{if .InDate}}<div class="row"><span>入库日期</span><b>{{.InDate}}</b></div>{{end}}
</div>
{{end}}
{{if .DescBody}}
<div class="card">
<div class="sec-t">{{.DescTitle}}</div>
<div class="desc">{{.DescBody}}</div>
</div>
{{end}}
{{if .HasShop}}
<div class="card">
<div class="sec-t">门店信息</div>
<div class="row"><span>门店</span><b>{{if .ShopCode}}<a class="shop-name" href="/shop/{{.ShopCode}}">{{.ShopName}} </a>{{else}}{{.ShopName}}{{end}}</b></div>
{{if .ShopPhone}}<div class="row"><span>电话</span><b><a href="tel:{{.ShopPhone}}">{{.ShopPhone}}</a></b></div>{{end}}
{{if .ShopAddr}}<div class="row"><span>地址</span><b>{{.ShopAddr}}</b></div>{{end}}
{{if .ShopHours}}<div class="row"><span>营业时间</span><b>{{.ShopHours}}</b></div>{{end}}
{{if .ShopWx}}<div class="row"><span>微信</span><b>{{.ShopWx}}</b></div>{{end}}
{{if .ShopCode}}<div class="row"><span></span><b><a href="/shop/{{.ShopCode}}">查看本店更多商品 </a></b></div>{{end}}
</div>
{{end}}
{{else}}
<div class="card empty">
<h1>商品不存在或已下架</h1>
<p>请确认链接来源,或联系门店核实。</p>
</div>
{{end}}
<div class="foot">岩美酒库 · 正品溯源{{if .AppURL}} · <a href="{{.AppURL}}">在 App 中查看</a>{{end}}</div>
</div>
</body>
</html>
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>{{.OGTags}}
<style>
:root{--primary:#2563AC;--primary-dark:#154072;--ink:#232934;--muted:#6E7888;
--border:#DCE2EB;--paper:#F5F7FA;--head:#F0F4FF;}
*{box-sizing:border-box;margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{background:var(--paper);color:var(--ink);line-height:1.6;font-size:15px;}
.wrap{max-width:640px;margin:0 auto;padding:14px 14px 40px;}
.head{display:flex;align-items:center;gap:12px;padding:6px 2px 14px;}
.head img{width:44px;height:44px;border-radius:10px;object-fit:cover;border:1px solid var(--border);background:#fff;}
.head h1{font-size:18px;line-height:1.3;}
.head .sub{font-size:12px;color:var(--muted);}
form{display:flex;gap:8px;margin-bottom:12px;}
input[type=search]{flex:1;border:1px solid var(--border);border-radius:10px;padding:9px 12px;font-size:14px;background:#fff;outline:none;}
input[type=search]:focus{border-color:var(--primary);}
button{border:none;background:var(--primary);color:#fff;border-radius:10px;padding:0 18px;font-size:14px;}
.item{display:flex;gap:12px;background:#fff;border:1px solid var(--border);border-radius:12px;padding:12px;margin-bottom:10px;color:var(--ink);}
.item img{width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid var(--border);flex:none;background:var(--paper);}
.item .ph{width:64px;height:64px;border-radius:8px;border:1px solid var(--border);flex:none;background:var(--head);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:11px;}
.item .m{flex:1;min-width:0;}
.item .nm{font-weight:600;font-size:15px;line-height:1.4;}
.item .sb{font-size:12.5px;color:var(--muted);margin-top:2px;}
.item .cd{font-size:12px;color:var(--muted);font-family:ui-monospace,Menlo,monospace;margin-top:2px;}
.item .pr{font-size:15px;font-weight:700;color:var(--primary);flex:none;align-self:center;}
a{text-decoration:none;color:inherit;}
.pager{display:flex;justify-content:space-between;align-items:center;margin-top:16px;font-size:14px;}
.pager a{color:var(--primary);font-weight:600;padding:8px 4px;}
.pager .off{color:var(--border);}
.pager .pg{color:var(--muted);font-size:12.5px;}
.empty{text-align:center;padding:60px 20px;color:var(--muted);background:#fff;border:1px solid var(--border);border-radius:12px;}
.empty h2{font-size:16px;color:var(--ink);margin-bottom:6px;}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;}
</style>
</head>
<body>
<div class="wrap">
{{if .Found}}
<div class="head">
{{if .LogoURL}}<img src="{{.LogoURL}}" alt="{{.ShopName}}" decoding="async">{{end}}
<div>
<h1>{{.ShopName}}</h1>
<div class="sub">在售商品 {{.Total}} 件 · 正品保障</div>
</div>
</div>
<form method="get" action="">
<input type="search" name="q" value="{{.Q}}" placeholder="搜索本店商品(名称 / 编号 / 拼音)">
<button type="submit">搜索</button>
</form>
{{if .Items}}
{{range .Items}}
<a class="item" href="/product/{{.PublicID}}">
{{if .Img}}<img src="{{.Img}}" alt="{{.Name}}" loading="lazy" decoding="async">{{else}}<span class="ph">暂无图</span>{{end}}
<span class="m">
<span class="nm">{{.Name}}</span>
{{if .Sub}}<span class="sb" style="display:block">{{.Sub}}</span>{{end}}
{{if .Code}}<span class="cd" style="display:block">{{.Code}}</span>{{end}}
</span>
{{if .Price}}<span class="pr">{{.Price}}</span>{{end}}
</a>
{{end}}
<div class="pager">
{{if .PrevURL}}<a href="{{.PrevURL}}"> 上一页</a>{{else}}<span class="off"> 上一页</span>{{end}}
<span class="pg">第 {{.Page}} 页 · 共 {{.Total}} 件</span>
{{if .NextURL}}<a href="{{.NextURL}}">下一页 </a>{{else}}<span class="off">下一页 </span>{{end}}
</div>
{{else}}
<div class="empty">
<h2>{{if .Q}}没有找到「{{.Q}}」相关商品{{else}}本店暂无在售商品{{end}}</h2>
{{if .Q}}<p>换个关键词试试,或清空搜索查看全部。</p>{{end}}
</div>
{{end}}
{{else}}
<div class="empty">
<h2>店铺不存在</h2>
<p>请确认链接来源。</p>
</div>
{{end}}
<div class="foot">岩美酒库 · 正品溯源</div>
</div>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
package middleware
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/ratelimit"
)
// DailyQuota 同一 IP 的自然日累计配额——分钟级限流之上的第二道反爬闸。
// key = "dq:<scope>:<yyyymmdd>:<ip>"TTL 26h(跨午夜/时钟偏移裕量,键按日期
// 自然分片,旧键过期自灭)。limit<=0 或总开关关闭时放行;计数器出错(redis
// 降级窗口等)也放行——宁可漏限不误伤真实客户。
func DailyQuota(scope string, limit func() int) gin.HandlerFunc {
return func(c *gin.Context) {
lim := limit()
if !config.C.RateLimit.Enabled || lim <= 0 {
c.Next()
return
}
ip := c.ClientIP()
if ip == "" {
c.Next()
return
}
now := time.Now()
key := fmt.Sprintf("dq:%s:%s:%s", scope, now.Format("20060102"), ip)
n, err := ratelimit.Default().Counter().Incr(key, 26*time.Hour)
if err == nil && n > int64(lim) {
c.Header("Retry-After", strconv.Itoa(secondsToMidnight(now)))
c.AbortWithStatusJSON(http.StatusTooManyRequests,
gin.H{"error": "今日访问次数已达上限,请明日再试"})
return
}
c.Next()
}
}
// secondsToMidnight 距下一个本地零点的秒数(日配额的自然重置时刻)。
func secondsToMidnight(now time.Time) int {
next := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1)
return int(next.Sub(now).Seconds())
}
@@ -0,0 +1,75 @@
package middleware
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/config"
)
func newQuotaRouter(scope string, limit int) *gin.Engine {
r := gin.New()
r.GET("/", DailyQuota(scope, func() int { return limit }), func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
return r
}
func TestDailyQuotaExceeded429(t *testing.T) {
enableRateLimit(t)
r := newQuotaRouter("tq1", 3)
for i := 0; i < 3; i++ {
if w := hit(r, "7.7.7.7"); w.Code != http.StatusOK {
t.Fatalf("配额内第 %d 发应放行,得到 %d", i+1, w.Code)
}
}
w := hit(r, "7.7.7.7")
if w.Code != http.StatusTooManyRequests {
t.Fatalf("超日配额应 429,得到 %d", w.Code)
}
if w.Header().Get("Retry-After") == "" {
t.Fatal("429 应带 Retry-After(到午夜秒数)")
}
// 不同 IP 独立
if w := hit(r, "8.8.8.8"); w.Code != http.StatusOK {
t.Fatalf("其他 IP 应不受影响,得到 %d", w.Code)
}
}
func TestDailyQuotaScopesIndependent(t *testing.T) {
enableRateLimit(t)
a := newQuotaRouter("tq2a", 1)
b := newQuotaRouter("tq2b", 1)
if w := hit(a, "9.9.9.9"); w.Code != http.StatusOK {
t.Fatalf("scope a 首发应放行,得到 %d", w.Code)
}
if w := hit(b, "9.9.9.9"); w.Code != http.StatusOK {
t.Fatalf("scope b 有独立配额池,应放行,得到 %d", w.Code)
}
if w := hit(a, "9.9.9.9"); w.Code != http.StatusTooManyRequests {
t.Fatalf("scope a 第二发应 429,得到 %d", w.Code)
}
}
func TestDailyQuotaDisabledOrZeroPassthrough(t *testing.T) {
// limit=0 → 该闸关闭
enableRateLimit(t)
r := newQuotaRouter("tq3", 0)
for i := 0; i < 5; i++ {
if w := hit(r, "10.0.0.1"); w.Code != http.StatusOK {
t.Fatalf("limit=0 第 %d 发应放行,得到 %d", i+1, w.Code)
}
}
// 总开关关闭 → 放行
prev := config.C.RateLimit.Enabled
config.C.RateLimit.Enabled = false
defer func() { config.C.RateLimit.Enabled = prev }()
r2 := newQuotaRouter("tq4", 1)
for i := 0; i < 5; i++ {
if w := hit(r2, "10.0.0.2"); w.Code != http.StatusOK {
t.Fatalf("总开关关闭第 %d 发应放行,得到 %d", i+1, w.Code)
}
}
}
+11 -65
View File
@@ -1,77 +1,19 @@
package middleware
import (
"math"
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/ratelimit"
)
// 限流器内存条目的清理参数:每 5 分钟扫一次,淘汰超过 10 分钟未活动的 key。
// 保证 map 不随攻击者构造的随机 key(IP/shop)无限增长
const (
rateLimitSweep = 5 * time.Minute
rateLimitIdleTTL = 10 * time.Minute
rateLimitRetryHdr = "60" // Retry-After 秒数(提示客户端退避)
)
// keyedLimiter 按任意字符串 keyIP 或 shop_id)维护独立令牌桶,内存有界(带 janitor)。
// 单实例进程内状态,重启即清零;多实例水平扩展时需改为 Redis(见方案「暂不做」)。
type keyedLimiter struct {
mu sync.Mutex
entries map[string]*limiterBucket
r rate.Limit
burst int
}
type limiterBucket struct {
lim *rate.Limiter
lastSeen time.Time
}
func newKeyedLimiter(r rate.Limit, burst int) *keyedLimiter {
kl := &keyedLimiter{entries: map[string]*limiterBucket{}, r: r, burst: burst}
go kl.janitor()
return kl
}
// get 取(或惰性创建)该 key 的令牌桶并刷新活动时间。
func (kl *keyedLimiter) get(key string) *rate.Limiter {
kl.mu.Lock()
defer kl.mu.Unlock()
b := kl.entries[key]
if b == nil {
b = &limiterBucket{lim: rate.NewLimiter(kl.r, kl.burst)}
kl.entries[key] = b
}
b.lastSeen = time.Now()
return b.lim
}
func (kl *keyedLimiter) janitor() {
t := time.NewTicker(rateLimitSweep)
defer t.Stop()
for range t.C {
kl.sweep(rateLimitIdleTTL)
}
}
// sweep 淘汰超过 ttl 未活动的 key。拆出便于测试。
func (kl *keyedLimiter) sweep(ttl time.Duration) {
now := time.Now()
kl.mu.Lock()
defer kl.mu.Unlock()
for k, b := range kl.entries {
if now.Sub(b.lastSeen) > ttl {
delete(kl.entries, k)
}
}
}
// 限流状态存储在 internal/ratelimit(默认内存;REDIS_ADDR 配置后外置 redis
// 跨重启保状态、支持多实例。2026-07 外置改造,原 keyedLimiter 迁入该包)
// PerMinute 把「每分钟 n 次」转成 rate.Limit(令牌/秒)。
func PerMinute(n int) rate.Limit {
@@ -86,7 +28,7 @@ func PerSecond(n int) rate.Limit {
// rateLimit 通用工厂:keyFn 抽取限流维度的 key(返回空串表示无法判定 → 放行,不误伤)。
// config.C.RateLimit.Enabled=false 时整体放行(应急/测试开关)。
func rateLimit(r rate.Limit, burst int, keyFn func(*gin.Context) string) gin.HandlerFunc {
kl := newKeyedLimiter(r, burst)
lim := ratelimit.Default().NewLimiter(r, burst)
return func(c *gin.Context) {
if !config.C.RateLimit.Enabled {
c.Next()
@@ -97,8 +39,12 @@ func rateLimit(r rate.Limit, burst int, keyFn func(*gin.Context) string) gin.Han
c.Next()
return
}
if !kl.get(key).Allow() {
c.Header("Retry-After", rateLimitRetryHdr)
if res := lim.Allow(key); !res.Allowed {
retry := int(math.Ceil(res.RetryAfter.Seconds()))
if retry <= 0 {
retry = 60
}
c.Header("Retry-After", strconv.Itoa(retry))
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
return
}
+1 -23
View File
@@ -107,29 +107,7 @@ func TestRateLimitDisabledPassthrough(t *testing.T) {
}
}
func TestKeyedLimiterSweepEvictsIdle(t *testing.T) {
kl := newKeyedLimiter(PerMinute(60), 1)
kl.get("ip:a")
kl.get("ip:b")
if len(kl.entries) != 2 {
t.Fatalf("应有 2 个 entry,得到 %d", len(kl.entries))
}
// 把 a 的活动时间推到很久以前,sweep 应只淘汰 a。
kl.mu.Lock()
kl.entries["ip:a"].lastSeen = time.Now().Add(-time.Hour)
kl.mu.Unlock()
kl.sweep(10 * time.Minute)
kl.mu.Lock()
defer kl.mu.Unlock()
if _, ok := kl.entries["ip:a"]; ok {
t.Fatal("空闲 key a 应被淘汰")
}
if _, ok := kl.entries["ip:b"]; !ok {
t.Fatal("活跃 key b 不应被淘汰")
}
}
// keyedLimiter 内部淘汰测试已随实现迁至 internal/ratelimit/memory_test.go。
// TestTrustedProxyRealIP 复刻 main.go 的可信代理配置:只信任本机写的 X-Real-IP,
// 客户端伪造的 X-Forwarded-For 不被采信 → c.ClientIP() 返回真实 IP,限流不可被请求头绕过。
+106
View File
@@ -0,0 +1,106 @@
package ratelimit
import (
"log"
"sync"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
// fallbackStoreredis 为主、内存为影子。每次调用先试 redis,出错即降级到
// 对应的内存实现(fail-open 到内存而非 fail-closed——redis 挂掉时防护降级但
// 不中断,业务请求不 5xx)。redis 恢复后自动回主路径。降级日志限频打印。
type fallbackStore struct {
redis *redisStore
mem *memoryStore
}
func newFallbackStore(rdb *redis.Client) *fallbackStore {
return &fallbackStore{redis: newRedisStore(rdb), mem: newMemoryStore()}
}
// warnDegraded 限频告警(每 30s 至多一条,防 redis 宕机刷爆日志)。
var (
warnMu sync.Mutex
warnLast time.Time
)
func warnDegraded(err error) {
warnMu.Lock()
defer warnMu.Unlock()
if time.Since(warnLast) < 30*time.Second {
return
}
warnLast = time.Now()
log.Printf("[ratelimit] redis 不可用,已降级内存限流:%v", err)
}
func (s *fallbackStore) NewLimiter(r rate.Limit, burst int) Limiter {
return &fallbackLimiter{r: s.redis.newLimiter(r, burst), m: s.mem.NewLimiter(r, burst)}
}
func (s *fallbackStore) Counter() Counter {
return &fallbackCounter{r: &redisCounter{rdb: s.redis.rdb}, m: s.mem.Counter()}
}
func (s *fallbackStore) FailLocker() FailLocker {
return &fallbackFailLocker{r: &redisFailLocker{rdb: s.redis.rdb}, m: s.mem.FailLocker()}
}
type fallbackLimiter struct {
r *redisLimiter
m Limiter
}
func (l *fallbackLimiter) Allow(key string) Result {
res, err := l.r.allow(key)
if err != nil {
warnDegraded(err)
return l.m.Allow(key)
}
return res
}
type fallbackCounter struct {
r *redisCounter
m Counter
}
func (c *fallbackCounter) Incr(key string, ttl time.Duration) (int64, error) {
n, err := c.r.Incr(key, ttl)
if err != nil {
warnDegraded(err)
return c.m.Incr(key, ttl)
}
return n, nil
}
type fallbackFailLocker struct {
r *redisFailLocker
m FailLocker
}
func (l *fallbackFailLocker) Locked(key string) bool {
locked, err := l.r.locked(key)
if err != nil {
warnDegraded(err)
return l.m.Locked(key)
}
return locked
}
func (l *fallbackFailLocker) RecordFailure(key string, max int, lockFor time.Duration) {
if err := l.r.recordFailure(key, max, lockFor); err != nil {
warnDegraded(err)
l.m.RecordFailure(key, max, lockFor)
}
}
func (l *fallbackFailLocker) Reset(key string) {
// 双清:降级期间可能在内存里积了计数,恢复后一并清掉。
if err := l.r.reset(key); err != nil {
warnDegraded(err)
}
l.m.Reset(key)
}
@@ -0,0 +1,95 @@
package ratelimit
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
// resetDefault 清空全局 Store(Init 测试用,避免污染其他测试)。
func resetDefault() {
defMu.Lock()
def = nil
defMu.Unlock()
}
func TestFallbackLimiterDegradesToMemory(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := newFallbackStore(rdb)
l := s.NewLimiter(rate.Limit(1.0/60.0), 2)
if !l.Allow("k").Allowed {
t.Fatal("redis 正常时应放行")
}
mr.Close() // 模拟 redis 宕机
// 降级内存后仍在限流:burst 2 内放行、超出拒绝(内存桶从零起算)
if !l.Allow("k").Allowed || !l.Allow("k").Allowed {
t.Fatal("降级内存后 burst 内应放行(不 5xx、不 fail-closed")
}
if l.Allow("k").Allowed {
t.Fatal("降级内存后超出 burst 仍应限流")
}
}
func TestFallbackFailLockerDegrades(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := newFallbackStore(rdb)
fl := s.FailLocker()
mr.Close()
for i := 0; i < 3; i++ {
fl.RecordFailure("k", 3, time.Minute)
}
if !fl.Locked("k") {
t.Fatal("降级内存后失败锁仍应生效")
}
fl.Reset("k")
if fl.Locked("k") {
t.Fatal("Reset 应解锁")
}
}
func TestFallbackCounterDegrades(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := newFallbackStore(rdb)
c := s.Counter()
mr.Close()
n, err := c.Incr("k", time.Hour)
if err != nil || n != 1 {
t.Fatalf("降级内存后计数应可用,得到 %d err=%v", n, err)
}
}
func TestInitBadAddrFallsToMemory(t *testing.T) {
defer resetDefault()
Init("127.0.0.1:1") // 无服务端口,Ping 必败:不 panic、落内存
l := Default().NewLimiter(rate.Limit(1), 1)
if !l.Allow("k").Allowed {
t.Fatal("坏地址应降级内存并正常工作")
}
}
func TestInitEmptyAddrMemory(t *testing.T) {
defer resetDefault()
Init("")
if _, ok := Default().(*memoryStore); !ok {
t.Fatal("空地址应为内存 Store")
}
}
func TestInitGoodAddrRedis(t *testing.T) {
defer resetDefault()
mr := miniredis.RunT(t)
Init(mr.Addr())
if _, ok := Default().(*fallbackStore); !ok {
t.Fatal("可用地址应为 redis(fallback) Store")
}
}
+212
View File
@@ -0,0 +1,212 @@
package ratelimit
import (
"sync"
"time"
"golang.org/x/time/rate"
)
// 内存实现:自 middleware.keyedLimiter 与 service.loginLimiter 迁入(2026-07
// 状态外置改造),行为与迁入前一致。单实例进程内状态,重启即清零。
// 所有 map 均带 janitor 定期淘汰,保证不随攻击者构造的随机 key 无限增长。
const (
memSweepEvery = 5 * time.Minute
memLimiterIdleTTL = 10 * time.Minute
memLockerIdleTTL = 30 * time.Minute
// 内存版给固定退避提示(redis 版有精确值)。
memRetryAfter = 60 * time.Second
)
type memoryStore struct {
counter *memCounter
locker *memFailLocker
}
func newMemoryStore() *memoryStore {
return &memoryStore{
counter: newMemCounter(),
locker: newMemFailLocker(),
}
}
func (s *memoryStore) NewLimiter(r rate.Limit, burst int) Limiter {
return newMemLimiter(r, burst)
}
func (s *memoryStore) Counter() Counter { return s.counter }
func (s *memoryStore) FailLocker() FailLocker { return s.locker }
// ── Limiter:按 key 的令牌桶(原 keyedLimiter)──────────────────────────────
type memLimiter struct {
mu sync.Mutex
entries map[string]*memBucket
r rate.Limit
burst int
}
type memBucket struct {
lim *rate.Limiter
lastSeen time.Time
}
func newMemLimiter(r rate.Limit, burst int) *memLimiter {
l := &memLimiter{entries: map[string]*memBucket{}, r: r, burst: burst}
go l.janitor()
return l
}
func (l *memLimiter) Allow(key string) Result {
l.mu.Lock()
b := l.entries[key]
if b == nil {
b = &memBucket{lim: rate.NewLimiter(l.r, l.burst)}
l.entries[key] = b
}
b.lastSeen = time.Now()
l.mu.Unlock()
return Result{Allowed: b.lim.Allow(), RetryAfter: memRetryAfter}
}
func (l *memLimiter) janitor() {
t := time.NewTicker(memSweepEvery)
defer t.Stop()
for range t.C {
l.sweep(memLimiterIdleTTL)
}
}
// sweep 淘汰超过 ttl 未活动的 key。拆出便于测试。
func (l *memLimiter) sweep(ttl time.Duration) {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
for k, b := range l.entries {
if now.Sub(b.lastSeen) > ttl {
delete(l.entries, k)
}
}
}
// ── Counter:带 TTL 的累加计数(日配额用)────────────────────────────────────
type memCounter struct {
mu sync.Mutex
entries map[string]*memCount
janitorOnce sync.Once
}
type memCount struct {
val int64
expireAt time.Time
}
func newMemCounter() *memCounter {
return &memCounter{entries: map[string]*memCount{}}
}
func (c *memCounter) Incr(key string, ttl time.Duration) (int64, error) {
c.startJanitor()
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
e := c.entries[key]
if e == nil || now.After(e.expireAt) {
e = &memCount{expireAt: now.Add(ttl)}
c.entries[key] = e
}
e.val++
return e.val, nil
}
func (c *memCounter) startJanitor() {
c.janitorOnce.Do(func() {
go func() {
t := time.NewTicker(memSweepEvery)
defer t.Stop()
for range t.C {
now := time.Now()
c.mu.Lock()
for k, e := range c.entries {
if now.After(e.expireAt) {
delete(c.entries, k)
}
}
c.mu.Unlock()
}
}()
})
}
// ── FailLocker:登录失败计数与锁(原 service.loginLimiter)───────────────────
type memFailLocker struct {
mu sync.Mutex
entries map[string]*memFailEntry
janitorOnce sync.Once
}
type memFailEntry struct {
failures int
lockedTill time.Time
lastSeen time.Time
}
func newMemFailLocker() *memFailLocker {
return &memFailLocker{entries: map[string]*memFailEntry{}}
}
func (l *memFailLocker) Locked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
if e == nil {
return false
}
e.lastSeen = time.Now()
return time.Now().Before(e.lockedTill)
}
func (l *memFailLocker) RecordFailure(key string, max int, lockFor time.Duration) {
l.startJanitor()
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
if e == nil {
e = &memFailEntry{}
l.entries[key] = e
}
e.lastSeen = time.Now()
e.failures++
if max > 0 && e.failures >= max {
e.lockedTill = time.Now().Add(lockFor)
e.failures = 0
}
}
func (l *memFailLocker) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.entries, key)
}
// startJanitor 惰性启动后台清理:淘汰「未锁定且空闲超 TTL」的 entry。
func (l *memFailLocker) startJanitor() {
l.janitorOnce.Do(func() {
go func() {
t := time.NewTicker(memSweepEvery)
defer t.Stop()
for range t.C {
now := time.Now()
l.mu.Lock()
for k, e := range l.entries {
if now.After(e.lockedTill) && now.Sub(e.lastSeen) > memLockerIdleTTL {
delete(l.entries, k)
}
}
l.mu.Unlock()
}
}()
})
}
+101
View File
@@ -0,0 +1,101 @@
package ratelimit
import (
"testing"
"time"
"golang.org/x/time/rate"
)
// 迁自 middleware.TestKeyedLimiterSweepEvictsIdle:空闲 key 被 sweep 淘汰、活跃保留。
func TestMemLimiterSweepEvictsIdle(t *testing.T) {
l := newMemLimiter(rate.Limit(1), 1)
l.Allow("ip:a")
l.Allow("ip:b")
if len(l.entries) != 2 {
t.Fatalf("应有 2 个 entry,得到 %d", len(l.entries))
}
l.mu.Lock()
l.entries["ip:a"].lastSeen = time.Now().Add(-time.Hour)
l.mu.Unlock()
l.sweep(10 * time.Minute)
l.mu.Lock()
defer l.mu.Unlock()
if _, ok := l.entries["ip:a"]; ok {
t.Fatal("空闲 key a 应被淘汰")
}
if _, ok := l.entries["ip:b"]; !ok {
t.Fatal("活跃 key b 不应被淘汰")
}
}
func TestMemLimiterBurst(t *testing.T) {
l := newMemLimiter(rate.Limit(1.0/60.0), 2) // 每分钟 1 个,突发 2
if !l.Allow("k").Allowed || !l.Allow("k").Allowed {
t.Fatal("burst 内应放行")
}
res := l.Allow("k")
if res.Allowed {
t.Fatal("超出 burst 应拒绝")
}
if res.RetryAfter <= 0 {
t.Fatal("拒绝时应给 RetryAfter")
}
if !l.Allow("other").Allowed {
t.Fatal("不同 key 独立桶")
}
}
func TestMemFailLockerLockAndExpire(t *testing.T) {
fl := newMemFailLocker()
const key = "S|u"
for i := 0; i < 3; i++ {
if fl.Locked(key) {
t.Fatalf("第 %d 次失败前不应锁定", i+1)
}
fl.RecordFailure(key, 3, 30*time.Millisecond)
}
if !fl.Locked(key) {
t.Fatal("达阈值应锁定")
}
time.Sleep(40 * time.Millisecond)
if fl.Locked(key) {
t.Fatal("锁定到期应自动解锁")
}
}
func TestMemFailLockerReset(t *testing.T) {
fl := newMemFailLocker()
fl.RecordFailure("k", 2, time.Minute)
fl.Reset("k")
fl.RecordFailure("k", 2, time.Minute)
if fl.Locked("k") {
t.Fatal("Reset 后重新计数,1 次失败不应锁定")
}
}
func TestMemFailLockerMaxZeroNeverLocks(t *testing.T) {
fl := newMemFailLocker()
for i := 0; i < 10; i++ {
fl.RecordFailure("k", 0, time.Minute)
}
if fl.Locked("k") {
t.Fatal("max<=0 不应锁定")
}
}
func TestMemCounterIncrAndTTL(t *testing.T) {
c := newMemCounter()
n1, _ := c.Incr("k", 30*time.Millisecond)
n2, _ := c.Incr("k", 30*time.Millisecond)
if n1 != 1 || n2 != 2 {
t.Fatalf("累加应为 1,2,得到 %d,%d", n1, n2)
}
time.Sleep(40 * time.Millisecond)
n3, _ := c.Incr("k", 30*time.Millisecond)
if n3 != 1 {
t.Fatalf("TTL 过期后应重新从 1 计,得到 %d", n3)
}
}
+131
View File
@@ -0,0 +1,131 @@
package ratelimit
import (
"context"
"time"
redis_rate "github.com/go-redis/redis_rate/v10"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
// redis 实现。键统一前缀 jiu:rl:(同 redis 将来可能被同宿主其他服务复用)。
// 所有方法返回 error 供 fallback 包装器降级判断;本文件不直接暴露给调用方。
const (
redisKeyPrefix = "jiu:rl:"
redisOpTimeout = 500 * time.Millisecond
// 失败计数累计窗口(对齐内存版 janitor 的 30min 空闲清理语义)。
redisFailTTL = 30 * time.Minute
)
type redisStore struct {
rdb *redis.Client
rl *redis_rate.Limiter
}
func newRedisStore(rdb *redis.Client) *redisStore {
return &redisStore{rdb: rdb, rl: redis_rate.NewLimiter(rdb)}
}
func opCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), redisOpTimeout)
}
// ── Limiter:GCRA ────────────────────────────────────────────────────────────
type redisLimiter struct {
rl *redis_rate.Limiter
limit redis_rate.Limit
}
// newLimiter 把 x/time/rate 的「令牌/秒」换算成 GCRA 的每事件间隔:
// Limit{Rate:1, Period: 1/r} 与 rate.Limiter(r) 的稳态速率等价,burst 语义一致。
func (s *redisStore) newLimiter(r rate.Limit, burst int) *redisLimiter {
if r <= 0 {
r = rate.Limit(1.0 / 60.0) // 防御:非法速率按每分钟 1 次
}
period := time.Duration(float64(time.Second) / float64(r))
return &redisLimiter{
rl: s.rl,
limit: redis_rate.Limit{Rate: 1, Period: period, Burst: burst},
}
}
func (l *redisLimiter) allow(key string) (Result, error) {
ctx, cancel := opCtx()
defer cancel()
res, err := l.rl.Allow(ctx, redisKeyPrefix+"lim:"+key, l.limit)
if err != nil {
return Result{}, err
}
ra := res.RetryAfter
if ra <= 0 {
ra = memRetryAfter
}
return Result{Allowed: res.Allowed > 0, RetryAfter: ra}, nil
}
// ── Counter ──────────────────────────────────────────────────────────────────
type redisCounter struct{ rdb *redis.Client }
func (c *redisCounter) Incr(key string, ttl time.Duration) (int64, error) {
ctx, cancel := opCtx()
defer cancel()
k := redisKeyPrefix + "cnt:" + key
n, err := c.rdb.Incr(ctx, k).Result()
if err != nil {
return 0, err
}
if n == 1 {
// 首次自增设 TTL;失败不致命(键会在下个周期被重建)
_ = c.rdb.Expire(ctx, k, ttl).Err()
}
return n, nil
}
// ── FailLocker ───────────────────────────────────────────────────────────────
// recordFailScriptINCR 失败计数 + 续窗口 TTL;达阈值则置锁并清计数(原子)。
// KEYS[1]=fail 键, KEYS[2]=lock 键;ARGV[1]=failTTL 秒, ARGV[2]=max, ARGV[3]=lock 秒。
var recordFailScript = redis.NewScript(`
local f = redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
if tonumber(ARGV[2]) > 0 and f >= tonumber(ARGV[2]) then
redis.call('SET', KEYS[2], 1, 'EX', ARGV[3])
redis.call('DEL', KEYS[1])
end
return f
`)
type redisFailLocker struct{ rdb *redis.Client }
func failKeys(key string) (string, string) {
return redisKeyPrefix + "fail:" + key, redisKeyPrefix + "lock:" + key
}
func (l *redisFailLocker) locked(key string) (bool, error) {
ctx, cancel := opCtx()
defer cancel()
_, lockKey := failKeys(key)
n, err := l.rdb.Exists(ctx, lockKey).Result()
return n > 0, err
}
func (l *redisFailLocker) recordFailure(key string, max int, lockFor time.Duration) error {
ctx, cancel := opCtx()
defer cancel()
failKey, lockKey := failKeys(key)
return recordFailScript.Run(ctx, l.rdb,
[]string{failKey, lockKey},
int(redisFailTTL.Seconds()), max, int(lockFor.Seconds()),
).Err()
}
func (l *redisFailLocker) reset(key string) error {
ctx, cancel := opCtx()
defer cancel()
failKey, lockKey := failKeys(key)
return l.rdb.Del(ctx, failKey, lockKey).Err()
}
+101
View File
@@ -0,0 +1,101 @@
package ratelimit
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
func newTestRedis(t *testing.T) (*miniredis.Miniredis, *redisStore) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
return mr, newRedisStore(rdb)
}
func TestRedisLimiterBurstThenDeny(t *testing.T) {
_, s := newTestRedis(t)
l := s.newLimiter(rate.Limit(1.0/60.0), 3) // 每分钟 1 个,突发 3
for i := 0; i < 3; i++ {
res, err := l.allow("ip:1.1.1.1")
if err != nil {
t.Fatalf("allow 报错:%v", err)
}
if !res.Allowed {
t.Fatalf("burst 内第 %d 个应放行", i+1)
}
}
res, err := l.allow("ip:1.1.1.1")
if err != nil {
t.Fatalf("allow 报错:%v", err)
}
if res.Allowed {
t.Fatal("超出 burst 应拒绝")
}
if res.RetryAfter <= 0 {
t.Fatal("拒绝时应给正的 RetryAfter")
}
// 不同 key 独立
if res, _ := l.allow("ip:2.2.2.2"); !res.Allowed {
t.Fatal("不同 key 应独立计")
}
}
func TestRedisFailLockerLockExpireReset(t *testing.T) {
mr, s := newTestRedis(t)
fl := &redisFailLocker{rdb: s.rdb}
const key = "S001|admin"
for i := 0; i < 3; i++ {
if locked, _ := fl.locked(key); locked {
t.Fatalf("第 %d 次失败前不应锁定", i+1)
}
if err := fl.recordFailure(key, 3, time.Minute); err != nil {
t.Fatalf("recordFailure 报错:%v", err)
}
}
if locked, _ := fl.locked(key); !locked {
t.Fatal("达阈值应锁定")
}
// 锁定即清零计数:fail 键应已删除
failKey, _ := failKeys(key)
if mr.Exists(failKey) {
t.Fatal("锁定后 fail 计数键应被清零删除")
}
// TTL 到期自动解锁
mr.FastForward(61 * time.Second)
if locked, _ := fl.locked(key); locked {
t.Fatal("锁定到期应自动解锁")
}
// Reset 清空计数
_ = fl.recordFailure(key, 3, time.Minute)
if err := fl.reset(key); err != nil {
t.Fatalf("reset 报错:%v", err)
}
if mr.Exists(failKey) {
t.Fatal("Reset 后 fail 键应删除")
}
}
func TestRedisCounterIncrTTL(t *testing.T) {
mr, s := newTestRedis(t)
c := &redisCounter{rdb: s.rdb}
n1, err := c.Incr("dq:test:x", time.Hour)
if err != nil || n1 != 1 {
t.Fatalf("首次应为 1,得到 %d err=%v", n1, err)
}
n2, _ := c.Incr("dq:test:x", time.Hour)
if n2 != 2 {
t.Fatalf("第二次应为 2,得到 %d", n2)
}
mr.FastForward(time.Hour + time.Second)
n3, _ := c.Incr("dq:test:x", time.Hour)
if n3 != 1 {
t.Fatalf("TTL 过期后应重新从 1 计,得到 %d", n3)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package ratelimit 提供限流/登录失败锁/日配额计数的统一状态存储抽象。
//
// 两个实现:内存(默认,单实例)与 Redis(REDIS_ADDR 非空时启用,跨重启保状态、
// 支持未来多实例共享)。Redis 出错时逐调用降级到内存(fail-open 到内存而非
// fail-closed,防护不中断,见 fallback.go)。
package ratelimit
import (
"context"
"log"
"sync"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
// Result 一次限流判定。RetryAfter 供 429 的 Retry-After 头。
type Result struct {
Allowed bool
RetryAfter time.Duration
}
// Limiter 速率限流(令牌桶/GCRA 语义:速率 r,突发 burst)。
type Limiter interface {
Allow(key string) Result
}
// Counter 累加计数(日配额等):首次自增时设 TTL,返回累加后的值。
type Counter interface {
Incr(key string, ttl time.Duration) (int64, error)
}
// FailLocker 登录失败锁:max 次失败锁定 lockFormax<=0 不锁);
// 锁定即清零计数;成功登录 Reset。
type FailLocker interface {
Locked(key string) bool
RecordFailure(key string, max int, lockFor time.Duration)
Reset(key string)
}
// Store 聚合工厂。
type Store interface {
NewLimiter(r rate.Limit, burst int) Limiter
Counter() Counter
FailLocker() FailLocker
}
var (
defMu sync.Mutex
def Store
)
// Init 由 main.go 在 config.Load 之后、router.Setup 之前调用。
// addr 为空 → 内存模式;Redis Ping 失败 → 打警告并落内存(启动不因 Redis 挂而失败)。
func Init(addr string) {
defMu.Lock()
defer defMu.Unlock()
if addr == "" {
def = newMemoryStore()
return
}
rdb := redis.NewClient(&redis.Options{Addr: addr})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
log.Printf("[ratelimit] redis %s 不可用(%v),降级为内存限流", addr, err)
def = newMemoryStore()
return
}
log.Printf("[ratelimit] 限流/登录锁状态外置 redis %s", addr)
def = newFallbackStore(rdb)
}
// Default 返回全局 Store;未 Init 时惰性落内存(测试零配置即用内存路径)。
func Default() Store {
defMu.Lock()
defer defMu.Unlock()
if def == nil {
def = newMemoryStore()
}
return def
}
+8 -3
View File
@@ -59,9 +59,14 @@ func Setup(r *gin.Engine, db *gorm.DB) {
refreshIP := middleware.RateLimitByIP(middleware.PerMinute(rl.RefreshPerMin), rl.RefreshPerMin)
registerIP := middleware.RateLimitByIP(middleware.PerMinute(rl.RegisterPerMin), rl.RegisterPerMin)
errorsIP := middleware.RateLimitByIP(middleware.PerMinute(rl.ErrorsPerMin), rl.ErrorsPerMin)
// 日配额二级闸(同一 IP 自然日累计,反爬):单品 API 与 OG 页同一配额池
dqProduct := middleware.DailyQuota("product", func() int { return config.C.RateLimit.DailyProductPerIP })
dqShopList := middleware.DailyQuota("shoplist", func() int { return config.C.RateLimit.DailyShopListPerIP })
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
r.GET("/product/:public_id", publicReadIP, publicH.ProductPage)
r.GET("/product/:public_id", publicReadIP, dqProduct, publicH.ProductPage)
// 店铺公开页 SSR(挂与 shops API 同一套闸:分钟限流 + 日配额)
r.GET("/shop/:shop_code", shopListIP, dqShopList, publicH.ShopPage)
v1 := r.Group("/api/v1")
@@ -77,8 +82,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
// 公开接口(无需登录):读接口防爬、写接口防刷
public := v1.Group("/public")
{
public.GET("/products/:public_id", publicReadIP, publicH.GetProduct)
public.GET("/shops/:shop_code/products", shopListIP, publicH.ListShopProducts)
public.GET("/products/:public_id", publicReadIP, dqProduct, publicH.GetProduct)
public.GET("/shops/:shop_code/products", shopListIP, dqShopList, publicH.ListShopProducts)
public.GET("/release", publicReadIP, publicH.GetRelease)
public.POST("/errors", errorsIP, errorReportH.Submit)
public.POST("/register", registerIP, authH.Register)
+15 -79
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -17,6 +16,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/ratelimit"
)
var (
@@ -42,81 +42,17 @@ type DeviceInfo struct {
UserAgent string
}
// loginLimiter 内存登录失败限流器(单实例,重启即清零)。
// 两个维度共用同一张表:账号维度 key="<shopCode>|<username>"IP 维度 key="ip|<addr>"
// 分别用不同阈值锁定。带 janitor 清理空闲 entry,避免攻击者用随机 key 灌爆内存。
type loginLimiter struct {
mu sync.Mutex
entries map[string]*limiterEntry
janitorOnce sync.Once
// 登录失败计数与锁定的状态存储在 internal/ratelimit(默认内存;REDIS_ADDR
// 配置后外置 redis,跨重启保锁。原 loginLimiter 于 2026-07 迁入该包)。
// 两个维度:账号维度 key="<shopCode>|<username>"IP 维度 key="ip|<addr>"
// 分别用不同阈值锁定。
func loginLocker() ratelimit.FailLocker {
return ratelimit.Default().FailLocker()
}
type limiterEntry struct {
failures int
lockedTill time.Time
lastSeen time.Time
}
// loginLimiterIdleTTL:已解锁且超过该时长未活动的 entry 会被 janitor 清理。
const loginLimiterIdleTTL = 30 * time.Minute
var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}}
// locked 返回该 key 是否处于锁定中。
func (l *loginLimiter) locked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
if e == nil {
return false
}
e.lastSeen = time.Now()
return time.Now().Before(e.lockedTill)
}
// recordFailure 记一次失败,达到 max 阈值则锁定(max<=0 表示该维度不锁)。
func (l *loginLimiter) recordFailure(key string, max int) {
l.startJanitor()
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
if e == nil {
e = &limiterEntry{}
l.entries[key] = e
}
e.lastSeen = time.Now()
e.failures++
if max > 0 && e.failures >= max {
e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute)
e.failures = 0
}
}
// reset 登录成功后清除失败计数。
func (l *loginLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.entries, key)
}
// startJanitor 惰性启动后台清理(仅一次):每 5 分钟淘汰「未锁定且超过 TTL 未活动」的 entry。
func (l *loginLimiter) startJanitor() {
l.janitorOnce.Do(func() {
go func() {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for range t.C {
now := time.Now()
l.mu.Lock()
for k, e := range l.entries {
if now.After(e.lockedTill) && now.Sub(e.lastSeen) > loginLimiterIdleTTL {
delete(l.entries, k)
}
}
l.mu.Unlock()
}
}()
})
// loginLockFor 锁定时长(读配置,调用点求值以便测试改配置生效)。
func loginLockFor() time.Duration {
return time.Duration(config.C.Session.LockMinutes) * time.Minute
}
type AuthService struct {
@@ -144,12 +80,12 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
ipKey = "ip|" + dev.IP
}
recordFail := func() {
loginLim.recordFailure(limiterKey, config.C.Session.MaxFailures)
loginLocker().RecordFailure(limiterKey, config.C.Session.MaxFailures, loginLockFor())
if ipKey != "" {
loginLim.recordFailure(ipKey, config.C.Session.IPMaxFailures)
loginLocker().RecordFailure(ipKey, config.C.Session.IPMaxFailures, loginLockFor())
}
}
if loginLim.locked(limiterKey) || (ipKey != "" && loginLim.locked(ipKey)) {
if loginLocker().Locked(limiterKey) || (ipKey != "" && loginLocker().Locked(ipKey)) {
s.recordLoginAttempt(shopCode, username, dev, false, "locked")
return nil, nil, ErrTooManyAttempts
}
@@ -258,9 +194,9 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
return nil, nil, err
}
loginLim.reset(limiterKey)
loginLocker().Reset(limiterKey)
if ipKey != "" {
loginLim.reset(ipKey)
loginLocker().Reset(ipKey)
}
user.LastLoginAt = &now
+2 -2
View File
@@ -155,7 +155,7 @@ func TestLogin_AccountLockoutAfterMaxFailures(t *testing.T) {
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
config.C.Session.MaxFailures = 3
svc := NewAuthService(db)
defer loginLim.reset("LOCK_ACC|admin")
defer loginLocker().Reset("LOCK_ACC|admin")
for i := 0; i < 3; i++ {
_, _, err := svc.Login("LOCK_ACC", "admin", "wrong", DeviceInfo{Platform: "windows", IP: "10.0.0.1"})
@@ -175,7 +175,7 @@ func TestLogin_IPLockoutAcrossAccounts(t *testing.T) {
config.C.Session.IPMaxFailures = 4
const attackIP = "203.0.113.9"
svc := NewAuthService(db)
defer loginLim.reset("ip|" + attackIP)
defer loginLocker().Reset("ip|" + attackIP)
// 4 次不同用户名(invalid_user),账号 key 各不相同永不锁;IP key 累计到 4 → 锁 IP。
for i := 0; i < 4; i++ {
+5
View File
@@ -11,6 +11,7 @@ import (
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/ratelimit"
"github.com/wangjia/jiu/backend/internal/router"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
@@ -20,6 +21,10 @@ func main() {
// 加载配置
config.Load()
// 限流/登录锁状态存储:REDIS_ADDR 配置后外置 redis(跨重启保状态),
// 未配置或连不上时落内存(与既往行为一致)。须在 router.Setup 之前。
ratelimit.Init(config.C.Redis.Addr)
// 生产环境启动前置检查
if config.C.Server.Mode == "release" {
if config.C.Server.CORSOrigin == "*" {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"provider": "岩美技术有限公司",
"website": "https://jiu.51yanmei.com",
"email": "yammy2023@163.com",
"email": "support@51yanmei.com",
"phone": "",
"wechat": "",
"terms_url": "https://jiu.51yanmei.com/terms/",
@@ -0,0 +1,33 @@
// App Store 合规开关(苹果审核指南 3.1.1 / 3.1.3(b))。
//
// iOS 包内不得出现第三方支付与任何外部购买引导(在线购买卡、套餐价格
// 链接、升级按钮、"去官网购买"类文案);兑换码激活与中性客服入口不受限
// (多平台服务条款:允许使用在别处获得的授权)。
// 其余平台(macOS/Windows/Android/Web 均不走 App Store 分发)不受影响。
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;
/// 测试注入:widget/golden 测试跑在 host 上,借此模拟 iOS 形态。
@visibleForTesting
bool? debugForceHideExternalPurchaseUi;
/// 测试注入:模拟「需要首启隐私同意」的移动端形态。
@visibleForTesting
bool? debugForcePrivacyConsentRequired;
/// 是否 iOS App Store 分发包(注销账号入口等合规件按此渲染)。
bool get isIosAppStoreBuild =>
debugForceHideExternalPurchaseUi ?? (!kIsWeb && Platform.isIOS);
/// 为真时隐藏一切外部购买 UIiOS App Store 包)。
bool get hideExternalPurchaseUi => isIosAppStoreBuild;
/// 是否需要首启「个人信息保护」同意弹层(个保法 + 应用商店审核):
/// 仅移动端(iOS/Android);桌面与 Web 不弹。
bool get needsPrivacyConsent =>
debugForcePrivacyConsentRequired ??
(!kIsWeb && (Platform.isIOS || Platform.isAndroid));
/// 客服邮箱(授权管理「联系客服」卡、异常兜底提示等处使用)。
const String supportEmail = 'support@51yanmei.com';
+4
View File
@@ -14,6 +14,7 @@ import 'core/theme/theme_controller.dart';
import 'core/theme/themes.dart';
import 'providers/connectivity_provider.dart';
import 'widgets/ds/ds_atoms.dart';
import 'widgets/privacy_consent_gate.dart';
void main() {
usePathUrlStrategy();
@@ -113,6 +114,9 @@ class _JiuAppState extends ConsumerState<JiuApp> {
title: kDebugMode ? '岩美 [DEBUG]' : '岩美',
theme: buildTheme(themeKey),
routerConfig: router,
// 首启隐私同意闸(移动端 only):覆盖全部路由,同意前遮罩交互
builder: (context, child) =>
PrivacyConsentGate(child: child ?? const SizedBox.shrink()),
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
+4 -2
View File
@@ -18,6 +18,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_config.dart';
import '../../core/config/app_constants.dart';
import '../../core/config/store_compliance.dart';
import '../../core/responsive/responsive.dart';
import '../../core/storage/login_history.dart';
import '../../core/theme/app_chrome.g.dart';
@@ -52,8 +53,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
bool _remember = true;
String? _errorMessage;
// 客服邮箱(授权续费 / 锁定协助)。与官网 web/_data/site.json 的 support.email 保持一致。
static const _supportEmail = 'yammy2023@163.com';
// 客服邮箱(授权续费 / 锁定协助)。单一来源 store_compliance.supportEmail
// 与官网 web/_data/site.json 的 support.email 保持一致。
static const _supportEmail = supportEmail;
/// 当前错误是否为「授权锁定」——后端返回固定文案
/// `license locked, please renew or contact support`(见 service.ErrLicenseLocked)。
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/auth/auth_state.dart';
import '../../core/storage/column_prefs.dart';
import '../../core/utils/export_util.dart';
import '../../models/inventory.dart';
@@ -114,12 +115,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
);
}
// 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。
bool get _isAdmin => ref.watch(isAdminProvider);
bool _colAllowed(ColDef c) =>
_isAdmin || (c.key != 'cost' && c.key != 'price');
// ── 列设置(原型 colBtn → openColMenu:多选保持打开) ──
void _openColMenu(BuildContext anchorContext) {
showDsMultiMenu<String>(
anchorContext,
itemsBuilder: () => [
for (final c in _colDefs.where((c) => !c.required))
for (final c in _colDefs.where((c) => !c.required && _colAllowed(c)))
DsMenuItem(
value: c.key,
label: c.label,
@@ -528,6 +534,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final sub = [
if (item.productCode.isNotEmpty) item.productCode,
if (item.spec.isNotEmpty) item.spec,
// 单价(成本进价)仅管理员可见(2026-07-06 用户拍板)
if (_isAdmin && item.unitPrice != null) _fmtCost(item.unitPrice!),
].join(' · ');
return MCard(
onTap: item.productId != null ? () => _openEditor(item) : null,
@@ -626,8 +634,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
.toList();
final hidden = _hiddenCols ?? <String>{};
final visibleCols =
_colDefs.where((c) => !hidden.contains(c.key)).toList();
final visibleCols = _colDefs
.where((c) => !hidden.contains(c.key) && _colAllowed(c))
.toList();
final mobile = context.isMobile;
// 原型 .main{padding:22px 26px}:桌面整页留白统一在此;窄屏保持紧凑。
@@ -715,7 +724,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
MKpiItem(
label: '库存货值',
value: summary != null
// 货值=成本口径,非管理员后端抹零,前端显示占位
value: _isAdmin && summary != null
? yuanWan(summary.stockValue)
: '',
delta: _mDeltaText(valDelta, valTone),
@@ -752,8 +762,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
DsKpi(
title: '库存货值',
value:
summary != null ? yuanWan(summary.stockValue) : '',
value: _isAdmin && summary != null
? yuanWan(summary.stockValue)
: '',
icon: LucideIcons.database,
tone: DsKpiTone.ok,
delta: valDelta,
+67
View File
@@ -7,8 +7,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/store_compliance.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../providers/shop_provider.dart';
@@ -86,6 +88,12 @@ class MeScreen extends ConsumerWidget {
icon: LucideIcons.info,
label: '关于我们',
onTap: () => context.go('/about')),
// iOS App Store 审核 5.1.1(v):应用内须可发起账号删除
if (isIosAppStoreBuild)
MHubItem(
icon: LucideIcons.userX,
label: '注销账号',
onTap: () => _showDeleteAccountSheet(context, user)),
]),
const SizedBox(height: 18),
_LogoutButton(onConfirm: () {
@@ -151,6 +159,65 @@ class MeScreen extends ConsumerWidget {
]),
);
}
/// 注销账号(iOS App Store 审核 5.1.1(v)):应用内发起 → 邮件人工核验。
/// 措辞保持中性,不涉及任何购买/续费引导。
void _showDeleteAccountSheet(BuildContext context, AuthUser? user) {
showMSheet<void>(
context,
title: '注销账号',
builder: (ctx) {
final t = ctx.tokens;
Widget note(String s) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Text('· $s',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted, height: 1.6)),
);
final who = user != null ? '${user.shopNo} · ${user.username}' : '';
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('注销将删除您的登录账号,操作不可恢复。',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading)),
const SizedBox(height: 8),
note('账号与登录凭据将被删除,无法找回'),
note('门店经营数据归属门店:门店内仍有其他用户时数据保留,'
'需整店删除请在申请中说明'),
note('为防止误操作与冒用,注销需人工核验身份,'
'我们将在收到申请后 15 个工作日内完成处理'),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: DsButton('发送注销申请邮件',
variant: DsBtnVariant.primary,
onPressed: () {
final subject = Uri.encodeComponent('账号注销申请($who');
final body = Uri.encodeComponent(
'申请注销账号:$who\n注销原因(选填):\n\n'
'请使用注册时预留的联系方式发送本邮件以便核验。');
launchUrl(Uri.parse(
'mailto:$supportEmail?subject=$subject&body=$body'));
}),
),
const SizedBox(height: 8),
Center(
child: Text('客服邮箱 $supportEmail',
style:
TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
),
],
),
);
},
);
}
}
class _LogoutButton extends StatelessWidget {
+54 -26
View File
@@ -5,8 +5,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/license_copy.dart';
import '../../core/config/store_compliance.dart';
import '../../core/utils/web_launch.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
@@ -98,32 +101,34 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
),
const SizedBox(height: 16),
// ── 卡2:在线购买 / 续费(后端 purchase 接口 admin only
// 非管理员引导看官网价格页)──
if (isAdmin)
const SettingsCard(
title: '在线购买 / 续费',
desc: '选择套餐支付宝支付,到账后授权自动续期',
child: PurchaseCard(),
)
else
SettingsCard(
title: '在线购买 / 续费',
desc: '在线购买仅门店管理员可操作',
child: Row(children: [
Text('在线购买请联系门店管理员 · ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
// 免登跳官网(一次性换票 SSO):浏览器落地即已登录,可直接购买
onTap: () => launchAuthedWebPath(ref, '/#pricing'),
child: Text('查看套餐价格 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
),
]),
),
const SizedBox(height: 16),
// 非管理员引导看官网价格页)。iOS App Store 包整卡不渲染 ──
if (!hideExternalPurchaseUi) ...[
if (isAdmin)
const SettingsCard(
title: '在线购买 / 续费',
desc: '选择套餐支付宝支付,到账后授权自动续期',
child: PurchaseCard(),
)
else
SettingsCard(
title: '在线购买 / 续费',
desc: '在线购买仅门店管理员可操作',
child: Row(children: [
Text('在线购买请联系门店管理员 · ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
// 免登跳官网(一次性换票 SSO):浏览器落地即已登录,可直接购买
onTap: () => launchAuthedWebPath(ref, '/#pricing'),
child: Text('查看套餐价格 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
),
]),
),
const SizedBox(height: 16),
],
// ── 卡3:兑换券(原型 .redeeminput + 按钮,max 520)──
SettingsCard(
title: '兑换券',
@@ -175,6 +180,29 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
],
),
),
// ── 卡5:联系客服(仅 iOS 形态,补位购买卡;措辞保持中性,
// 不得出现购买/续费引导字样——苹果 3.1.1 红线)──
if (hideExternalPurchaseUi) ...[
const SizedBox(height: 16),
SettingsCard(
title: '联系客服',
desc: '授权与使用问题可通过邮件联系我们',
child: Row(children: [
Text('客服邮箱 · ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
onTap: () =>
launchUrl(Uri(scheme: 'mailto', path: supportEmail)),
child: Text(supportEmail,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
fontFamily: AppFonts.mono,
color: t.primary)),
),
]),
),
],
],
);
}
@@ -27,7 +27,10 @@ import '../../widgets/ds/ds_toast.dart';
/// 明细为可键盘操作的内联网格(名称/系列/规格可搜索+新增,生产日期/批次/数量/单价/售价内联编辑)。
class StockInFormScreen extends ConsumerStatefulWidget {
final int? editOrderId;
const StockInFormScreen({super.key, this.editOrderId});
/// 仅供 golden 测试钉死单据日期(默认 DateTime.now() 会让 golden 随日期漂移)。
final DateTime? initialDate;
const StockInFormScreen({super.key, this.editOrderId, this.initialDate});
@override
ConsumerState<StockInFormScreen> createState() => _StockInFormScreenState();
@@ -39,7 +42,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final _warehouseFocus = FocusNode();
int? _warehouseId;
int? _partnerId;
DateTime _orderDate = DateTime.now();
late DateTime _orderDate = widget.initialDate ?? DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
StockInOrder? _loadedOrder;
@@ -142,7 +142,10 @@ class _ItemRow {
/// ///
class StockOutFormScreen extends ConsumerStatefulWidget {
final int? editOrderId;
const StockOutFormScreen({super.key, this.editOrderId});
/// golden DateTime.now() golden
final DateTime? initialDate;
const StockOutFormScreen({super.key, this.editOrderId, this.initialDate});
@override
ConsumerState<StockOutFormScreen> createState() => _StockOutFormScreenState();
@@ -154,7 +157,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final _warehouseFocus = FocusNode();
int? _warehouseId;
int? _partnerId;
DateTime _orderDate = DateTime.now();
late DateTime _orderDate = widget.initialDate ?? DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
Map<int, double> _inventoryMap = {};
@@ -0,0 +1,145 @@
// + App Store 5.1.1
//
// iOS/Android +
// + /SharedPreferences
// Android 退iOS HIG
// 退 Web
// MaterialApp.router builder
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import '../core/config/app_info.dart';
import '../core/config/store_compliance.dart';
import '../core/theme/app_dims.g.dart';
import '../core/theme/context_tokens.dart';
import 'ds/ds_atoms.dart';
const _kConsentKey = 'privacy_consent_v1';
class PrivacyConsentGate extends StatefulWidget {
final Widget child;
const PrivacyConsentGate({super.key, required this.child});
@override
State<PrivacyConsentGate> createState() => _PrivacyConsentGateState();
}
class _PrivacyConsentGateState extends State<PrivacyConsentGate> {
bool _needConsent = false;
bool _declinedHint = false;
@override
void initState() {
super.initState();
if (needsPrivacyConsent) _check();
}
Future<void> _check() async {
final prefs = await SharedPreferences.getInstance();
if (mounted && !(prefs.getBool(_kConsentKey) ?? false)) {
setState(() => _needConsent = true);
}
}
Future<void> _agree() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kConsentKey, true);
if (mounted) setState(() => _needConsent = false);
}
void _decline() {
// Android 退iOS 退
if (!kIsWeb && Platform.isAndroid) {
SystemNavigator.pop();
} else {
setState(() => _declinedHint = true);
}
}
void _open(String path) {
final base = AppInfo.website.isNotEmpty
? AppInfo.website
: 'https://jiu.51yanmei.com';
launchUrl(Uri.parse('$base$path'), mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
if (!_needConsent) return widget.child;
final t = context.tokens;
final link = TextStyle(
fontSize: AppDims.fsSm, color: t.primary, fontWeight: FontWeight.w600);
return Stack(children: [
widget.child,
const ModalBarrier(
dismissible: false, color: Colors.black54), // ds-ignore
Center(
child: Material(
color: Colors.transparent,
child: Container(
width: 320,
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.fromLTRB(22, 22, 22, 18),
decoration: BoxDecoration(
color: t.surface,
borderRadius: BorderRadius.circular(AppDims.rLg),
border: Border.all(color: t.border),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text('个人信息保护提示',
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(height: 12),
Text.rich(
TextSpan(
style: TextStyle(
fontSize: AppDims.fsSm, color: t.text, height: 1.7),
children: [
const TextSpan(
text: '感谢使用岩美酒库。为向您提供门店库存管理服务,'
'我们仅收集必要的账号信息、设备信息与操作日志,'
'用于多端数据同步与账号安全保障。详情请阅读'),
TextSpan(
text: '《隐私政策》',
style: link,
recognizer: TapGestureRecognizer()
..onTap = () => _open('/privacy/')),
const TextSpan(text: ''),
TextSpan(
text: '《用户协议》',
style: link,
recognizer: TapGestureRecognizer()
..onTap = () => _open('/terms/')),
const TextSpan(text: '。点击「同意并继续」即表示您已阅读并同意上述条款。'),
],
),
),
if (_declinedHint) ...[
const SizedBox(height: 10),
Text('需同意后方可使用岩美酒库',
style: TextStyle(fontSize: AppDims.fsXs, color: t.warn)),
],
const SizedBox(height: 16),
Row(children: [
Expanded(
child: DsButton('不同意', onPressed: _decline)),
const SizedBox(width: 10),
Expanded(
child: DsButton('同意并继续',
variant: DsBtnVariant.primary, onPressed: _agree)),
]),
]),
),
),
),
]);
}
}
@@ -11,6 +11,7 @@ import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/auth/auth_state.dart';
import '../core/config/app_config.dart';
import '../core/utils/dialog_util.dart';
import '../core/responsive/responsive.dart';
@@ -356,10 +357,14 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
b('${_fmtQty(widget.qty!)} ${widget.unit ?? p.unit}'.trim())
));
}
// /p.purchasePrice
final isAdmin = ref.watch(isAdminProvider);
final cost = widget.cost ?? p.purchasePrice;
if (cost != null && cost > 0) rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null && cost != null && cost > 0) {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
if (isAdmin && cost != null && cost > 0) {
rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null) {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
}
}
if ((widget.status ?? '').isNotEmpty) {
rows.add((
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 145 KiB

@@ -242,6 +242,8 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false),
// //golden
isAdminProvider.overrideWithValue(true),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
@@ -165,6 +165,8 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false),
// //golden
isAdminProvider.overrideWithValue(true),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
@@ -73,7 +73,7 @@ void main() {
goldenAcrossThemes(
'stock-in form 录单(桌面)',
goldenPrefix: 'stock_in_form',
child: () => const Scaffold(body: StockInFormScreen()),
child: () => Scaffold(body: StockInFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(1280, 920),
);
@@ -81,7 +81,7 @@ void main() {
goldenAcrossThemes(
'stock-in form 录单(移动)',
goldenPrefix: 'stock_in_form_mobile',
child: () => const Scaffold(body: StockInFormScreen()),
child: () => Scaffold(body: StockInFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(390, 1400),
);
@@ -53,7 +53,7 @@ void main() {
goldenAcrossThemes(
'stock-out form 录单(桌面)',
goldenPrefix: 'stock_out_form',
child: () => const Scaffold(body: StockOutFormScreen()),
child: () => Scaffold(body: StockOutFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(1280, 920),
);
@@ -61,7 +61,7 @@ void main() {
goldenAcrossThemes(
'stock-out form 录单(移动)',
goldenPrefix: 'stock_out_form_mobile',
child: () => const Scaffold(body: StockOutFormScreen()),
child: () => Scaffold(body: StockOutFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(390, 1400),
);
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/auth/auth_state.dart';
import 'package:jiu_client/core/config/store_compliance.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/providers/license_provider.dart';
import 'package:jiu_client/screens/settings/license_panel.dart';
/// iOS App Store 3.1.1 UI
///
class _FakeAuth extends AuthNotifier {
_FakeAuth(String role) {
state = AuthState(
user: AuthUser(
accessToken: 't',
refreshToken: 'r',
id: 1,
username: 'wang',
realName: '王经理',
shopNo: 'S001',
shopId: 1,
role: role),
);
}
}
class _FakeLicense extends LicenseNotifier {
@override
Future<LicenseInfo?> build() async => LicenseInfo(
id: 1,
type: 'annual',
isActive: true,
maxDevices: 2,
phase: 'active',
expiresAt: DateTime.now().add(const Duration(days: 100)),
);
}
Widget _panel({required String role}) => ProviderScope(
overrides: [
authStateProvider.overrideWith((ref) => _FakeAuth(role)),
licenseProvider.overrideWith(() => _FakeLicense()),
],
child: MaterialApp(
theme: buildTheme('a'),
home: const Scaffold(
body: SingleChildScrollView(child: LicensePanel()),
),
),
);
void main() {
tearDown(() => debugForceHideExternalPurchaseUi = null);
testWidgets('iOS 形态:购买卡与价格链接消失,出现联系客服卡', (tester) async {
debugForceHideExternalPurchaseUi = true;
await tester.pumpWidget(_panel(role: 'operator'));
await tester.pumpAndSettle();
expect(find.text('在线购买 / 续费'), findsNothing);
expect(find.textContaining('查看套餐价格'), findsNothing);
expect(find.text('联系客服'), findsOneWidget);
expect(find.text(supportEmail), findsOneWidget);
});
testWidgets('非 iOS:购买卡照旧,无联系客服卡', (tester) async {
debugForceHideExternalPurchaseUi = false;
await tester.pumpWidget(_panel(role: 'operator'));
await tester.pumpAndSettle();
expect(find.text('在线购买 / 续费'), findsOneWidget);
expect(find.textContaining('查看套餐价格'), findsOneWidget);
expect(find.text('联系客服'), findsNothing);
});
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/config/store_compliance.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/widgets/privacy_consent_gate.dart';
import 'package:shared_preferences/shared_preferences.dart';
///
/// Android
Widget _app() => MaterialApp(
theme: buildTheme('a'),
builder: (c, child) =>
PrivacyConsentGate(child: child ?? const SizedBox.shrink()),
home: const Scaffold(body: Text('APP')),
);
void main() {
tearDown(() => debugForcePrivacyConsentRequired = null);
testWidgets('首启弹出,同意后关闭并持久化', (tester) async {
SharedPreferences.setMockInitialValues({});
debugForcePrivacyConsentRequired = true;
await tester.pumpWidget(_app());
await tester.pumpAndSettle();
expect(find.text('个人信息保护提示'), findsOneWidget);
await tester.tap(find.text('同意并继续'));
await tester.pumpAndSettle();
expect(find.text('个人信息保护提示'), findsNothing);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('privacy_consent_v1'), isTrue);
});
testWidgets('已同意过:不再弹出', (tester) async {
SharedPreferences.setMockInitialValues({'privacy_consent_v1': true});
debugForcePrivacyConsentRequired = true;
await tester.pumpWidget(_app());
await tester.pumpAndSettle();
expect(find.text('个人信息保护提示'), findsNothing);
expect(find.text('APP'), findsOneWidget);
});
testWidgets('不同意(非 Android):留在弹层并提示', (tester) async {
SharedPreferences.setMockInitialValues({});
debugForcePrivacyConsentRequired = true;
await tester.pumpWidget(_app());
await tester.pumpAndSettle();
await tester.tap(find.text('不同意'));
await tester.pump();
expect(find.text('需同意后方可使用岩美酒库'), findsOneWidget);
expect(find.text('个人信息保护提示'), findsOneWidget);
});
testWidgets('桌面/Web 形态:不弹', (tester) async {
SharedPreferences.setMockInitialValues({});
debugForcePrivacyConsentRequired = false;
await tester.pumpWidget(_app());
await tester.pumpAndSettle();
expect(find.text('个人信息保护提示'), findsNothing);
});
}
+14
View File
@@ -1,5 +1,19 @@
version: "3.9"
services:
# 限流/登录锁状态外置(后端 REDIS_ADDR=127.0.0.1:6379 时启用)。
# 刻意无持久化(--save "" + appendonly no):状态丢了秒级重建,省小内存机的
# fork/写盘开销;noeviction 防锁键被 LRU 逐出;仅绑回环无密码(同宿主
# payd 容器经 bridge 网络够不着宿主回环),禁 CONFIG 命令兜底。
jiu-redis:
image: redis:7-alpine
container_name: jiu_redis
restart: always
ports:
- "127.0.0.1:6379:6379"
command: >
redis-server --maxmemory 64mb --maxmemory-policy noeviction
--appendonly no --save "" --rename-command CONFIG ""
jiu-mysql:
image: mysql:8.0
container_name: jiu_mysql
+76 -3
View File
@@ -10,7 +10,27 @@
# 后端上游 = 127.0.0.1:8081(非 8080ali 上 8080 已被 pay 项目 payd 占用;
# 对应 /opt/jiu/config/production.env 的 SERVER_PORT=8081)。
limit_req_zone $binary_remote_addr zone=jiu_pub:10m rate=10r/s;
# 店铺公开商品列表专属(主爬取入口,更严)
limit_req_zone $binary_remote_addr zone=jiu_shoplist:10m rate=2r/s;
# per-IP 并发连接(公开路径 20 / 列表 10 / 下载 3)
limit_conn_zone $binary_remote_addr zone=jiu_conn:10m;
limit_conn_zone $binary_remote_addr zone=jiu_dl:10m;
limit_req_status 429;
limit_conn_status 429;
# 抓取库 UA / 空 UA 拦截(2026-07 反爬)。刻意不封 curl/wget(运维自用、改 UA 零成本)、
# 不封泛 "bot"(微信/飞书等社交分享爬虫 UA 不可控)。
# $jiu_bad_uaAPI 公开路径用(含空 UA);$jiu_bad_ua_libOG 分享页用(不拦空 UA,
# 社交平台抓卡片的客户端五花八门,空 UA 误杀不可接受)。
map $http_user_agent $jiu_bad_ua {
default 0;
"" 1;
~*(python-requests|python-urllib|scrapy|go-http-client|apache-httpclient|libwww|aiohttp|okhttp|httpx|java/) 1;
}
map $http_user_agent $jiu_bad_ua_lib {
default 0;
~*(python-requests|python-urllib|scrapy|go-http-client|apache-httpclient|libwww|aiohttp|okhttp|httpx|java/) 1;
}
server {
# ali nginx 1.24 无 `http2 on;`1.25.1+),用 listen 参数旧语法
@@ -34,14 +54,28 @@ server {
client_max_body_size 20m;
# 传输压缩(2026-07-07 公开页提速方案 A′):运行时 gzip,覆盖 Flutter Web 静态
# 大文件(main.dart.js 5.3MB→~1.3MB、canvaskit.wasm 7.2MB→~3MB)与反代 JSON。
# 图片(JPEG/PNG)刻意不在列——本身已是压缩格式,上传侧另有 1200px+q85 压缩。
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;
# certbot HTTP-01 续期通道(webroot 独立目录,不放 marketing——
# deploy-site 的 rsync --delete 会清掉站点目录里的挑战文件)
location ^~ /.well-known/acme-challenge/ {
root /var/www/acme;
}
# 商品图片静态文件
# 商品图片静态文件(防盗链:none 放行空 Referer——微信 webview/App 直连
# 不带 Referer 必须放行;只挡第三方网页热链。回滚=删 valid_referers+if 三行)
location ^~ /images/ {
valid_referers none blocked server_names 51yanmei.com *.51yanmei.com;
if ($invalid_referer) { return 403; }
alias /opt/jiu/images/;
expires 30d;
add_header Cache-Control "public, immutable";
@@ -56,9 +90,23 @@ server {
proxy_read_timeout 300s;
}
# 店铺公开商品列表(主爬取入口):更严的独立限流。
# 正则 location 按文件顺序首匹配,本块必须在下面 (public|auth) 之前。
location ~ ^/api/v1/public/shops/ {
if ($jiu_bad_ua) { return 403; }
limit_req zone=jiu_shoplist burst=5 nodelay;
limit_conn jiu_conn 10;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# 未鉴权公开/登录接口:加最外层 per-IP 限流
location ~ ^/api/v1/(public|auth)/ {
if ($jiu_bad_ua) { return 403; }
limit_req zone=jiu_pub burst=20 nodelay;
limit_conn jiu_conn 20;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -78,6 +126,13 @@ server {
alias /opt/jiu/web/;
index index.html;
try_files $uri $uri/ /app/index.html;
# 协商缓存(2026-07-07 方案 A′):Flutter 产物文件名无 hash,长缓存会在发版后
# 新旧错配白屏,故走 etag 304——复访零字节,首访靠上面的 gzip。
# 注意 add_header 覆盖继承:补回 server 级安全头。
add_header Cache-Control "no-cache";
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
}
location = /app/index.html {
@@ -88,9 +143,22 @@ server {
expires 0;
}
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签
# 店铺公开商品列表页 SSR2026-07-07 新增短链):与 shops API 同一套边缘闸
location ~ ^/shop/ {
if ($jiu_bad_ua_lib) { return 403; }
limit_req zone=jiu_shoplist burst=5 nodelay;
limit_conn jiu_conn 10;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# 公开商品详情页(扫码跳转)→ 后端 SSR 轻量页(UA 只拦抓取库,不拦空 UA)
location ~ ^/product/ {
if ($jiu_bad_ua_lib) { return 403; }
limit_req zone=jiu_pub burst=20 nodelay;
limit_conn jiu_conn 20;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -99,7 +167,9 @@ server {
# Flutter base-href=/app/ 会把 /product/:id 重写为 /app/product/:id
location ~ ^/app/product/ {
if ($jiu_bad_ua_lib) { return 403; }
limit_req zone=jiu_pub burst=20 nodelay;
limit_conn jiu_conn 20;
rewrite ^/app(/product/.+)$ $1 break;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
@@ -107,8 +177,11 @@ server {
proxy_read_timeout 30s;
}
# 桌面客户端安装包下载
# 桌面客户端安装包下载(限并发+超 10m 后限速,防几个并发拖爆小水管带宽)
location /downloads/ {
limit_conn jiu_dl 3;
limit_rate_after 10m;
limit_rate 4m;
alias /opt/jiu/downloads/;
add_header Content-Disposition "attachment";
add_header Cache-Control "no-cache";
+2
View File
@@ -13,3 +13,5 @@ STORAGE_WEB_DIR=/opt/jiu/web
DB_PASSWORD=CHANGE_ME_DB_PASS
# pay 回调/下单 HMAC 共享密钥(与 pay 侧一致,Bitwarden 渲染)
PAY_SECRET=CHANGE_ME_PAY_SECRET
# 限流/登录锁状态外置(jiu_redis 容器,仅回环;留空=进程内存模式)
REDIS_ADDR=127.0.0.1:6379
+173
View File
@@ -0,0 +1,173 @@
<!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>
+3
View File
@@ -38,6 +38,7 @@
<h2>🏗 设计方案</h2>
<ul>
<li><a href="pay支付对接开发指南.html">pay 支付对接开发指南</a><span class="tag html">HTML</span> <span class="hint">— jiu 门店应用内购买/续费授权:付款走 pay,付成功后 pay 签名 webhook 回调 jiu 直接续期。含签名算法、下单/回调接口契约、套餐 biz_code→权益映射、续期逻辑、安全红线、客户端(Web+App webview)、联调清单。pay 侧已就绪,本文档=jiu 侧要实现的部分</span></li>
<li><a href="design/public-page-speedup.html">公开页提速方案(传输压缩 + 公开页全面去 Flutter 化)</a><span class="tag html">HTML</span> <span class="hint">— 扫码首开 30s 根因与两级修复:CI 预压缩+gzip_static+协商缓存 / 商品页+店铺列表页后端 SSR,公开动线零 Flutter2026-07-07</span></li>
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>
<li><a href="design/stock-in-cost-confirm-design.html">入库确认进价(暂估价前向补偿)设计</a><span class="tag html">HTML</span></li>
@@ -58,6 +59,7 @@
<h2>🔧 Runbook · 排障</h2>
<ul>
<li><a href="runbooks/ec2-to-ali-migration.html">EC2 → 阿里云 迁移计划</a><span class="tag html">HTML</span> <span class="hint">— jiu 服务/数据/DB/文件/域名/CI 整体迁移(2026-07-02 已割接)</span></li>
<li><a href="runbooks/ddos-response.html">DDoS / 爬取洪峰应急</a><span class="tag html">HTML</span> <span class="hint">— 判定/CC 处置/高防·CDN 切换/黑洞预案(2026-07-05,配套安全三件套加固)</span></li>
</ul>
<h2>🧪 测试 · 审计报告</h2>
@@ -74,6 +76,7 @@
<h2>实现计划</h2>
<ul>
<li><a href="plans/mobile-screens-implementation.html">移动端全屏落地实现计划(m-* 原型 → Flutter2026-07-04 已执行)</a><span class="tag html">HTML</span> <span class="hint">— 底部 tab + 我的 hub + sheet 交互范式;Stage0 基建 + 4 并行工作包 + 合流验收全记录</span></li>
<li><a href="plans/security-hardening.html">安全三件套实现计划(2026-07-05)</a><span class="tag html">HTML</span> <span class="hint">— 限流/登录锁外置 Redis + 公开接口反爬(日配额/UA/防盗链)+ 边缘加固(nginx limit_conn/安全组/DDoS runbook</span></li>
</ul>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<!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.7;max-width:1000px;}
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;}
.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;font-size:13.5px;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
ul,ol{margin:6px 0;padding-left:22px;} li{margin:4px 0;font-size:13px;}
.chk{color:var(--success);font-weight:700;margin-right:4px;}
</style></head><body>
<h1>安全三件套:限流状态外置 Redis + 公开接口反爬(轻量)+ 边缘层加固(todo #1/#2/#3</h1><div class="sub">2026-07-05 定稿 · 执行真相源为 plan .md,本页为阅读版 · 状态:已批准,本地实现完成待发版</div>
<h2>Context</h2><div class="card"><ul>
<li style="list-style:none;margin-left:-18px">三条安全 todo 一次做完。现状:限流桶(<code>internal/middleware/ratelimit.go</code> keyedLimiter)与登录失败锁(<code>internal/service/auth.go:45-120</code> loginLimiter)全是<b>进程内存态</b>,重启清零、无法多实例;商品公开接口 public_id 是 UUIDv4 不可枚举,但<b>店铺列表接口</b>shop_code 可猜,S001…)是真实爬取面;ali 安全组还留着 <b>3389 对全网开放</b>的系统默认残留,nginx 只有 limit_req 无 limit_conn。原 todo #1 写的 AWS Shield/EC2 已过时(EC2 已下线),按阿里云现状重定义。#3 用户拍板<b>轻量版</b>(日配额+UA 拦截+防盗链;不做滑块/签名/登录墙)。</li>
<li style="list-style:none;margin-left:-18px">代码顺序 <b>#2 → #3 → #1</b>#3 日配额复用 #2 的 Counter;#1 纯运维)。<b>所有生产变更(发版 / ssh ali / 安全组 CLI)单独设批准点,用户明示后才执行</b>;本地改码+测试先行。机器仅 1.8GiB RAMmysql+jiu+payd+nginx 在跑),redis 内存上限克制。</li>
</ul></div>
<h2>阶段 #2 · 限流/登录锁外置 Redis</h2><div class="card"><ul>
<li style="list-style:none;margin-left:-18px">外置:per-IP/per-shop 限流桶、登录失败计数与锁、(为 #3 预留)日配额 Counter。不动:web ticket / 会话(已在 MySQL)、login_attempts 审计表。</li>
<li><span class="chk"></span><b>新包 <code>backend/internal/ratelimit</code></b><code>store.go</code>(接口+<code>Init(addr)</code>/<code>Default()</code>,addr 空或 Ping 失败→内存并 warn,<code>Default()</code> 未 Init 惰性内存——现有测试零改动的关键)、<code>memory.go</code>keyedLimiter+loginLimiter 原样迁入,janitor 一起)、<code>redis.go</code>(限流用 <code>go-redis/redis_rate/v10</code> GCRA 对齐 rate+burst;登录锁 INCR+EXPIRE 30m、达阈值 <code>SET locked: EX</code> 并清计数,三行 Lua 保原子;键前缀 <code>jiu:rl:</code>)、<code>fallback.go</code>(redis 报错逐调用降级内存+限频 warn,恢复自动回 redis)。接口:<code>Limiter.Allow(key)→{Allowed,RetryAfter}</code><code>Counter.Incr(key,ttl)→int64</code><code>FailLocker.Locked/RecordFailure(key,max,lockFor)/Reset</code></li>
<li><span class="chk"></span><b>调用方改造</b><code>middleware/ratelimit.go</code> 改用 <code>ratelimit.Default().NewLimiter(n,per,burst)</code>429 用 Result.RetryAfter<code>RateLimitByIP/ByShop</code> 签名改 <code>(n,per,burst)</code>router.go:56-61 六处同步;Enabled=false 全放行、keyFn 空串放行语义不变。<code>service/auth.go</code> 删 loginLimiter~75 行)改 <code>ratelimit.Default().FailLocker()</code>lockFor 传 <code>config.C.Session.LockMinutes</code><code>main.go</code> 在 config.Load 后 <code>ratelimit.Init(config.C.Redis.Addr)</code><code>config/config.go</code><code>Redis.Addr</code> + <code>BindEnv(&quot;redis.addr&quot;,&quot;REDIS_ADDR&quot;)</code>,默认空=内存模式</li>
<li><span class="chk"></span><b>基础设施</b>go.mod 加 go-redis/v9、redis_rate/v10、miniredis/v2test)。<code>deploy/docker-compose.jiu.yml</code><code>jiu-redis</code>redis:7-alpine、<code>127.0.0.1:6379</code><code>--maxmemory 64mb --maxmemory-policy noeviction --appendonly no --save &quot;&quot; --rename-command CONFIG &quot;&quot;</code>、无 volume(刻意无持久化:redis 重启丢限流计数可接受,noeviction 防锁键被逐出,回环无密码 payd 容器够不着)。<code>production.env.template</code><code>REDIS_ADDR=127.0.0.1:6379</code>(非密钥,deploy-server.sh 漂移检查正好当上线核对项)</li>
<li><span class="chk"></span><b>测试</b>:现有 ratelimit/auth 测试零改动通过(内存路径);<code>memory_test.go</code> 迁移对等用例;<code>redis_test.go</code>miniredisrate+burst、锁定+FastForward 过期解锁、Counter TTL);<code>fallback_test.go</code>(Close 后不 500、降级仍限流);<code>Init(&quot;坏地址&quot;)</code> 不 panic。DoD<code>go build/vet/test ./...</code> 全绿、零真实外部依赖</li>
</ul></div>
<h2>阶段 #3 · 公开接口反爬(轻量版)</h2><div class="card"><ul>
<li><span class="chk"></span><b>日配额二级闸</b><code>middleware/dailyquota.go</code>——<code>DailyQuota(scope, limit)</code>key=<code>dq:&lt;scope&gt;:&lt;yyyymmdd&gt;:&lt;ip&gt;</code><code>Counter().Incr(key, 26h)</code>,超限 429+Retry-After(到午夜)Counter 出错放行(不误伤)、Enabled=false/limit=0 放行。配置默认:<code>daily_product_per_ip=1000</code>(单品 API+OG 页同池)、<code>daily_shoplist_per_ip=300</code>。挂载:<code>/product/:public_id</code><code>public/products/:public_id</code><code>public/shops/:shop_code/products</code>release/errors/pay-callback 不挂。内存版 Counter 带 TTL janitor(防随机 IP 灌爆)</li>
<li><span class="chk"></span><b>店铺列表收紧</b><code>handler/public.go:235</code> page_size 上限 50→20(已核实唯一调用方 client 固定传 20,无破坏);<code>public_test.go</code> 加断言:公开两接口响应 JSON 不含 cost/purchase_price 等敏感字段名(防未来回归泄露,参考 pricing_fields_test.go 写法)</li>
<li><span class="chk"></span><b>测试</b>dailyquota 小配额打满 429、跨 scope 独立、关闭放行、Counter 错误放行;page_size 夹到 20</li>
</ul></div>
<h2>阶段 #1 · 边缘层加固</h2><div class="card"><ul>
<li><span class="chk"></span><b>nginx</b><code>deploy/nginx-jiu-ali.conf</code>CI 随 server 发版下发):</li>
<li style="list-style:none;margin-left:-18px"> - 新 zone<code>jiu_shoplist 2r/s</code>(列表专属,location <code>~ ^/api/v1/public/shops/</code> <b>须放现有 <code>(public|auth)</code> 正则之前</b>)、<code>limit_conn_zone jiu_conn</code><code>jiu_dl</code></li>
<li style="list-style:none;margin-left:-18px"> - UA map ×2<code>$jiu_bad_ua</code>(空 UA + python-requests/scrapy/go-http-client/aiohttp/okhttp 等抓取库)用于 API 公开路径;<code>$jiu_bad_ua_lib</code>(同表<b>去掉空 UA</b>)用于 <code>/product/</code><code>/app/product/</code> OG 页——社交分享爬虫 UA 不可控,OG 路径不拦空 UA;<b>不封 curl/wget</b>(运维自用+攻击者改 UA 零成本)</li>
<li style="list-style:none;margin-left:-18px"> - 公开 API location 加 <code>limit_conn jiu_conn 20</code>shops 路径 10);<code>/downloads/</code><code>limit_conn jiu_dl 3</code> + <code>limit_rate_after 10m; limit_rate 4m</code>(上线前查实例带宽再定值)</li>
<li style="list-style:none;margin-left:-18px"> - <code>/images/</code> 防盗链:<code>valid_referers none blocked server_names 51yanmei.com *.51yanmei.com</code>invalid→403<code>none</code> 放行空 Referer——微信 webview/App 直连必须放行;只挡第三方网页热链)</li>
<li><span class="chk"></span><b>安全组</b>aliyun CLI,批准点):Describe 留档 → <code>RevokeSecurityGroup</code> 删 3389/0.0.0.0/0 → 复核;22/443/80/ICMP 不动 → baize 台账记变更</li>
<li><span class="chk"></span><b>DDoS runbook</b>:新建 <code>docs/runbooks/ddos-response.html</code>(沿用项目深色 HTML 家族样式)+ 登记 <code>docs/index.html</code>。内容:判定指标(带宽/QPS/CPU steal/控制台 DDoS 事件页)、基础防护黑洞机制(约 5G 阈值、黑洞 2.5-24h)、决策树(CC→调紧 zone+封 IP;容量型→按量高防 ¥2-3k/月起 CNAME 切换 30min,或套 CDN/ESA,回源 182.92.213.171:443)、DNS TTL 平时保持 ≤600s、黑洞期客户公告话术。<b>明确决策:不预购高防/CDN</b>,接受极端容量攻击下的黑洞期不可用</li>
</ul></div>
<h2>上线顺序(每个【批准点】单独等用户点头)</h2><div class="card"><ul>
<li>本地全部改码+测试全绿 → 分阶段 commitfeat(backend) ×2 + devops ×1 + docs ×1</li>
<li>【批准点】server 发版走 CI:后端二进制(线上无 REDIS_ADDR → 仍内存模式,行为与现状一致的安全中间态)+ 新 nginx conf 同时带出 → 验收 nginx 项(UA 403 / 防盗链 403 / 微信分享卡片不误伤 / hey 并发出 429)</li>
<li>【批准点】ssh ali<code>free -m</code> 核内存(余量 &lt;150MB 则 maxmemory 降 32mb)→ 部署 compose 拉起 jiu_redis → ping 通 → <code>production.env</code> 加 REDIS_ADDR → <code>systemctl restart jiu</code> → 验收:触发限流看 429、<code>redis-cli --scan &#x27;jiu:rl:*&#x27;</code> 有键、restart jiu 后键仍在(跨重启达成)、<code>docker stop jiu_redis</code> 降级不 500 再 start</li>
<li>【批准点】aliyun CLI 删 3389 → baize 记账</li>
<li>回滚路径:nginx=上一 tag 重发;redis=env 删 REDIS_ADDR 重启(回内存模式);安全组=Authorize 原参数回加</li>
<li>收尾:todo #1/#2/#3 标 done 等验收;plan 定稿 HTML 阅读版落 <code>docs/</code>、登记 index(全局双产物规则)</li>
</ul></div>
<h2>明确不做(写入 runbook/commit message</h2><div class="card"><ul>
<li style="list-style:none;margin-left:-18px">签名链接(QR 已印刷不可过期、UUIDv4 已不可枚举)|滑块/验证码(过重,杀零门槛分享体验)|登录后可见(杀死扫码分享功能)|预购高防/CDN(成本不匹配体量,转 runbook 预案)|封 curl/wget/泛&quot;bot&quot; UA(误伤运维与社交分享爬虫)</li>
</ul></div>
<h2>风险</h2><div class="card"><ul>
<li>1.8GiB 内存紧:redis 实占 &lt;80MB,上线前实测 free -m</li>
<li>REDIS_ADDR「模板有、线上无」窗口期 deploy-server.sh 会告警一次(步骤 2→3 之间),预期内</li>
<li>shops 路径从 jiu_pub zone 移入 jiu_shoplist 后不再占原配额,行为变化在预期内</li>
</ul></div>
</body></html>
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DDoS / 爬取洪峰应急 Runbook — 酒库管理系统</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);}
.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>DDoS / 爬取洪峰应急 Runbook</h1>
<div class="sub">目标机:ali182.92.213.1712vCPU/1.8GiBnginx 443 → jiu:8081 / payd:8080)· 2026-07-05 制定 · 配套加固:nginx limit_req/limit_conn/UA 拦截 + 应用层分钟限流/日配额(redis 外置)</div>
<div class="callout"><b>已拍板的风险决策:</b>不预购 DDoS 高防 / 不常备 CDN——当前业务体量(B2B 门店工具,客户量有限)与其成本(高防 ¥2-3k/月起)不匹配。接受「极端容量型攻击下最长黑洞期(2.5~24h)不可用」的残余风险;客户端 App 有本地态可短时离线操作。应用层 CC 攻击由既有多层限流覆盖,不在此风险内。</div>
<h2>一、判定:现在发生的是什么</h2>
<div class="card lead">
<table>
<tr><th>症状</th><th>检查命令 / 位置</th><th>判定</th></tr>
<tr><td>ssh 卡顿、ping 大量丢包,但机器负载不高</td><td class="mono">ping 182.92.213.171 · 阿里云控制台 ECS 监控「网络带宽」打满</td><td><span class="tag risk">容量型 L3/L4</span>(带宽被灌满)→ 走第三节</td></tr>
<tr><td>机器能登,nginx 连接数/QPS 暴涨、CPU 高</td><td class="mono">ss -s · tail -f /var/log/nginx/access.log · top</td><td><span class="tag risk">应用层 CC / 爬取洪峰</span> → 走第二节</td></tr>
<tr><td>IP 全网不可达(连阿里云内网也 ping 不通)</td><td>控制台 → DDoS 基础防护 → 实例列表:状态「黑洞中」</td><td><span class="tag risk">已进黑洞</span> → 走第四节</td></tr>
</table>
<p>阿里云免费「DDoS 基础防护」自动生效:清洗阈值与黑洞阈值查询路径 = <b>控制台 → 安全 → DDoS 基础防护 → 实例列表</b>(北京区黑洞阈值约 5Gbps 量级,以控制台实际显示为准)。超过黑洞阈值即封 IP,黑洞期默认 2.5h,反复被打会延长到 24h,<b>期间无法主动解封</b></p>
</div>
<h2>二、应用层 CC / 爬取洪峰(机器还活着)</h2>
<div class="card lead">
<ol>
<li><b>看清攻击面</b><code>awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20</code> 找 top IP;再看打的路径。</li>
<li><b>封单个/少量 IP</b>nginx conf 对应 location 或 server 级加 <code>deny 1.2.3.4;</code><code>nginx -t && systemctl reload nginx</code>;或阿里云安全组加拒绝规则(源 IP /32,优先级 1)。</li>
<li><b>整体调紧</b>:把 <code>/etc/nginx/conf.d/jiu.conf</code><code>jiu_pub</code> 从 10r/s 降到 2r/s、<code>jiu_shoplist</code> 降到 1r/s、<code>jiu_conn</code> 降到 5reload。注意这是<b>临时手改线上</b>,恢复后必须回滚(下次 server 发版会覆盖为仓库版本)。</li>
<li><b>应用层兜底</b>:后端还有分钟级限流 + 日配额(redis 外置,重启不丢);紧急时可在 <code>/opt/jiu/config/production.env</code> 调小 <code>RATELIMIT_*</code><code>systemctl restart jiu</code></li>
</ol>
</div>
<h2>三、容量型 L3/L4(带宽被灌满,还没进黑洞)</h2>
<div class="card lead">
<p>本机和 nginx 层<b>无能为力</b>(流量在进机器前就占满管道),只有两条路,二选一:</p>
<h3>路 A:购买阿里云 DDoS 高防(攻击打 IP 也有效,成本高)</h3>
<ol>
<li>控制台购买「DDoS 高防(中国内地)」保险版(约 ¥2-3k/月起 + 弹性按次计费)。</li>
<li>配置网站接入:源站 <code>182.92.213.171</code>,转发 443/80,回源保持 HTTPS。</li>
<li>把 jiu.51yanmei.com 的 A 记录改为高防给的 CNAME(阿里云云解析,TTL 600 内约 10-30 分钟全网生效)。</li>
<li>攻击停止后可退回直连并停止续费。</li>
</ol>
<h3>路 B:套 CDN / 边缘安全加速(只有攻击打域名时有效)</h3>
<ol>
<li>域名已备案 ✓,可开阿里云 CDN 或 ESA,源站 182.92.213.171:443。</li>
<li>动态 API 全走回源(CDN 主要挡静态与摊薄入口),若攻击者直打源 IP 则无效——届时只能走路 A 或换公网 IP(换 IP 需同步改 DNS + baize 台账 + pay 相关配置)。</li>
</ol>
<div class="callout warn"><b>预备项(平时保持):</b>云解析里 jiu.51yanmei.com 的 TTL 保持 ≤600s,应急切换才快;本 runbook 的回源参数保持最新。</div>
</div>
<h2>四、已进黑洞</h2>
<div class="card lead">
<ol>
<li>黑洞无法主动解除,等待期(控制台显示解封时间)内做两件事:给客户发公告(模板见下);评估是否走第三节路 A(高防接入可在黑洞期间完成配置,解封即切换)。</li>
<li>客户公告话术模板:<i>「系统服务器正遭受网络攻击,运营商已启动防护,预计 X 时 X 分前恢复。期间已录入的数据不受影响,App 可离线查看,恢复后自动同步。给您带来不便深表歉意。」</i></li>
<li>事后复盘:控制台下载攻击流量报表,评估是否升级常备防护,更新本 runbook。</li>
</ol>
</div>
<h2>五、既有防线速查(平时状态)</h2>
<div class="card lead">
<table>
<tr><th></th><th>措施</th><th>位置</th></tr>
<tr><td>阿里云</td><td>免费 DDoS 基础防护(自动清洗);安全组仅放行 22/80/443/ICMP</td><td>控制台 · sg-2ze5y7jqe889jmqb6ci4</td></tr>
<tr><td>nginx</td><td>limit_req(公开 10r/s、店铺列表 2r/s+ limit_conn20/10/下载 3+ 抓取库/空 UA 403 + 图片防盗链 + 下载限速</td><td class="mono">deploy/nginx-jiu-ali.confCI 下发)</td></tr>
<tr><td>应用</td><td>per-IP 分钟限流 + 每店 RPS + 登录失败锁 + 日配额(单品 1000/日、列表 300/日);状态外置 redis 跨重启</td><td class="mono">backend/internal/ratelimit + middleware</td></tr>
</table>
</div>
</body>
</html>
+1 -1
View File
@@ -57,7 +57,7 @@ if [ "${1:-}" = "--ios" ]; then
echo "!! 找不到构建产物 Runner.app" >&2
exit 1
fi
echo "==> 安装到 iPhone$CD_ID"
echo "==> 安装到 iPhone${CD_ID}"
xcrun devicectl device install app --device "$CD_ID" "$APP_PATH" | grep -m1 databaseSequenceNumber || true
echo "==> 完成:解锁手机打开 岩美酒库(版本 v${VER}"
exit 0
+1 -1
View File
@@ -4,7 +4,7 @@
"copyright": "© 2026 岩美科技 · 保留所有权利",
"icp": "京ICP备2026039814号",
"support": {
"email": "yammy2023@163.com",
"email": "support@51yanmei.com",
"wechat": ""
},
"appUrl": "/app/",