Compare commits

..

14 Commits

Author SHA1 Message Date
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
59 changed files with 2146 additions and 227 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>
+1 -1
View File
@@ -621,7 +621,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
+20
View File
@@ -5,6 +5,26 @@
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.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))
}
}
+2 -1
View File
@@ -232,7 +232,8 @@ 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
}
@@ -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())
}
+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
}
+6 -3
View File
@@ -59,9 +59,12 @@ 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)
v1 := r.Group("/api/v1")
@@ -77,8 +80,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
+47 -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 参数旧语法
@@ -40,8 +60,11 @@ server {
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 +79,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;
@@ -88,9 +125,11 @@ server {
expires 0;
}
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签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 +138,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 +148,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
+2
View File
@@ -58,6 +58,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 +75,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/",