Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bf645c30 | |||
| 3573f015c3 | |||
| d9f1f6956e | |||
| 75d0accb14 | |||
| 3fc0ca5f0c | |||
| 4966892030 | |||
| a49a962161 | |||
| 290054dc8c | |||
| 3c687e5e0b | |||
| e850a1987c | |||
| fd45bece17 | |||
| b740cc6edf | |||
| dddf402d92 | |||
| faaeee0bfe |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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`)、商品抽屉成本行。
|
||||
- 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。
|
||||
|
||||
### 库存变更
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
// admin:unit_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 / readonly:unit_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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 按任意字符串 key(IP 或 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
|
||||
}
|
||||
|
||||
@@ -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,限流不可被请求头绕过。
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// fallbackStore:redis 为主、内存为影子。每次调用先试 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// recordFailScript:INCR 失败计数 + 续窗口 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 次失败锁定 lockFor(max<=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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
/// 为真时隐藏一切外部购买 UI(iOS App Store 包)。
|
||||
bool get hideExternalPurchaseUi => isIosAppStoreBuild;
|
||||
|
||||
/// 是否需要首启「个人信息保护」同意弹层(个保法 + 应用商店审核):
|
||||
/// 仅移动端(iOS/Android);桌面与 Web 不弹。
|
||||
bool get needsPrivacyConsent =>
|
||||
debugForcePrivacyConsentRequired ??
|
||||
(!kIsWeb && (Platform.isIOS || Platform.isAndroid));
|
||||
|
||||
/// 客服邮箱(授权管理「联系客服」卡、异常兜底提示等处使用)。
|
||||
const String supportEmail = 'support@51yanmei.com';
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:兑换券(原型 .redeem:input + 按钮,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((
|
||||
|
||||
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 142 KiB |
|
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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,27 @@
|
||||
# 后端上游 = 127.0.0.1:8081(非 8080!ali 上 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_ua:API 公开路径用(含空 UA);$jiu_bad_ua_lib:OG 分享页用(不拦空 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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-* 原型 → Flutter,2026-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>
|
||||
|
||||
@@ -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 RAM(mysql+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("redis.addr","REDIS_ADDR")</code>,默认空=内存模式</li>
|
||||
<li><span class="chk">☑</span><b>基础设施</b>:go.mod 加 go-redis/v9、redis_rate/v10、miniredis/v2(test)。<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 "" --rename-command CONFIG ""</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>(miniredis:rate+burst、锁定+FastForward 过期解锁、Counter TTL);<code>fallback_test.go</code>(Close 后不 500、降级仍限流);<code>Init("坏地址")</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:<scope>:<yyyymmdd>:<ip></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>本地全部改码+测试全绿 → 分阶段 commit(feat(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> 核内存(余量 <150MB 则 maxmemory 降 32mb)→ 部署 compose 拉起 jiu_redis → ping 通 → <code>production.env</code> 加 REDIS_ADDR → <code>systemctl restart jiu</code> → 验收:触发限流看 429、<code>redis-cli --scan 'jiu:rl:*'</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/泛"bot" UA(误伤运维与社交分享爬虫)</li>
|
||||
</ul></div>
|
||||
<h2>风险</h2><div class="card"><ul>
|
||||
<li>1.8GiB 内存紧:redis 实占 <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>
|
||||
@@ -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">目标机:ali(182.92.213.171,2vCPU/1.8GiB,nginx 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> 降到 5,reload。注意这是<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_conn(20/10/下载 3)+ 抓取库/空 UA 403 + 图片防盗链 + 下载限速</td><td class="mono">deploy/nginx-jiu-ali.conf(CI 下发)</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>
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"copyright": "© 2026 岩美科技 · 保留所有权利",
|
||||
"icp": "京ICP备2026039814号",
|
||||
"support": {
|
||||
"email": "yammy2023@163.com",
|
||||
"email": "support@51yanmei.com",
|
||||
"wechat": ""
|
||||
},
|
||||
"appUrl": "/app/",
|
||||
|
||||