Files
NexusGate/MONTHLY-RESET-REPORT.md
T
aaaamoooo c6f6b9dbea 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
2026-10-21 03:16:30 +08:00

157 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NexusGate 客户流量按月自动重置 — 实施报告
日期:2026-10-20 | 基线:`f4ef3d9` | 范围:独立功能,不含任何数据迁移代码。
## 0. 开工前工作区处理
`git status` 检查结果:工作区与 HEAD (`f4ef3d9`) 一致,无已修改文件;上一条被中止的
迁移工作流未留下任何残件。唯一未跟踪文件 `LINK-MIGRATION.md` 是更早的链接迁移说明
文档(本功能之前就未入仓),与本功能无关 — 原样保留,未纳入本次提交。
## 1. 用量模型摸底(设计前提)
- 客户用量记在客户记录的三个字段:`usedBytes` / `usedUplinkBytes` / `usedDownlinkBytes`,
由 orchestrator 按 agent 上报的 epoch 累计计数做增量累加(面板侧累计制)。
- 额度判定全部读 `usedBytes`:`CustomerBlockReason`、housekeeping 的额度暂停、
客户状态变更守卫、订阅发放前检查,四处同一口径。
- 订阅输出(`Subscription-Userinfo` 头与订阅信息页)读同三字段,其中
`historical = usedBytes - up - down`(≥0)是给"只灌总数"的导入数据兜底的派生量。
- 模型没有独立的终身累计字段:三字段既是周期计数也是展示口径。已有手动
`POST /api/customers/:id/reset-usage` 把三字段同时清零并在拦截消除后恢复客户 —
本功能与其保持完全一致的清零语义。
- **绝不能动**的是各 server/nodeClient 上的 epoch 基线计数器
(`serverUsage[].usageCounters` 等):那是增量记账的基准,清了会重复计费。
本功能只动客户三字段。
## 2. 设计
### 字段(客户级,共享 JSON schema,全部 omitempty)
| 字段 | 类型 | 含义 |
|---|---|---|
| `trafficResetDay` | int | 每月重置日 1–31;0/缺省 = 不重置 |
| `trafficResetPeriod` | string | 幂等标记:上次已应用重置所属周期键 `YYYY-MM` |
| `trafficResetAt` | string(ISO) | 上次实际重置时间(展示用) |
Node 版面板无副作用已核实:Node 的客户 PATCH 是逐字段改已加载对象、整对象落盘,
未知字段原样保留;Node 只是不执行重置逻辑(生产主控是 Go 版)。
### 周期键与触发判定(纯函数,无内存状态)
- 某月的重置时刻 = `min(重置日, 当月天数)` 号 00:00(面板本地时间)— 31 号遇
2 月在 28 号触发,闰年 29 号正常存在。
- 周期键 = `now` 时刻"最近一个已到来的重置时刻"所在月份(`YYYY-MM`)。
跨过重置时刻键才翻页。
- 判定(housekeeping 每轮):`marker != 当前周期键` 且 marker 非空 ⇒ 到期,执行一次
清零并把 marker 写为当前键。**面板停机跨过多个周期也只清一次**(落到当前周期),
这就是重启补判:不依赖 ticker 记忆,第一轮 housekeeping(启动后 ≤60 秒,
与 Node 版同节律,未额外加启动调用以保持差分行为对齐)即补触发。
### 布防(arm)语义 — 防误清
- marker 为空(老数据、或外部写入只写了 `trafficResetDay`)时,第一轮检查只写
marker、**不清零**:本周期已累计用量保留,下个重置日正常触发。
- API 创建客户带重置日、或 PATCH **改动**重置日时,当场布防(新排期从下个重置
时刻生效,绝不在设置瞬间清零)。PATCH 回显**未改动**的值不重布防 — 否则管理员
在"到期未触发"的窗口里编辑客户会把待触发的重置吞掉。
### 到期动作(镜像手动清零)
1. 三计数清零;marker ← 当前周期键;`trafficResetAt` / `updatedAt` ← 现在;
2. 写审计 `reset_customer_usage_monthly`(actor `system:monthly_reset`,
detail 含清零前用量、重置日、周期键);
3. 若客户是 `traffic_limit` 暂停且清零后无其它拦截(未过期等),自动恢复
active 并 `ResumeCustomerResources`(与手动清零同路径)。
手动暂停、到期暂停的客户只清零、不恢复。
### 挂载点
`main.go` 的 60 秒 housekeeping 事务内、额度暂停评估**之前**调用
`ApplyMonthlyTrafficResets`(`panel-go/reset.go`)— 同事务原子生效:清零后本轮
额度判定即按新周期口径走。不新起 goroutine,内存零增量(几个时间计算/客户)。
### API / UI 配套(最小)
- `POST /api/customers` 与 `PATCH /api/customers/:id` 接受 `trafficResetDay`
(0–31 整数,null = 清除;32 / 小数 / 负数 → 400)。
- 前端客户表单新增"每月流量重置日"输入(0 不重置 + 说明),编辑时回显
"上次重置 时间";提交 payload 带该字段。`public/app.js` 与
`panel-go/embed_public/app.js`(构建期副本)已同步。
## 3. 改动清单
| 文件 | 改动 |
|---|---|
| `panel-go/types.go` | Customer 加 3 字段(+3 行) |
| `panel-go/reset.go` | 新增:周期键/时刻计算、布防、到期应用、入参校验 |
| `panel-go/main.go` | housekeeping 事务内挂载(+9 行) |
| `panel-go/handlers.go` | 创建/PATCH 接受并布防 `trafficResetDay`(+22 行) |
| `public/app.js` | 表单字段 + payload(+3/−1 行) |
| `panel-go/reset_test.go` | 新增测试(14 个测试函数) |
| `MONTHLY-RESET-REPORT.md` | 本报告 |
## 4. 测试清单与结果
**基线(改动前)**:panel-go `go test ./...` 全过;Node `node --test` 52/52。
**新增用例**(`panel-go/reset_test.go`,全部通过):
| 用例 | 覆盖点 |
|---|---|
| `TestTrafficResetPeriodKey` | 16 个历法断言:跨月、时刻整点边界、同日、年翻转(1/1→上年 12 月)、31→2/28 夹逼、30→2 月、29 闰/非闰年 |
| `TestApplyMonthlyResetDueAndIdempotent` | 正常跨月触发清零 + marker/resetAt/审计落位;同周期二次检查不清零(新流量保留) |
| `TestApplyMonthlyResetNotYetDue` | 本周期已重置者不动 |
| `TestApplyMonthlyResetArmsOnFirstSight` | marker 空只布防不清零;布防后下个周期正常触发 |
| `TestApplyMonthlyResetCatchUpMissedPeriods` | 停机跨 4 个周期(重启补判):只清一次、落当前周期、审计恰 1 条 |
| `TestApplyMonthlyResetShortMonthClamp` | 31 号:2/27 不触发、2/28 00:01 触发且周期为 2 月 |
| `TestApplyMonthlyResetIndependentDays` | 多客户不同重置日互不干扰(1 号已清、25 号未动) |
| `TestApplyMonthlyResetUnsetOrInvalidUntouched` | 未设置 / 45 / −3 的客户完全不受影响 |
| `TestApplyMonthlyResetResumesTrafficSuspended` | 流量暂停客户清零后自动恢复、拦截原因消除 |
| `TestApplyMonthlyResetKeepsOtherSuspensions` | 手动暂停 / 到期暂停:只清零不恢复 |
| `TestParseResetDay` | nil/0/1/31/json.Number 合法;32/2.5/−1 拒绝 |
| `TestResetFieldsPersistAndOmitWhenUnset` | 落盘原文含三字段、重载不丢;未设置客户 JSON 不出现新键(Node 互通前提) |
| `TestHousekeepingAppliesMonthlyReset` | 走真实 housekeeping 全链:到期清零、周期落位;第二轮不重复清 |
| `TestCustomerAPIResetDayLifecycle` | 登录后 API 全程:创建带日(布防、无 resetAt)→ PATCH 32 被拒 → 改日重布防 → 清 0 后记录中字段消失 |
**改动后全套**:
- panel-go `go test ./...` 全过(另 `-count=1` 全量复跑过);`gofmt` / `go vet` 干净。
- agent-go `go test ./...` 全过(未改动,回归确认)。
- Node `node --test` 52/52;`npm run check`(server.js / agent / app.js 语法)通过。
- **差分 E2E(difftest)正式模式 50/50 PASS**(Node vs Go 同种子同剧本,
含客户 PATCH 与客户列表比对)— 新增字段未扰动任何既有响应形状。
## 5. 代码自审清单(边界专项)
| # | 发现 | 处置 |
|---|---|---|
| 1 | `TrafficResetPeriodKey` 初版混用时区:年月取 `now` 的 Location、时刻构造写死 `time.Local`,两者不一致必算错 | **已修**:全程 `now.Location()`;`daysInMonth` 改纯历法(UTC)计算;历法测试全程显式构造本地时间 |
| 2 | epoch 基线计数器(serverUsage / nodeClient usageCounters)若被清会重复计费 | 已确认设计只动客户三字段,基线零接触(见 §1) |
| 3 | 事务原子性:重置在 housekeeping 事务内,若 `ResumeCustomerResources` 出错整轮回滚 — marker 与计数同生同灭,下轮重试,不会出现"清了零但 marker 没落"或重复清 | 核实 `Store.Transaction` 为 clone-草稿语义后**接受**;代价:单个客户恢复出错会阻塞整轮 housekeeping,与既有 ip_limit 恢复同构 |
| 4 | 跨界流量归属:重置时刻前后一个上报周期(≤60 秒)的增量整笔计入新周期 | 清零式重置的固有近似(3x-ui 同样),量级 ≤1 分钟流量,**接受并记录** |
| 5 | PATCH 回显吞重置:前端每次编辑都回显该字段,若"提交即布防"会在到期窗口内吞掉待触发重置 | **已防**:仅值变化才重布防(§2),API 测试覆盖 |
| 6 | PATCH 传 `trafficResetDay: null` 的语义 | 定为清除,与 `expiresAt` 的 null 清除口径一致 |
| 7 | marker 被写成垃圾字符串(仅可能来自手工改库) | 按"到期"处理:清一次后写入合法键自愈收敛;前提是重置日本身合法,**接受** |
| 8 | DST:00:00 在个别时区不存在(春跳日) | Go `time.Date` 自动顺延,周期键不受影响;生产时区 Asia/Shanghai 无 DST,**记录** |
| 9 | 多名流量暂停客户同轮恢复 → 每人可能触发节点重建下发 | 与手动清零恢复同路径同量级,路由器客户规模小,**接受** |
| 10 | 临时回滚到 Node 版当主控时重置停摆 | Node 不执行但不破坏字段;切回 Go 后按 marker 补判、不会重复清(幂等),**记录为运维须知** |
| 11 | 补判延迟:启动后第一轮 housekeeping 在 ≤60 秒 | 满足"启动/下个检查点补触发";未加启动即时调用是为与 Node 节律保持差分对齐,**接受** |
自审未发现其它未处置的边界问题。
## 6. 与迁移的衔接点
- 转换器对 13 名按月重置客户**只需写 `trafficResetDay`(1–31)**,
`trafficResetPeriod` 可不写:面板首轮自动布防、绝不误清迁移带入的期初用量,
到下个重置日正常触发。若转换器想显式写 marker,写"转换时刻的当前周期键"即可
(`TrafficResetPeriodKey(转换时刻, 重置日)`)。
- 期初用量落位不变:`usedBytes = client_traffics 上+下`、方向两字段按原值 —
其中属于重置客户的部分将在其首个重置日按新周期清零,符合 3x-ui 现行语义。
## 7. 未做 / 边界
- Node 版面板未实现重置执行逻辑(定版生产主控为 Go 版;Node 仅需字段无副作用,
已满足)。如未来 Node 回主,需在 `server.js` housekeeping 补同语义实现。
- 客户列表页未加"重置日"列(按最小配套要求只做了表单入口 + 上次重置显示)。
- 本报告不含生产部署动作:新二进制构建/换装按既有流程另行执行。