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:
@@ -0,0 +1,156 @@
|
||||
# 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 补同语义实现。
|
||||
- 客户列表页未加"重置日"列(按最小配套要求只做了表单入口 + 上次重置显示)。
|
||||
- 本报告不含生产部署动作:新二进制构建/换装按既有流程另行执行。
|
||||
@@ -1652,6 +1652,10 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resetDay, err := ParseResetDay(body["trafficResetDay"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := &Customer{
|
||||
ID: NewID("cus"),
|
||||
SubscriptionToken: RandomToken(32),
|
||||
@@ -1665,11 +1669,15 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
ExpiresAt: expires,
|
||||
IPLimit: int64(ipLimit),
|
||||
DeviceLimit: int64(deviceLimit),
|
||||
TrafficResetDay: resetDay,
|
||||
Tags: sliceStrings(asIds(body["tags"]), 20),
|
||||
Notes: CleanText(body["notes"], 1000),
|
||||
CreatedAt: NowISO(),
|
||||
UpdatedAt: NowISO(),
|
||||
}
|
||||
// Arm the monthly-reset schedule: it starts with the next
|
||||
// reset instant, it never fires retroactively at creation.
|
||||
ArmTrafficReset(next, time.Now())
|
||||
d.Customers = append(d.Customers, next)
|
||||
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
|
||||
return cloneJSONValue(next), nil
|
||||
@@ -1736,6 +1744,20 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if _, ok := body["tags"]; ok {
|
||||
item.Tags = sliceStrings(asIds(body["tags"]), 20)
|
||||
}
|
||||
if _, ok := body["trafficResetDay"]; ok {
|
||||
v, err := ParseResetDay(body["trafficResetDay"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v != item.TrafficResetDay {
|
||||
item.TrafficResetDay = v
|
||||
// A changed schedule is re-armed rather than fired:
|
||||
// it takes effect from the next reset instant. An
|
||||
// unchanged value (the UI echoes it on every edit)
|
||||
// leaves the marker — and any pending reset — alone.
|
||||
ArmTrafficReset(item, time.Now())
|
||||
}
|
||||
}
|
||||
reason := CustomerBlockReason(d, item)
|
||||
statusWanted := JSString(body["status"])
|
||||
if statusWanted == "active" && reason != "" {
|
||||
|
||||
@@ -194,6 +194,15 @@ func (a *App) housekeeping() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Monthly traffic resets run before the quota evaluation below,
|
||||
// so a customer whose new period just started is judged on the
|
||||
// zeroed counters (and a reset can lift a traffic suspension in
|
||||
// the same pass). Due-ness derives from the persisted period
|
||||
// marker, so this also catches up resets missed while the panel
|
||||
// was down — at the first pass after it is back (<=60s).
|
||||
if err := ApplyMonthlyTrafficResets(d, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, customer := range d.Customers {
|
||||
expired := false
|
||||
if customer.ExpiresAt != nil {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
// Monthly traffic reset. A customer with trafficResetDay (1-31) gets the
|
||||
// cycle counters (usedBytes / usedUplinkBytes / usedDownlinkBytes — the
|
||||
// same fields the quota checks and the subscription userinfo read) zeroed
|
||||
// once per monthly period, from the regular 60s housekeeping pass.
|
||||
//
|
||||
// The design is deliberately stateless and restart-safe: whether a reset
|
||||
// is due is derived only from the persisted trafficResetPeriod marker, so
|
||||
// a reset instant missed while the panel was down fires at the first
|
||||
// housekeeping pass after it comes back — no in-memory schedule to lose.
|
||||
// All dates are panel-local, matching the rest of housekeeping.
|
||||
|
||||
import "time"
|
||||
|
||||
// daysInMonth returns the number of days in the given month (pure
|
||||
// calendar math, location-independent).
|
||||
func daysInMonth(year int, month time.Month) int {
|
||||
// Day 0 of the next month is the last day of this one.
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
|
||||
// resetInstant returns the scheduled reset instant inside the given
|
||||
// month: the reset day clamped to the month's length (the 31st fires on
|
||||
// Feb 28), at 00:00 in loc.
|
||||
func resetInstant(year int, month time.Month, day int, loc *time.Location) time.Time {
|
||||
if dim := daysInMonth(year, month); day > dim {
|
||||
day = dim
|
||||
}
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
func prevMonth(year int, month time.Month) (int, time.Month) {
|
||||
if month == time.January {
|
||||
return year - 1, time.December
|
||||
}
|
||||
return year, month - 1
|
||||
}
|
||||
|
||||
// TrafficResetPeriodKey names the monthly period `now` belongs to for the
|
||||
// given reset day: the "2006-01" of the most recent scheduled reset
|
||||
// instant at or before now. Crossing a reset instant flips the key.
|
||||
func TrafficResetPeriodKey(now time.Time, day int) string {
|
||||
loc := now.Location()
|
||||
year, month, _ := now.Date()
|
||||
cand := resetInstant(year, month, day, loc)
|
||||
if now.Before(cand) {
|
||||
// This month's reset instant is still ahead: the current period
|
||||
// began at last month's reset instant.
|
||||
year, month = prevMonth(year, month)
|
||||
cand = resetInstant(year, month, day, loc)
|
||||
}
|
||||
return cand.Format("2006-01")
|
||||
}
|
||||
|
||||
// ArmTrafficReset (re)arms a customer's period marker for its current
|
||||
// reset day WITHOUT zeroing usage: the schedule takes effect from the next
|
||||
// reset instant. Used when the reset day is set or changed through the
|
||||
// API, and for customers whose marker is empty (data written before this
|
||||
// feature existed, or externally — e.g. by the future 3x-ui converter,
|
||||
// which only has to write trafficResetDay).
|
||||
func ArmTrafficReset(customer *Customer, now time.Time) {
|
||||
if customer.TrafficResetDay < 1 || customer.TrafficResetDay > 31 {
|
||||
customer.TrafficResetPeriod = ""
|
||||
return
|
||||
}
|
||||
customer.TrafficResetPeriod = TrafficResetPeriodKey(now, customer.TrafficResetDay)
|
||||
}
|
||||
|
||||
// ApplyMonthlyTrafficResets performs the due monthly resets for all
|
||||
// customers in d as of now. Idempotent per period via the persisted
|
||||
// marker; safe to call on every housekeeping pass.
|
||||
func ApplyMonthlyTrafficResets(d *Data, now time.Time) error {
|
||||
for _, customer := range d.Customers {
|
||||
day := customer.TrafficResetDay
|
||||
if day < 1 || day > 31 {
|
||||
continue
|
||||
}
|
||||
key := TrafficResetPeriodKey(now, day)
|
||||
if customer.TrafficResetPeriod == "" {
|
||||
// First sight: arm the marker only — usage already accrued in
|
||||
// the current period must survive until the next reset day.
|
||||
customer.TrafficResetPeriod = key
|
||||
continue
|
||||
}
|
||||
if customer.TrafficResetPeriod == key {
|
||||
continue
|
||||
}
|
||||
// A new period has begun since the last applied reset. Zero the
|
||||
// cycle counters exactly like the manual reset-usage action (the
|
||||
// model has no separate lifetime counter: these three fields are
|
||||
// also what subscriptions report as upload/download).
|
||||
previousUsed := customer.UsedBytes
|
||||
customer.UsedBytes = 0
|
||||
customer.UsedUplinkBytes = 0
|
||||
customer.UsedDownlinkBytes = 0
|
||||
customer.TrafficResetPeriod = key
|
||||
resetAt := ISOFromTime(now)
|
||||
customer.TrafficResetAt = &resetAt
|
||||
customer.UpdatedAt = resetAt
|
||||
Audit(d, "system:monthly_reset", "reset_customer_usage_monthly", customer.ID, map[string]any{
|
||||
"previousUsedBytes": previousUsed,
|
||||
"resetDay": day,
|
||||
"period": key,
|
||||
})
|
||||
// Mirror the manual reset: a customer suspended only for the
|
||||
// traffic limit comes back once nothing blocks it any more.
|
||||
if customer.Status == "suspended" && strVal(customer.SuspendReason) == "traffic_limit" &&
|
||||
CustomerBlockReason(d, customer) == "" {
|
||||
customer.Status = "active"
|
||||
customer.SuspendReason = nil
|
||||
customer.SuspendReasonPresent = true
|
||||
if _, err := ResumeCustomerResources(d, customer, "system:monthly_reset"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseResetDay validates a trafficResetDay request value: 0 disables
|
||||
// the schedule, 1-31 is the day of month. JSON null/absent means 0.
|
||||
func ParseResetDay(v any) (int, error) {
|
||||
n, err := NonNegativeNumber(v, "每月重置日", true)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 31 {
|
||||
return 0, httpErr(400, "每月重置日须在 0–31 之间(0 表示不重置)")
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package main
|
||||
|
||||
// Tests for the monthly traffic reset: period-key calendar math (incl.
|
||||
// short-month clamping), the housekeeping apply pass (due / arming /
|
||||
// idempotency / missed-period catch-up / suspension interplay), API
|
||||
// surface and persistence of the new customer fields.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func local(y int, m time.Month, d, hh, mm int) time.Time {
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func TestTrafficResetPeriodKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
now time.Time
|
||||
day int
|
||||
want string
|
||||
}{
|
||||
{local(2026, 10, 20, 10, 0), 15, "2026-10"}, // mid-period
|
||||
{local(2026, 10, 15, 0, 0), 15, "2026-10"}, // exactly at the instant: new period
|
||||
{local(2026, 10, 14, 23, 59), 15, "2026-09"},
|
||||
{local(2026, 10, 20, 0, 0), 20, "2026-10"}, // reset day == today
|
||||
{local(2026, 10, 1, 0, 0), 1, "2026-10"}, // day 1 at month start
|
||||
{local(2027, 1, 1, 0, 0), 31, "2026-12"}, // year rollover, before Jan 31
|
||||
{local(2026, 12, 31, 0, 0), 31, "2026-12"}, // at Dec 31 instant
|
||||
{local(2027, 2, 27, 12, 0), 31, "2027-01"}, // Feb has no 31st yet
|
||||
{local(2027, 2, 28, 0, 0), 31, "2027-02"}, // clamped instant: Feb 28
|
||||
{local(2027, 3, 1, 0, 0), 31, "2027-02"}, // still the Feb period
|
||||
{local(2027, 2, 28, 12, 0), 30, "2027-02"}, // day 30 clamps to Feb 28 too
|
||||
{local(2027, 2, 27, 12, 0), 30, "2027-01"},
|
||||
{local(2028, 2, 29, 0, 0), 29, "2028-02"}, // leap year: Feb 29 exists
|
||||
{local(2028, 3, 1, 0, 0), 29, "2028-02"},
|
||||
{local(2027, 3, 1, 0, 0), 29, "2027-02"}, // non-leap: day 29 clamps to Feb 28
|
||||
{local(2027, 2, 28, 0, 0), 29, "2027-02"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := TrafficResetPeriodKey(c.now, c.day); got != c.want {
|
||||
t.Errorf("key(%v, %d) = %q, want %q", c.now, c.day, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetAudits(d *Data, id string) int {
|
||||
n := 0
|
||||
for _, e := range d.Activity {
|
||||
if e.Action == "reset_customer_usage_monthly" && e.Target == id {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetDueAndIdempotent(t *testing.T) {
|
||||
c := &Customer{ID: "cus_a", Status: "active", TrafficLimitBytes: 1000,
|
||||
UsedBytes: 500, UsedUplinkBytes: 200, UsedDownlinkBytes: 300,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
now := local(2026, 10, 20, 8, 0)
|
||||
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.UsedUplinkBytes != 0 || c.UsedDownlinkBytes != 0 {
|
||||
t.Fatalf("counters not zeroed: %+v", c)
|
||||
}
|
||||
if c.TrafficResetPeriod != "2026-10" {
|
||||
t.Fatalf("period = %q", c.TrafficResetPeriod)
|
||||
}
|
||||
if c.TrafficResetAt == nil || *c.TrafficResetAt != ISOFromTime(now) {
|
||||
t.Fatalf("resetAt = %v", c.TrafficResetAt)
|
||||
}
|
||||
if n := resetAudits(d, "cus_a"); n != 1 {
|
||||
t.Fatalf("audits = %d", n)
|
||||
}
|
||||
// Same period, fresh usage accrues: a second pass must not zero again.
|
||||
c.UsedBytes, c.UsedUplinkBytes, c.UsedDownlinkBytes = 50, 20, 30
|
||||
if err := ApplyMonthlyTrafficResets(d, now.Add(24*time.Hour)); err != nil {
|
||||
t.Fatalf("apply2: %v", err)
|
||||
}
|
||||
if c.UsedBytes != 50 {
|
||||
t.Fatalf("second pass zeroed within the same period: %d", c.UsedBytes)
|
||||
}
|
||||
if n := resetAudits(d, "cus_a"); n != 1 {
|
||||
t.Fatalf("audits after second pass = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetNotYetDue(t *testing.T) {
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-10"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 500 || c.TrafficResetAt != nil {
|
||||
t.Fatalf("not-due customer touched: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetArmsOnFirstSight(t *testing.T) {
|
||||
// Marker empty (legacy / converter-written data): arm only, the usage
|
||||
// accrued in the current period must survive.
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 500,
|
||||
TrafficResetDay: 15}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
now := local(2026, 10, 20, 0, 0)
|
||||
if err := ApplyMonthlyTrafficResets(d, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 500 {
|
||||
t.Fatalf("first sight zeroed usage: %d", c.UsedBytes)
|
||||
}
|
||||
if c.TrafficResetPeriod != "2026-10" || c.TrafficResetAt != nil {
|
||||
t.Fatalf("arming wrong: period=%q at=%v", c.TrafficResetPeriod, c.TrafficResetAt)
|
||||
}
|
||||
// And the armed schedule really fires at the next reset instant.
|
||||
c.UsedBytes = 700
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 11, 15, 0, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-11" {
|
||||
t.Fatalf("armed schedule did not fire: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetCatchUpMissedPeriods(t *testing.T) {
|
||||
// Panel down across several reset days (restart catch-up): exactly
|
||||
// one reset, landing in the current period.
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 900,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-06"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2026-10" {
|
||||
t.Fatalf("catch-up wrong: %+v", c)
|
||||
}
|
||||
if n := resetAudits(d, "cus_a"); n != 1 {
|
||||
t.Fatalf("catch-up audits = %d, want exactly 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetShortMonthClamp(t *testing.T) {
|
||||
c := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: 31, TrafficResetPeriod: "2027-01"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
// Feb 27: the clamped instant (Feb 28) has not arrived — no fire.
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 27, 23, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 100 {
|
||||
t.Fatalf("fired before the clamped instant")
|
||||
}
|
||||
// Feb 28 00:01: fires, period is February's.
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2027, 2, 28, 0, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.TrafficResetPeriod != "2027-02" {
|
||||
t.Fatalf("clamp fire wrong: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetIndependentDays(t *testing.T) {
|
||||
a := &Customer{ID: "cus_a", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: 1, TrafficResetPeriod: "2026-09"}
|
||||
b := &Customer{ID: "cus_b", Status: "active", UsedBytes: 200,
|
||||
TrafficResetDay: 25, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{a, b}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.UsedBytes != 0 {
|
||||
t.Errorf("day-1 customer not reset")
|
||||
}
|
||||
if b.UsedBytes != 200 || b.TrafficResetAt != nil {
|
||||
t.Errorf("day-25 customer wrongly reset: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetUnsetOrInvalidUntouched(t *testing.T) {
|
||||
off := &Customer{ID: "cus_off", Status: "active", UsedBytes: 100}
|
||||
bad := &Customer{ID: "cus_bad", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: 45, TrafficResetPeriod: "2020-01"}
|
||||
neg := &Customer{ID: "cus_neg", Status: "active", UsedBytes: 100,
|
||||
TrafficResetDay: -3, TrafficResetPeriod: "2020-01"}
|
||||
d := &Data{Customers: []*Customer{off, bad, neg}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range d.Customers {
|
||||
if c.UsedBytes != 100 || c.TrafficResetAt != nil {
|
||||
t.Errorf("customer %s touched: %+v", c.ID, c)
|
||||
}
|
||||
}
|
||||
if off.TrafficResetPeriod != "" {
|
||||
t.Errorf("unset customer got a marker: %q", off.TrafficResetPeriod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetResumesTrafficSuspended(t *testing.T) {
|
||||
reason := "traffic_limit"
|
||||
c := &Customer{ID: "cus_a", Status: "suspended", SuspendReason: &reason,
|
||||
SuspendReasonPresent: true, TrafficLimitBytes: 1000,
|
||||
UsedBytes: 1500, UsedUplinkBytes: 500, UsedDownlinkBytes: 1000,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{c}}
|
||||
if got := CustomerBlockReason(d, c); got != "traffic_limit" {
|
||||
t.Fatalf("precondition block reason = %q", got)
|
||||
}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UsedBytes != 0 || c.Status != "active" || c.SuspendReason != nil {
|
||||
t.Fatalf("not resumed after reset: %+v", c)
|
||||
}
|
||||
if got := CustomerBlockReason(d, c); got != "" {
|
||||
t.Fatalf("block reason after reset = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMonthlyResetKeepsOtherSuspensions(t *testing.T) {
|
||||
manual := "manual"
|
||||
expired := "expired"
|
||||
past := "2020-01-01T00:00:00.000Z"
|
||||
a := &Customer{ID: "cus_m", Status: "suspended", SuspendReason: &manual,
|
||||
SuspendReasonPresent: true, UsedBytes: 100,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
b := &Customer{ID: "cus_e", Status: "suspended", SuspendReason: &expired,
|
||||
SuspendReasonPresent: true, UsedBytes: 100, ExpiresAt: &past,
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-09"}
|
||||
d := &Data{Customers: []*Customer{a, b}}
|
||||
if err := ApplyMonthlyTrafficResets(d, local(2026, 10, 20, 0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Counters reset (a fresh cycle awaits renewal), status untouched.
|
||||
if a.UsedBytes != 0 || b.UsedBytes != 0 {
|
||||
t.Fatalf("counters not reset: %d %d", a.UsedBytes, b.UsedBytes)
|
||||
}
|
||||
if a.Status != "suspended" || b.Status != "suspended" {
|
||||
t.Fatalf("suspension lifted wrongly: %s %s", a.Status, b.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResetDay(t *testing.T) {
|
||||
for in, want := range map[any]int{nil: 0, float64(0): 0, float64(1): 1, float64(31): 31, json.Number("15"): 15} {
|
||||
got, err := ParseResetDay(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseResetDay(%v) = %d, %v; want %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []any{float64(32), float64(2.5), float64(-1)} {
|
||||
if _, err := ParseResetDay(in); err == nil {
|
||||
t.Errorf("ParseResetDay(%v) accepted", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetFieldsPersistAndOmitWhenUnset(t *testing.T) {
|
||||
s, file := newTestStore(t)
|
||||
at := "2026-10-15T00:00:00.000Z"
|
||||
_, err := s.Transaction(func(d *Data) (any, error) {
|
||||
d.Customers = append(d.Customers,
|
||||
&Customer{ID: "cus_set", Name: "set", Status: "active",
|
||||
TrafficResetDay: 15, TrafficResetPeriod: "2026-10", TrafficResetAt: &at},
|
||||
&Customer{ID: "cus_unset", Name: "unset", Status: "active"})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"trafficResetDay":15`) ||
|
||||
!strings.Contains(string(raw), `"trafficResetPeriod":"2026-10"`) ||
|
||||
!strings.Contains(string(raw), `"trafficResetAt":"2026-10-15T00:00:00.000Z"`) {
|
||||
t.Fatalf("reset fields not persisted: %s", raw)
|
||||
}
|
||||
// Reload through a fresh store: fields survive the round trip.
|
||||
s2 := NewStore(file)
|
||||
if err := s2.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var set, unset *Customer
|
||||
for _, c := range s2.Data.Customers {
|
||||
if c.ID == "cus_set" {
|
||||
set = c
|
||||
} else {
|
||||
unset = c
|
||||
}
|
||||
}
|
||||
if set == nil || set.TrafficResetDay != 15 || set.TrafficResetPeriod != "2026-10" ||
|
||||
set.TrafficResetAt == nil || *set.TrafficResetAt != at {
|
||||
t.Fatalf("round trip lost fields: %+v", set)
|
||||
}
|
||||
// Unset customer: none of the new keys may appear in its JSON.
|
||||
out, err := json.Marshal(unset)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "trafficReset") {
|
||||
t.Fatalf("unset customer gained reset keys: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHousekeepingAppliesMonthlyReset(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.Now()
|
||||
day := now.Day()
|
||||
_, err := s.Transaction(func(d *Data) (any, error) {
|
||||
d.Customers = append(d.Customers, &Customer{ID: "cus_a", Name: "a", Status: "active",
|
||||
UsedBytes: 123, TrafficResetDay: day, TrafficResetPeriod: "2000-01"})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp(s, NewSessionManager(12), NewOrchestrator(s))
|
||||
app.housekeeping()
|
||||
c := s.Snapshot().Customers[0]
|
||||
if c.UsedBytes != 0 {
|
||||
t.Fatalf("housekeeping did not reset: %d", c.UsedBytes)
|
||||
}
|
||||
if want := TrafficResetPeriodKey(now, day); c.TrafficResetPeriod != want {
|
||||
t.Fatalf("period = %q, want %q", c.TrafficResetPeriod, want)
|
||||
}
|
||||
// Second pass with fresh usage: no double reset through the real loop.
|
||||
_, err = s.Transaction(func(d *Data) (any, error) {
|
||||
d.Customers[0].UsedBytes = 7
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.housekeeping()
|
||||
if got := s.Snapshot().Customers[0].UsedBytes; got != 7 {
|
||||
t.Fatalf("second housekeeping pass zeroed again: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func loginHeaders(t *testing.T, app *App) map[string]string {
|
||||
t.Helper()
|
||||
rec := doRequest(app, "POST", "/api/auth/login", `{"username":"admin","password":"password12345"}`, nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := decodeBody(t, rec)
|
||||
csrf, _ := body["csrf"].(string)
|
||||
cookie := ""
|
||||
for _, h := range rec.Header().Values("Set-Cookie") {
|
||||
if strings.HasPrefix(h, "ng_session=") {
|
||||
cookie = strings.Split(h, ";")[0]
|
||||
}
|
||||
}
|
||||
if csrf == "" || cookie == "" {
|
||||
t.Fatalf("login artifacts missing: csrf=%q cookie=%q", csrf, cookie)
|
||||
}
|
||||
return map[string]string{"Cookie": cookie, "X-CSRF-Token": csrf}
|
||||
}
|
||||
|
||||
func TestCustomerAPIResetDayLifecycle(t *testing.T) {
|
||||
app := newSmokeApp(t)
|
||||
headers := loginHeaders(t, app)
|
||||
|
||||
// Create with a reset day: armed marker for the current period, no
|
||||
// reset timestamp yet.
|
||||
rec := doRequest(app, "POST", "/api/customers",
|
||||
`{"name":"monthly","trafficResetDay":15}`, headers)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
customer, _ := decodeBody(t, rec)["customer"].(map[string]any)
|
||||
if customer == nil || customer["trafficResetDay"] != float64(15) {
|
||||
t.Fatalf("create shape %v", customer)
|
||||
}
|
||||
if want := TrafficResetPeriodKey(time.Now(), 15); customer["trafficResetPeriod"] != want {
|
||||
t.Fatalf("armed period = %v, want %q", customer["trafficResetPeriod"], want)
|
||||
}
|
||||
if _, ok := customer["trafficResetAt"]; ok {
|
||||
t.Fatalf("fresh customer has resetAt: %v", customer)
|
||||
}
|
||||
id, _ := customer["id"].(string)
|
||||
|
||||
// Out-of-range day is rejected.
|
||||
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":32}`, headers)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("patch 32 status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Changing the day re-arms for the new schedule.
|
||||
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":20}`, headers)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("patch 20 status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
|
||||
if customer["trafficResetDay"] != float64(20) ||
|
||||
customer["trafficResetPeriod"] != TrafficResetPeriodKey(time.Now(), 20) {
|
||||
t.Fatalf("re-armed shape %v", customer)
|
||||
}
|
||||
|
||||
// Clearing the day removes the schedule from the record entirely.
|
||||
rec = doRequest(app, "PATCH", "/api/customers/"+id, `{"trafficResetDay":0}`, headers)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("patch 0 status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
customer, _ = decodeBody(t, rec)["customer"].(map[string]any)
|
||||
if _, ok := customer["trafficResetDay"]; ok {
|
||||
t.Fatalf("cleared customer still carries resetDay: %v", customer)
|
||||
}
|
||||
if _, ok := customer["trafficResetPeriod"]; ok {
|
||||
t.Fatalf("cleared customer still carries resetPeriod: %v", customer)
|
||||
}
|
||||
}
|
||||
@@ -532,6 +532,9 @@ type Customer struct {
|
||||
UsedBytes int64 `json:"usedBytes"`
|
||||
UsedUplinkBytes int64 `json:"usedUplinkBytes"`
|
||||
UsedDownlinkBytes int64 `json:"usedDownlinkBytes"`
|
||||
TrafficResetDay int `json:"trafficResetDay,omitempty"`
|
||||
TrafficResetPeriod string `json:"trafficResetPeriod,omitempty"`
|
||||
TrafficResetAt *string `json:"trafficResetAt,omitempty"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
IPLimit int64 `json:"ipLimit"`
|
||||
DeviceLimit int64 `json:"deviceLimit"`
|
||||
|
||||
+2
-1
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user