feat(panel-go): monthly traffic reset for customers

- trafficResetDay/Period/At on the shared customer record (omitempty,
  inert for the Node panel, which preserves unknown fields)
- housekeeping applies due resets before quota evaluation: period-key
  idempotency, short-month clamping (31st fires Feb 28), missed-period
  catch-up after panel downtime, traffic suspensions auto-resume
- first sight of a schedule only arms the period marker, never zeroes;
  API create/PATCH accept trafficResetDay (0=off) and re-arm on change
- UI: reset-day input + last-reset display in the customer form
- tests: calendar math, apply pass, API lifecycle, persistence;
  full Go/Node suites green, difftest formal 50/50
This commit is contained in:
aaaamoooo
2026-10-21 03:16:30 +08:00
parent f4ef3d96a6
commit c6f6b9dbea
7 changed files with 744 additions and 1 deletions
+2 -1
View File
@@ -335,6 +335,7 @@ function customerForm(item = null) {
<label>流量上限(GB,0 不限)<input name="trafficGb" type="number" min="0" step="0.01" value="${Number(customer.trafficLimitBytes || 0) / 1024 ** 3}"></label><label>快速到期<select data-expiry-quick><option value="">自定义 / 不限</option><option value="7">7 天后</option><option value="30">30 天后</option><option value="90">90 天后</option><option value="365">1 年后</option></select></label>
<label>到期日期<input name="expiryDate" type="date" value="${esc(expiry.date)}"><small>留空表示不限期,不再需要手工输入日期格式。</small></label><label>到期时间<select name="expiryTime">${timeOptions(expiry.time)}</select></label>
<label>滚动节点 IP 上限<input name="ipLimit" type="number" min="0" step="1" value="${Number(customer.ipLimit || 0)}"><small>0 不限;Agent 每分钟上报,超限后暂停客户和节点。</small></label><label>订阅客户端估计上限<input name="deviceLimit" type="number" min="0" step="1" value="${Number(customer.deviceLimit || 0)}"><small>0 不限;24 小时内按客户端标识或 User-Agent 计数,只拦截新订阅请求,无法限制已导入的节点。</small></label>
<label>每月流量重置日<input name="trafficResetDay" type="number" min="0" max="31" step="1" value="${Number(customer.trafficResetDay || 0)}"><small>0 不重置;1–31 为每月重置日(超过当月天数按月末),到日自动清零已用流量。${customer.trafficResetAt ? `上次重置 ${fmtDate(customer.trafficResetAt)}。` : ''}</small></label>
<label class="wide">标签(逗号分隔)<input name="tags" value="${esc((customer.tags || []).join(', '))}"></label><label class="wide">备注<textarea name="notes">${esc(customer.notes || '')}</textarea></label>
<div class="form-actions"><button type="button" data-close>取消</button><button class="primary" type="submit">${item ? '保存修改' : '创建客户'}</button></div></form>`);
}
@@ -475,7 +476,7 @@ document.addEventListener('submit', async (event) => {
$('#modal').close(); toast(resourceId ? '设备信息已更新' : '设备已添加'); await load('servers');
} else if (form.id === 'customer-form') {
const resourceId = data.get('resourceId');
const payload = { name:data.get('name'), group:data.get('group'), trafficLimitBytes:Number(data.get('trafficGb') || 0) * 1024 ** 3, expiresAt:expiryIso(data), ipLimit:Number(data.get('ipLimit') || 0), deviceLimit:Number(data.get('deviceLimit') || 0), tags:splitList(data.get('tags')), notes:data.get('notes') };
const payload = { name:data.get('name'), group:data.get('group'), trafficLimitBytes:Number(data.get('trafficGb') || 0) * 1024 ** 3, expiresAt:expiryIso(data), ipLimit:Number(data.get('ipLimit') || 0), deviceLimit:Number(data.get('deviceLimit') || 0), trafficResetDay:Number(data.get('trafficResetDay') || 0), tags:splitList(data.get('tags')), notes:data.get('notes') };
await api(resourceId ? `/api/customers/${resourceId}` : '/api/customers', { method:resourceId ? 'PATCH' : 'POST', body:JSON.stringify(payload) });
$('#modal').close(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
} else if (form.id === 'node-form') {