- 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
157 lines
11 KiB
Markdown
157 lines
11 KiB
Markdown
# 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 补同语义实现。
|
||
- 客户列表页未加"重置日"列(按最小配套要求只做了表单入口 + 上次重置显示)。
|
||
- 本报告不含生产部署动作:新二进制构建/换装按既有流程另行执行。
|